package llm import ( "context" "encoding/json" "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) } } 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 !asHTTPStatus(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 asHTTPStatus(err error, target **HTTPStatusError) bool { for err != nil { if se, ok := err.(*HTTPStatusError); ok { *target = se return true } u, ok := err.(interface{ Unwrap() error }) if !ok { return false } err = u.Unwrap() } return false }