// 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. // // v0.29.0-cs7a: Replaced raw SQL with store methods. package handlers import ( "context" "database/sql" "encoding/json" "fmt" "log" "chat-switchboard/crypto" "chat-switchboard/providers" "chat-switchboard/store" "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( 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 != "" { channelConfigID, err := stores.Channels.GetProviderConfigID(ctx, channelID) if err == nil && channelConfigID != nil { configID = *channelConfigID } } // 3. User's first active config (personal first, then global — excludes team providers) if 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 cfg, err := stores.Providers.LoadAccessible(ctx, configID, userID) 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 == "" && cfg.ModelDefault != "" { model = cfg.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(cfg.APIKeyEnc) > 0 { if vault != nil { var err error 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") } return ProviderResolution{}, fmt.Errorf("failed to decrypt API key: %w", err) } } else { // No vault — key stored as raw bytes (unencrypted fallback) key = string(cfg.APIKeyEnc) } } // Parse custom headers customHeaders := make(map[string]string) if cfg.Headers != nil { b, _ := json.Marshal(cfg.Headers) _ = json.Unmarshal(b, &customHeaders) } // Parse provider-specific settings providerSettings := make(map[string]interface{}) if cfg.Settings != nil { for k, v := range cfg.Settings { providerSettings[k] = v } } proxyMode := cfg.ProxyMode if proxyMode == "" { proxyMode = "system" } proxyURLStr := "" if cfg.ProxyURL != nil { proxyURLStr = *cfg.ProxyURL } return ProviderResolution{ Config: providers.ProviderConfig{ Endpoint: cfg.Endpoint, APIKey: key, CustomHeaders: customHeaders, Settings: providerSettings, ProxyMode: proxyMode, ProxyURL: proxyURLStr, }, ProviderID: cfg.Provider, Model: model, ConfigID: cfg.ID, ProviderScope: cfg.Scope, }, 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 and starlark-tier tool schemas from extensions. // v0.29.2: starlark tools are always included (not gated on browser connection). if 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 { // Browser tools only when a browser client is connected. if pkg.Tier == "browser" && !includeBrowser { continue } // Only browser and starlark tiers expose tools this way. if pkg.Tier != "browser" && pkg.Tier != "starlark" { continue } // Starlark packages must be active to expose tools. if pkg.Tier == "starlark" && pkg.Status != "active" { continue } var manifest struct { Tools []struct { Name string `json:"name"` Description string `json:"description"` Parameters json.RawMessage `json:"parameters"` } `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 } // BuildExtToolMap returns a map of toolName → PackageRegistration for all // active starlark-tier extensions that declare tools in their manifest. // Used by the tool loop to dispatch matched calls to on_tool_call. func BuildExtToolMap(ctx context.Context, stores store.Stores, userID string) map[string]*store.PackageRegistration { if stores.Packages == nil { return nil } pkgs, err := stores.Packages.ListForUser(ctx, userID) if err != nil { return nil } result := make(map[string]*store.PackageRegistration) for i, pkg := range pkgs { if pkg.Tier != "starlark" || pkg.Status != "active" { continue } var manifest struct { Tools []struct { Name string `json:"name"` } `json:"tools"` } if json.Unmarshal(marshalManifest(pkg.Manifest), &manifest) != nil { continue } for _, t := range manifest.Tools { if t.Name != "" { result[t.Name] = &pkgs[i].PackageRegistration } } } return result } // 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 }