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