// Package handlers — resolve.go // // v0.27.2: Extracted provider resolution and tool definition building // from CompletionHandler methods to standalone functions. Both the HTTP // completion handler and the headless task scheduler need these paths. // // The CompletionHandler methods (resolveConfig, buildToolDefs) remain as // thin wrappers for backward compat — existing callers are unchanged. package handlers import ( "context" "database/sql" "encoding/json" "fmt" "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" ) // ── Provider Resolution ──────────────────────── // ProviderResolution holds the resolved provider configuration and metadata. type ProviderResolution struct { Config providers.ProviderConfig ProviderID string // e.g. "anthropic", "openai" Model string // resolved model ID ConfigID string // provider_configs.id ProviderScope string // "personal", "team", "global" } // ResolveProviderConfig resolves the provider configuration for a completion // request. Resolution order: // 1. Explicit providerConfigID (from request or task definition) // 2. Channel's configured provider (provider_config_id on channels table) // 3. User's first active config (personal first, then global) // // The vault is used to decrypt API keys. Pass nil for unencrypted fallback. func ResolveProviderConfig( vault *crypto.KeyResolver, userID, channelID, providerConfigID, modelID string, ) (ProviderResolution, error) { 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) if err == nil && channelConfigID != nil { configID = *channelConfigID } } // 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) 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) if err == sql.ErrNoRows { return ProviderResolution{}, fmt.Errorf("API config not found or not accessible") } if err != nil { return ProviderResolution{}, fmt.Errorf("failed to load API config: %w", err) } // Resolve model: explicit > config default model := modelID if model == "" && modelDefault != nil { model = *modelDefault } if model == "" { return ProviderResolution{}, fmt.Errorf("no model specified and no default model in config") } // Decrypt API key using the appropriate tier key := "" if len(apiKeyEnc) > 0 { if vault != nil { var err error key, err = vault.Decrypt(apiKeyEnc, keyNonce, keyScope, userID) if err != nil { if err == crypto.ErrVaultLocked { return ProviderResolution{}, fmt.Errorf("personal vault is locked — please log in again") } return ProviderResolution{}, fmt.Errorf("failed to decrypt API key: %w", err) } } else { // No vault — key stored as raw bytes (unencrypted fallback) key = string(apiKeyEnc) } } // Parse custom headers customHeaders := make(map[string]string) if customHeadersJSON != nil { _ = json.Unmarshal(customHeadersJSON, &customHeaders) } // Parse provider-specific settings providerSettings := make(map[string]interface{}) if providerSettingsJSON != nil { _ = json.Unmarshal(providerSettingsJSON, &providerSettings) } proxyURLStr := "" if proxyURL != nil { proxyURLStr = *proxyURL } return ProviderResolution{ Config: providers.ProviderConfig{ Endpoint: endpoint, APIKey: key, CustomHeaders: customHeaders, Settings: providerSettings, ProxyMode: proxyMode, ProxyURL: proxyURLStr, }, ProviderID: providerID, Model: model, ConfigID: configID, ProviderScope: providerScope, }, nil } // ── Tool Definition Building ─────────────────── // BuildToolDefs assembles the tool definitions for a completion request. // Includes server-registered tools filtered by context predicates, // browser extension tools (if includeBrowser), and persona tool grant // allowlisting. // // Additional tool grant filtering (e.g. task-level grants) can be applied // by the caller after this function returns. func BuildToolDefs( ctx context.Context, stores store.Stores, userID string, includeBrowser bool, disabledTools []string, tctx tools.ToolContext, personaID string, ) []providers.ToolDef { // Build disabled set for O(1) lookup disabled := make(map[string]bool, len(disabledTools)) for _, name := range disabledTools { disabled[name] = true } // v0.25.0: Tools self-declare availability via predicates. allTools := tools.AvailableFor(tctx, disabled) defs := make([]providers.ToolDef, 0, len(allTools)) for _, t := range allTools { defs = append(defs, providers.ToolDef{ Type: "function", Function: providers.FunctionDef{ Name: t.Name, Description: t.Description, Parameters: t.Parameters, }, }) } // Append browser-defined tool schemas from extensions if includeBrowser && stores.Packages != nil { pkgs, err := stores.Packages.ListForUser(ctx, userID) if err != nil { log.Printf("⚠️ Failed to load extensions for tools: %v", err) return defs } for _, pkg := range pkgs { if pkg.Tier != "browser" { continue } var manifest struct { Tools []struct { Name string `json:"name"` Description string `json:"description"` Parameters json.RawMessage `json:"parameters"` Tier string `json:"tier"` } `json:"tools"` } if err := json.Unmarshal(marshalManifest(pkg.Manifest), &manifest); err != nil { continue } for _, t := range manifest.Tools { if disabled[t.Name] { continue } defs = append(defs, providers.ToolDef{ Type: "function", Function: providers.FunctionDef{ Name: t.Name, Description: t.Description, Parameters: t.Parameters, }, }) } } } // v0.25.0: Persona tool grants — second-pass allowlist. // If the active persona has explicit tool grants, restrict to only those tools. // Empty grants = persona inherits all context-available tools (backward compat). if personaID != "" && stores.Personas != nil { grants, err := stores.Personas.GetToolGrants(ctx, personaID) if err != nil { log.Printf("⚠️ Failed to load tool grants for persona %s: %v", personaID, err) } else if len(grants) > 0 { allowed := make(map[string]bool, len(grants)) for _, g := range grants { allowed[g] = true } filtered := defs[:0] for _, d := range defs { if allowed[d.Function.Name] { filtered = append(filtered, d) } } defs = filtered } } return defs } // FilterToolDefsByGrants applies an additional allowlist to tool defs. // Used by the task scheduler to enforce task-level tool grants on top // of persona-level grants. Passing nil or empty grants returns defs unchanged. func FilterToolDefsByGrants(defs []providers.ToolDef, grants []string) []providers.ToolDef { if len(grants) == 0 { return defs } allowed := make(map[string]bool, len(grants)) for _, g := range grants { allowed[g] = true } filtered := make([]providers.ToolDef, 0, len(defs)) for _, d := range defs { if allowed[d.Function.Name] { filtered = append(filtered, d) } } return filtered }