Changeset 0.9.3 (#53)

This commit is contained in:
2026-02-23 23:55:22 +00:00
parent ac7c7c7d59
commit 90021157e6
20 changed files with 897 additions and 414 deletions

View File

@@ -368,7 +368,7 @@ func (h *AdminHandler) DeleteGlobalConfig(c *gin.Context) {
// ── Model Catalog ───────────────────────────
func (h *AdminHandler) ListModelConfigs(c *gin.Context) {
entries, err := h.stores.Catalog.ListAll(c.Request.Context())
entries, err := h.stores.Catalog.ListAllGlobal(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
return

View File

@@ -35,7 +35,10 @@ func (h *ModelHandler) ListEnabledModels(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{"models": userModels})
// Include admin default model so frontend can use it in resolution chain
defaultModel, _ := h.stores.Policies.Get(c.Request.Context(), "default_model")
c.JSON(http.StatusOK, gin.H{"models": userModels, "default_model": defaultModel})
}
// ResolveModelCaps is the canonical capability resolver for any model.

View File

@@ -137,7 +137,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
})
// Persist user message
if _, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil); err != nil {
if _, err := h.persistMessage(channelID, userID, "user", req.Content, "", 0, 0, nil, nil); err != nil {
log.Printf("Failed to persist user message: %v", err)
}
@@ -210,159 +210,11 @@ func (h *CompletionHandler) streamCompletion(
req providers.CompletionRequest,
channelID, userID, model string,
) {
// Set SSE headers
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no") // Disable nginx buffering
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID)
c.Status(http.StatusOK)
flusher, _ := c.Writer.(http.Flusher)
flush := func() {
if flusher != nil {
flusher.Flush()
}
}
// sendSSE sends a standard OpenAI-compatible SSE data line.
sendSSE := func(data string) {
fmt.Fprintf(c.Writer, "data: %s\n\n", data)
flush()
}
// sendEvent sends a named SSE event (for tool status — non-standard).
sendEvent := func(event, data string) {
fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, data)
flush()
}
var fullContent string
for iteration := 0; iteration < maxToolIterations; iteration++ {
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, req)
if err != nil {
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error())))
return
}
var iterContent string
var toolCalls []providers.ToolCall
for event := range ch {
if event.Error != nil {
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error())))
return
}
// Stream text deltas to client
if event.Delta != "" {
iterContent += event.Delta
sendSSE(fmt.Sprintf(
`{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q}`,
event.Delta, model))
}
if event.Done {
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
toolCalls = event.ToolCalls
} else {
// Normal completion — send finish event
finishReason := event.FinishReason
if finishReason == "" {
finishReason = "stop"
}
fullContent += iterContent
sendSSE(fmt.Sprintf(
`{"choices":[{"delta":{},"finish_reason":%q}],"model":%q}`,
finishReason, model))
sendSSE("[DONE]")
// Persist assistant response
if fullContent != "" {
if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil {
log.Printf("Failed to persist assistant message: %v", err)
}
}
return
}
break
}
}
// ── Tool execution round ────────────────
if len(toolCalls) == 0 {
// Stream ended without tool calls or finish — shouldn't happen
sendSSE("[DONE]")
if iterContent != "" {
fullContent += iterContent
if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil {
log.Printf("Failed to persist assistant message: %v", err)
}
}
return
}
// Notify client about tool calls
toolCallsJSON, _ := json.Marshal(toolCalls)
sendEvent("tool_use", string(toolCallsJSON))
// Append assistant message (with tool_calls, possibly with text) to conversation
assistantMsg := providers.Message{
Role: "assistant",
Content: iterContent,
}
for _, tc := range toolCalls {
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc)
}
req.Messages = append(req.Messages, assistantMsg)
// Execute all tool calls
execCtx := tools.ExecutionContext{
UserID: userID,
ChannelID: channelID,
}
for _, tc := range 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)
// Notify client about tool result
resultJSON, _ := json.Marshal(map[string]interface{}{
"tool_call_id": result.ToolCallID,
"name": result.Name,
"content": result.Content,
"is_error": result.IsError,
})
sendEvent("tool_result", string(resultJSON))
// Append tool result to conversation for next iteration
req.Messages = append(req.Messages, providers.Message{
Role: "tool",
Content: result.Content,
ToolCallID: result.ToolCallID,
Name: result.Name,
})
}
fullContent += iterContent
// Loop: send updated messages back to provider
}
// If we hit max iterations, send what we have
log.Printf("⚠️ Tool loop hit max iterations (%d) for channel %s", maxToolIterations, channelID)
sendSSE(fmt.Sprintf(
`{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q}`,
escapeJSON("[Tool execution limit reached]"), model))
sendSSE("[DONE]")
if fullContent != "" {
if _, err := h.persistMessage(channelID, userID, "assistant", fullContent, model, 0, 0, nil); err != nil {
// Persist assistant response
if result.Content != "" {
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, 0, 0, nil, toolActivityJSON(result.ToolActivity)); err != nil {
log.Printf("Failed to persist assistant message: %v", err)
}
}
@@ -379,6 +231,7 @@ func (h *CompletionHandler) syncCompletion(
) {
var totalInput, totalOutput int
var finalContent string
var allToolActivity []map[string]interface{}
for iteration := 0; iteration < maxToolIterations; iteration++ {
resp, err := provider.ChatCompletion(c.Request.Context(), cfg, req)
@@ -395,7 +248,7 @@ func (h *CompletionHandler) syncCompletion(
finalContent = resp.Content
// Persist assistant response
if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil); err != nil {
if _, err := h.persistMessage(channelID, userID, "assistant", finalContent, model, totalInput, totalOutput, nil, toolActivityJSON(allToolActivity)); err != nil {
log.Printf("Failed to persist assistant message: %v", err)
}
@@ -443,6 +296,15 @@ func (h *CompletionHandler) syncCompletion(
log.Printf("🔧 Executing tool: %s (call %s)", call.Name, call.ID)
result := tools.ExecuteCall(c.Request.Context(), execCtx, call)
// 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,
@@ -632,7 +494,16 @@ func (h *CompletionHandler) loadConversation(channelID, userID, presetSystemProm
// - 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)
func (h *CompletionHandler) persistMessage(channelID, userID, role, content, model string, inputTokens, outputTokens int, parentOverride *string) (string, error) {
// 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) (string, error) {
var tokensUsed *int
if inputTokens > 0 || outputTokens > 0 {
total := inputTokens + outputTokens
@@ -658,15 +529,21 @@ func (h *CompletionHandler) persistMessage(channelID, userID, role, content, mod
participantID = model
}
// Prepare tool_calls JSONB (nil → NULL)
var tcVal interface{}
if len(toolCallsJSON) > 0 {
tcVal = string(toolCallsJSON)
}
// Insert with RETURNING id
var newID string
err := database.DB.QueryRow(`
INSERT INTO messages (channel_id, role, content, model, tokens_used,
parent_id, participant_type, participant_id, sibling_index)
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9)
tool_calls, parent_id, participant_type, participant_id, sibling_index)
VALUES ($1, $2, $3, NULLIF($4, ''), $5, $6, $7, $8, $9, $10)
RETURNING id
`, channelID, role, content, model, tokensUsed,
parentID, participantType, participantID, siblingIdx,
tcVal, parentID, participantType, participantID, siblingIdx,
).Scan(&newID)
if err != nil {
return "", err

View File

@@ -1547,7 +1547,7 @@ func TestUserJourney_FullMatrix(t *testing.T) {
}
})
t.Run("admin_models_shows_all_including_disabled", func(t *testing.T) {
t.Run("admin_models_shows_global_only", func(t *testing.T) {
w := h.request("GET", "/api/v1/admin/models", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("admin/models: %d", w.Code)
@@ -1555,9 +1555,9 @@ func TestUserJourney_FullMatrix(t *testing.T) {
var resp map[string]interface{}
decode(w, &resp)
allModels := resp["models"].([]interface{})
// global(3) + team(2) + member-byok(1) + outsider-byok(1) = 7
if len(allModels) < 7 {
t.Fatalf("admin/models should show all 7 catalog entries, got %d", len(allModels))
// Admin should see only global-scope models (3), not team(2) or BYOK(2)
if len(allModels) != 3 {
t.Fatalf("admin/models should show 3 global entries, got %d", len(allModels))
}
})
}

View File

@@ -4,7 +4,6 @@ import (
"database/sql"
"encoding/json"
"fmt"
"io"
"log"
"math"
"net/http"
@@ -14,6 +13,7 @@ import (
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/tools"
)
// ── Request / Response types ────────────────
@@ -443,74 +443,43 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
provReq.Temperature = temperature
}
// ── Stream the response ──
ch, err := provider.StreamCompletion(c.Request.Context(), providerCfg, provReq)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "provider error: " + err.Error()})
return
// Attach tool definitions (same as normal completion)
if caps.ToolCalling && tools.HasTools() {
provReq.Tools = comp.buildToolDefs()
}
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)
// ── Stream the response (shared loop handles tools, reasoning, SSE) ──
var fullContent string
flusher, _ := c.Writer.(http.Flusher)
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID)
for event := range ch {
if event.Error != nil {
fmt.Fprintf(c.Writer, "data: {\"error\":\"%s\"}\n\n", event.Error.Error())
if flusher != nil {
flusher.Flush()
}
break
}
if event.Delta != "" {
fullContent += event.Delta
fmt.Fprintf(c.Writer, "data: {\"choices\":[{\"delta\":{\"content\":%q},\"finish_reason\":null}],\"model\":%q}\n\n",
event.Delta, model)
if flusher != nil {
flusher.Flush()
}
}
if event.Done {
finishReason := event.FinishReason
if finishReason == "" {
finishReason = "stop"
}
fmt.Fprintf(c.Writer, "data: {\"choices\":[{\"delta\":{},\"finish_reason\":%q}],\"model\":%q}\n\n",
finishReason, model)
io.WriteString(c.Writer, "data: [DONE]\n\n")
if flusher != nil {
flusher.Flush()
}
break
}
}
// Persist as child or sibling depending on target role
if fullContent != "" {
// Persist as sibling (regen) with tool activity
if result.Content != "" {
siblingIdx := nextSiblingIndex(channelID, newParentID)
// Match persistMessage pattern: nil interface{} → SQL NULL,
// non-nil string → valid JSONB. A nil json.RawMessage ([]byte)
// is sent by pq as empty bytes, not NULL, causing "invalid input
// syntax for type json".
var tcVal interface{}
if len(result.ToolActivity) > 0 {
b, _ := json.Marshal(result.ToolActivity)
tcVal = string(b)
}
var newID string
err := database.DB.QueryRow(`
INSERT INTO messages (channel_id, role, content, model,
INSERT INTO messages (channel_id, role, content, model, tool_calls,
parent_id, participant_type, participant_id, sibling_index)
VALUES ($1, 'assistant', $2, $3, $4, 'model', $5, $6)
VALUES ($1, 'assistant', $2, $3, $4, $5, 'model', $6, $7)
RETURNING id
`, channelID, fullContent, model, newParentID, model, siblingIdx).Scan(&newID)
`, channelID, result.Content, model, tcVal, newParentID, model, siblingIdx).Scan(&newID)
if err != nil {
log.Printf("Failed to persist regenerated message: %v", err)
} else {
_ = updateCursor(channelID, userID, newID)
// Send message metadata in a final SSE event so frontend can update
flusher, _ := c.Writer.(http.Flusher)
msgJSON, _ := json.Marshal(gin.H{
"id": newID,
"parent_id": newParentID,

View File

@@ -0,0 +1,202 @@
package handlers
import (
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/tools"
)
// streamResult holds the accumulated output from a streaming completion
// with tool execution. Callers are responsible for persistence.
type streamResult struct {
Content string
ToolActivity []map[string]interface{}
}
// streamWithToolLoop is the single, canonical streaming implementation.
// It handles SSE setup, multi-round tool execution, reasoning_content
// forwarding, and client notifications. Returns the accumulated content
// and tool activity for the caller to persist however it needs to.
//
// Used by:
// - streamCompletion (normal chat — persists via persistMessage)
// - Regenerate (regen/edit — persists as sibling with tree metadata)
//
// If you add features here (new event types, new tool capabilities, new
// persistence fields), both callers get them automatically.
func streamWithToolLoop(
c *gin.Context,
provider providers.Provider,
cfg providers.ProviderConfig,
req *providers.CompletionRequest,
model, userID, channelID string,
) streamResult {
// Set SSE headers
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()
}
var result streamResult
for iteration := 0; iteration < maxToolIterations; iteration++ {
ch, err := provider.StreamCompletion(c.Request.Context(), cfg, *req)
if err != nil {
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(err.Error())))
return result
}
var iterContent string
var iterReasoning string
var toolCalls []providers.ToolCall
for event := range ch {
if event.Error != nil {
sendSSE(fmt.Sprintf(`{"error":"%s"}`, escapeJSON(event.Error.Error())))
return result
}
// Stream reasoning deltas to client
if event.Reasoning != "" {
iterReasoning += event.Reasoning
sendSSE(fmt.Sprintf(
`{"choices":[{"delta":{"reasoning_content":%q},"finish_reason":null}],"model":%q}`,
event.Reasoning, model))
}
// Stream text deltas to client
if event.Delta != "" {
iterContent += event.Delta
sendSSE(fmt.Sprintf(
`{"choices":[{"delta":{"content":%q},"finish_reason":null}],"model":%q}`,
event.Delta, model))
}
if event.Done {
if event.FinishReason == "tool_calls" && len(event.ToolCalls) > 0 {
toolCalls = event.ToolCalls
} else {
// Normal completion — send finish event
finishReason := event.FinishReason
if finishReason == "" {
finishReason = "stop"
}
if iterReasoning != "" {
result.Content += "<think>" + iterReasoning + "</think>"
}
result.Content += iterContent
sendSSE(fmt.Sprintf(
`{"choices":[{"delta":{},"finish_reason":%q}],"model":%q}`,
finishReason, model))
sendSSE("[DONE]")
return result
}
break
}
}
// ── Tool execution round ────────────────
if len(toolCalls) == 0 {
// Stream ended without tool calls or finish — shouldn't happen
sendSSE("[DONE]")
if iterReasoning != "" {
result.Content += "<think>" + iterReasoning + "</think>"
}
result.Content += iterContent
return result
}
// Notify client about tool calls
toolCallsJSON, _ := json.Marshal(toolCalls)
sendEvent("tool_use", string(toolCallsJSON))
// Append assistant message (with tool_calls, possibly with text) to conversation
assistantMsg := providers.Message{
Role: "assistant",
Content: iterContent,
}
for _, tc := range toolCalls {
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, tc)
}
req.Messages = append(req.Messages, assistantMsg)
// Execute all tool calls
execCtx := tools.ExecutionContext{
UserID: userID,
ChannelID: channelID,
}
for _, tc := range 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)
toolResult := tools.ExecuteCall(c.Request.Context(), execCtx, call)
// Notify client about tool result
resultJSON, _ := json.Marshal(map[string]interface{}{
"tool_call_id": toolResult.ToolCallID,
"name": toolResult.Name,
"content": toolResult.Content,
"is_error": toolResult.IsError,
})
sendEvent("tool_result", string(resultJSON))
// Collect for persistence
result.ToolActivity = append(result.ToolActivity, map[string]interface{}{
"id": tc.ID,
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
"result": toolResult.Content,
"is_error": toolResult.IsError,
})
// Append tool result to conversation for next iteration
req.Messages = append(req.Messages, providers.Message{
Role: "tool",
Content: toolResult.Content,
ToolCallID: toolResult.ToolCallID,
Name: toolResult.Name,
})
}
result.Content += iterContent
// Loop: send updated messages back to provider
}
// Hit max iterations
log.Printf("⚠️ Tool loop hit max iterations (%d) for channel %s", maxToolIterations, channelID)
sendSSE(fmt.Sprintf(
`{"choices":[{"delta":{"content":"%s"},"finish_reason":"stop"}],"model":%q}`,
escapeJSON("[Tool execution limit reached]"), model))
sendSSE("[DONE]")
return result
}

View File

@@ -2,6 +2,7 @@ package handlers
import (
"database/sql"
"encoding/json"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/database"
@@ -12,17 +13,18 @@ import (
// PathMessage represents a single message in an active path, enriched
// with sibling metadata for the frontend branch indicator.
type PathMessage struct {
ID string `json:"id"`
ParentID *string `json:"parent_id"`
Role string `json:"role"`
Content string `json:"content"`
Model *string `json:"model"`
TokensUsed *int `json:"tokens_used,omitempty"`
SiblingCount int `json:"sibling_count"`
SiblingIndex int `json:"sibling_index"`
ParticipantType string `json:"participant_type,omitempty"`
ParticipantID string `json:"participant_id,omitempty"`
CreatedAt string `json:"created_at"`
ID string `json:"id"`
ParentID *string `json:"parent_id"`
Role string `json:"role"`
Content string `json:"content"`
Model *string `json:"model"`
TokensUsed *int `json:"tokens_used,omitempty"`
ToolCalls *json.RawMessage `json:"tool_calls,omitempty"`
SiblingCount int `json:"sibling_count"`
SiblingIndex int `json:"sibling_index"`
ParticipantType string `json:"participant_type,omitempty"`
ParticipantID string `json:"participant_id,omitempty"`
CreatedAt string `json:"created_at"`
}
// SiblingInfo is a lightweight entry for the siblings listing endpoint.
@@ -96,7 +98,7 @@ func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) {
rows, err := database.DB.Query(`
WITH RECURSIVE path AS (
-- Anchor: start at the leaf
SELECT id, parent_id, role, content, model, tokens_used,
SELECT id, parent_id, role, content, model, tokens_used, tool_calls,
participant_type, participant_id, sibling_index, created_at,
0 AS depth
FROM messages
@@ -105,14 +107,14 @@ func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) {
UNION ALL
-- Walk up via parent_id
SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used,
SELECT m.id, m.parent_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls,
m.participant_type, m.participant_id, m.sibling_index, m.created_at,
p.depth + 1
FROM messages m
JOIN path p ON m.id = p.parent_id
WHERE m.deleted_at IS NULL
)
SELECT id, parent_id, role, content, model, tokens_used,
SELECT id, parent_id, role, content, model, tokens_used, tool_calls,
participant_type, participant_id, sibling_index, created_at
FROM path
ORDER BY depth DESC
@@ -126,12 +128,18 @@ func getPathToLeaf(channelID, leafID string) ([]PathMessage, error) {
for rows.Next() {
var m PathMessage
var participantType, participantID sql.NullString
var toolCallsJSON []byte
if err := rows.Scan(
&m.ID, &m.ParentID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
&toolCallsJSON,
&participantType, &participantID, &m.SiblingIndex, &m.CreatedAt,
); err != nil {
return nil, fmt.Errorf("getPathToLeaf scan: %w", err)
}
if len(toolCallsJSON) > 0 && string(toolCallsJSON) != "null" {
raw := json.RawMessage(toolCallsJSON)
m.ToolCalls = &raw
}
if participantType.Valid {
m.ParticipantType = participantType.String
}