Files

259 lines
8.3 KiB
Go

package providers
import (
"context"
"errors"
"testing"
)
type testFallbackProvider struct {
name string
model string
err error
streamErr error
calls int
}
func (p *testFallbackProvider) Chat(_ context.Context, req ChatRequest) (*ChatResponse, error) {
p.calls++
if p.err != nil {
return nil, p.err
}
return &ChatResponse{Content: req.Model, FinishReason: "stop"}, nil
}
func (p *testFallbackProvider) ChatStream(_ context.Context, req ChatRequest, onChunk func(StreamChunk)) (*ChatResponse, error) {
p.calls++
if p.streamErr != nil {
if req.Model == "primary-model" {
onChunk(StreamChunk{Content: "partial"})
}
return nil, p.streamErr
}
return &ChatResponse{Content: req.Model, FinishReason: "stop"}, nil
}
func (p *testFallbackProvider) DefaultModel() string { return p.model }
func (p *testFallbackProvider) Name() string { return p.name }
func TestModelFallbackProviderFallsBackOnClassifiedError(t *testing.T) {
primary := &testFallbackProvider{
name: "primary",
model: "primary-model",
err: &HTTPError{Status: 429, Body: "rate limited"},
}
backup := &testFallbackProvider{name: "backup", model: "backup-model"}
provider := NewModelFallbackProvider(FallbackCandidate{
ProviderName: "primary",
Provider: primary,
Model: "primary-model",
}, []FallbackCandidate{
{ProviderName: "backup", Provider: backup, Model: "backup-model"},
}, 2, false)
resp, err := provider.Chat(context.Background(), ChatRequest{})
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if resp.Content != "backup-model" {
t.Fatalf("Chat() content = %q, want backup model", resp.Content)
}
if primary.calls != 1 || backup.calls != 1 {
t.Fatalf("calls primary=%d backup=%d, want 1/1", primary.calls, backup.calls)
}
}
func TestModelFallbackProviderDoesNotFallbackAfterStreamChunk(t *testing.T) {
streamErr := &HTTPError{Status: 429, Body: "rate limited"}
primary := &testFallbackProvider{
name: "primary",
model: "primary-model",
streamErr: streamErr,
}
backup := &testFallbackProvider{name: "backup", model: "backup-model"}
provider := NewModelFallbackProvider(FallbackCandidate{
ProviderName: "primary",
Provider: primary,
Model: "primary-model",
}, []FallbackCandidate{
{ProviderName: "backup", Provider: backup, Model: "backup-model"},
}, 2, false)
var chunks int
_, err := provider.ChatStream(context.Background(), ChatRequest{}, func(StreamChunk) {
chunks++
})
if err == nil {
t.Fatal("ChatStream() error = nil, want primary stream error")
}
if chunks != 1 {
t.Fatalf("chunks = %d, want 1", chunks)
}
if backup.calls != 0 {
t.Fatalf("backup calls = %d, want 0 after partial stream", backup.calls)
}
}
func TestModelFallbackProviderChatStreamWithHookReportsStreamedChunks(t *testing.T) {
streamErr := &HTTPError{Status: 429, Body: "rate limited"}
primary := &testFallbackProvider{
name: "primary",
model: "primary-model",
streamErr: streamErr,
}
provider := NewModelFallbackProvider(FallbackCandidate{
ProviderName: "primary",
Provider: primary,
Model: "primary-model",
}, nil, 1, false)
var streamed bool
_, err := provider.ChatStreamWithHook(context.Background(), ChatRequest{}, func(StreamChunk) {}, func(context.Context, FallbackCandidate, ChatRequest) (FallbackAfterCall, error) {
return func(_ *ChatResponse, _ error, info FallbackCallInfo) {
streamed = info.Streamed
}, nil
})
if err == nil {
t.Fatal("ChatStreamWithHook() error = nil, want stream error")
}
if !streamed {
t.Fatal("FallbackCallInfo.Streamed = false, want true after partial stream")
}
}
func TestModelFallbackProviderFallsBackToSameModelOnDifferentProvider(t *testing.T) {
primary := &testFallbackProvider{
name: "primary",
model: "shared-model",
err: &HTTPError{Status: 404, Body: "model not found"},
}
backup := &testFallbackProvider{name: "backup", model: "shared-model"}
provider := NewModelFallbackProvider(FallbackCandidate{
ProviderName: "primary",
Provider: primary,
Model: "shared-model",
}, []FallbackCandidate{
{ProviderName: "backup", Provider: backup, Model: "shared-model"},
}, 0, false)
resp, err := provider.Chat(context.Background(), ChatRequest{})
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if resp.Content != "shared-model" {
t.Fatalf("Chat() content = %q, want shared model from backup", resp.Content)
}
if primary.calls != 1 || backup.calls != 1 {
t.Fatalf("calls primary=%d backup=%d, want 1/1", primary.calls, backup.calls)
}
}
func TestModelFallbackProviderDoesNotFallbackOnUnknownError(t *testing.T) {
unknownErr := errors.New("request serialization failed")
primary := &testFallbackProvider{
name: "primary",
model: "primary-model",
err: unknownErr,
}
backup := &testFallbackProvider{name: "backup", model: "backup-model"}
provider := NewModelFallbackProvider(FallbackCandidate{
ProviderName: "primary",
Provider: primary,
Model: "primary-model",
}, []FallbackCandidate{
{ProviderName: "backup", Provider: backup, Model: "backup-model"},
}, 0, false)
_, err := provider.Chat(context.Background(), ChatRequest{})
if !errors.Is(err, unknownErr) {
t.Fatalf("Chat() error = %v, want original unknown error", err)
}
if backup.calls != 0 {
t.Fatalf("backup calls = %d, want 0 for unknown error", backup.calls)
}
}
func TestModelFallbackProviderContinuesAfterContentPolicyFallback(t *testing.T) {
primary := &testFallbackProvider{
name: "primary",
model: "primary-model",
err: &HTTPError{Status: 429, Body: "rate limited"},
}
blocked := &testFallbackProvider{
name: "blocked",
model: "blocked-model",
err: &HTTPError{Status: 400, Body: `{"error":{"code":"data_inspection_failed","message":"Input text data may contain inappropriate content."}}`},
}
backup := &testFallbackProvider{name: "backup", model: "backup-model"}
provider := NewModelFallbackProvider(FallbackCandidate{
ProviderName: "primary",
Provider: primary,
Model: "primary-model",
}, []FallbackCandidate{
{ProviderName: "blocked", Provider: blocked, Model: "blocked-model"},
{ProviderName: "backup", Provider: backup, Model: "backup-model"},
}, 0, false)
resp, err := provider.Chat(context.Background(), ChatRequest{})
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if resp.Content != "backup-model" {
t.Fatalf("Chat() content = %q, want backup model", resp.Content)
}
if primary.calls != 1 || blocked.calls != 1 || backup.calls != 1 {
t.Fatalf("calls primary=%d blocked=%d backup=%d, want 1/1/1", primary.calls, blocked.calls, backup.calls)
}
}
func TestModelFallbackProviderFallsBackOnCodexSafetyRefusalString(t *testing.T) {
primary := &testFallbackProvider{
name: "codex-digitop",
model: "gpt-5.5",
err: errors.New("codex: response failed: Invalid prompt: we've limited access to this content for safety reasons"),
}
backup := &testFallbackProvider{name: "anthropic", model: "claude-sonnet-4-5"}
provider := NewModelFallbackProvider(FallbackCandidate{
ProviderName: "codex-digitop",
Provider: primary,
Model: "gpt-5.5",
}, []FallbackCandidate{
{ProviderName: "anthropic", Provider: backup, Model: "claude-sonnet-4-5"},
}, 2, false)
resp, err := provider.Chat(context.Background(), ChatRequest{})
if err != nil {
t.Fatalf("Chat() error = %v", err)
}
if resp.Content != "claude-sonnet-4-5" {
t.Fatalf("Chat() content = %q, want fallback model response", resp.Content)
}
if primary.calls != 1 || backup.calls != 1 {
t.Fatalf("calls primary=%d backup=%d, want 1/1", primary.calls, backup.calls)
}
}
func TestModelFallbackProviderMaxAttemptsCapsTotalAttempts(t *testing.T) {
primary := &testFallbackProvider{
name: "primary",
model: "primary-model",
err: &HTTPError{Status: 429, Body: "rate limited"},
}
backup := &testFallbackProvider{name: "backup", model: "backup-model"}
provider := NewModelFallbackProvider(FallbackCandidate{
ProviderName: "primary",
Provider: primary,
Model: "primary-model",
}, []FallbackCandidate{
{ProviderName: "backup", Provider: backup, Model: "backup-model"},
}, 1, false)
_, err := provider.Chat(context.Background(), ChatRequest{})
if err == nil {
t.Fatal("Chat() error = nil, want exhausted after primary only")
}
if primary.calls != 1 || backup.calls != 0 {
t.Fatalf("calls primary=%d backup=%d, want 1/0", primary.calls, backup.calls)
}
}