-
-
Notifications
You must be signed in to change notification settings - Fork 63
feat(routing): add canonical model pools and admin controls #352
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,152 @@ | ||
| package config | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "sort" | ||
| "strings" | ||
| "time" | ||
| ) | ||
|
|
||
| type RoutingStrategy string | ||
|
|
||
| const ( | ||
| RoutingStrategyPriorityFailover RoutingStrategy = "priority_failover" | ||
| RoutingStrategyWeightedRoundRobin RoutingStrategy = "weighted_round_robin" | ||
| ) | ||
|
|
||
| func normalizeRoutingStrategy(strategy RoutingStrategy) RoutingStrategy { | ||
| return RoutingStrategy(strings.ToLower(strings.TrimSpace(string(strategy)))) | ||
| } | ||
|
|
||
| func ResolveRoutingStrategy(strategy RoutingStrategy) RoutingStrategy { | ||
| strategy = normalizeRoutingStrategy(strategy) | ||
| if strategy == "" { | ||
| return RoutingStrategyPriorityFailover | ||
| } | ||
| return strategy | ||
| } | ||
|
|
||
| func (s RoutingStrategy) Valid() bool { | ||
| switch normalizeRoutingStrategy(s) { | ||
| case RoutingStrategyPriorityFailover, RoutingStrategyWeightedRoundRobin: | ||
| return true | ||
| default: | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| // RoutingConfig holds canonical model pool routing configuration. | ||
| type RoutingConfig struct { | ||
| Defaults RoutingDefaultsConfig `yaml:"defaults"` | ||
| ModelPools map[string]ModelPoolConfig `yaml:"model_pools"` | ||
| } | ||
|
|
||
| // RoutingDefaultsConfig holds default routing behavior for canonical pools. | ||
| type RoutingDefaultsConfig struct { | ||
| Strategy RoutingStrategy `yaml:"strategy"` | ||
| SessionAffinity bool `yaml:"session_affinity"` | ||
| SessionAffinityTTL time.Duration `yaml:"session_affinity_ttl"` | ||
| Failover RoutingFailoverConfig `yaml:"failover"` | ||
| } | ||
|
|
||
| // RoutingFailoverConfig controls fallback between candidates within the same pool. | ||
| type RoutingFailoverConfig struct { | ||
| Enabled bool `yaml:"enabled"` | ||
| MaxAttempts int `yaml:"max_attempts"` | ||
| RetryOnStatuses []int `yaml:"retry_on_statuses"` | ||
| RetryOnModelErrors bool `yaml:"retry_on_model_errors"` | ||
| } | ||
|
|
||
| // ModelPoolConfig maps one public canonical model name to concrete provider candidates. | ||
| type ModelPoolConfig struct { | ||
| Candidates []ModelPoolCandidateConfig `yaml:"candidates"` | ||
| } | ||
|
|
||
| // ModelPoolCandidateConfig defines one concrete provider/model candidate. | ||
| type ModelPoolCandidateConfig struct { | ||
| Provider string `yaml:"provider"` | ||
| Model string `yaml:"model"` | ||
| Priority int `yaml:"priority"` | ||
| Weight int `yaml:"weight"` | ||
| } | ||
|
|
||
| func loadRoutingConfig(cfg *RoutingConfig) error { | ||
| if cfg == nil { | ||
| return nil | ||
| } | ||
|
|
||
| cfg.Defaults.Strategy = ResolveRoutingStrategy(cfg.Defaults.Strategy) | ||
| if !cfg.Defaults.Strategy.Valid() { | ||
| return fmt.Errorf("routing.defaults.strategy must be one of: priority_failover, weighted_round_robin") | ||
| } | ||
| if cfg.Defaults.SessionAffinityTTL <= 0 { | ||
| cfg.Defaults.SessionAffinityTTL = 30 * time.Minute | ||
| } | ||
|
Comment on lines
+75
to
+84
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The loader restores durations, attempts, and retry statuses when they are omitted, but it does not restore boolean defaults. If a config file includes a partial Context Used: CLAUDE.md (source) |
||
| if cfg.Defaults.Failover.MaxAttempts <= 0 { | ||
| cfg.Defaults.Failover.MaxAttempts = 3 | ||
| } | ||
| if len(cfg.Defaults.Failover.RetryOnStatuses) == 0 { | ||
| cfg.Defaults.Failover.RetryOnStatuses = []int{429, 500, 502, 503, 504} | ||
| } | ||
|
Comment on lines
+88
to
+90
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Validate Invalid HTTP codes (e.g., Proposed fix if len(cfg.Defaults.Failover.RetryOnStatuses) == 0 {
cfg.Defaults.Failover.RetryOnStatuses = []int{429, 500, 502, 503, 504}
}
+ for idx, status := range cfg.Defaults.Failover.RetryOnStatuses {
+ if status < 100 || status > 599 {
+ return fmt.Errorf("routing.defaults.failover.retry_on_statuses[%d] must be a valid HTTP status code (100-599)", idx)
+ }
+ }🤖 Prompt for AI Agents |
||
|
|
||
| if len(cfg.ModelPools) == 0 { | ||
| cfg.ModelPools = nil | ||
| return nil | ||
| } | ||
|
|
||
| normalized := make(map[string]ModelPoolConfig, len(cfg.ModelPools)) | ||
| keys := make([]string, 0, len(cfg.ModelPools)) | ||
| for key := range cfg.ModelPools { | ||
| keys = append(keys, key) | ||
| } | ||
| sort.Strings(keys) | ||
|
|
||
| for _, key := range keys { | ||
| trimmedKey := strings.TrimSpace(key) | ||
| if trimmedKey == "" { | ||
| return fmt.Errorf("routing.model_pools: model key cannot be empty") | ||
| } | ||
| if _, exists := normalized[trimmedKey]; exists { | ||
| return fmt.Errorf("routing.model_pools: duplicate model key after trimming: %q", trimmedKey) | ||
| } | ||
| pool := cfg.ModelPools[key] | ||
| if len(pool.Candidates) == 0 { | ||
| return fmt.Errorf("routing.model_pools[%q]: at least one candidate is required", trimmedKey) | ||
| } | ||
|
|
||
| seenCandidates := make(map[string]struct{}, len(pool.Candidates)) | ||
| normalizedCandidates := make([]ModelPoolCandidateConfig, 0, len(pool.Candidates)) | ||
| for idx, candidate := range pool.Candidates { | ||
| candidate.Provider = strings.TrimSpace(candidate.Provider) | ||
| candidate.Model = strings.TrimSpace(candidate.Model) | ||
| if candidate.Provider == "" { | ||
| return fmt.Errorf("routing.model_pools[%q].candidates[%d].provider is required", trimmedKey, idx) | ||
| } | ||
| if candidate.Model == "" { | ||
| return fmt.Errorf("routing.model_pools[%q].candidates[%d].model is required", trimmedKey, idx) | ||
| } | ||
| candidateKey := candidate.Provider + "/" + candidate.Model | ||
| if _, exists := seenCandidates[candidateKey]; exists { | ||
| return fmt.Errorf("routing.model_pools[%q]: duplicate candidate %q", trimmedKey, candidateKey) | ||
| } | ||
| seenCandidates[candidateKey] = struct{}{} | ||
|
|
||
| switch cfg.Defaults.Strategy { | ||
| case RoutingStrategyPriorityFailover: | ||
| if candidate.Priority <= 0 { | ||
| return fmt.Errorf("routing.model_pools[%q].candidates[%d].priority must be > 0 for priority_failover", trimmedKey, idx) | ||
| } | ||
| case RoutingStrategyWeightedRoundRobin: | ||
| if candidate.Weight <= 0 { | ||
| return fmt.Errorf("routing.model_pools[%q].candidates[%d].weight must be > 0 for weighted_round_robin", trimmedKey, idx) | ||
| } | ||
| } | ||
|
|
||
| normalizedCandidates = append(normalizedCandidates, candidate) | ||
| } | ||
| normalized[trimmedKey] = ModelPoolConfig{Candidates: normalizedCandidates} | ||
| } | ||
|
|
||
| cfg.ModelPools = normalized | ||
| return nil | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Assert loaded failover fields in the routing YAML test.
Line 730 through Line 735 sets failover inputs, but the test only validates strategy, affinity, TTL, and candidate count. A regression in failover parsing could pass unnoticed.
✅ Suggested test assertions
if cfg.Routing.Defaults.SessionAffinityTTL != 45*time.Minute { t.Fatalf("SessionAffinityTTL = %s, want 45m", cfg.Routing.Defaults.SessionAffinityTTL) } + failover := cfg.Routing.Defaults.Failover + if !failover.Enabled { + t.Fatal("expected Failover.Enabled=true from YAML") + } + if failover.MaxAttempts != 5 { + t.Fatalf("Failover.MaxAttempts = %d, want 5", failover.MaxAttempts) + } + if !reflect.DeepEqual(failover.RetryOnStatuses, []int{429, 503}) { + t.Fatalf("Failover.RetryOnStatuses = %v, want [429 503]", failover.RetryOnStatuses) + } + if failover.RetryOnModelErrors { + t.Fatal("expected Failover.RetryOnModelErrors=false from YAML") + } pool := cfg.Routing.ModelPools["claude-sonnet-4-6"]As per coding guidelines: “Add or update tests for behavior changes to cover request translation, response normalization, error handling, default configuration, and provider-specific parameter mapping.”
🤖 Prompt for AI Agents