package llm import ( "context" "errors" "log/slog" "testing" ) // fakeLLM — дублёр интерфейса (стиль донора): фиксированный ответ или ошибка. type fakeLLM struct { resp *LLMResponse err error calls int } func (f *fakeLLM) Complete(_ context.Context, _ LLMRequest) (*LLMResponse, error) { f.calls++ if f.err != nil { return nil, f.err } return f.resp, nil } func quietLogger() *slog.Logger { return slog.New(slog.DiscardHandler) } // newTestFailover собирает декоратор без пробера (healthy выставляется руками // через noteProbe) — детерминированная машина брейкера, как в failover_test // донора. func newTestFailover(primary, fallback LLMClient) *failoverClient { return &failoverClient{ primary: primary, fallback: fallback, log: quietLogger(), legTimeout: 1e9, needOK: 1, } } func TestFailoverUnhealthyGoesStraightToCloud(t *testing.T) { local := &fakeLLM{resp: &LLMResponse{Text: "local"}} cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}} f := newTestFailover(local, cloud) // healthy=false на старте resp, err := f.Complete(context.Background(), LLMRequest{}) if err != nil || resp.Text != "cloud" { t.Fatalf("resp=%v err=%v", resp, err) } if local.calls != 0 { t.Fatal("unhealthy leg must not be tried") } } func TestFailoverHealthyServesLocal(t *testing.T) { local := &fakeLLM{resp: &LLMResponse{Text: "local", Model: "local-model"}} cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}} f := newTestFailover(local, cloud) f.noteProbe(true) resp, err := f.Complete(context.Background(), LLMRequest{}) if err != nil || resp.Text != "local" { t.Fatalf("resp=%v err=%v", resp, err) } if cloud.calls != 0 { t.Fatal("cloud must not be billed when local serves") } } func TestFailoverEmptyLocalRetriesOnCloud(t *testing.T) { // Пустой 2xx локальной ноги (thinking съел бюджет) — бесплатный, поэтому // ретраим в облако; брейкер НЕ срабатывает. local := &fakeLLM{resp: &LLMResponse{Text: " "}} cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}} f := newTestFailover(local, cloud) f.noteProbe(true) resp, err := f.Complete(context.Background(), LLMRequest{}) if err != nil || resp.Text != "cloud" { t.Fatalf("resp=%v err=%v", resp, err) } if !f.isHealthy() { t.Fatal("empty content must not trip the breaker") } } func TestFailoverTerminal4xxFailsLoud(t *testing.T) { // Терминальный 4xx локальной ноги — конфиг-ошибка (неверный тег модели); // облако её маскировать не должно. local := &fakeLLM{err: &HTTPStatusError{Provider: "local", Status: 404, Body: "no such model"}} cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}} f := newTestFailover(local, cloud) f.noteProbe(true) _, err := f.Complete(context.Background(), LLMRequest{}) var se *HTTPStatusError if err == nil || !errors.As(err, &se) || se.Status != 404 { t.Fatalf("want loud 404, got %v", err) } if cloud.calls != 0 { t.Fatal("terminal 4xx must not be masked by the cloud") } } func TestFailover5xxTripsBreakerAndFallsBack(t *testing.T) { local := &fakeLLM{err: &HTTPStatusError{Provider: "local", Status: 502, Body: "boom"}} cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}} f := newTestFailover(local, cloud) f.noteProbe(true) resp, err := f.Complete(context.Background(), LLMRequest{}) if err != nil || resp.Text != "cloud" { t.Fatalf("resp=%v err=%v", resp, err) } if f.isHealthy() { t.Fatal("5xx must trip the breaker") } // Анти-flapping: после request-trip одной успешной пробы мало. f.noteProbe(true) if f.isHealthy() { t.Fatal("one probe success must not re-close a request-tripped breaker") } f.noteProbe(true) if !f.isHealthy() { t.Fatal("two consecutive probe successes must re-close the breaker") } } func Test429FallsBackLikeTimeout(t *testing.T) { // 429 — занятый single-slot GPU, падает в облако как таймаут (не как // терминальный 4xx). local := &fakeLLM{err: &HTTPStatusError{Provider: "local", Status: 429, Body: "busy"}} cloud := &fakeLLM{resp: &LLMResponse{Text: "cloud"}} f := newTestFailover(local, cloud) f.noteProbe(true) resp, err := f.Complete(context.Background(), LLMRequest{}) if err != nil || resp.Text != "cloud" { t.Fatalf("resp=%v err=%v", resp, err) } }