textmachine/backend/internal/llm/httpllm_test.go

276 lines
12 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 (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"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 = totalpromptcompletion = 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())
}
}
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())
}
}