Changeset 0.27.2 (#169)
This commit is contained in:
289
server/handlers/resolve.go
Normal file
289
server/handlers/resolve.go
Normal file
@@ -0,0 +1,289 @@
|
||||
// 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.Extensions != nil {
|
||||
exts, err := stores.Extensions.ListForUser(ctx, userID)
|
||||
if err != nil {
|
||||
log.Printf("⚠️ Failed to load extensions for tools: %v", err)
|
||||
return defs
|
||||
}
|
||||
for _, ext := range exts {
|
||||
if ext.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(ext.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
|
||||
}
|
||||
Reference in New Issue
Block a user