Changeset 0.20.0 (#85)
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/events"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/knowledge"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/mentions"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/storage"
|
||||
@@ -218,6 +219,19 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Multi-model @mention routing (v0.20.0) ──────────
|
||||
// Check if the message @mentions specific channel models.
|
||||
// If multiple models are targeted, fan out sequentially.
|
||||
roster, _ := h.stores.Channels.GetModels(c.Request.Context(), channelID)
|
||||
if len(roster) > 1 {
|
||||
parsed := mentions.Parse(req.Content, roster)
|
||||
targets := mentions.ResolvedModels(parsed)
|
||||
if len(targets) > 1 {
|
||||
h.multiModelStream(c, targets, messages, channelID, userID, personaID, presetSystemPrompt, req)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Build provider request
|
||||
provReq := providers.CompletionRequest{
|
||||
Model: model,
|
||||
@@ -257,6 +271,122 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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, presetSystemPrompt 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.APIConfigID = 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) {
|
||||
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools, req.DisabledTools)
|
||||
}
|
||||
|
||||
// Stream this model's response using the shared streaming core
|
||||
result := streamModelResponse(c, provider, providerCfg, &provReq, model, target.DisplayName, userID, channelID, personaID, h.hub)
|
||||
|
||||
// Persist assistant message with model attribution
|
||||
if result.Content != "" {
|
||||
if _, err := h.persistMessage(channelID, userID, "assistant", result.Content, model, result.InputTokens, result.OutputTokens, nil, toolActivityJSON(result.ToolActivity)); 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 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.
|
||||
|
||||
Reference in New Issue
Block a user