A free tier's ceiling is tokens per MINUTE, and one run can exceed a whole
minute's worth by itself: a three-call run measured 12,123 against a ceiling of
8,000. withRetry already fires three times and all three are refused, because
1.6 seconds of backoff does not buy back a minute's budget. The run ends
GatewayFailure and somebody reads "the model did not answer".
Retrying harder cannot fix a ceiling. Asking somebody else can — the ceilings
are per provider, so a second key is a second budget. Groq, Cerebras, Gemini,
Mistral and OpenRouter all serve the same chat-completions shape, which is why
this is a list of Configs and not a second implementation.
Configured as MODEL_FALLBACK_<n>_BASE_URL / _API_KEY / _FAST / _BALANCED /
_DEEP, numbered because five fields times three providers packed into one
delimited string is a parser nobody can read under pressure. Empty is the
ordinary case and returns the primary unwrapped, so a single-provider
deployment carries no wrapper and behaves exactly as before.
Failover is NOT unconditional, and the two guards are the design:
- Only a transient failure moves. Error.Retryable() already draws that line
for retries and it is the same line here. A 401 is this deployment's own
credential and a 400 is a malformed request; both fail identically at every
vendor, so trying three turns one visible fault into three invisible ones.
- Only an unpinned conversation moves. ToolCall.Extra carries provider
metadata echoed back verbatim — Gemini 3's thought signature — and a vendor
rejects a follow-up that drops its own. A conversation carrying any belongs
to whoever started it, so failover is available on the first model call,
which is where a rate limit usually lands anyway.
Streaming falls over only before the first fragment: once text is in the
reader's window, a second provider would continue that sentence in a different
voice.
What this does not do, since the gap is where the next bug lives: it does not
make a run cheaper, does not raise any one ceiling, and does not help when every
provider is exhausted at once. It turns one busy provider into a slower answer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
122 lines
3.7 KiB
Go
122 lines
3.7 KiB
Go
package gateway
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"testing"
|
|
)
|
|
|
|
type scripted struct {
|
|
name string
|
|
err error
|
|
calls *[]string
|
|
}
|
|
|
|
func (s *scripted) Complete(ctx context.Context, req Request) (*Response, error) {
|
|
*s.calls = append(*s.calls, s.name)
|
|
if s.err != nil {
|
|
return nil, s.err
|
|
}
|
|
return &Response{Text: "answered by " + s.name, Model: s.name}, nil
|
|
}
|
|
|
|
func gwErr(code string, status int) error {
|
|
return &Error{Code: code, Status: status, Message: code}
|
|
}
|
|
|
|
func TestFailoverAsksTheNextProviderOnARateLimit(t *testing.T) {
|
|
var calls []string
|
|
f := NewFailover(
|
|
&scripted{name: "groq", err: gwErr(CodeRateLimited, 429), calls: &calls},
|
|
&scripted{name: "cerebras", calls: &calls},
|
|
)
|
|
resp, err := f.Complete(context.Background(), Request{
|
|
Messages: []Message{{Role: RoleUser, Text: "hello"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("want an answer from the fallback, got %v", err)
|
|
}
|
|
if resp.Model != "cerebras" {
|
|
t.Errorf("answered by %q, want cerebras", resp.Model)
|
|
}
|
|
if len(calls) != 2 || calls[0] != "groq" {
|
|
t.Errorf("provider order was %v, want groq then cerebras", calls)
|
|
}
|
|
}
|
|
|
|
func TestFailoverDoesNotMaskABadCredential(t *testing.T) {
|
|
// A 401 is THIS deployment's own configuration and fails identically
|
|
// everywhere. Trying three providers would turn one visible fault into
|
|
// three invisible ones and leave the operator nothing to fix.
|
|
var calls []string
|
|
f := NewFailover(
|
|
&scripted{name: "groq", err: gwErr(CodeUnauthorized, 401), calls: &calls},
|
|
&scripted{name: "cerebras", calls: &calls},
|
|
)
|
|
_, err := f.Complete(context.Background(), Request{
|
|
Messages: []Message{{Role: RoleUser, Text: "hello"}},
|
|
})
|
|
var e *Error
|
|
if !errors.As(err, &e) || e.Code != CodeUnauthorized {
|
|
t.Fatalf("want the unauthorized error raised, got %v", err)
|
|
}
|
|
if len(calls) != 1 {
|
|
t.Errorf("called %v; a terminal error must not reach the fallback", calls)
|
|
}
|
|
}
|
|
|
|
func TestFailoverWillNotMoveAConversationBoundToItsProvider(t *testing.T) {
|
|
// ToolCall.Extra is provider metadata echoed back verbatim — Gemini's
|
|
// thought signature. Replaying it at a different vendor sends it a field it
|
|
// cannot read; dropping it kills the vendor that issued it. Either way the
|
|
// conversation belongs to whoever started it.
|
|
var calls []string
|
|
f := NewFailover(
|
|
&scripted{name: "gemini", err: gwErr(CodeRateLimited, 429), calls: &calls},
|
|
&scripted{name: "groq", calls: &calls},
|
|
)
|
|
_, err := f.Complete(context.Background(), Request{
|
|
Messages: []Message{
|
|
{Role: RoleUser, Text: "how many open positions?"},
|
|
{Role: RoleAssistant, ToolCalls: []ToolCall{{
|
|
ID: "c1", Name: "open_positions",
|
|
Input: json.RawMessage(`{}`),
|
|
Extra: json.RawMessage(`{"thought_signature":"abc"}`),
|
|
}}},
|
|
},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("want the rate limit raised, not a second provider's answer")
|
|
}
|
|
if len(calls) != 1 {
|
|
t.Errorf("called %v; a pinned conversation must not fail over", calls)
|
|
}
|
|
}
|
|
|
|
func TestFailoverWithNoFallbacksIsTheProviderItself(t *testing.T) {
|
|
var calls []string
|
|
p := &scripted{name: "groq", calls: &calls}
|
|
if got := NewFailover(p); got != Gateway(p) {
|
|
t.Error("with no fallbacks the primary must be returned unwrapped")
|
|
}
|
|
}
|
|
|
|
func TestFailoverExhaustedReturnsTheLastError(t *testing.T) {
|
|
var calls []string
|
|
f := NewFailover(
|
|
&scripted{name: "a", err: gwErr(CodeRateLimited, 429), calls: &calls},
|
|
&scripted{name: "b", err: gwErr(CodeUpstream, 503), calls: &calls},
|
|
)
|
|
_, err := f.Complete(context.Background(), Request{
|
|
Messages: []Message{{Role: RoleUser, Text: "hi"}},
|
|
})
|
|
var e *Error
|
|
if !errors.As(err, &e) || e.Status != 503 {
|
|
t.Fatalf("want the LAST provider's error, got %v", err)
|
|
}
|
|
if len(calls) != 2 {
|
|
t.Errorf("called %v, want both tried", calls)
|
|
}
|
|
}
|