package llm import ( "bytes" "context" "encoding/json" "errors" "log/slog" "net/http" "net/http/httptest" "strings" "sync/atomic" "testing" "time" ) // Сценарные тесты wire-контракта через httptest — стиль донора (реальные // HTTP-раундтрипы, не моки транспорта). func fastProfile() RetryProfile { return RetryProfile{AttemptTimeout: 2 * time.Second, MaxAttempts: 3, BackoffBase: time.Millisecond, BackoffCap: 5 * time.Millisecond} } func openAIOK(t *testing.T, w http.ResponseWriter, body string) { t.Helper() w.Header().Set("Content-Type", "application/json") if _, err := w.Write([]byte(body)); err != nil { t.Fatal(err) } } func TestOpenAICompatHappyPath(t *testing.T) { var gotBody map[string]any srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/chat/completions" { t.Errorf("path = %s", r.URL.Path) } if got := r.Header.Get("Authorization"); got != "Bearer k" { t.Errorf("auth = %q", got) } if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { t.Fatal(err) } openAIOK(t, w, `{"id":"r1","model":"m-actual","choices":[{"message":{"content":"привет"},"finish_reason":"stop"}], "usage":{"prompt_tokens":100,"completion_tokens":20,"prompt_tokens_details":{"cached_tokens":40}}}`) })) defer srv.Close() c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", BaseURL: srv.URL, APIKey: "k", Profile: fastProfile(), ExtraBody: map[string]any{"thinking": map[string]any{"type": "disabled"}}}, nil) resp, err := c.Complete(context.Background(), LLMRequest{ Model: "m", Messages: []Message{{Role: "system", Content: "s"}, {Role: "user", Content: "u"}}, MaxTokens: 256, Temperature: 0.3, }) if err != nil { t.Fatal(err) } if resp.Text != "привет" || resp.Model != "m-actual" || resp.FinishReason != FinishStop { t.Fatalf("resp = %+v", resp) } if resp.Usage.PromptTokens != 100 || resp.Usage.CachedTokens != 40 || resp.Usage.CompletionTokens != 20 { t.Fatalf("usage = %+v", resp.Usage) } if resp.Usage.ReasoningTokens != 0 { t.Fatalf("subset semantics must keep reasoning 0, got %d", resp.Usage.ReasoningTokens) } // ExtraBody из models.yaml домержен в тело запроса. if _, ok := gotBody["thinking"]; !ok { t.Fatalf("extra_body not merged into wire body: %v", gotBody) } if gotBody["stream"] != false { t.Fatalf("stream must be false, got %v", gotBody["stream"]) } } func TestDeepSeekCacheFieldsVariant(t *testing.T) { // DeepSeek-вариант usage: prompt_cache_hit_tokens вместо // prompt_tokens_details — иначе эксперимент по кэшу покажет ложный ноль. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { openAIOK(t, w, `{"id":"r2","choices":[{"message":{"content":"x"},"finish_reason":"stop"}], "usage":{"prompt_tokens":100,"completion_tokens":5,"prompt_cache_hit_tokens":64,"prompt_cache_miss_tokens":36}}`) })) defer srv.Close() c := NewOpenAICompatClient(OpenAICompatConfig{Name: "deepseek", 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.Usage.CachedTokens != 64 { t.Fatalf("deepseek cache-hit tokens not parsed: %+v", resp.Usage) } } func TestAdditiveReasoningSemantics(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { openAIOK(t, w, `{"id":"r3","choices":[{"message":{"content":"x"},"finish_reason":"stop"}], "usage":{"prompt_tokens":10,"completion_tokens":5,"completion_tokens_details":{"reasoning_tokens":50}}}`) })) defer srv.Close() // additive (xAI): reasoning поверх completion. c := NewOpenAICompatClient(OpenAICompatConfig{Name: "xai", BaseURL: srv.URL, Profile: fastProfile(), Reasoning: ReasoningAdditive}, 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.Usage.ReasoningTokens != 50 { t.Fatalf("additive semantics must surface reasoning tokens, got %+v", resp.Usage) } // subset: то же тело, но reasoning остаётся 0 (иначе двойной биллинг). c2 := NewOpenAICompatClient(OpenAICompatConfig{Name: "openai", BaseURL: srv.URL, Profile: fastProfile(), Reasoning: ReasoningSubset}, nil) resp2, err := c2.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16}) if err != nil { t.Fatal(err) } if resp2.Usage.ReasoningTokens != 0 { t.Fatalf("subset semantics must keep reasoning 0, got %+v", resp2.Usage) } } // TestAdditiveTotalReasoningSemantics covers the Gemini case (live-probe 2026-07-10): thinking // tokens are billed as output but appear ONLY in total_tokens — completion_tokens excludes them // and there is no reasoning_tokens field. The adapter must derive reasoning = total − prompt − // completion so the mandatory-thinking spend is not invisible to the ledger. Reverting the // ReasoningAdditiveTotal branch drops reasoning to 0 and fails this test. func TestAdditiveTotalReasoningSemantics(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Mirrors the real Gemini shape: completion=2, prompt=22, total=847 → 823 hidden thinking, // NO completion_tokens_details.reasoning_tokens field at all. openAIOK(t, w, `{"id":"g1","model":"gemini-3.1-pro-preview","choices":[{"message":{"content":"391"},"finish_reason":"stop"}], "usage":{"prompt_tokens":22,"completion_tokens":2,"total_tokens":847}}`) })) defer srv.Close() c := NewOpenAICompatClient(OpenAICompatConfig{Name: "gemini", BaseURL: srv.URL, Profile: fastProfile(), Reasoning: ReasoningAdditiveTotal}, nil) resp, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 8192}) if err != nil { t.Fatal(err) } if resp.Usage.ReasoningTokens != 823 { t.Fatalf("additive_total must derive hidden thinking = total−prompt−completion = 823, got %+v", resp.Usage) } if resp.Usage.CompletionTokens != 2 { t.Fatalf("completion tokens must stay the visible 2 (thinking is separate reasoning), got %+v", resp.Usage) } // A spec provider whose total == prompt+completion must NOT derive a phantom reasoning count // (guard the >0 branch — never inflate the ceiling). srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { openAIOK(t, w, `{"id":"g2","choices":[{"message":{"content":"x"},"finish_reason":"stop"}], "usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}`) })) defer srv2.Close() c2 := NewOpenAICompatClient(OpenAICompatConfig{Name: "gemini", BaseURL: srv2.URL, Profile: fastProfile(), Reasoning: ReasoningAdditiveTotal}, nil) resp2, err := c2.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16}) if err != nil { t.Fatal(err) } if resp2.Usage.ReasoningTokens != 0 { t.Fatalf("total==prompt+completion must derive 0 reasoning, got %+v", resp2.Usage) } } func TestRetryOn429HonoursRetryAfter(t *testing.T) { var calls atomic.Int32 var firstRetryAt, secondCallAt time.Time srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n := calls.Add(1) if n == 1 { firstRetryAt = time.Now() w.Header().Set("Retry-After", "1") w.WriteHeader(http.StatusTooManyRequests) return } secondCallAt = time.Now() openAIOK(t, w, `{"id":"r","choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`) })) defer srv.Close() c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", 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.Text != "ok" || calls.Load() != 2 { t.Fatalf("text=%q calls=%d", resp.Text, calls.Load()) } // Retry-After: 1s должен перебить миллисекундный backoff профиля. if wait := secondCallAt.Sub(firstRetryAt); wait < 900*time.Millisecond { t.Fatalf("Retry-After not honoured: waited only %v", wait) } } func TestTerminal4xxNoRetry(t *testing.T) { var calls atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) w.WriteHeader(http.StatusBadRequest) w.Write([]byte(`{"error":"bad model"}`)) })) defer srv.Close() 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}) if err == nil { t.Fatal("terminal 4xx must fail") } var se *HTTPStatusError if !errors.As(err, &se) || se.Status != 400 { t.Fatalf("want typed 400, got %v", err) } if calls.Load() != 1 { t.Fatalf("terminal 4xx must not retry, calls=%d", calls.Load()) } } func TestEmptyContent2xxIsSuccess(t *testing.T) { // 2xx с пустым контентом — оплаченный вызов: транспорт возвращает успех, // деньги книжатся, гейты разбираются с пустым текстом. srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { openAIOK(t, w, `{"id":"r","choices":[],"usage":{"prompt_tokens":10,"completion_tokens":0}}`) })) defer srv.Close() c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", 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.Fatalf("empty-content 2xx must be a success (billed call): %v", err) } if resp.Text != "" || resp.Usage.PromptTokens != 10 { t.Fatalf("resp = %+v", resp) } } func TestRetryOn5xxThenSuccess(t *testing.T) { var calls atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if calls.Add(1) < 3 { w.WriteHeader(http.StatusBadGateway) return } openAIOK(t, w, `{"id":"r","choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}`) })) defer srv.Close() c := NewOpenAICompatClient(OpenAICompatConfig{Name: "test", 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 || resp.Text != "ok" { t.Fatalf("resp=%v err=%v", resp, err) } if calls.Load() != 3 { t.Fatalf("calls = %d", calls.Load()) } } // TestRetryLoopLogsWillRetryOnlyWhileAttemptsRemain pins the retryLoop logging contract // (D23.4, httpllm.go:87-89): a "will retry" WARN is emitted ONLY when an attempt actually // remains — carrying the REAL wait (retry_in), which Retry-After can silently stretch — // and NEVER before the terminal exhausted error. Before the fix the last failure also // logged "will retry" right before giving up. slog-capture, no live provider assertion. func TestRetryLoopLogsWillRetryOnlyWhileAttemptsRemain(t *testing.T) { var buf bytes.Buffer logger := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) var calls atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) w.WriteHeader(http.StatusBadGateway) // always 5xx → retryable, runs to exhaustion })) defer srv.Close() c := NewOpenAICompatClient(OpenAICompatConfig{Name: "prov", BaseURL: srv.URL, Profile: fastProfile()}, logger) _, err := c.Complete(context.Background(), LLMRequest{Model: "m", Messages: []Message{{Role: "user", Content: "u"}}, MaxTokens: 16}) if err == nil { t.Fatal("a persistent 5xx must exhaust to an error") } if calls.Load() != 3 { // fastProfile MaxAttempts=3 t.Fatalf("want 3 attempts, got %d", calls.Load()) } logs := buf.String() // Exactly MaxAttempts-1 (2) retry warnings: after attempts 1 and 2, never after the // last one (the loop breaks on att+1>=MaxAttempts before the WARN). if n := strings.Count(logs, "attempt failed, will retry"); n != 2 { t.Fatalf("want exactly 2 'will retry' logs (none on the terminal attempt), got %d:\n%s", n, logs) } // Each retry log carries the real wait it is about to sleep. if !strings.Contains(logs, "retry_in=") { t.Fatalf("a retry log must carry retry_in (the actual wait, Retry-After-aware):\n%s", logs) } // The final outcome is the exhausted error, not another "will retry". if !strings.Contains(err.Error(), "exhausted") { t.Fatalf("terminal error must say exhausted, got %v", err) } } func TestBilledDecodeErrorOn2xxGarbage(t *testing.T) { // 2xx с нечитаемым телом: ретраится, а на исчерпании отдаёт типизированную // billed-ошибку — раннер по ней селтлит оценку вместо возврата резерва. var calls atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) w.Write([]byte(`{"truncated`)) // обрыв тела через прокси })) defer srv.Close() 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 bde *BilledDecodeError if err == nil || !errors.As(err, &bde) { t.Fatalf("want BilledDecodeError, got %v", err) } if calls.Load() != 3 { t.Fatalf("garbled 2xx must be retried to exhaustion, calls=%d", calls.Load()) } }