276 lines
12 KiB
Go
276 lines
12 KiB
Go
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 = 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())
|
||
}
|
||
}
|
||
|
||
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())
|
||
}
|
||
}
|