This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/server/routing/evaluator.go
2026-03-19 18:50:27 +00:00

379 lines
10 KiB
Go

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
}