Changeset 0.22.2 (#96)
This commit is contained in:
@@ -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 {
|
||||
|
||||
241
server/handlers/routing_admin.go
Normal file
241
server/handlers/routing_admin.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.gobha.me/xcaliber/chat-switchboard/health"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/routing"
|
||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||
)
|
||||
|
||||
// ── Routing Admin Handler ───────────────────
|
||||
|
||||
type RoutingAdminHandler struct {
|
||||
stores store.Stores
|
||||
evaluator *routing.Evaluator
|
||||
healthStore health.Store
|
||||
}
|
||||
|
||||
func NewRoutingAdminHandler(stores store.Stores, evaluator *routing.Evaluator, hs health.Store) *RoutingAdminHandler {
|
||||
return &RoutingAdminHandler{stores: stores, evaluator: evaluator, healthStore: hs}
|
||||
}
|
||||
|
||||
// ── CRUD ────────────────────────────────────
|
||||
|
||||
// ListPolicies returns all routing policies.
|
||||
// GET /api/v1/admin/routing/policies
|
||||
func (h *RoutingAdminHandler) ListPolicies(c *gin.Context) {
|
||||
if h.stores.RoutingPolicies == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"data": []models.RoutingPolicy{}})
|
||||
return
|
||||
}
|
||||
|
||||
policies, err := h.stores.RoutingPolicies.ListAll(context.Background())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list policies: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if policies == nil {
|
||||
policies = []models.RoutingPolicy{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": policies})
|
||||
}
|
||||
|
||||
// GetPolicy returns a single routing policy.
|
||||
// GET /api/v1/admin/routing/policies/:id
|
||||
func (h *RoutingAdminHandler) GetPolicy(c *gin.Context) {
|
||||
if h.stores.RoutingPolicies == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "routing not available"})
|
||||
return
|
||||
}
|
||||
|
||||
p, err := h.stores.RoutingPolicies.GetByID(context.Background(), c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "policy not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, p)
|
||||
}
|
||||
|
||||
// CreatePolicy creates a new routing policy.
|
||||
// POST /api/v1/admin/routing/policies
|
||||
func (h *RoutingAdminHandler) CreatePolicy(c *gin.Context) {
|
||||
if h.stores.RoutingPolicies == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing not available"})
|
||||
return
|
||||
}
|
||||
|
||||
var p models.RoutingPolicy
|
||||
if err := c.ShouldBindJSON(&p); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate policy type
|
||||
switch routing.PolicyType(p.Type) {
|
||||
case routing.PolicyProviderPrefer, routing.PolicyTeamRoute,
|
||||
routing.PolicyCostLimit, routing.PolicyModelAlias:
|
||||
// valid
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid policy_type: " + p.Type})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate scope
|
||||
if p.Scope != "global" && p.Scope != "team" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be 'global' or 'team'"})
|
||||
return
|
||||
}
|
||||
if p.Scope == "team" && (p.TeamID == nil || *p.TeamID == "") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "team_id required for team-scoped policies"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.RoutingPolicies.Create(context.Background(), &p); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create policy: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, p)
|
||||
}
|
||||
|
||||
// UpdatePolicy updates an existing routing policy.
|
||||
// PUT /api/v1/admin/routing/policies/:id
|
||||
func (h *RoutingAdminHandler) UpdatePolicy(c *gin.Context) {
|
||||
if h.stores.RoutingPolicies == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing not available"})
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
|
||||
// Verify exists
|
||||
if _, err := h.stores.RoutingPolicies.GetByID(context.Background(), id); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "policy not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var p models.RoutingPolicy
|
||||
if err := c.ShouldBindJSON(&p); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
p.ID = id
|
||||
|
||||
if err := h.stores.RoutingPolicies.Update(context.Background(), &p); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update policy: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, p)
|
||||
}
|
||||
|
||||
// DeletePolicy removes a routing policy.
|
||||
// DELETE /api/v1/admin/routing/policies/:id
|
||||
func (h *RoutingAdminHandler) DeletePolicy(c *gin.Context) {
|
||||
if h.stores.RoutingPolicies == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing not available"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.stores.RoutingPolicies.Delete(context.Background(), c.Param("id")); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete policy: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"deleted": true})
|
||||
}
|
||||
|
||||
// ── Dry-Run Test ────────────────────────────
|
||||
|
||||
type routingTestRequest struct {
|
||||
Model string `json:"model" binding:"required"`
|
||||
UserID string `json:"user_id" binding:"required"`
|
||||
TeamIDs []string `json:"team_ids,omitempty"`
|
||||
}
|
||||
|
||||
// TestRouting performs a dry-run policy evaluation for a given model + user.
|
||||
// POST /api/v1/admin/routing/test
|
||||
func (h *RoutingAdminHandler) TestRouting(c *gin.Context) {
|
||||
if h.stores.RoutingPolicies == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "routing not available"})
|
||||
return
|
||||
}
|
||||
|
||||
var req routingTestRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Load active policies
|
||||
dbPolicies, err := h.stores.RoutingPolicies.ListActive(context.Background())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load policies"})
|
||||
return
|
||||
}
|
||||
policies := routing.FromModels(dbPolicies)
|
||||
|
||||
// Build candidates from all active provider configs
|
||||
candidates, err := h.buildCandidates(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build candidates: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Build health status
|
||||
healthStatus := make(map[string]models.ProviderStatus)
|
||||
if h.healthStore != nil {
|
||||
windows, _ := h.healthStore.ListAllCurrent(context.Background())
|
||||
for _, w := range windows {
|
||||
healthStatus[w.ProviderConfigID] = health.DeriveStatus(w.ErrorRate(), w.RequestCount)
|
||||
}
|
||||
}
|
||||
|
||||
ctx := &routing.Context{
|
||||
UserID: req.UserID,
|
||||
TeamIDs: req.TeamIDs,
|
||||
Model: req.Model,
|
||||
HealthStatus: healthStatus,
|
||||
Pricing: map[string]*models.ModelPricing{},
|
||||
}
|
||||
|
||||
result, decision := h.evaluator.Evaluate(ctx, policies, candidates)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"candidates": result,
|
||||
"decision": decision,
|
||||
"policies": len(policies),
|
||||
})
|
||||
}
|
||||
|
||||
// buildCandidates creates routing candidates from all active provider configs.
|
||||
func (h *RoutingAdminHandler) buildCandidates(c *gin.Context) ([]routing.Candidate, error) {
|
||||
if h.stores.Providers == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// For dry-run, list all global configs (admin scope).
|
||||
configs, err := h.stores.Providers.ListGlobal(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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 is applied per-policy or from request
|
||||
Endpoint: cfg.Endpoint,
|
||||
})
|
||||
}
|
||||
return candidates, nil
|
||||
}
|
||||
Reference in New Issue
Block a user