Changeset 0.27.2 (#169)
This commit is contained in:
@@ -2,7 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -857,97 +856,10 @@ func (h *CompletionHandler) multiModelStream(
|
||||
sendSSE("[DONE]")
|
||||
}
|
||||
|
||||
// buildToolDefs converts registered tools to provider-format tool definitions.
|
||||
// If includeBrowser is true, also fetches tool schemas from enabled browser extensions.
|
||||
// Tools whose names appear in disabledTools are excluded.
|
||||
// v0.25.0: Tools self-declare availability via predicates on ToolContext.
|
||||
// v0.25.0: Persona tool grants apply as a second-pass allowlist when personaID
|
||||
// has explicit grants. Empty grants = inherit all (backward compat).
|
||||
// buildToolDefs delegates to the standalone BuildToolDefs function.
|
||||
// See resolve.go for the full implementation.
|
||||
func (h *CompletionHandler) buildToolDefs(ctx context.Context, 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.
|
||||
// AvailableFor evaluates each tool's Availability() against tctx,
|
||||
// then applies the disabled set. Replaces manual WorkspaceToolNames()
|
||||
// and GitToolNames() suppression.
|
||||
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 {
|
||||
exts, err := h.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 != "" && h.stores.Personas != nil {
|
||||
grants, err := h.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
|
||||
return BuildToolDefs(ctx, h.stores, userID, includeBrowser, disabledTools, tctx, personaID)
|
||||
}
|
||||
|
||||
// ── List Available Tools ────────────────────
|
||||
@@ -1017,8 +929,6 @@ func (h *CompletionHandler) ListTools(c *gin.Context) {
|
||||
|
||||
// ── Streaming Completion (SSE) with Tool Loop ──
|
||||
|
||||
const maxToolIterations = 10 // safety limit on tool call rounds
|
||||
|
||||
func (h *CompletionHandler) streamCompletion(
|
||||
c *gin.Context,
|
||||
provider providers.Provider,
|
||||
@@ -1071,125 +981,92 @@ func (h *CompletionHandler) syncCompletion(
|
||||
hooks.PreRequest(cfg, &req)
|
||||
}
|
||||
|
||||
var totalInput, totalOutput int
|
||||
var totalCacheCreation, totalCacheRead int
|
||||
var finalContent string
|
||||
var allToolActivity []map[string]interface{}
|
||||
|
||||
for iteration := 0; iteration < maxToolIterations; iteration++ {
|
||||
callStart := time.Now()
|
||||
resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req)
|
||||
h.recordHealth(configID, callStart, err)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
totalInput += resp.InputTokens
|
||||
totalOutput += resp.OutputTokens
|
||||
totalCacheCreation += resp.CacheCreationTokens
|
||||
totalCacheRead += resp.CacheReadTokens
|
||||
|
||||
// No tool calls — normal response
|
||||
if len(resp.ToolCalls) == 0 || resp.FinishReason != "tool_calls" {
|
||||
finalContent = resp.Content
|
||||
|
||||
// Persist assistant response
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil, toolActivityJSON(allToolActivity), configID, personaID); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
|
||||
// AI-to-AI chaining
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[chain] PANIC recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
h.chainIfMentioned(channelID, userID, personaID, finalContent, 0)
|
||||
}()
|
||||
|
||||
// Log usage
|
||||
h.logUsage(c, channelID, userID, configID, providerScope, model,
|
||||
totalInput, totalOutput, totalCacheCreation, totalCacheRead)
|
||||
|
||||
// Return OpenAI-compatible response
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"model": resp.Model,
|
||||
"choices": []gin.H{
|
||||
{
|
||||
"message": gin.H{
|
||||
"role": "assistant",
|
||||
"content": finalContent,
|
||||
},
|
||||
"finish_reason": resp.FinishReason,
|
||||
},
|
||||
},
|
||||
"usage": gin.H{
|
||||
"prompt_tokens": totalInput,
|
||||
"completion_tokens": totalOutput,
|
||||
"total_tokens": totalInput + totalOutput,
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// ── Tool execution round ────────────────
|
||||
assistantMsg := providers.Message{
|
||||
Role: "assistant",
|
||||
Content: resp.Content,
|
||||
}
|
||||
for _, tc := range resp.ToolCalls {
|
||||
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc)
|
||||
}
|
||||
req.Messages = append(req.Messages, assistantMsg)
|
||||
|
||||
execCtx := tools.ExecutionContext{
|
||||
result := CoreToolLoop(c.Request.Context(), LoopConfig{
|
||||
Provider: provider,
|
||||
Cfg: cfg,
|
||||
Req: &req,
|
||||
Model: model,
|
||||
ProviderType: providerID,
|
||||
ExecCtx: tools.ExecutionContext{
|
||||
UserID: userID,
|
||||
ChannelID: channelID,
|
||||
PersonaID: personaID,
|
||||
WorkspaceID: workspaceID,
|
||||
TeamID: teamID,
|
||||
}
|
||||
for _, tc := range resp.ToolCalls {
|
||||
call := tools.ToolCall{
|
||||
ID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Arguments: tc.Function.Arguments,
|
||||
}
|
||||
log.Printf("🔧 Executing tool: %s (call %s)", call.Name, call.ID)
|
||||
result := tools.ExecuteCall(c.Request.Context(), execCtx, call)
|
||||
},
|
||||
Hub: h.hub,
|
||||
Health: h.health,
|
||||
ConfigID: configID,
|
||||
Budget: LoopBudget{},
|
||||
Streaming: false,
|
||||
}, accumSink{})
|
||||
|
||||
// Collect for persistence
|
||||
allToolActivity = append(allToolActivity, map[string]interface{}{
|
||||
"id": tc.ID,
|
||||
"name": tc.Function.Name,
|
||||
"arguments": tc.Function.Arguments,
|
||||
"result": result.Content,
|
||||
"is_error": result.IsError,
|
||||
})
|
||||
|
||||
req.Messages = append(req.Messages, providers.Message{
|
||||
Role: "tool",
|
||||
Content: result.Content,
|
||||
ToolCallID: result.ToolCallID,
|
||||
Name: result.Name,
|
||||
})
|
||||
}
|
||||
// Provider error
|
||||
if result.Error != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + result.Error.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Max iterations reached
|
||||
// Max iterations hit with no final content
|
||||
if result.BudgetExceeded == "max_rounds" && result.Content == "" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"model": model,
|
||||
"choices": []gin.H{
|
||||
{
|
||||
"message": gin.H{
|
||||
"role": "assistant",
|
||||
"content": "[Tool execution limit reached]",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
finalContent := result.Content
|
||||
|
||||
// Persist assistant response
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, personaID); err != nil {
|
||||
log.Printf("Failed to persist assistant message: %v", err)
|
||||
}
|
||||
|
||||
// AI-to-AI chaining
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[chain] PANIC recovered: %v", r)
|
||||
}
|
||||
}()
|
||||
h.chainIfMentioned(channelID, userID, personaID, finalContent, 0)
|
||||
}()
|
||||
|
||||
// Log usage
|
||||
h.logUsage(c, channelID, userID, configID, providerScope, model,
|
||||
result.InputTokens, result.OutputTokens,
|
||||
result.CacheCreationTokens, result.CacheReadTokens)
|
||||
|
||||
// Return OpenAI-compatible response
|
||||
finishReason := "stop"
|
||||
if result.BudgetExceeded != "" {
|
||||
finishReason = "budget_exceeded"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"model": model,
|
||||
"choices": []gin.H{
|
||||
{
|
||||
"message": gin.H{
|
||||
"role": "assistant",
|
||||
"content": "[Tool execution limit reached]",
|
||||
"content": finalContent,
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
"finish_reason": finishReason,
|
||||
},
|
||||
},
|
||||
"usage": gin.H{
|
||||
"prompt_tokens": result.InputTokens,
|
||||
"completion_tokens": result.OutputTokens,
|
||||
"total_tokens": result.InputTokens + result.OutputTokens,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1629,125 +1506,11 @@ func extractFirstMention(content string) string {
|
||||
// Priority: request.provider_config_id → chat.provider_config_id → user's first active config
|
||||
|
||||
func (h *CompletionHandler) resolveConfig(userID string, channelID string, req completionRequest) (providers.ProviderConfig, string, string, string, string, error) {
|
||||
var configID string
|
||||
|
||||
// 1. Explicit config from request
|
||||
if req.ProviderConfigID != "" {
|
||||
configID = req.ProviderConfigID
|
||||
}
|
||||
|
||||
// 2. Config from channel
|
||||
if configID == "" {
|
||||
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 providers.ProviderConfig{}, "", "", "", "", 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 providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("API config not found or not accessible")
|
||||
}
|
||||
res, err := ResolveProviderConfig(h.vault, userID, channelID, req.ProviderConfigID, req.Model)
|
||||
if err != nil {
|
||||
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("failed to load API config: %w", err)
|
||||
return providers.ProviderConfig{}, "", "", "", "", err
|
||||
}
|
||||
|
||||
// Resolve model: request > config default
|
||||
model := req.Model
|
||||
if model == "" && modelDefault != nil {
|
||||
model = *modelDefault
|
||||
}
|
||||
if model == "" {
|
||||
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("no model specified and no default model in config")
|
||||
}
|
||||
|
||||
// Decrypt API key using the appropriate tier
|
||||
key := ""
|
||||
if len(apiKeyEnc) > 0 {
|
||||
if h.vault != nil {
|
||||
var err error
|
||||
key, err = h.vault.Decrypt(apiKeyEnc, keyNonce, keyScope, userID)
|
||||
if err != nil {
|
||||
if err == crypto.ErrVaultLocked {
|
||||
return providers.ProviderConfig{}, "", "", "", "", fmt.Errorf("personal vault is locked — please log in again")
|
||||
}
|
||||
return providers.ProviderConfig{}, "", "", "", "", 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 providers.ProviderConfig{
|
||||
Endpoint: endpoint,
|
||||
APIKey: key,
|
||||
CustomHeaders: customHeaders,
|
||||
Settings: providerSettings,
|
||||
ProxyMode: proxyMode,
|
||||
ProxyURL: proxyURLStr,
|
||||
}, providerID, model, configID, providerScope, nil
|
||||
return res.Config, res.ProviderID, res.Model, res.ConfigID, res.ProviderScope, nil
|
||||
}
|
||||
|
||||
// ── Conversation Loader ─────────────────────
|
||||
|
||||
Reference in New Issue
Block a user