feat(failover): model failover chains via comma-separated specs

Parse("a,b,c") now returns one composite *llm.Model that tries each model
in order, retrying transient failures, benching dead models, and failing
over to the next. Comma-free specs are completely unchanged.

- classify.go: Classify(err) ErrKind + IsTransient(err) error classifier
  mapping anthropic (typed Is*Err helpers + RequestError status),
  openai-go (*openai.Error status), openaicompat.FeatureUnsupportedError,
  context errors, and ollama "HTTP <code>" strings to
  transient/auth-dead/request-specific/unknown.
- failover.go: failoverProvider (satisfies provider.Provider) wrapped into a
  *Model via NewClient. Process-wide mutex-guarded modelHealth bench
  registry keyed by concrete spec, with cooldowns and a control API
  (ListBenched/BenchModel/UnbenchModel/IsBenched). NewFailoverModel +
  ParseChain constructors, FailoverOption config, FailoverObserver (carries
  the full request), and configurable package-level defaults.
- parse.go: comma-aware Parse splits into a failover chain; alias/resolver
  targets that expand to comma chains are routed through the comma-aware
  path and flattened.

All access to global health is mutex-guarded; tests reset it via
resetHealthForTest and pass under go test -race.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 00:30:08 +02:00
parent 67c3ebe067
commit ae8e194fad
6 changed files with 1335 additions and 5 deletions
+40
View File
@@ -109,6 +109,25 @@ func Parse(spec string) (*Model, error) {
// What: walks the resolution chain and returns the final *Model with reasoning applied.
// Test: see parse_test.go for comprehensive table-driven tests.
func (r *Registry) Parse(spec string) (*Model, error) {
// Comma-separated specs become an ordered failover chain. A single part
// (after trimming/dropping empties) falls through to normal single-model
// parsing, preserving exact existing behavior for comma-free specs.
if strings.Contains(spec, ",") {
var parts []string
for _, p := range strings.Split(spec, ",") {
if p = strings.TrimSpace(p); p != "" {
parts = append(parts, p)
}
}
if len(parts) == 0 {
return nil, fmt.Errorf("%w: empty failover spec %q", ErrUnknownProvider, spec)
}
if len(parts) > 1 {
return r.ParseChain(parts)
}
spec = parts[0]
}
m, level, err := r.parse(spec, 0)
if err != nil {
return nil, err
@@ -134,6 +153,15 @@ func (r *Registry) parse(spec string, depth int) (*Model, ReasoningLevel, error)
target, isAlias := r.aliases[base]
r.mu.RUnlock()
if isAlias {
// An alias may expand to a comma-separated failover chain; route those
// through the comma-aware public Parse so the chain is built correctly.
if strings.Contains(target, ",") {
m, err := r.Parse(target)
if err != nil {
return nil, "", err
}
return m, userLevel, nil
}
m, aliasLevel, err := r.parse(target, depth+1)
if err != nil {
return nil, "", err
@@ -155,6 +183,18 @@ func (r *Registry) parse(spec string, depth int) (*Model, ReasoningLevel, error)
if resolved == "" {
return nil, "", fmt.Errorf("resolver returned empty spec for %q", base)
}
// A resolver may return a comma-separated failover chain.
if strings.Contains(resolved, ",") {
m, err := r.Parse(resolved)
if err != nil {
return nil, "", err
}
level := defaultLevel
if userLevel != "" {
level = userLevel
}
return m, level, nil
}
m, resolvedLevel, err := r.parse(resolved, depth+1)
if err != nil {
return nil, "", err