- go.mod module name
- All 714 import references across 289 Go files
- VERSION: 0.1.0
- CI DB names: switchboard_core_{ci,dev,test}
- Docker image: gobha/switchboard-core
- Test fixtures: JWT issuer, repo names
- .env.example, docker-compose container name
- Compiles clean (go build exit 0)
307 lines
9.1 KiB
Go
307 lines
9.1 KiB
Go
// 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"
|
|
|
|
"switchboard-core/crypto"
|
|
"switchboard-core/providers"
|
|
"switchboard-core/store"
|
|
"switchboard-core/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
|
|
}
|