Changeset 0.29.0 (#195)

This commit is contained in:
2026-03-17 16:28:47 +00:00
parent 128cbb8174
commit 5d637d3a90
129 changed files with 9418 additions and 3016 deletions

View File

@@ -6,6 +6,8 @@
//
// The CompletionHandler methods (resolveConfig, buildToolDefs) remain as
// thin wrappers for backward compat — existing callers are unchanged.
//
// v0.29.0-cs7a: Replaced raw SQL with store methods.
package handlers
import (
@@ -16,7 +18,6 @@ import (
"log"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/tools"
@@ -41,17 +42,16 @@ type ProviderResolution struct {
//
// The vault is used to decrypt API keys. Pass nil for unencrypted fallback.
func ResolveProviderConfig(
stores store.Stores,
vault *crypto.KeyResolver,
userID, channelID, providerConfigID, modelID string,
) (ProviderResolution, error) {
ctx := context.Background()
configID := providerConfigID
// 2. Config from channel
if configID == "" && channelID != "" {
var channelConfigID *string
err := database.DB.QueryRow(
database.Q(`SELECT provider_config_id FROM channels WHERE id = $1`), channelID,
).Scan(&channelConfigID)
channelConfigID, err := stores.Channels.GetProviderConfigID(ctx, channelID)
if err == nil && channelConfigID != nil {
configID = *channelConfigID
}
@@ -59,47 +59,15 @@ func ResolveProviderConfig(
// 3. User's first active config (personal first, then global — excludes team providers)
if configID == "" {
err := database.DB.QueryRow(database.Q(`
SELECT id FROM provider_configs
WHERE is_active = true AND (
(scope = 'personal' AND owner_id = $1)
OR scope = 'global'
)
ORDER BY scope ASC, created_at ASC
LIMIT 1
`), userID).Scan(&configID)
var err error
configID, err = stores.Providers.FindFirstForUser(ctx, userID)
if err != nil {
return ProviderResolution{}, fmt.Errorf("no API config found — add one at /api-configs")
}
}
// Load the config — allow personal, global, OR team configs the user belongs to
var providerID, endpoint string
var providerScope string
var modelDefault *string
var apiKeyEnc, keyNonce []byte
var keyScope string
var customHeadersJSON, providerSettingsJSON []byte
var proxyMode string
var proxyURL *string
// $2/userID appears twice in the query; Postgres reuses positional params,
// SQLite needs each ? bound separately.
configArgs := []interface{}{configID, userID}
if database.IsSQLite() {
configArgs = append(configArgs, userID)
}
err := database.DB.QueryRow(database.Q(`
SELECT provider, endpoint, scope, api_key_enc, key_nonce, key_scope,
model_default, headers, settings, COALESCE(proxy_mode, 'system'), proxy_url
FROM provider_configs
WHERE id = $1 AND is_active = true
AND (scope = 'global'
OR (scope = 'personal' AND owner_id = $2)
OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2)))
`), configArgs...).Scan(&providerID, &endpoint, &providerScope, &apiKeyEnc, &keyNonce, &keyScope,
&modelDefault, &customHeadersJSON, &providerSettingsJSON, &proxyMode, &proxyURL)
cfg, err := stores.Providers.LoadAccessible(ctx, configID, userID)
if err == sql.ErrNoRows {
return ProviderResolution{}, fmt.Errorf("API config not found or not accessible")
}
@@ -109,8 +77,8 @@ func ResolveProviderConfig(
// Resolve model: explicit > config default
model := modelID
if model == "" && modelDefault != nil {
model = *modelDefault
if model == "" && cfg.ModelDefault != "" {
model = cfg.ModelDefault
}
if model == "" {
return ProviderResolution{}, fmt.Errorf("no model specified and no default model in config")
@@ -118,10 +86,10 @@ func ResolveProviderConfig(
// Decrypt API key using the appropriate tier
key := ""
if len(apiKeyEnc) > 0 {
if len(cfg.APIKeyEnc) > 0 {
if vault != nil {
var err error
key, err = vault.Decrypt(apiKeyEnc, keyNonce, keyScope, userID)
key, err = vault.Decrypt(cfg.APIKeyEnc, cfg.KeyNonce, cfg.KeyScope, userID)
if err != nil {
if err == crypto.ErrVaultLocked {
return ProviderResolution{}, fmt.Errorf("personal vault is locked — please log in again")
@@ -130,40 +98,47 @@ func ResolveProviderConfig(
}
} else {
// No vault — key stored as raw bytes (unencrypted fallback)
key = string(apiKeyEnc)
key = string(cfg.APIKeyEnc)
}
}
// Parse custom headers
customHeaders := make(map[string]string)
if customHeadersJSON != nil {
_ = json.Unmarshal(customHeadersJSON, &customHeaders)
if cfg.Headers != nil {
b, _ := json.Marshal(cfg.Headers)
_ = json.Unmarshal(b, &customHeaders)
}
// Parse provider-specific settings
providerSettings := make(map[string]interface{})
if providerSettingsJSON != nil {
_ = json.Unmarshal(providerSettingsJSON, &providerSettings)
if cfg.Settings != nil {
for k, v := range cfg.Settings {
providerSettings[k] = v
}
}
proxyMode := cfg.ProxyMode
if proxyMode == "" {
proxyMode = "system"
}
proxyURLStr := ""
if proxyURL != nil {
proxyURLStr = *proxyURL
if cfg.ProxyURL != nil {
proxyURLStr = *cfg.ProxyURL
}
return ProviderResolution{
Config: providers.ProviderConfig{
Endpoint: endpoint,
Endpoint: cfg.Endpoint,
APIKey: key,
CustomHeaders: customHeaders,
Settings: providerSettings,
ProxyMode: proxyMode,
ProxyURL: proxyURLStr,
},
ProviderID: providerID,
ProviderID: cfg.Provider,
Model: model,
ConfigID: configID,
ProviderScope: providerScope,
ConfigID: cfg.ID,
ProviderScope: cfg.Scope,
}, nil
}