Changeset 0.22.2 (#96)

This commit is contained in:
2026-03-02 10:31:18 +00:00
parent cae6fd9f93
commit a44c768741
20 changed files with 1563 additions and 22 deletions

View File

@@ -18,10 +18,12 @@ import (
"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/mentions"
"git.gobha.me/xcaliber/chat-switchboard/models"
"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"
@@ -46,12 +48,14 @@ type completionRequest struct {
// 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 attachment content (nil = disabled)
embedder *knowledge.Embedder // for memory semantic recall (v0.18.0)
health HealthRecorder // provider health tracking (v0.22.0, nil = disabled)
vault *crypto.KeyResolver
stores store.Stores
hub *events.Hub // WebSocket hub for browser tool bridge
objStore storage.ObjectStore // file storage for attachment 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.
@@ -62,6 +66,12 @@ type HealthRecorder interface {
RecordTimeout(providerConfigID string, latencyMs int, errMsg string)
}
// 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}
@@ -72,6 +82,94 @@ 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{}, // TODO: populate from pricing store
}
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
//
@@ -177,6 +275,32 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
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.APIConfigID = 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.
@@ -200,6 +324,15 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
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, presetSystemPrompt, personaID)
if err != nil {