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:
+83
-5
@@ -72,12 +72,12 @@ func TestSplitReasoning(t *testing.T) {
|
||||
{"gpt-4o:high", "gpt-4o", ReasoningHigh},
|
||||
{"gpt-4o:low", "gpt-4o", ReasoningLow},
|
||||
{"gpt-4o:medium", "gpt-4o", ReasoningMedium},
|
||||
{"qwen3:30b", "qwen3:30b", ""}, // Ollama tag, not a level
|
||||
{"qwen3:30b", "qwen3:30b", ""}, // Ollama tag, not a level
|
||||
{"qwen3:30b:high", "qwen3:30b", ReasoningHigh}, // tag + reasoning
|
||||
{"model:", "model:", ""}, // trailing colon, empty suffix
|
||||
{"", "", ""}, // empty string
|
||||
{"a:b:c:high", "a:b:c", ReasoningHigh}, // multiple colons
|
||||
{"a:b:c:30b", "a:b:c:30b", ""}, // multiple colons, non-level
|
||||
{"model:", "model:", ""}, // trailing colon, empty suffix
|
||||
{"", "", ""}, // empty string
|
||||
{"a:b:c:high", "a:b:c", ReasoningHigh}, // multiple colons
|
||||
{"a:b:c:30b", "a:b:c:30b", ""}, // multiple colons, non-level
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
@@ -637,3 +637,81 @@ func TestNewRegistryIsolation(t *testing.T) {
|
||||
t.Error("alias registered in r1 should not appear in r2")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comma-separated failover chains
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestParse_CommaProducesFailover(t *testing.T) {
|
||||
resetHealthForTest()
|
||||
r, alpha, beta := testRegistry(func(string) string { return "" })
|
||||
|
||||
m, err := r.Parse("alpha/model-a,beta/model-b")
|
||||
if err != nil {
|
||||
t.Fatalf("Parse failover spec: %v", err)
|
||||
}
|
||||
fp, ok := m.provider.(*failoverProvider)
|
||||
if !ok {
|
||||
t.Fatalf("expected *failoverProvider, got %T", m.provider)
|
||||
}
|
||||
if len(fp.entries) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d", len(fp.entries))
|
||||
}
|
||||
if fp.entries[0].specKey != "alpha/model-a" || fp.entries[1].specKey != "beta/model-b" {
|
||||
t.Errorf("unexpected specKeys: %q, %q", fp.entries[0].specKey, fp.entries[1].specKey)
|
||||
}
|
||||
// Complete routes to the first provider and passes the bare model name.
|
||||
_, err = m.Complete(context.Background(), []Message{{Role: RoleUser, Content: Content{Text: "hi"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if alpha.lastModel != "model-a" {
|
||||
t.Errorf("alpha got model %q, want model-a", alpha.lastModel)
|
||||
}
|
||||
if beta.lastModel != "" {
|
||||
t.Errorf("beta should not have been called, got model %q", beta.lastModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_NoCommaUnchanged(t *testing.T) {
|
||||
resetHealthForTest()
|
||||
r, _, _ := testRegistry(func(string) string { return "" })
|
||||
m, err := r.Parse("alpha/model-a")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := m.provider.(*failoverProvider); ok {
|
||||
t.Error("single (comma-free) spec must NOT produce a failover provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_CommaSinglePartFallsThrough(t *testing.T) {
|
||||
resetHealthForTest()
|
||||
r, _, _ := testRegistry(func(string) string { return "" })
|
||||
// Trailing comma / whitespace collapses to a single real part.
|
||||
m, err := r.Parse("alpha/model-a, ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := m.provider.(*failoverProvider); ok {
|
||||
t.Error("a single effective part must not produce a failover provider")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParse_CommaFlattensNested(t *testing.T) {
|
||||
resetHealthForTest()
|
||||
r, _, _ := testRegistry(func(string) string { return "" })
|
||||
// Register an alias that is itself a comma chain.
|
||||
r.RegisterAlias("pair", "alpha/model-a,beta/model-b")
|
||||
m, err := r.Parse("pair,beta/model-b2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fp, ok := m.provider.(*failoverProvider)
|
||||
if !ok {
|
||||
t.Fatalf("expected *failoverProvider, got %T", m.provider)
|
||||
}
|
||||
if len(fp.entries) != 3 {
|
||||
t.Errorf("expected flattened 3 entries, got %d", len(fp.entries))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user