textmachine/backend/internal/llm/httpllm_test.go

226 lines
8.7 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"
"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
}