package handlers import ( "context" "encoding/base64" "encoding/json" "fmt" "io" "log" "net/http" "strings" "time" "github.com/gin-gonic/gin" "git.gobha.me/xcaliber/chat-switchboard/auth" capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities" "git.gobha.me/xcaliber/chat-switchboard/crypto" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/events" "git.gobha.me/xcaliber/chat-switchboard/health" "git.gobha.me/xcaliber/chat-switchboard/knowledge" "git.gobha.me/xcaliber/chat-switchboard/models" "git.gobha.me/xcaliber/chat-switchboard/notifications" "git.gobha.me/xcaliber/chat-switchboard/providers" "git.gobha.me/xcaliber/chat-switchboard/routing" "git.gobha.me/xcaliber/chat-switchboard/storage" "git.gobha.me/xcaliber/chat-switchboard/store" "git.gobha.me/xcaliber/chat-switchboard/tools" ) // ── Request Types ─────────────────────────── type completionRequest struct { ChannelID string `json:"channel_id"` Content string `json:"content" binding:"required"` Model string `json:"model,omitempty"` PersonaID string `json:"persona_id,omitempty"` ProviderConfigID string `json:"provider_config_id,omitempty"` MaxTokens int `json:"max_tokens,omitempty"` Temperature *float64 `json:"temperature,omitempty"` TopP *float64 `json:"top_p,omitempty"` Stream *bool `json:"stream,omitempty"` FileIDs []string `json:"file_ids,omitempty"` // staged file UUIDs to include in request DisabledTools []string `json:"disabled_tools,omitempty"` // tool names to exclude from this request } // CompletionHandler proxies LLM requests through the backend. type CompletionHandler struct { vault *crypto.KeyResolver stores store.Stores hub *events.Hub // WebSocket hub for browser tool bridge objStore storage.ObjectStore // file storage for uploaded/generated content (nil = disabled) embedder *knowledge.Embedder // for memory semantic recall (v0.18.0) health HealthRecorder // provider health tracking (v0.22.0, nil = disabled) healthStore HealthStatusQuerier // health status queries for routing (v0.22.2, nil = disabled) router *routing.Evaluator // routing policy evaluator (v0.22.2, nil = disabled) } // HealthRecorder is the interface for recording provider call outcomes. // Matches health.Accumulator methods without importing the package. type HealthRecorder interface { RecordSuccess(providerConfigID string, latencyMs int) RecordError(providerConfigID string, latencyMs int, errMsg string) RecordTimeout(providerConfigID string, latencyMs int, errMsg string) RecordRateLimit(providerConfigID string, latencyMs int, errMsg string) // v0.22.4 RecordToolSuccess(toolName string, latencyMs int) // v0.22.4 RecordToolError(toolName string, latencyMs int, errMsg string) // v0.22.4 } // HealthStatusQuerier retrieves current provider health for routing decisions. // Matches health.Store.ListAllCurrent without importing the package. type HealthStatusQuerier interface { ListAllCurrent(ctx context.Context) ([]models.ProviderHealthWindow, error) } // NewCompletionHandler creates a new handler. func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub, objStore storage.ObjectStore, embedder *knowledge.Embedder) *CompletionHandler { return &CompletionHandler{vault: vault, stores: stores, hub: hub, objStore: objStore, embedder: embedder} } // SetHealthRecorder attaches the provider health accumulator. func (h *CompletionHandler) SetHealthRecorder(hr HealthRecorder) { h.health = hr } // SetHealthStore attaches the health store for routing status queries (v0.22.2). func (h *CompletionHandler) SetHealthStore(hs HealthStatusQuerier) { h.healthStore = hs } // SetRoutingEvaluator attaches the routing policy engine (v0.22.2). func (h *CompletionHandler) SetRoutingEvaluator(r *routing.Evaluator) { h.router = r } // evaluateRouting applies routing policies to select the best provider config // for this request. Returns the winning config details and a routing decision // for observability. If routing is disabled or no policies match, returns // the original resolved config unchanged. func (h *CompletionHandler) evaluateRouting( ctx context.Context, userID, model, configID, providerID string, ) (winConfigID, winProviderID string, decision *routing.Decision) { // Fallthrough: return original config if routing is disabled if h.router == nil || h.stores.RoutingPolicies == nil { return configID, providerID, nil } // Load active policies (scoped to user's teams) var dbPolicies []models.RoutingPolicy var err error teamIDs, _ := h.stores.Teams.GetUserTeamIDs(ctx, userID) if len(teamIDs) > 0 { // Load global + team-scoped policies for user's teams dbPolicies, err = h.stores.RoutingPolicies.ListActive(ctx) } else { dbPolicies, err = h.stores.RoutingPolicies.ListActive(ctx) } if err != nil || len(dbPolicies) == 0 { return configID, providerID, nil } policies := routing.FromModels(dbPolicies) // Build candidates from all configs accessible to this user configs, err := h.stores.Providers.ListAccessible(ctx, userID) if err != nil || len(configs) < 2 { // No point routing with a single config return configID, providerID, nil } candidates := make([]routing.Candidate, 0, len(configs)) for _, cfg := range configs { if !cfg.IsActive { continue } candidates = append(candidates, routing.Candidate{ ConfigID: cfg.ID, ProviderID: cfg.Provider, Model: model, Endpoint: cfg.Endpoint, }) } if len(candidates) < 2 { return configID, providerID, nil } // Build health status map healthStatus := make(map[string]models.ProviderStatus) if h.healthStore != nil { windows, _ := h.healthStore.ListAllCurrent(ctx) for _, w := range windows { healthStatus[w.ProviderConfigID] = health.DeriveStatus(w.ErrorRate(), w.RequestCount) } } routingCtx := &routing.Context{ UserID: userID, TeamIDs: teamIDs, Model: model, ConfigID: configID, HealthStatus: healthStatus, Pricing: map[string]*models.ModelPricing{}, // populated when cost_limit routing is enabled (deferred) } ranked, dec := h.router.Evaluate(routingCtx, policies, candidates) if len(ranked) > 0 && ranked[0].ConfigID != configID { return ranked[0].ConfigID, ranked[0].ProviderID, dec } return configID, providerID, dec } // ── Chat Completion ───────────────────────── // POST /api/v1/chat/completions // // Flow: // 1. Validate request, verify chat ownership // 2. Resolve api_config (from request, chat, or user default) // 3. Load conversation history from messages // 4. Persist user message // 5. Attach tool definitions if model supports tools // 6. Call provider (stream or non-stream) // 7. Tool loop: if LLM requests tool calls, execute them and re-call // 8. Stream SSE to client / return JSON // 9. Persist assistant message with token counts func (h *CompletionHandler) Complete(c *gin.Context) { var req completionRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } channelID := req.ChannelID if channelID == "" { // v0.24.3: Workflow API routes have channel in URL param channelID = c.Param("id") } if channelID == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "channel_id is required"}) return } userID := getUserID(c) // v0.24.3: Session participants are pre-validated by AuthOrSession middleware if isSessionAuth(c) { if !sessionCanAccessChannel(c, channelID) { c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"}) return } // Use session_id as effective identity for cursor/participant tracking. // persistMessage will record participant_type=user with this ID — // acceptable for v0.24.3; v0.25.0 can refine to participant_type=session. userID = c.GetString("session_id") } else { // Verify participation: check channel_participants first, fall back to // legacy channels.user_id ownership for direct channels. isParticipant, _ := h.stores.Channels.IsParticipant(c.Request.Context(), channelID, "user", userID) if !isParticipant { if !userOwnsChannel(c, channelID, userID) { return } } } // ── ai_mode guard (v0.23.1) ──────────────────────────────────────────────── // Check channel ai_mode before doing any work. For DM channels with // mention_only mode and no @mention in the content, persist the message // but skip the completion entirely. { var aiMode string _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` SELECT COALESCE(ai_mode, 'auto') FROM channels WHERE id = $1 `), channelID).Scan(&aiMode) if aiMode == "off" { c.JSON(http.StatusForbidden, gin.H{"error": "AI responses are disabled for this channel"}) return } if aiMode == "mention_only" && extractFirstMention(req.Content) == "" { // Persist the user message before returning msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil, "", "") if err != nil { log.Printf("[mention_only] Failed to persist user message: %v", err) } // Broadcast via WS to ALL user participants (not just sender) if h.hub != nil && msgID != "" { payload, _ := json.Marshal(map[string]any{ "id": msgID, "channel_id": channelID, "role": "user", "content": req.Content, "user_id": userID, }) evt := events.Event{ Label: "message.created", Payload: payload, Ts: time.Now().UnixMilli(), } // Send to sender h.hub.SendToUser(userID, evt) // Send to other user participants in the channel pRows, pErr := database.DB.QueryContext(c.Request.Context(), database.Q(` SELECT participant_id FROM channel_participants WHERE channel_id = $1 AND participant_type = 'user' AND participant_id != $2 `), channelID, userID) if pErr == nil { defer pRows.Close() for pRows.Next() { var pid string if pRows.Scan(&pid) == nil { h.hub.SendToUser(pid, evt) } } } } c.JSON(http.StatusOK, gin.H{"status": "delivered", "ai_skipped": true, "message_id": msgID}) return } } // ── Persona unwrap: persona overrides defaults, explicit request fields win ── var personaSystemPrompt string var personaID string // tracks active persona for KB scoping var personaThinkingBudget *int // persona-level thinking budget for hook injection (v0.22.1) // v0.25.0: Query channel metadata once — used by group leader check, tool context, // and execution context. Avoids redundant SELECTs on channels. var channelType string var channelTeamID *string _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` SELECT COALESCE(type, 'direct'), team_id FROM channels WHERE id = $1 `), channelID).Scan(&channelType, &channelTeamID) teamID := "" if channelTeamID != nil { teamID = *channelTeamID } // v0.23.2: Group leader default — when type=group and no @mention, use the leader persona if req.PersonaID == "" && extractFirstMention(req.Content) == "" { if channelType == "group" { // Find the leader persona via channel_participants → persona_group_members var leaderPersonaID string _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` SELECT cp.participant_id FROM channel_participants cp JOIN persona_group_members pgm ON pgm.persona_id = cp.participant_id WHERE cp.channel_id = $1 AND cp.participant_type = 'persona' AND pgm.is_leader = true LIMIT 1 `), channelID).Scan(&leaderPersonaID) // Fallback: first persona participant if no leader flag set if leaderPersonaID == "" { _ = database.DB.QueryRowContext(c.Request.Context(), database.Q(` SELECT participant_id FROM channel_participants WHERE channel_id = $1 AND participant_type = 'persona' ORDER BY created_at LIMIT 1 `), channelID).Scan(&leaderPersonaID) } if leaderPersonaID != "" { req.PersonaID = leaderPersonaID log.Printf("[group] channel %s: leader persona %s", channelID[:min(8, len(channelID))], leaderPersonaID[:min(8, len(leaderPersonaID))]) } } } if req.PersonaID != "" { persona := ResolvePersona(h.stores, req.PersonaID, userID) if persona == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "persona not found or not accessible"}) return } personaID = persona.ID // Persona provides defaults; explicit request fields take priority if req.Model == "" { req.Model = persona.BaseModelID } if req.ProviderConfigID == "" && persona.ProviderConfigID != nil { req.ProviderConfigID = *persona.ProviderConfigID } if req.Temperature == nil && persona.Temperature != nil { req.Temperature = persona.Temperature } if req.MaxTokens == 0 && persona.MaxTokens != nil { req.MaxTokens = *persona.MaxTokens } if persona.SystemPrompt != "" { personaSystemPrompt = persona.SystemPrompt } personaThinkingBudget = persona.ThinkingBudget } // ── Project persona fallback (v0.19.2): if no explicit persona, check project ── if req.PersonaID == "" && h.stores.Projects != nil { projID, _ := h.stores.Projects.GetProjectIDForChannel(context.Background(), channelID) if projID != "" { if proj, err := h.stores.Projects.GetByID(context.Background(), projID); err == nil { if pid, ok := proj.Settings["persona_id"].(string); ok && pid != "" { if persona := ResolvePersona(h.stores, pid, userID); persona != nil { personaID = persona.ID if req.Model == "" { req.Model = persona.BaseModelID } if req.ProviderConfigID == "" && persona.ProviderConfigID != nil { req.ProviderConfigID = *persona.ProviderConfigID } if req.Temperature == nil && persona.Temperature != nil { req.Temperature = persona.Temperature } if req.MaxTokens == 0 && persona.MaxTokens != nil { req.MaxTokens = *persona.MaxTokens } if persona.SystemPrompt != "" { personaSystemPrompt = persona.SystemPrompt } personaThinkingBudget = persona.ThinkingBudget } } } } } // Resolve provider config providerCfg, providerID, model, configID, providerScope, err := h.resolveConfig(userID, channelID, req) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // ── Routing policy evaluation (v0.22.2) ────────── // After resolving the primary config, evaluate routing policies to // potentially select a different (better/healthier) provider. var routingDecision *routing.Decision if h.router != nil { winConfigID, _, dec := h.evaluateRouting(c.Request.Context(), userID, model, configID, providerID) routingDecision = dec if winConfigID != configID { // Routing selected a different provider — reload its credentials req.ProviderConfigID = winConfigID providerCfg2, providerID2, model2, configID2, providerScope2, err := h.resolveConfig(userID, channelID, req) if err == nil { providerCfg = providerCfg2 providerID = providerID2 if model2 != "" { model = model2 } configID = configID2 providerScope = providerScope2 } else { // Routing target failed to load — fall back to original. log.Printf("[routing] Failed to load winning config %s: %v — using original %s", winConfigID, err, configID) } } } // ── Inject persona-level thinking budget into provider settings (v0.22.1) ── // When a persona specifies a thinking budget, promote it into provider // settings so hooks can activate extended thinking automatically. if personaThinkingBudget != nil && *personaThinkingBudget > 0 { if providerCfg.Settings == nil { providerCfg.Settings = make(map[string]interface{}) } providerCfg.Settings["extended_thinking"] = true providerCfg.Settings["thinking_budget"] = *personaThinkingBudget } // ── Team policy: require_private_providers ── if err := enforcePrivateProviderPolicy(userID, configID); err != nil { c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) return } provider, err := providers.Get(providerID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Routing observability header (v0.22.2) c.Header("X-Switchboard-Provider", providerID+"/"+configID) if routingDecision != nil { c.Header("X-Switchboard-Route", routingDecision.PolicyName) if routingDecision.FallbackDepth > 0 { c.Header("X-Switchboard-Fallback", fmt.Sprintf("%d", routingDecision.FallbackDepth)) } } // Load conversation history messages, err := h.loadConversation(channelID, userID, personaSystemPrompt, personaID, model) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load conversation"}) return } // ── Workflow form template injection (v0.26.4) ── // When the channel is a workflow instance, inject the current stage's // form_template as a system message so the persona knows what to collect. if channelType == "workflow" { if hint := h.buildWorkflowFormHint(c.Request.Context(), channelID); hint != "" { messages = append(messages[:1], append([]providers.Message{{ Role: "system", Content: hint, }}, messages[1:]...)...) } } // Resolve capabilities early — needed for vision gating below caps := h.getModelCapabilities(c, model, configID) // Build user message — multimodal if files are present userMsg := providers.Message{ Role: "user", Content: req.Content, } if len(req.FileIDs) > 0 && h.objStore != nil { parts, augContent, _, err := h.buildMultimodalParts(c, channelID, req.Content, req.FileIDs, caps) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if len(parts) > 0 { // Multimodal (images present): use ContentParts userMsg.ContentParts = parts } else if augContent != req.Content { // Doc-only: use augmented text content userMsg.Content = augContent } } messages = append(messages, userMsg) // Persist user message (text-only for storage — multimodal parts are ephemeral) msgID, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil, "", "") if err != nil { log.Printf("Failed to persist user message: %v", err) } // Link files to the persisted message if msgID != "" && len(req.FileIDs) > 0 { for _, fID := range req.FileIDs { if err := h.stores.Files.SetMessageID(c.Request.Context(), fID, msgID); err != nil { log.Printf("Failed to link file %s to message %s: %v", fID, msgID, err) } } } // ── Workspace resolution (v0.21.1) ──────── // Resolve effective workspace: channel.workspace_id > project.workspace_id workspaceID, _ := h.stores.Channels.ResolveWorkspaceID(c.Request.Context(), channelID) // ── Tool context (v0.25.0) ───────────────── // Uses channel metadata queried above (channelType, teamID). tctx := tools.ToolContext{ ChannelType: channelType, WorkspaceID: workspaceID, TeamID: teamID, IsVisitor: isSessionAuth(c), // PersonaID set below after persona resolution } // ── @mention routing (v0.23.0 / v0.23.1) ───────────────────────────────── // Resolve @persona-handle, @model-id, or @username. // - User mention → skip completion, notify recipient (v0.23.1) // - AI mention → override model/persona for this turn (v0.23.0) mentionModel, mentionConfig, mentionPersona, mentionedUserID := h.resolveMention(c.Request.Context(), userID, req.Content) // v0.23.2: @all fan-out — route to every persona participant (depth-1 only) if strings.Contains(strings.ToLower(req.Content), "@all") { // Get all persona participants for this channel allRows, err := database.DB.QueryContext(c.Request.Context(), database.Q(` SELECT participant_id FROM channel_participants WHERE channel_id = $1 AND participant_type = 'persona' ORDER BY created_at `), channelID) if err == nil { defer allRows.Close() var allPersonaIDs []string for allRows.Next() { var pid string if allRows.Scan(&pid) == nil { allPersonaIDs = append(allPersonaIDs, pid) } } if len(allPersonaIDs) > 0 { // Use the first persona for the streamed response first := ResolvePersona(h.stores, allPersonaIDs[0], userID) if first != nil { mentionModel = first.BaseModelID if first.ProviderConfigID != nil { mentionConfig = *first.ProviderConfigID } mentionPersona = first } // Chain remaining personas via goroutine after the stream completes if len(allPersonaIDs) > 1 && h.hub != nil { remaining := allPersonaIDs[1:] go func() { for _, pid := range remaining { time.Sleep(500 * time.Millisecond) h.chainToPersona(channelID, userID, pid, req.Content, 1) } }() } } } } if mentionedUserID != "" { // Human @mention — message already persisted; notify recipient, skip AI. if h.hub != nil { payload, _ := json.Marshal(map[string]any{ "channel_id": channelID, "from_user": userID, "content": truncateContent(req.Content, 120), }) h.hub.SendToUser(mentionedUserID, events.Event{ Label: "user.mentioned", Payload: payload, Ts: time.Now().UnixMilli(), }) } // Persist notification for bell/inbox (v0.28.2) if svc := notifications.Default(); svc != nil { notifications.NotifyUserMentioned(svc, mentionedUserID, channelID, userID, truncateContent(req.Content, 120)) } c.JSON(http.StatusOK, gin.H{"status": "delivered", "mentioned_user": mentionedUserID}) return } if mentionModel != "" { req.Model = mentionModel if mentionConfig != "" { req.ProviderConfigID = mentionConfig } if mentionPersona != nil { personaID = mentionPersona.ID personaThinkingBudget = mentionPersona.ThinkingBudget if mentionPersona.SystemPrompt != "" { if len(messages) > 0 && messages[0].Role == "system" { messages[0].Content = mentionPersona.SystemPrompt } else { messages = append([]providers.Message{{Role: "system", Content: mentionPersona.SystemPrompt}}, messages...) } } // Context boundary: tell this persona to ignore other personas' styles boundary := fmt.Sprintf( "[You are now %s. Previous assistant messages may be from different AI personas with different styles — ignore their tone, character, and mannerisms. Respond only as yourself per your system prompt.]", mentionPersona.Name, ) if len(messages) >= 2 { last := messages[len(messages)-1] messages[len(messages)-1] = providers.Message{Role: "system", Content: boundary} messages = append(messages, last) } } // Re-resolve provider config for the @mentioned target providerCfg, providerID, model, configID, providerScope, err = h.resolveConfig(userID, channelID, req) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } caps = h.getModelCapabilities(c, model, configID) if personaThinkingBudget != nil && *personaThinkingBudget > 0 { if providerCfg.Settings == nil { providerCfg.Settings = make(map[string]interface{}) } providerCfg.Settings["extended_thinking"] = true providerCfg.Settings["thinking_budget"] = *personaThinkingBudget } provider, err = providers.Get(providerID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } } else { // No @mention: inject a boundary if conversation has persona responses // to prevent the default model from mimicking persona styles. if h.conversationHasPersonaMessages(c.Request.Context(), channelID) { boundary := "[Previous assistant messages in this conversation may include responses from AI personas with specific characters and styles. You are the default assistant — respond in your own natural style, not theirs.]" if len(messages) >= 2 { last := messages[len(messages)-1] messages[len(messages)-1] = providers.Message{Role: "system", Content: boundary} messages = append(messages, last) } } } // Build provider request provReq := providers.CompletionRequest{ Model: model, Messages: messages, } if req.MaxTokens > 0 { provReq.MaxTokens = req.MaxTokens } else { // ResolveMaxOutput checks: caps → known models → context/8 → 4096 provReq.MaxTokens = capspkg.ResolveMaxOutput(model, caps) } if req.Temperature != nil { provReq.Temperature = req.Temperature } if req.TopP != nil { provReq.TopP = req.TopP } // Attach tool definitions if model supports tool calling and tools are available tctx.PersonaID = personaID // finalize after all persona resolution (@mention, group leader, etc.) hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID) if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) { provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, tctx, personaID) } // ── Permission pre-flight ───────────────────────────────────────────── // Budget enforcement and model allowlist only apply when the request draws // from a team or global provider. BYOK (personal scope) is the user's money // — no ceiling applies. // v0.24.3: Session participants bypass user-level budget/allowlist checks. // They use the workflow channel's allocated resources. role, _ := c.Get("role") if role != "admin" && providerScope != "personal" && !isSessionAuth(c) { // Token budget if h.stores.Usage != nil { budget, err := auth.ResolveTokenBudget(c.Request.Context(), h.stores, userID) if err == nil && budget != nil { var opts store.UsageQueryOptions now := time.Now() if budget.Period == "daily" { dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) opts.Since = &dayStart } else { monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) opts.Since = &monthStart } totals, err := h.stores.Usage.GetPersonalTotals(c.Request.Context(), userID, opts) if err == nil && totals != nil { used := int64(totals.InputTokens + totals.OutputTokens) if used >= budget.Limit { c.JSON(http.StatusTooManyRequests, gin.H{"error": "token budget exceeded"}) return } } } } // Model allowlist allowlist, err := auth.ResolveModelAllowlist(c.Request.Context(), h.stores, userID) if err == nil && allowlist != nil && !allowlist[model] { c.JSON(http.StatusForbidden, gin.H{"error": "model not permitted for your account: " + model}) return } } // Determine streaming stream := true // default if req.Stream != nil { stream = *req.Stream } if stream { h.streamCompletion(c, provider, providerCfg, provReq, channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID, teamID) } else { h.syncCompletion(c, provider, providerCfg, provReq, channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID, teamID) } } // ── Multi-Model Sequential Stream (v0.20.0) ── // multiModelStream handles the case where a message @mentions multiple // channel models. It streams each model's response sequentially within // a single SSE response, sending model_start/model_end delimiters. func (h *CompletionHandler) multiModelStream( c *gin.Context, targets []models.ChannelModel, messages []providers.Message, channelID, userID, personaID, personaSystemPrompt, workspaceID, teamID string, req completionRequest, ) { // Set SSE headers once for the entire multi-model stream c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") c.Header("X-Accel-Buffering", "no") c.Status(http.StatusOK) flusher, _ := c.Writer.(http.Flusher) flush := func() { if flusher != nil { flusher.Flush() } } sendSSE := func(data string) { fmt.Fprintf(c.Writer, "data: %s\n\n", data) flush() } sendEvent := func(event, data string) { fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data) flush() } for _, target := range targets { // Notify client which model is about to respond startJSON, _ := json.Marshal(map[string]string{ "model_id": target.ModelID, "display_name": target.DisplayName, }) sendEvent("model_start", string(startJSON)) // Resolve config for this specific model targetReq := req targetReq.Model = target.ModelID if target.ProviderConfigID != "" { targetReq.ProviderConfigID = target.ProviderConfigID } providerCfg, providerID, model, configID, providerScope, err := h.resolveConfig(userID, channelID, targetReq) if err != nil { sendSSE(fmt.Sprintf(`{"error":"failed to resolve config for %s: %s"}`, target.DisplayName, escapeJSON(err.Error()))) sendEvent("model_end", string(startJSON)) continue } provider, err := providers.Get(providerID) if err != nil { sendSSE(fmt.Sprintf(`{"error":"provider unavailable for %s"}`, target.DisplayName)) sendEvent("model_end", string(startJSON)) continue } // Build per-model messages (inject model-specific system prompt if set) modelMessages := make([]providers.Message, len(messages)) copy(modelMessages, messages) if target.SystemPrompt != "" { // Prepend model-specific system prompt modelMessages = append([]providers.Message{{ Role: "system", Content: target.SystemPrompt, }}, modelMessages...) } caps := h.getModelCapabilities(c, model, configID) provReq := providers.CompletionRequest{ Model: model, Messages: modelMessages, } if targetReq.MaxTokens > 0 { provReq.MaxTokens = targetReq.MaxTokens } else { provReq.MaxTokens = capspkg.ResolveMaxOutput(model, caps) } if targetReq.Temperature != nil { provReq.Temperature = targetReq.Temperature } if targetReq.TopP != nil { provReq.TopP = targetReq.TopP } hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID) if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) { tctx := tools.ToolContext{ WorkspaceID: workspaceID, PersonaID: personaID, IsVisitor: isSessionAuth(c), } provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools, tctx, personaID) } // Apply provider-specific request hooks (v0.22.1) if hooks := providers.GetHooks(providerID); hooks != nil { hooks.PreRequest(providerCfg, &provReq) } // Stream this model's response using the shared streaming core result := streamModelResponse(c, provider, providerCfg, &provReq, model, providerID, target.DisplayName, userID, channelID, personaID, workspaceID, configID, teamID, h.hub, h.health) // Persist assistant message with model attribution if result.Content != "" { pID := "" if target.PersonaID != nil { pID = *target.PersonaID } if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity), configID, pID); err != nil { log.Printf("Failed to persist multi-model assistant message: %v", err) } } // Log usage h.logUsage(c, channelID, userID, configID, providerScope, model, result.InputTokens, result.OutputTokens, result.CacheCreationTokens, result.CacheReadTokens) sendEvent("model_end", string(startJSON)) } sendSSE("[DONE]") } // 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 { return BuildToolDefs(ctx, h.stores, userID, includeBrowser, disabledTools, tctx, personaID) } // ── List Available Tools ──────────────────── // GET /api/v1/tools // // Returns all registered server-side tools plus browser extension tools // for the current user. Used by the frontend to build the tools toggle menu. type toolInfo struct { Name string `json:"name"` DisplayName string `json:"display_name"` Description string `json:"description"` Category string `json:"category"` } func (h *CompletionHandler) ListTools(c *gin.Context) { userID := c.GetString("user_id") // Server-side tools allDefs := tools.AllDefinitions() result := make([]toolInfo, 0, len(allDefs)) for _, d := range allDefs { result = append(result, toolInfo{ Name: d.Name, DisplayName: d.DisplayName, Description: d.Description, Category: d.Category, }) } // Browser extension tools if h.hub != nil && h.hub.IsConnected(userID) { exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID) if err == nil { for _, ext := range exts { if ext.Tier != "browser" { continue } var manifest struct { Tools []struct { Name string `json:"name"` DisplayName string `json:"display_name"` Description string `json:"description"` } `json:"tools"` } if err := json.Unmarshal(ext.Manifest, &manifest); err != nil { continue } for _, t := range manifest.Tools { displayName := t.DisplayName if displayName == "" { displayName = t.Name } result = append(result, toolInfo{ Name: t.Name, DisplayName: displayName, Description: t.Description, Category: "browser", }) } } } } c.JSON(http.StatusOK, gin.H{"tools": result}) } // ── Streaming Completion (SSE) with Tool Loop ── func (h *CompletionHandler) streamCompletion( c *gin.Context, provider providers.Provider, cfg providers.ProviderConfig, req providers.CompletionRequest, channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID, teamID string, ) { // Apply provider-specific request hooks (v0.22.1) if hooks := providers.GetHooks(providerID); hooks != nil { hooks.PreRequest(cfg, &req) } result := streamWithToolLoop(c, provider, cfg, &req, model, providerID, userID, channelID, personaID, workspaceID, configID, teamID, h.hub, h.health) // Persist assistant response if result.Content != "" { if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, 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: if the response @mentions another persona participant, // trigger a follow-up completion asynchronously via WebSocket delivery. go func() { defer func() { if r := recover(); r != nil { log.Printf("[chain] PANIC recovered: %v", r) } }() h.chainIfMentioned(channelID, userID, personaID, result.Content, 0) }() } // Log usage h.logUsage(c, channelID, userID, configID, providerScope, model, result.InputTokens, result.OutputTokens, result.CacheCreationTokens, result.CacheReadTokens) } // ── Non-Streaming Completion with Tool Loop ── func (h *CompletionHandler) syncCompletion( c *gin.Context, provider providers.Provider, cfg providers.ProviderConfig, req providers.CompletionRequest, channelID, userID, model, providerID, configID, providerScope, personaID, workspaceID, teamID string, ) { // Apply provider-specific request hooks (v0.22.1) if hooks := providers.GetHooks(providerID); hooks != nil { hooks.PreRequest(cfg, &req) } 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, }, Hub: h.hub, Health: h.health, ConfigID: configID, Budget: LoopBudget{}, Streaming: false, }, accumSink{}) // Provider error if result.Error != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + result.Error.Error()}) return } // 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": finalContent, }, "finish_reason": finishReason, }, }, "usage": gin.H{ "prompt_tokens": result.InputTokens, "completion_tokens": result.OutputTokens, "total_tokens": result.InputTokens + result.OutputTokens, }, }) } // ── Model Capabilities ────────────────────── // escapeJSON escapes a string for safe embedding in a JSON string value. func escapeJSON(s string) string { s = strings.ReplaceAll(s, `\`, `\\`) s = strings.ReplaceAll(s, `"`, `\"`) s = strings.ReplaceAll(s, "\n", `\n`) s = strings.ReplaceAll(s, "\r", `\r`) s = strings.ReplaceAll(s, "\t", `\t`) return s } // getModelCapabilities looks up capabilities from model_catalog DB, // then overlays with known model defaults and heuristic detection. func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfigID string) models.ModelCapabilities { return ResolveModelCaps(c, model, apiConfigID) } // ── Multimodal Assembly ───────────────────── // Builds content parts from files for the user message. // // Returns: // - parts: ContentParts array (non-nil only when images are present) // - augContent: enriched text content with document context (for doc-only case) // - validIDs: file IDs that were successfully processed // - error: if any file is invalid or vision is needed but missing // // Rules: // - Images → base64 data URI (requires vision capability) // - Documents with extracted_text → text injection // - Documents without extraction → filename placeholder // - Text is always the first part func (h *CompletionHandler) buildMultimodalParts( c *gin.Context, channelID, textContent string, fileIDs []string, caps models.ModelCapabilities, ) ([]providers.ContentPart, string, []string, error) { parts := []providers.ContentPart{ {Type: "text", Text: textContent}, } var docTexts []string var validIDs []string hasImage := false for _, fileID := range fileIDs { att, err := h.stores.Files.GetByID(c.Request.Context(), fileID) if err != nil { return nil, "", nil, fmt.Errorf("file %s not found", fileID) } // Security: verify file belongs to this channel if att.ChannelID != channelID { return nil, "", nil, fmt.Errorf("file %s does not belong to this channel", fileID) } if isImageContentType(att.ContentType) { // Vision gating: reject images if model lacks vision if !caps.Vision { return nil, "", nil, fmt.Errorf("model does not support image input; remove image files or choose a vision-capable model") } // Read image from storage, base64 encode, build data URI reader, _, _, err := h.objStore.Get(c.Request.Context(), att.StorageKey) if err != nil { log.Printf("Failed to read file %s from storage: %v", fileID, err) parts = append(parts, providers.ContentPart{ Type: "text", Text: fmt.Sprintf("[Image: %s — failed to read from storage]", att.Filename), }) validIDs = append(validIDs, fileID) continue } data, err := io.ReadAll(reader) reader.Close() if err != nil { log.Printf("Failed to read file %s bytes: %v", fileID, err) validIDs = append(validIDs, fileID) continue } b64 := base64.StdEncoding.EncodeToString(data) dataURI := fmt.Sprintf("data:%s;base64,%s", att.ContentType, b64) parts = append(parts, providers.ContentPart{ Type: "image_url", ImageURL: &providers.ImageURL{ URL: dataURI, Detail: "auto", }, }) hasImage = true } else if att.ExtractedText != nil && *att.ExtractedText != "" { // Document with extracted text → inject as context docText := fmt.Sprintf("[Document: %s]\n%s", att.Filename, *att.ExtractedText) parts = append(parts, providers.ContentPart{ Type: "text", Text: docText, }) docTexts = append(docTexts, docText) } else { // Document without extraction (pending, failed, or not extractable) status := "pending" if s, ok := att.Metadata["extraction_status"].(string); ok { status = s } placeholder := fmt.Sprintf("[Attached file: %s (extraction %s)]", att.Filename, status) parts = append(parts, providers.ContentPart{ Type: "text", Text: placeholder, }) docTexts = append(docTexts, placeholder) } validIDs = append(validIDs, fileID) } // If images present → use ContentParts (multimodal array) if hasImage { return parts, textContent, validIDs, nil } // Doc-only: merge document context into a single text string (more efficient) augmented := textContent for _, dt := range docTexts { augmented += "\n\n" + dt } return nil, augmented, validIDs, nil } // isImageContentType returns true for MIME types that should be sent as // base64 image content parts rather than text extraction. func isImageContentType(ct string) bool { return strings.HasPrefix(ct, "image/") } // ── @mention Resolution (v0.23.0) ─────────── // conversationHasPersonaMessages checks if any assistant message in the // channel was generated by a persona (participant_type = 'persona'). // Used to decide whether to inject a context boundary for the default model. func (h *CompletionHandler) conversationHasPersonaMessages(ctx context.Context, channelID string) bool { var id string err := database.DB.QueryRowContext(ctx, database.Q(` SELECT id FROM messages WHERE channel_id = $1 AND role = 'assistant' AND participant_type = 'persona' LIMIT 1 `), channelID).Scan(&id) return err == nil && id != "" } // buildWorkflowFormHint loads the current workflow stage's form_template // and returns a system message instructing the persona what to collect. // Returns empty string if not a workflow or no template defined. func (h *CompletionHandler) buildWorkflowFormHint(ctx context.Context, channelID string) string { var workflowID *string var currentStage int _ = database.DB.QueryRowContext(ctx, database.Q(` SELECT workflow_id, COALESCE(current_stage, 0) FROM channels WHERE id = $1 AND type = 'workflow' `), channelID).Scan(&workflowID, ¤tStage) if workflowID == nil || *workflowID == "" { return "" } if h.stores.Workflows == nil { return "" } stages, err := h.stores.Workflows.ListStages(ctx, *workflowID) if err != nil || currentStage >= len(stages) { return "" } stage := stages[currentStage] if len(stage.FormTemplate) == 0 || string(stage.FormTemplate) == "{}" { return "" } // Parse form template to extract field descriptions var fields map[string]interface{} if err := json.Unmarshal(stage.FormTemplate, &fields); err != nil { return "" } if len(fields) == 0 { return "" } hint := "You are in workflow stage: " + stage.Name + ".\n\n" hint += "Collect the following information from the user through natural conversation:\n\n" for name, spec := range fields { switch v := spec.(type) { case map[string]interface{}: desc, _ := v["description"].(string) required, _ := v["required"].(bool) if desc != "" { hint += "- " + name + ": " + desc } else { hint += "- " + name } if required { hint += " (required)" } hint += "\n" default: hint += "- " + name + "\n" } } hint += "\nWhen you have gathered all required information, call the workflow_advance tool " + "with the collected data as a JSON object. Do not advance until all required fields are filled." return hint } // buildParticipantHint builds a system message listing available @mentionable // personas so the LLM knows who it can direct responses to. // Only includes personas accessible to this user. Excludes the current persona. func (h *CompletionHandler) buildParticipantHint(channelID, userID, currentPersonaID string) string { ctx := context.Background() personas, err := h.stores.Personas.ListForUser(ctx, userID) if err != nil || len(personas) == 0 { return "" } var lines []string for _, p := range personas { if !p.IsActive || p.Handle == "" || p.ID == currentPersonaID { continue } desc := p.Description if len(desc) > 80 { desc = desc[:80] + "..." } if desc != "" { lines = append(lines, fmt.Sprintf("- @%s (%s) — %s", p.Handle, p.Name, desc)) } else { lines = append(lines, fmt.Sprintf("- @%s (%s)", p.Handle, p.Name)) } } if len(lines) == 0 { return "" } hint := "Available conversation participants you can @mention to direct a response:\n" for _, l := range lines { hint += l + "\n" } hint += "To ask another participant to respond, include their @handle in your message." return hint } // Resolves @tokens in message content directly against the model catalog // and personas table. Works in any chat — no channel roster needed. // // Resolution order: // 1. Persona handle (exact match, then prefix) // 2. User handle/username (exact, then prefix) — v0.23.1 // When a user is @mentioned in a DM/channel, skip completion and deliver // a notification instead. Returns non-empty mentionedUserID in that case. // 3. Model ID in enabled catalog (exact match, then prefix) // // Returns (modelID, providerConfigID, *Persona, mentionedUserID). // Exactly one of (modelID, mentionedUserID) will be non-empty on a successful // match, or all empty if no @mention found/resolved. func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content string) (string, string, *models.Persona, string) { // Extract first @token token := extractFirstMention(content) if token == "" { return "", "", nil, "" } // Normalize: lowercase, hyphens normalized := strings.ToLower(strings.TrimSpace(token)) // 1. Try persona handle (exact) var personaID, personaHandle string err := database.DB.QueryRowContext(ctx, database.Q(` SELECT id, handle FROM personas WHERE LOWER(handle) = $1 AND is_active = true `), normalized).Scan(&personaID, &personaHandle) if err == nil && personaID != "" { p := ResolvePersona(h.stores, personaID, userID) if p != nil { cfgID := "" if p.ProviderConfigID != nil { cfgID = *p.ProviderConfigID } log.Printf("[mention] @%s → persona %s (%s)", token, p.Name, p.BaseModelID) return p.BaseModelID, cfgID, p, "" } } // 2. Try persona handle (prefix — unambiguous only) if personaID == "" { var count int database.DB.QueryRowContext(ctx, database.Q(` SELECT COUNT(*) FROM personas WHERE LOWER(handle) LIKE $1 AND is_active = true `), normalized+"%").Scan(&count) if count == 1 { database.DB.QueryRowContext(ctx, database.Q(` SELECT id FROM personas WHERE LOWER(handle) LIKE $1 AND is_active = true `), normalized+"%").Scan(&personaID) if personaID != "" { p := ResolvePersona(h.stores, personaID, userID) if p != nil { cfgID := "" if p.ProviderConfigID != nil { cfgID = *p.ProviderConfigID } log.Printf("[mention] @%s → persona prefix %s (%s)", token, p.Name, p.BaseModelID) return p.BaseModelID, cfgID, p, "" } } } } // 3. Try user handle (exact match) — v0.24.0 // Only resolves to a user if the caller is not the same user (no self-mention) var mentionedUserID string err = database.DB.QueryRowContext(ctx, database.Q(` SELECT id FROM users WHERE LOWER(handle) = $1 AND id != $2 AND is_active = true LIMIT 1 `), normalized, userID).Scan(&mentionedUserID) if err == nil && mentionedUserID != "" { log.Printf("[mention] @%s → user %s", token, mentionedUserID) return "", "", nil, mentionedUserID } // 4. Try user handle (prefix — unambiguous only) — v0.24.0 var userCount int database.DB.QueryRowContext(ctx, database.Q(` SELECT COUNT(*) FROM users WHERE LOWER(handle) LIKE $1 AND id != $2 AND is_active = true `), normalized+"%", userID).Scan(&userCount) if userCount == 1 { database.DB.QueryRowContext(ctx, database.Q(` SELECT id FROM users WHERE LOWER(handle) LIKE $1 AND id != $2 AND is_active = true LIMIT 1 `), normalized+"%", userID).Scan(&mentionedUserID) if mentionedUserID != "" { log.Printf("[mention] @%s → user prefix %s", token, mentionedUserID) return "", "", nil, mentionedUserID } } var modelID, provConfigID string err = database.DB.QueryRowContext(ctx, database.Q(` SELECT mc.model_id, mc.provider_config_id FROM model_catalog mc JOIN provider_configs pc ON pc.id = mc.provider_config_id WHERE LOWER(mc.model_id) = $1 AND mc.visibility = 'enabled' AND pc.is_active = true ORDER BY CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END LIMIT 1 `), normalized).Scan(&modelID, &provConfigID) if err == nil && modelID != "" { log.Printf("[mention] @%s → model %s (config %s)", token, modelID, provConfigID) return modelID, provConfigID, nil, "" } // 5. Try model_id prefix (unambiguous) var prefixCount int database.DB.QueryRowContext(ctx, database.Q(` SELECT COUNT(DISTINCT mc.model_id) FROM model_catalog mc JOIN provider_configs pc ON pc.id = mc.provider_config_id WHERE LOWER(mc.model_id) LIKE $1 AND mc.visibility = 'enabled' AND pc.is_active = true `), normalized+"%").Scan(&prefixCount) if prefixCount == 1 { database.DB.QueryRowContext(ctx, database.Q(` SELECT mc.model_id, mc.provider_config_id FROM model_catalog mc JOIN provider_configs pc ON pc.id = mc.provider_config_id WHERE LOWER(mc.model_id) LIKE $1 AND mc.visibility = 'enabled' AND pc.is_active = true ORDER BY CASE pc.scope WHEN 'global' THEN 0 WHEN 'team' THEN 1 WHEN 'personal' THEN 2 END LIMIT 1 `), normalized+"%").Scan(&modelID, &provConfigID) if modelID != "" { log.Printf("[mention] @%s → model prefix %s (config %s)", token, modelID, provConfigID) return modelID, provConfigID, nil, "" } } return "", "", nil, "" } // extractFirstMention finds the first @token in content. // Returns the token without the @ prefix, or empty string. func extractFirstMention(content string) string { for i := 0; i < len(content); i++ { if content[i] != '@' { continue } // @ must be at start or preceded by whitespace if i > 0 && content[i-1] != ' ' && content[i-1] != '\n' && content[i-1] != '\t' { continue } // Extract token start := i + 1 end := start for end < len(content) && content[end] != ' ' && content[end] != '\n' && content[end] != '\t' { end++ } if end > start { // Strip trailing punctuation for end > start && (content[end-1] == ',' || content[end-1] == '.' || content[end-1] == '!' || content[end-1] == '?' || content[end-1] == ':' || content[end-1] == ';') { end-- } if end > start { return content[start:end] } } } return "" } // ── Config Resolution ─────────────────────── // 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) { res, err := ResolveProviderConfig(h.vault, userID, channelID, req.ProviderConfigID, req.Model) if err != nil { return providers.ProviderConfig{}, "", "", "", "", err } return res.Config, res.ProviderID, res.Model, res.ConfigID, res.ProviderScope, nil } // ── Conversation Loader ───────────────────── // loadConversation builds the message history for the LLM by walking the // active path through the message tree. Only the current branch is sent — // sibling branches are never included in context. // // Summary-aware: if the path contains a summary node (metadata.type = "summary"), // messages before it are replaced by the summary content as a system message. func (h *CompletionHandler) loadConversation(channelID, userID, personaSystemPrompt, personaID, currentModel string) ([]providers.Message, error) { messages := make([]providers.Message, 0) // ── Admin system prompt (always injected first, no opt out) ── adminPrompt, err := h.stores.GlobalConfig.Get(context.Background(), "system_prompt") if err == nil { if content, ok := adminPrompt["content"].(string); ok && content != "" { messages = append(messages, providers.Message{ Role: "system", Content: content, }) } } // ── User/persona system prompt (appended after admin prompt) ── var systemPrompt *string _ = database.DB.QueryRow( database.Q(`SELECT system_prompt FROM channels WHERE id = $1`), channelID, ).Scan(&systemPrompt) // Persona system prompt takes priority; channel system prompt is fallback if personaSystemPrompt != "" { messages = append(messages, providers.Message{ Role: "system", Content: personaSystemPrompt, }) } else if systemPrompt != nil && *systemPrompt != "" { messages = append(messages, providers.Message{ Role: "system", Content: *systemPrompt, }) } // ── Project system prompt (v0.19.1) ── if h.stores.Projects != nil { projID, _ := h.stores.Projects.GetProjectIDForChannel(context.Background(), channelID) if projID != "" { if proj, err := h.stores.Projects.GetByID(context.Background(), projID); err == nil { if sp, ok := proj.Settings["system_prompt"].(string); ok && sp != "" { messages = append(messages, providers.Message{ Role: "system", Content: sp, }) } } } } // ── KB hint (nudge LLM to use kb_search when KBs are active) ── if kbHint := BuildKBHint(context.Background(), h.stores, channelID, userID, personaID); kbHint != "" { messages = append(messages, providers.Message{ Role: "system", Content: kbHint, }) } // ── Memory hint (inject known facts about this user — v0.18.0) ── // We pass empty lastUserMessage here; the current message content is available // at the call site but not in loadConversation. Semantic recall will improve // when the hint is built closer to the user's message. if memHint := BuildMemoryHint(context.Background(), h.stores, h.embedder, userID, personaID, ""); memHint != "" { messages = append(messages, providers.Message{ Role: "system", Content: memHint, }) } // ── Participant roster (v0.23.0) ── // Inject the list of available @mentionable personas so the LLM // knows who it can direct responses to. This enables LLM→LLM chaining. if participantHint := h.buildParticipantHint(channelID, userID, personaID); participantHint != "" { messages = append(messages, providers.Message{ Role: "system", Content: participantHint, }) } // Walk the active path (root → leaf) instead of loading all messages path, err := getActivePath(channelID, userID) if err != nil { return nil, err } // Find the most recent summary node in the path summaryIdx := -1 for i, m := range path { if isSummaryMessage(&m) { summaryIdx = i } } // If we found a summary, inject it as a system message and skip prior messages startIdx := 0 if summaryIdx >= 0 { messages = append(messages, providers.Message{ Role: "system", Content: "Previous conversation summary:\n" + path[summaryIdx].Content, }) startIdx = summaryIdx + 1 } // ── Build persona name cache for history rewrite (v0.23.0) ── // Collect unique persona IDs from the path so we can attribute // foreign persona messages by name instead of raw UUID. personaNameCache := make(map[string]string) hasForeignPersona := false { seen := make(map[string]bool) for _, m := range path { if m.ParticipantType == "persona" && m.ParticipantID != "" && m.ParticipantID != personaID { if !seen[m.ParticipantID] { seen[m.ParticipantID] = true } } } for pid := range seen { var name string database.DB.QueryRow(database.Q(`SELECT name FROM personas WHERE id = $1`), pid).Scan(&name) if name != "" { personaNameCache[pid] = name } } } for _, m := range path[startIdx:] { if m.Role == "system" { continue // system prompts handled above } // Skip summary nodes that aren't the boundary (shouldn't happen, but defensive) if isSummaryMessage(&m) { continue } // ── Foreign speaker attribution (v0.23.0) ── // ALL assistant messages get a Name field identifying who generated them. // Providers that support "name" (OpenAI, OpenRouter, Venice) use it // to distinguish speakers. Anthropic rewrites named foreign messages // to user role with attribution. msg := providers.Message{ Role: m.Role, Content: m.Content, } if m.Role == "assistant" { isForeign := false if m.ParticipantType == "persona" && m.ParticipantID != "" { if m.ParticipantID != personaID { // Foreign persona — tag with name isForeign = true name := personaNameCache[m.ParticipantID] if name != "" { msg.Name = name } } } else if m.Model != nil && *m.Model != "" { // Raw model — foreign if model differs or we're a persona if *m.Model != currentModel || personaID != "" { isForeign = true msg.Name = *m.Model } } if isForeign { hasForeignPersona = true } } messages = append(messages, msg) } // If foreign persona messages exist, add a system hint explaining // the multi-participant context (helps all providers) if hasForeignPersona { hint := "This is a multi-participant AI conversation. Messages from other AI participants are marked with their name. You are one of several models — respond only as yourself, ignoring other participants' styles and characters." // Insert after the last system message, before conversation history insertIdx := 0 for i, m := range messages { if m.Role == "system" { insertIdx = i + 1 } else { break } } messages = append(messages[:insertIdx+1], messages[insertIdx:]...) messages[insertIdx] = providers.Message{Role: "system", Content: hint} } return messages, nil } // ── Message Persistence ───────────────────── // persistMessage inserts a message into the tree, updates the cursor, and // returns the new message's ID. // // Parent resolution: // - parentOverride (explicit parent for edit/regen operations) // - cursor's active_leaf_id (normal conversation flow) // - latest message by created_at (fallback for legacy/missing cursor) // toolActivityJSON marshals tool activity for persistence. Returns nil if empty. func toolActivityJSON(activity []map[string]interface{}) json.RawMessage { if len(activity) == 0 { return nil } b, _ := json.Marshal(activity) return b } func (h *CompletionHandler) persistMessage(channelID, userID, role, content, model string, inputTokens, outputTokens int, parentOverride *string, toolCallsJSON json.RawMessage, providerConfigID string, personaID string) (string, error) { var tokensUsed *int if inputTokens > 0 || outputTokens > 0 { total := inputTokens + outputTokens tokensUsed = &total } // Determine parent var parentID *string if parentOverride != nil { parentID = parentOverride } else { parentID, _ = getActiveLeaf(channelID, userID) } // Compute sibling_index siblingIdx := nextSiblingIndex(channelID, parentID) // Determine participant: persona for assistant messages with personaID, otherwise user/model participantType := "user" participantID := userID if role == "assistant" { if personaID != "" { participantType = "persona" participantID = personaID } else { participantType = "model" participantID = model } } // provider_config_id: nil if empty var provCfgVal interface{} if providerConfigID != "" { provCfgVal = providerConfigID } // Prepare tool_calls JSONB (nil → NULL) var tcVal interface{} if len(toolCallsJSON) > 0 { tcVal = string(toolCallsJSON) } // Insert with RETURNING id (Postgres) or pre-generated id (SQLite) var newID string if database.IsSQLite() { newID = store.NewID() _, err := database.DB.Exec(` INSERT INTO messages (id, channel_id, role, content, model, tokens_used, tool_calls, parent_id, participant_type, participant_id, provider_config_id, sibling_index) VALUES (?, ?, ?, ?, NULLIF(?, ''), ?, ?, ?, ?, ?, ?, ?) `, newID, channelID, role, content, model, tokensUsed, tcVal, parentID, participantType, participantID, provCfgVal, siblingIdx) if err != nil { return "", err } } else { err := database.DB.QueryRow(` INSERT INTO messages (channel_id, role, content, model, tokens_used, tool_calls, parent_id, participant_type, participant_id, provider_config_id, sibling_index) VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10, $11) RETURNING id `, channelID, role, content, model, tokensUsed, tcVal, parentID, participantType, participantID, provCfgVal, siblingIdx, ).Scan(&newID) if err != nil { return "", err } } // Update cursor to point at new message if userID != "" { _ = updateCursor(channelID, userID, newID) } // Touch channel updated_at _, _ = database.DB.Exec(database.Q(`UPDATE channels SET updated_at = NOW() WHERE id = $1`), channelID) return newID, nil } // ── Usage Logging ───────────────────────── // logUsage records token usage and cost in the usage_log table. // Always logs even if tokens are zero — the request happened. func (h *CompletionHandler) logUsage( c *gin.Context, channelID, userID, configID, providerScope, modelID string, inputTokens, outputTokens, cacheCreation, cacheRead int, ) { if h.stores.Usage == nil { return } entry := &models.UsageEntry{ ChannelID: &channelID, UserID: userID, ProviderConfigID: &configID, ProviderScope: providerScope, ModelID: modelID, PromptTokens: inputTokens, CompletionTokens: outputTokens, CacheCreationTokens: cacheCreation, CacheReadTokens: cacheRead, } // Look up pricing for cost calculation if h.stores.Pricing != nil { pricing, err := h.stores.Pricing.GetForModel(c.Request.Context(), configID, modelID) if err == nil && pricing != nil { entry.CostInput = calcCost(inputTokens, pricing.InputPerM) entry.CostOutput = calcCost(outputTokens, pricing.OutputPerM) } } if err := h.stores.Usage.Log(c.Request.Context(), entry); err != nil { log.Printf("⚠ Failed to log usage: %v", err) } } // calcCost computes the cost for a given token count and price-per-million. func calcCost(tokens int, pricePerM *float64) *float64 { if pricePerM == nil || tokens == 0 { return nil } cost := float64(tokens) * *pricePerM / 1_000_000.0 return &cost } // recordHealth records provider call outcome for health tracking (v0.22.0). func (h *CompletionHandler) recordHealth(configID string, start time.Time, err error) { if h.health == nil || configID == "" { return } latencyMs := int(time.Since(start).Milliseconds()) if err != nil { errMsg := err.Error() if strings.Contains(errMsg, "context deadline exceeded") || strings.Contains(errMsg, "timeout") { h.health.RecordTimeout(configID, latencyMs, errMsg) } else if strings.Contains(errMsg, "HTTP 429") || strings.Contains(errMsg, "rate limit") || strings.Contains(errMsg, "Too Many Requests") { h.health.RecordRateLimit(configID, latencyMs, errMsg) } else { h.health.RecordError(configID, latencyMs, errMsg) } } else { h.health.RecordSuccess(configID, latencyMs) } } // truncateContent returns content truncated to maxLen runes with ellipsis. func truncateContent(s string, maxLen int) string { runes := []rune(s) if len(runes) <= maxLen { return s } return string(runes[:maxLen]) + "…" }