package routing import ( "sort" "strings" "chat-switchboard/models" ) // Evaluator processes routing policies to produce a ranked candidate list. type Evaluator struct{} // NewEvaluator creates a new routing evaluator. func NewEvaluator() *Evaluator { return &Evaluator{} } // Evaluate applies active policies to the available candidates and returns // a ranked list. Candidates are filtered and reordered based on policy rules // and health status. The first candidate in the result is the preferred choice. // // Policies are evaluated in priority order (lower priority number = first). // The first matching policy wins — subsequent policies of the same type are skipped. func (e *Evaluator) Evaluate(ctx *Context, policies []Policy, candidates []Candidate) ([]Candidate, *Decision) { if len(candidates) == 0 { return nil, nil } // Start with all candidates result := make([]Candidate, len(candidates)) copy(result, candidates) var decision Decision decision.Candidates = len(candidates) // Sort policies by priority sorted := make([]Policy, len(policies)) copy(sorted, policies) sort.Slice(sorted, func(i, j int) bool { return sorted[i].Priority < sorted[j].Priority }) // Evaluate policies in priority order appliedTypes := map[PolicyType]bool{} for _, p := range sorted { if !p.IsActive { continue } // Skip if a policy of this type already matched if appliedTypes[p.Type] { continue } // Scope check: global applies to all; team only to matching teams if p.Scope == "team" && p.TeamID != nil { if !contains(ctx.TeamIDs, *p.TeamID) { continue } } matched := false switch p.Type { case PolicyProviderPrefer: result, matched = applyProviderPrefer(result, p.Config) case PolicyTeamRoute: result, matched = applyTeamRoute(result, p.Config) case PolicyCostLimit: result, matched = applyCostLimit(result, p.Config, ctx) case PolicyModelAlias: result, matched = applyModelAlias(result, p.Config, ctx) case PolicyCapabilityMatch: result, matched = applyCapabilityMatch(result, p.Config, ctx) } if matched { appliedTypes[p.Type] = true decision.PolicyID = p.ID decision.PolicyName = p.Name decision.PolicyType = string(p.Type) } } // Global health-aware sorting: healthy > degraded > unknown > down. // Also skip "down" providers if any policy requested it, or by default // when there are healthy alternatives. skipDown := anyPolicySkipsDown(sorted) result = sortByHealth(result, ctx.HealthStatus, skipDown) if len(result) > 0 { decision.Selected = result[0].ConfigID } return result, &decision } // ── Policy Implementations ────────────────── // applyProviderPrefer reorders candidates to match the preferred provider list. // Providers not in the preferred list are appended after, in original order. func applyProviderPrefer(candidates []Candidate, cfg PolicyConfig) ([]Candidate, bool) { if len(cfg.PreferredProviders) == 0 { return candidates, false } // Build index: configID → position in preferred list rank := make(map[string]int, len(cfg.PreferredProviders)) for i, id := range cfg.PreferredProviders { rank[id] = i } preferred := make([]Candidate, 0, len(candidates)) rest := make([]Candidate, 0) for _, c := range candidates { if _, ok := rank[c.ConfigID]; ok { c.Reason = "preferred provider" preferred = append(preferred, c) } else { c.Reason = "fallback" rest = append(rest, c) } } // Sort preferred by their order in the preference list sort.Slice(preferred, func(i, j int) bool { return rank[preferred[i].ConfigID] < rank[preferred[j].ConfigID] }) return append(preferred, rest...), len(preferred) > 0 } // applyTeamRoute filters candidates to only allowed providers for this team. func applyTeamRoute(candidates []Candidate, cfg PolicyConfig) ([]Candidate, bool) { if len(cfg.AllowedProviders) == 0 { return candidates, false } allowed := make(map[string]bool, len(cfg.AllowedProviders)) for _, id := range cfg.AllowedProviders { allowed[id] = true } filtered := make([]Candidate, 0, len(candidates)) for _, c := range candidates { if allowed[c.ConfigID] { c.Reason = "team-allowed provider" filtered = append(filtered, c) } } // Only apply if at least one allowed provider is available if len(filtered) > 0 { return filtered, true } return candidates, false } // applyCostLimit removes candidates whose estimated cost exceeds the limit. func applyCostLimit(candidates []Candidate, cfg PolicyConfig, ctx *Context) ([]Candidate, bool) { if cfg.MaxCostUSD == nil || *cfg.MaxCostUSD <= 0 { return candidates, false } // For cost estimation we'd need request token count, which isn't // available at routing time. Instead, filter out providers with // pricing above the threshold per-million-tokens as a heuristic. // A 4K-token request at $10/M = $0.04, so if max is $0.10 we'd // filter out providers above ~$25/M. For now, just ensure pricing // exists and tag the decision. maxPerM := *cfg.MaxCostUSD * 1_000_000 / 4096 // rough heuristic: 4K tokens avg filtered := make([]Candidate, 0, len(candidates)) for _, c := range candidates { key := c.ConfigID + ":" + c.Model if pricing, ok := ctx.Pricing[key]; ok && pricing != nil { if pricing.InputPerM > maxPerM { continue // too expensive } } filtered = append(filtered, c) } if len(filtered) > 0 && len(filtered) < len(candidates) { return filtered, true } return candidates, false } // applyModelAlias replaces the model in matching candidates. func applyModelAlias(candidates []Candidate, cfg PolicyConfig, ctx *Context) ([]Candidate, bool) { if cfg.AliasFrom == "" || cfg.AliasTo == "" { return candidates, false } // Only apply if the requested model matches the alias source if !strings.EqualFold(ctx.Model, cfg.AliasFrom) { return candidates, false } // If a specific provider is targeted, reorder to prefer it if cfg.AliasProvider != "" { for i, c := range candidates { if c.ConfigID == cfg.AliasProvider { candidates[i].Model = cfg.AliasTo candidates[i].Reason = "model alias: " + cfg.AliasFrom + " → " + cfg.AliasTo // Move to front reordered := make([]Candidate, 0, len(candidates)) reordered = append(reordered, candidates[i]) reordered = append(reordered, candidates[:i]...) reordered = append(reordered, candidates[i+1:]...) return reordered, true } } } // No specific provider — just rewrite the model on all candidates for i := range candidates { candidates[i].Model = cfg.AliasTo candidates[i].Reason = "model alias: " + cfg.AliasFrom + " → " + cfg.AliasTo } return candidates, true } // applyCapabilityMatch filters candidates to those whose model has all // required capabilities. Optionally sorts remaining candidates by cheapest // output price when PreferCheapest is set. // // Required capabilities map to ModelCapabilities boolean fields: // "tool_calling", "vision", "thinking", "reasoning", // "code_optimized", "web_search", "streaming" func applyCapabilityMatch(candidates []Candidate, cfg PolicyConfig, ctx *Context) ([]Candidate, bool) { if len(cfg.RequiredCaps) == 0 { return candidates, false } if ctx.Capabilities == nil { return candidates, false } filtered := make([]Candidate, 0, len(candidates)) for _, c := range candidates { key := c.ConfigID + ":" + c.Model caps, ok := ctx.Capabilities[key] if !ok { continue // no capability data — skip (conservative) } if hasAllCaps(caps, cfg.RequiredCaps) { c.Reason = "capability match" filtered = append(filtered, c) } } if len(filtered) == 0 { // No candidates match — fall back to original set rather than // returning empty (better to try than fail immediately). return candidates, false } // Sort by cheapest output price if requested if cfg.PreferCheapest && ctx.Pricing != nil { sort.SliceStable(filtered, func(i, j int) bool { ki := filtered[i].ConfigID + ":" + filtered[i].Model kj := filtered[j].ConfigID + ":" + filtered[j].Model pi, oki := ctx.Pricing[ki] pj, okj := ctx.Pricing[kj] if !oki || pi == nil { return false // unknown price sorts last } if !okj || pj == nil { return true } return pi.OutputPerM < pj.OutputPerM }) } return filtered, true } // hasAllCaps checks whether a ModelCapabilities struct has all requested // capability flags set. Unrecognized capability names are ignored. func hasAllCaps(caps *models.ModelCapabilities, required []string) bool { if caps == nil { return false } for _, req := range required { switch req { case "tool_calling": if !caps.ToolCalling { return false } case "vision": if !caps.Vision { return false } case "thinking": if !caps.Thinking { return false } case "reasoning": if !caps.Reasoning { return false } case "code_optimized": if !caps.CodeOptimized { return false } case "web_search": if !caps.WebSearch { return false } case "streaming": if !caps.Streaming { return false } // Unrecognized caps are ignored (forward-compatible) } } return true } // ── Health Sorting ────────────────────────── var statusOrder = map[models.ProviderStatus]int{ models.StatusHealthy: 0, models.StatusDegraded: 1, models.StatusUnknown: 2, models.StatusDown: 3, } func sortByHealth(candidates []Candidate, health map[string]models.ProviderStatus, skipDown bool) []Candidate { // Annotate status for i, c := range candidates { if s, ok := health[c.ConfigID]; ok { candidates[i].Status = s } else { candidates[i].Status = models.StatusUnknown } } // Filter down if requested and alternatives exist if skipDown { healthy := make([]Candidate, 0, len(candidates)) for _, c := range candidates { if c.Status != models.StatusDown { healthy = append(healthy, c) } } if len(healthy) > 0 { candidates = healthy } // If all are down, keep them — better to try than fail immediately. } // Stable sort by health status sort.SliceStable(candidates, func(i, j int) bool { return statusOrder[candidates[i].Status] < statusOrder[candidates[j].Status] }) return candidates } // ── Helpers ───────────────────────────────── func contains(slice []string, item string) bool { for _, s := range slice { if s == item { return true } } return false } func anyPolicySkipsDown(policies []Policy) bool { for _, p := range policies { if p.IsActive && p.Config.SkipDown { return true } } return false }