Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
204 lines
5.6 KiB
Go
204 lines
5.6 KiB
Go
package handlers
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"chat-switchboard/events"
|
|
"chat-switchboard/metrics"
|
|
"chat-switchboard/providers"
|
|
"chat-switchboard/sandbox"
|
|
"chat-switchboard/store"
|
|
"chat-switchboard/tools"
|
|
)
|
|
|
|
// recordHealthFn records provider health from standalone streaming functions
|
|
// and updates Prometheus completion metrics (v0.33.0).
|
|
func recordHealthFn(hr HealthRecorder, configID string, start time.Time, err error) {
|
|
duration := time.Since(start)
|
|
latencyMs := int(duration.Milliseconds())
|
|
|
|
if configID != "" {
|
|
metrics.CompletionDuration.WithLabelValues(configID, "").Observe(duration.Seconds())
|
|
}
|
|
|
|
if hr == nil || configID == "" {
|
|
return
|
|
}
|
|
if err != nil {
|
|
errMsg := err.Error()
|
|
status := "error"
|
|
if strings.Contains(errMsg, "HTTP 429") || strings.Contains(errMsg, "rate limit") || strings.Contains(errMsg, "Too Many Requests") {
|
|
hr.RecordRateLimit(configID, latencyMs, errMsg)
|
|
status = "rate_limited"
|
|
} else {
|
|
hr.RecordError(configID, latencyMs, errMsg)
|
|
}
|
|
metrics.CompletionsTotal.WithLabelValues(configID, "", status).Inc()
|
|
} else {
|
|
hr.RecordSuccess(configID, latencyMs)
|
|
metrics.CompletionsTotal.WithLabelValues(configID, "", "success").Inc()
|
|
}
|
|
}
|
|
|
|
// streamWithToolLoop is the SSE streaming entry point for single-model
|
|
// completion with tool execution. Sets SSE headers, delegates to
|
|
// CoreToolLoop with an sseSink, and returns the accumulated result.
|
|
//
|
|
// Used by:
|
|
// - streamCompletion (normal chat — persists via persistMessage)
|
|
// - Regenerate (regen/edit — persists as sibling with tree metadata)
|
|
func streamWithToolLoop(
|
|
c *gin.Context,
|
|
provider providers.Provider,
|
|
cfg providers.ProviderConfig,
|
|
req *providers.CompletionRequest,
|
|
model, providerType, userID, channelID, personaID, workspaceID, configID, teamID string,
|
|
hub *events.Hub,
|
|
health HealthRecorder,
|
|
runner *sandbox.Runner,
|
|
extTools map[string]*store.PackageRegistration,
|
|
) LoopResult {
|
|
// 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)
|
|
|
|
sink := newSSESink(c, model)
|
|
|
|
return CoreToolLoop(c.Request.Context(), LoopConfig{
|
|
Provider: provider,
|
|
Cfg: cfg,
|
|
Req: req,
|
|
Model: model,
|
|
ProviderType: providerType,
|
|
ExecCtx: tools.ExecutionContext{
|
|
UserID: userID,
|
|
ChannelID: channelID,
|
|
PersonaID: personaID,
|
|
WorkspaceID: workspaceID,
|
|
TeamID: teamID,
|
|
},
|
|
Hub: hub,
|
|
Health: health,
|
|
ConfigID: configID,
|
|
Budget: LoopBudget{},
|
|
Streaming: true,
|
|
Runner: runner,
|
|
ExtTools: extTools,
|
|
}, sink)
|
|
}
|
|
|
|
const browserToolTimeout = 30 * time.Second
|
|
|
|
// streamModelResponse is the multi-model streaming variant. Runs on an
|
|
// already-established SSE connection — does NOT set SSE headers or send
|
|
// [DONE]. SSE deltas include a "model_display" field for frontend attribution.
|
|
func streamModelResponse(
|
|
c *gin.Context,
|
|
provider providers.Provider,
|
|
cfg providers.ProviderConfig,
|
|
req *providers.CompletionRequest,
|
|
model, providerType, displayName, userID, channelID, personaID, workspaceID, configID, teamID string,
|
|
hub *events.Hub,
|
|
health HealthRecorder,
|
|
runner *sandbox.Runner,
|
|
extTools map[string]*store.PackageRegistration,
|
|
) LoopResult {
|
|
sink := newSSEModelSink(c, model, displayName)
|
|
|
|
return CoreToolLoop(c.Request.Context(), LoopConfig{
|
|
Provider: provider,
|
|
Cfg: cfg,
|
|
Req: req,
|
|
Model: model,
|
|
ProviderType: providerType,
|
|
ExecCtx: tools.ExecutionContext{
|
|
UserID: userID,
|
|
ChannelID: channelID,
|
|
PersonaID: personaID,
|
|
WorkspaceID: workspaceID,
|
|
TeamID: teamID,
|
|
},
|
|
Hub: hub,
|
|
Health: health,
|
|
ConfigID: configID,
|
|
Budget: LoopBudget{},
|
|
Streaming: true,
|
|
Runner: runner,
|
|
ExtTools: extTools,
|
|
}, sink)
|
|
}
|
|
|
|
// executeBrowserTool sends a tool call to the user's browser via WebSocket
|
|
// and blocks until a result is returned or the timeout elapses.
|
|
func executeBrowserTool(hub *events.Hub, userID string, call tools.ToolCall) tools.ToolResult {
|
|
b := make([]byte, 16)
|
|
rand.Read(b)
|
|
callID := hex.EncodeToString(b)
|
|
|
|
// Publish tool.call event to the user's browser
|
|
payload, _ := json.Marshal(map[string]interface{}{
|
|
"call_id": callID,
|
|
"tool": call.Name,
|
|
"arguments": json.RawMessage(call.Arguments),
|
|
})
|
|
|
|
hub.PublishToUser(userID, events.Event{
|
|
Label: "tool.call." + callID,
|
|
Payload: payload,
|
|
Ts: time.Now().UnixMilli(),
|
|
})
|
|
|
|
// Block until browser sends tool.result.{callID} or timeout
|
|
event, ok := hub.GetBus().WaitFor("tool.result."+callID, browserToolTimeout)
|
|
if !ok {
|
|
log.Printf("⚠️ Browser tool %s timed out after %v (call %s)", call.Name, browserToolTimeout, callID)
|
|
return tools.ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Content: `{"error":"browser tool timed out (30s) — browser may be disconnected"}`,
|
|
IsError: true,
|
|
}
|
|
}
|
|
|
|
// Parse result from the browser event payload
|
|
var result struct {
|
|
CallID string `json:"call_id"`
|
|
Result string `json:"result"`
|
|
Error string `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(event.Payload, &result); err != nil {
|
|
return tools.ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Content: `{"error":"invalid result from browser tool"}`,
|
|
IsError: true,
|
|
}
|
|
}
|
|
|
|
if result.Error != "" {
|
|
return tools.ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Content: result.Error,
|
|
IsError: true,
|
|
}
|
|
}
|
|
|
|
return tools.ToolResult{
|
|
ToolCallID: call.ID,
|
|
Name: call.Name,
|
|
Content: result.Result,
|
|
}
|
|
}
|