textmachine/backend/internal/llm/pack12_transport_test.go

239 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package llm
import (
"bytes"
"context"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
// pack12_transport_test.go — drift-guards and behaviour pins for the research/21
// transport-hygiene pack (points 2, 3, 5, 6, 8a). Each is wire-neutral: none asserts
// on the request BODY, only on retry/terminal classification, backoff parsing, usage
// accounting and jitter bounds.
// TestRetryableStatusMap pins the ENTIRE status→retryable map (point 8а): a new status
// class cannot silently change retry policy without failing this test. 429 and 5xx are
// transient; a credits-exhausted 429 body is terminal; every other non-2xx is terminal.
func TestRetryableStatusMap(t *testing.T) {
plain := []byte(`{"error":{"message":"slow down"}}`)
quota := []byte(`{"error":{"code":"insufficient_quota","message":"you exceeded your current quota"}}`)
cases := []struct {
status int
body []byte
want bool
}{
{http.StatusBadRequest, plain, false}, // 400
{http.StatusUnauthorized, plain, false}, // 401
{http.StatusPaymentRequired, plain, false}, // 402 — credits, terminal
{http.StatusForbidden, plain, false}, // 403
{http.StatusNotFound, plain, false}, // 404
{http.StatusRequestTimeout, plain, false}, // 408 — we do NOT retry (codex-style avoided)
{http.StatusConflict, plain, false}, // 409
{http.StatusUnprocessableEntity, plain, false}, // 422 (Mistral extra_forbidden class)
{http.StatusTooManyRequests, plain, true}, // 429 plain → retry
{http.StatusTooManyRequests, quota, false}, // 429 credits exhausted → terminal
{http.StatusInternalServerError, plain, true}, // 500
{http.StatusBadGateway, plain, true}, // 502
{http.StatusServiceUnavailable, plain, true}, // 503
{529, plain, true}, // Anthropic overloaded (≥500)
}
for _, c := range cases {
if got := retryableStatus(c.status, c.body); got != c.want {
t.Errorf("retryableStatus(%d) = %v, want %v", c.status, got, c.want)
}
}
}
// TestQuota429IsTerminal (point 2): a 429 whose body marks credits exhausted fails loud
// in ONE attempt (no 3× retries under a held USD reservation), while a plain 429 still
// retries. Both narrow markers are exercised.
func TestQuota429IsTerminal(t *testing.T) {
for _, marker := range []string{"insufficient_quota", "quota_exceeded"} {
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte(`{"error":{"code":"` + marker + `","message":"credits gone"}}`))
}))
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, Profile: fastProfile()}, nil)
_, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
var se *HTTPStatusError
if !errors.As(err, &se) || se.Status != http.StatusTooManyRequests {
t.Fatalf("%s: want typed 429, got %v", marker, err)
}
if calls.Load() != 1 {
t.Fatalf("%s: a credits-exhausted 429 must be terminal (1 call), got %d", marker, calls.Load())
}
srv.Close()
}
// A plain 429 (no credits marker) still retries to exhaustion.
var calls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte(`{"error":{"message":"rate limited, slow down"}}`))
}))
defer srv.Close()
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, Profile: fastProfile()}, nil)
_, _ = c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
if calls.Load() != 3 {
t.Fatalf("a plain 429 must stay retryable (3 attempts), got %d", calls.Load())
}
}
// TestParseRetryAfterFractionalAndMs (point 3): Retry-After-Ms wins; fractional and
// integer seconds parse; an HTTP-date parses; anything else is 0. The old strconv.Atoi
// dropped "1.5"/ms → 0 → blind backoff.
func TestParseRetryAfterFractionalAndMs(t *testing.T) {
mk := func(kv ...string) http.Header {
h := http.Header{}
for i := 0; i+1 < len(kv); i += 2 {
h.Set(kv[i], kv[i+1])
}
return h
}
cases := []struct {
name string
h http.Header
want time.Duration
}{
{"ms wins over seconds", mk("Retry-After-Ms", "1500", "Retry-After", "9"), 1500 * time.Millisecond},
{"fractional ms", mk("Retry-After-Ms", "250.5"), 250500 * time.Microsecond},
{"fractional seconds", mk("Retry-After", "1.5"), 1500 * time.Millisecond},
{"integer seconds", mk("Retry-After", "3"), 3 * time.Second},
{"empty", mk(), 0},
{"garbage", mk("Retry-After", "soon"), 0},
{"zero ms ignored, falls to seconds", mk("Retry-After-Ms", "0", "Retry-After", "2"), 2 * time.Second},
}
for _, c := range cases {
if got := parseRetryAfter(c.h); got != c.want {
t.Errorf("%s: parseRetryAfter = %v, want %v", c.name, got, c.want)
}
}
// HTTP-date form resolves to a positive duration near the delta.
future := time.Now().Add(4 * time.Second).UTC().Format(http.TimeFormat)
if d := parseRetryAfter(mk("Retry-After", future)); d <= 0 || d > 5*time.Second {
t.Errorf("HTTP-date Retry-After = %v, want ~4s", d)
}
}
// TestAdditiveReasoningIdentityGuard (point 6): xAI additive reasoning is surfaced only
// when the identity total==prompt+completion+reasoning holds (or total is unreported);
// a broken identity means reasoning is already inside completion → treat as subset (0)
// and WARN, so the ledger never double-bills the (latent) 3044% overcount.
func TestAdditiveReasoningIdentityGuard(t *testing.T) {
serve := func(body string) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { openAIOK(t, w, body) }))
}
call := func(srv *httptest.Server, logger *slog.Logger) *LLMResponse {
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "xai", BaseURL: srv.URL, Profile: fastProfile(), Reasoning: ReasoningAdditive}, logger)
resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
if err != nil {
t.Fatal(err)
}
return resp
}
// Identity holds: total=100 == prompt10 + completion40 + reasoning50 → surface 50.
srv := serve(`{"id":"a","choices":[{"message":{"content":"x"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":10,"completion_tokens":40,"total_tokens":100,"completion_tokens_details":{"reasoning_tokens":50}}}`)
if r := call(srv, nil); r.Usage.ReasoningTokens != 50 {
t.Fatalf("identity holds → reasoning must be 50, got %d", r.Usage.ReasoningTokens)
}
srv.Close()
// Identity BROKEN: total=60 ≠ 10+40+50=100 → reasoning already in completion → 0 + WARN.
var buf bytes.Buffer
logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}))
srv = serve(`{"id":"b","choices":[{"message":{"content":"x"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":10,"completion_tokens":40,"total_tokens":60,"completion_tokens_details":{"reasoning_tokens":50}}}`)
if r := call(srv, logger); r.Usage.ReasoningTokens != 0 {
t.Fatalf("broken identity → reasoning must be 0 (subset guard), got %d", r.Usage.ReasoningTokens)
}
srv.Close()
if !strings.Contains(buf.String(), "additive-reasoning identity broken") {
t.Fatalf("a broken identity must WARN as a quirk candidate, logs:\n%s", buf.String())
}
// Total unreported (0): unverifiable → keep the known xAI additive default.
srv = serve(`{"id":"c","choices":[{"message":{"content":"x"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":10,"completion_tokens":40,"completion_tokens_details":{"reasoning_tokens":50}}}`)
if r := call(srv, nil); r.Usage.ReasoningTokens != 50 {
t.Fatalf("total unreported → keep additive default 50, got %d", r.Usage.ReasoningTokens)
}
srv.Close()
}
// TestNormalizeOpenAIFinish pins point 8b (composite finish) AND the vendor-checked GLM
// mapping at the adapter boundary (docs.z.ai verified 2026-07-24). The Gemini composite
// `content_filter: PROHIBITED_CONTENT` and GLM's `sensitive` both normalize to the neutral
// content_filter, so the downstream classifier's exact match still fires — exact matchers
// are dead on those wires (D22.8а / research/21 §1.18б). Every other value — `network_error`,
// `model_context_window_exceeded`, an unknown, a lookalike without the colon — stays RAW so
// it remains FAIL-LOUD (never a silently-mapped clean stop, the litellm unknown→stop trap).
func TestNormalizeOpenAIFinish(t *testing.T) {
cases := map[string]string{
"content_filter: PROHIBITED_CONTENT": FinishContentFilter, // Gemini composite (point 8b)
"content_filter:SAFETY": FinishContentFilter, // composite, no space
"sensitive": FinishContentFilter, // GLM content filter (vendor-check)
"content_filter": FinishContentFilter, // bare, unchanged
"content_filtered_ok": "content_filtered_ok", // lookalike WITHOUT the colon → raw
"network_error": "network_error", // raw — fail-loud, NOT stop
"model_context_window_exceeded": "model_context_window_exceeded", // raw (deferred GLM-overflow item)
"stop": "stop",
"length": "length",
"": "",
}
for in, want := range cases {
if got := normalizeOpenAIFinish(in); got != want {
t.Errorf("normalizeOpenAIFinish(%q) = %q, want %q", in, got, want)
}
}
// End-to-end: a Gemini composite content_filter completion surfaces as neutral
// content_filter through the adapter.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
openAIOK(t, w, `{"id":"g","choices":[{"message":{"content":"x"},"finish_reason":"content_filter: PROHIBITED_CONTENT"}],
"usage":{"prompt_tokens":10,"completion_tokens":1}}`)
}))
defer srv.Close()
c := NewOpenAICompatClient(OpenAICompatConfig{Name: "gemini", BaseURL: srv.URL, Profile: fastProfile()}, nil)
resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16})
if err != nil {
t.Fatal(err)
}
if resp.FinishReason != FinishContentFilter {
t.Fatalf("Gemini composite content_filter must surface as neutral content_filter, got %q", resp.FinishReason)
}
}
// TestProportionalJitterBounds (point 5): symmetric jitter stays within ±25% and never
// drives a wait non-positive; positive-only jitter (for an honoured Retry-After) never
// shortens the wait below the server hint.
func TestProportionalJitterBounds(t *testing.T) {
d := 20 * time.Second
for i := 0; i < 2000; i++ {
sym := proportionalJitter(d, true)
if sym < -d/4 || sym > d/4 {
t.Fatalf("symmetric jitter %v out of ±25%% of %v", sym, d)
}
if d+sym <= 0 {
t.Fatalf("jitter drove the wait non-positive: %v", d+sym)
}
pos := proportionalJitter(d, false)
if pos < 0 || pos > d/4 {
t.Fatalf("positive-only jitter %v out of [0,25%%] of %v", pos, d)
}
}
if got := proportionalJitter(0, true); got != 0 {
t.Fatalf("zero backoff must yield zero jitter, got %v", got)
}
}