281 lines
8.0 KiB
Go
281 lines
8.0 KiB
Go
package routing
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
|
|
"git.gobha.me/xcaliber/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)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// ── 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
|
|
}
|