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:
+40
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user