1430 lines
46 KiB
Go
1430 lines
46 KiB
Go
package llm
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/promptkit/internal/domain"
|
|
)
|
|
|
|
var errTransportStopped = errors.New("transport stopped after request inspection")
|
|
|
|
type deadlineCapturingTransport struct {
|
|
deadline time.Time
|
|
hasDeadline bool
|
|
err error
|
|
}
|
|
|
|
func (t *deadlineCapturingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
t.deadline, t.hasDeadline = req.Context().Deadline()
|
|
if t.err != nil {
|
|
return nil, t.err
|
|
}
|
|
return nil, errTransportStopped
|
|
}
|
|
|
|
type contextErrorTransport struct{}
|
|
|
|
func (contextErrorTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return nil, req.Context().Err()
|
|
}
|
|
|
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return f(req)
|
|
}
|
|
|
|
type waitingContextTransport struct{}
|
|
|
|
func (waitingContextTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
<-req.Context().Done()
|
|
return nil, req.Context().Err()
|
|
}
|
|
|
|
func assertDeadlineNear(t *testing.T, deadline, before, after time.Time, duration time.Duration) {
|
|
t.Helper()
|
|
|
|
const tolerance = 100 * time.Millisecond
|
|
earliest := before.Add(duration - tolerance)
|
|
latest := after.Add(duration + tolerance)
|
|
if deadline.Before(earliest) || deadline.After(latest) {
|
|
t.Fatalf("expected deadline between %v and %v, got %v", earliest, latest, deadline)
|
|
}
|
|
}
|
|
|
|
func TestNewOpenAICompatibleClientRejectsInvalidBaseURL(t *testing.T) {
|
|
for _, endpoint := range []string{
|
|
"://invalid",
|
|
"/v1",
|
|
"https:///v1",
|
|
"ftp://provider.example/v1",
|
|
"https://user@provider.example/v1",
|
|
"https://provider.example/v1?mode=chat",
|
|
"https://provider.example/v1#chat",
|
|
} {
|
|
t.Run(endpoint, func(t *testing.T) {
|
|
_, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: endpoint})
|
|
if !errors.Is(err, ErrInvalidConfig) {
|
|
t.Fatalf("expected ErrInvalidConfig, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewOpenAICompatibleClientDoesNotMutateSuppliedZeroTimeoutClient(t *testing.T) {
|
|
transport := http.DefaultTransport
|
|
supplied := &http.Client{Transport: transport}
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
|
HTTPClient: supplied,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected constructor error: %v", err)
|
|
}
|
|
|
|
if supplied.Timeout != 0 {
|
|
t.Fatalf("expected supplied client timeout to remain zero, got %v", supplied.Timeout)
|
|
}
|
|
if client.httpClient == supplied {
|
|
t.Fatal("expected constructed client to use a cloned HTTP client")
|
|
}
|
|
if client.httpClient.Timeout <= 0 {
|
|
t.Fatalf("expected constructed client to use a positive default timeout, got %v", client.httpClient.Timeout)
|
|
}
|
|
if client.httpClient.Transport != transport {
|
|
t.Fatal("expected cloned client to preserve the supplied transport")
|
|
}
|
|
}
|
|
|
|
func TestNewOpenAICompatibleClientDoesNotMutateSuppliedNonzeroTimeoutClient(t *testing.T) {
|
|
transport := http.DefaultTransport
|
|
suppliedTimeout := 37 * time.Second
|
|
supplied := &http.Client{
|
|
Timeout: suppliedTimeout,
|
|
Transport: transport,
|
|
}
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
|
Timeout: 2 * time.Second,
|
|
HTTPClient: supplied,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected constructor error: %v", err)
|
|
}
|
|
|
|
if supplied.Timeout != suppliedTimeout {
|
|
t.Fatalf("expected supplied client timeout to remain %v, got %v", suppliedTimeout, supplied.Timeout)
|
|
}
|
|
if client.httpClient == supplied {
|
|
t.Fatal("expected constructed client to use a cloned HTTP client")
|
|
}
|
|
if client.httpClient.Timeout != suppliedTimeout {
|
|
t.Fatalf("expected cloned client timeout %v, got %v", suppliedTimeout, client.httpClient.Timeout)
|
|
}
|
|
if client.httpClient.Transport != transport {
|
|
t.Fatal("expected cloned client to preserve the supplied transport")
|
|
}
|
|
}
|
|
|
|
func TestNewOpenAICompatibleClientTreatsSuppliedNegativeTimeoutAsUnset(t *testing.T) {
|
|
transport := http.DefaultTransport
|
|
supplied := &http.Client{
|
|
Timeout: -time.Second,
|
|
Transport: transport,
|
|
}
|
|
configuredTimeout := 23 * time.Second
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
|
Timeout: configuredTimeout,
|
|
HTTPClient: supplied,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected constructor error: %v", err)
|
|
}
|
|
|
|
if supplied.Timeout != -time.Second {
|
|
t.Fatalf("expected supplied client timeout to remain negative, got %v", supplied.Timeout)
|
|
}
|
|
if client.httpClient == supplied {
|
|
t.Fatal("expected constructed client to use a cloned HTTP client")
|
|
}
|
|
if client.httpClient.Timeout != configuredTimeout {
|
|
t.Fatalf("expected cloned client timeout %v, got %v", configuredTimeout, client.httpClient.Timeout)
|
|
}
|
|
if client.httpClient.Transport != transport {
|
|
t.Fatal("expected cloned client to preserve the supplied transport")
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientGenerateSuccess(t *testing.T) {
|
|
type observedRequest struct {
|
|
Authorization string
|
|
Body map[string]any
|
|
}
|
|
obs := &observedRequest{}
|
|
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
obs.Authorization = r.Header.Get("Authorization")
|
|
if r.URL.Path != "/v1/chat/completions" {
|
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
|
t.Fatalf("unexpected content type: %s", ct)
|
|
}
|
|
|
|
defer r.Body.Close()
|
|
if err := json.NewDecoder(r.Body).Decode(&obs.Body); err != nil {
|
|
t.Fatalf("failed to decode request body: %v", err)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"choices": [{"message": {"role": "assistant", "content": "hello from model"}}],
|
|
"usage": {"prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33}
|
|
}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
|
BaseURL: ts.URL + "/v1",
|
|
Timeout: 2 * time.Second,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("unexpected constructor error: %v", err)
|
|
}
|
|
t.Setenv("PROMPTKIT_TEST_API_KEY", "secret-key")
|
|
|
|
resp, err := client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{
|
|
{Role: "system", Content: "You are helpful."},
|
|
{Role: "user", Content: "Say hello"},
|
|
}},
|
|
Target: domain.ExecutionTarget{
|
|
Model: "gpt-test",
|
|
Temperature: 0.4,
|
|
MaxTokens: 123,
|
|
TopP: 0.7,
|
|
ServiceTier: "priority",
|
|
APIKeyEnv: "PROMPTKIT_TEST_API_KEY",
|
|
},
|
|
StructuredOutput: &domain.StructuredOutputSpec{
|
|
Type: domain.StructuredOutputJSONSchema,
|
|
JSONSchema: &domain.StructuredOutputJSONSpec{
|
|
Name: "weather_schema",
|
|
Strict: true,
|
|
Schema: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"location": map[string]any{"type": "string"},
|
|
},
|
|
"required": []any{"location"},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
|
|
if resp.Content != "hello from model" {
|
|
t.Fatalf("unexpected content: %q", resp.Content)
|
|
}
|
|
if resp.Usage.PromptTokens != 11 || resp.Usage.CompletionTokens != 22 || resp.Usage.TotalTokens != 33 {
|
|
t.Fatalf("unexpected usage: %+v", resp.Usage)
|
|
}
|
|
if resp.Usage.CachedTokens != 0 || resp.Usage.CacheWriteTokens != 0 {
|
|
t.Fatalf("expected absent cache usage fields to remain zero, got %+v", resp.Usage)
|
|
}
|
|
|
|
if obs.Authorization != "Bearer secret-key" {
|
|
t.Fatalf("unexpected Authorization header: %q", obs.Authorization)
|
|
}
|
|
if got, ok := obs.Body["model"].(string); !ok || got != "gpt-test" {
|
|
t.Fatalf("unexpected model payload: %#v", obs.Body["model"])
|
|
}
|
|
if got, ok := obs.Body["temperature"].(float64); !ok || got != 0.4 {
|
|
t.Fatalf("unexpected temperature payload: %#v", obs.Body["temperature"])
|
|
}
|
|
if got, ok := obs.Body["max_tokens"].(float64); !ok || got != 123 {
|
|
t.Fatalf("unexpected max_tokens payload: %#v", obs.Body["max_tokens"])
|
|
}
|
|
if got, ok := obs.Body["top_p"].(float64); !ok || got != 0.7 {
|
|
t.Fatalf("unexpected top_p payload: %#v", obs.Body["top_p"])
|
|
}
|
|
if got, ok := obs.Body["service_tier"].(string); !ok || got != "priority" {
|
|
t.Fatalf("unexpected service_tier payload: %#v", obs.Body["service_tier"])
|
|
}
|
|
|
|
msgs, ok := obs.Body["messages"].([]any)
|
|
if !ok || len(msgs) != 2 {
|
|
t.Fatalf("unexpected messages payload: %#v", obs.Body["messages"])
|
|
}
|
|
msg0 := msgs[0].(map[string]any)
|
|
if msg0["role"] != "system" || msg0["content"] != "You are helpful." {
|
|
t.Fatalf("unexpected first message: %#v", msg0)
|
|
}
|
|
msg1 := msgs[1].(map[string]any)
|
|
if msg1["role"] != "user" || msg1["content"] != "Say hello" {
|
|
t.Fatalf("unexpected second message: %#v", msg1)
|
|
}
|
|
|
|
responseFormat, ok := obs.Body["response_format"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected response_format payload, got %#v", obs.Body["response_format"])
|
|
}
|
|
if responseFormat["type"] != "json_schema" {
|
|
t.Fatalf("expected response_format.type=json_schema, got %#v", responseFormat["type"])
|
|
}
|
|
jsonSchema, ok := responseFormat["json_schema"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected response_format.json_schema map, got %#v", responseFormat["json_schema"])
|
|
}
|
|
if jsonSchema["name"] != "weather_schema" {
|
|
t.Fatalf("expected json_schema.name weather_schema, got %#v", jsonSchema["name"])
|
|
}
|
|
if jsonSchema["strict"] != true {
|
|
t.Fatalf("expected json_schema.strict=true, got %#v", jsonSchema["strict"])
|
|
}
|
|
if _, ok := jsonSchema["schema"].(map[string]any); !ok {
|
|
t.Fatalf("expected json_schema.schema object, got %#v", jsonSchema["schema"])
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientDirectAPIKeyPreferredOverEnv(t *testing.T) {
|
|
const directKey = "direct-llm-key"
|
|
t.Setenv("PROMPTKIT_TEST_API_KEY", "env-key")
|
|
|
|
var gotAuth string
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotAuth = r.Header.Get("Authorization")
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
Target: domain.ExecutionTarget{
|
|
Model: "model",
|
|
APIKeyEnv: "PROMPTKIT_TEST_API_KEY",
|
|
APIKey: directKey,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if gotAuth != "Bearer "+directKey {
|
|
t.Fatalf("unexpected Authorization header: %q", gotAuth)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientSerializesCacheControlledMessageAsContentBlock(t *testing.T) {
|
|
var observedBody map[string]any
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer r.Body.Close()
|
|
if err := json.NewDecoder(r.Body).Decode(&observedBody); err != nil {
|
|
t.Fatalf("failed to decode request body: %v", err)
|
|
}
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{
|
|
{
|
|
Role: "system",
|
|
Content: "Stable instructions.",
|
|
CacheControl: &domain.CacheControl{
|
|
Type: domain.CacheControlEphemeral,
|
|
TTL: "1h",
|
|
},
|
|
},
|
|
{Role: "user", Content: "Dynamic request."},
|
|
}},
|
|
Target: domain.ExecutionTarget{Model: "model"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
|
|
for _, forbidden := range []string{"cache_control", "extra_params"} {
|
|
if _, exists := observedBody[forbidden]; exists {
|
|
t.Fatalf("expected top-level %s to be omitted, got %#v", forbidden, observedBody[forbidden])
|
|
}
|
|
}
|
|
|
|
msgs, ok := observedBody["messages"].([]any)
|
|
if !ok || len(msgs) != 2 {
|
|
t.Fatalf("unexpected messages payload: %#v", observedBody["messages"])
|
|
}
|
|
msg0 := msgs[0].(map[string]any)
|
|
if msg0["role"] != "system" {
|
|
t.Fatalf("unexpected first message role: %#v", msg0["role"])
|
|
}
|
|
contentBlocks, ok := msg0["content"].([]any)
|
|
if !ok || len(contentBlocks) != 1 {
|
|
t.Fatalf("expected first message content block array, got %#v", msg0["content"])
|
|
}
|
|
block := contentBlocks[0].(map[string]any)
|
|
if block["type"] != "text" || block["text"] != "Stable instructions." {
|
|
t.Fatalf("unexpected text content block: %#v", block)
|
|
}
|
|
cacheControl, ok := block["cache_control"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("expected cache_control on content block, got %#v", block)
|
|
}
|
|
if cacheControl["type"] != string(domain.CacheControlEphemeral) || cacheControl["ttl"] != "1h" {
|
|
t.Fatalf("unexpected cache_control payload: %#v", cacheControl)
|
|
}
|
|
|
|
msg1 := msgs[1].(map[string]any)
|
|
if msg1["role"] != "user" || msg1["content"] != "Dynamic request." {
|
|
t.Fatalf("expected uncached message to keep string content, got %#v", msg1)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientOmitsEmptyCacheControlTTL(t *testing.T) {
|
|
var observedBody map[string]any
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer r.Body.Close()
|
|
if err := json.NewDecoder(r.Body).Decode(&observedBody); err != nil {
|
|
t.Fatalf("failed to decode request body: %v", err)
|
|
}
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{
|
|
{
|
|
Role: "system",
|
|
Content: "Stable instructions.",
|
|
CacheControl: &domain.CacheControl{
|
|
Type: domain.CacheControlEphemeral,
|
|
},
|
|
},
|
|
}},
|
|
Target: domain.ExecutionTarget{Model: "model"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
|
|
msgs := observedBody["messages"].([]any)
|
|
msg0 := msgs[0].(map[string]any)
|
|
contentBlocks := msg0["content"].([]any)
|
|
block := contentBlocks[0].(map[string]any)
|
|
cacheControl := block["cache_control"].(map[string]any)
|
|
if cacheControl["type"] != string(domain.CacheControlEphemeral) {
|
|
t.Fatalf("unexpected cache_control type: %#v", cacheControl)
|
|
}
|
|
if _, exists := cacheControl["ttl"]; exists {
|
|
t.Fatalf("expected empty ttl to be omitted, got %#v", cacheControl)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientSerializesSessionID(t *testing.T) {
|
|
var observedBody map[string]any
|
|
var observedSessionHeader string
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
observedSessionHeader = r.Header.Get("x-session-id")
|
|
defer r.Body.Close()
|
|
if err := json.NewDecoder(r.Body).Decode(&observedBody); err != nil {
|
|
t.Fatalf("failed to decode request body: %v", err)
|
|
}
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{
|
|
SessionID: " session-123 ",
|
|
Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}},
|
|
},
|
|
Target: domain.ExecutionTarget{Model: "model"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if observedBody["session_id"] != "session-123" {
|
|
t.Fatalf("expected top-level session_id, got %#v", observedBody["session_id"])
|
|
}
|
|
if observedSessionHeader != "" {
|
|
t.Fatalf("did not expect x-session-id header, got %q", observedSessionHeader)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientOmitsEmptySessionID(t *testing.T) {
|
|
var observedBody map[string]any
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer r.Body.Close()
|
|
if err := json.NewDecoder(r.Body).Decode(&observedBody); err != nil {
|
|
t.Fatalf("failed to decode request body: %v", err)
|
|
}
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{
|
|
SessionID: " ",
|
|
Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}},
|
|
},
|
|
Target: domain.ExecutionTarget{Model: "model"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if _, exists := observedBody["session_id"]; exists {
|
|
t.Fatalf("expected empty session_id to be omitted, got %#v", observedBody["session_id"])
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientRejectsTooLongSessionID(t *testing.T) {
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
|
BaseURL: "http://example.com/v1",
|
|
Model: "model",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{
|
|
SessionID: strings.Repeat("x", domain.SessionIDMaxLength+1),
|
|
Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}},
|
|
},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected invalid request error")
|
|
}
|
|
if !errors.Is(err, ErrInvalidRequest) {
|
|
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientParsesCacheUsage(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{
|
|
"choices": [{"message": {"role": "assistant", "content": "ok"}}],
|
|
"usage": {
|
|
"prompt_tokens": 100,
|
|
"completion_tokens": 20,
|
|
"total_tokens": 120,
|
|
"prompt_tokens_details": {"cached_tokens": 80},
|
|
"cache_write_tokens": 60
|
|
}
|
|
}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1", Model: "model"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
resp, err := client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if resp.Usage.PromptTokens != 100 || resp.Usage.CompletionTokens != 20 || resp.Usage.TotalTokens != 120 {
|
|
t.Fatalf("unexpected base usage fields: %+v", resp.Usage)
|
|
}
|
|
if resp.Usage.CachedTokens != 80 || resp.Usage.CacheWriteTokens != 60 {
|
|
t.Fatalf("unexpected cache usage fields: %+v", resp.Usage)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientOmitsResponseFormatWhenNoStructuredOutput(t *testing.T) {
|
|
var observedBody map[string]any
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer r.Body.Close()
|
|
if err := json.NewDecoder(r.Body).Decode(&observedBody); err != nil {
|
|
t.Fatalf("failed to decode request body: %v", err)
|
|
}
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
Target: domain.ExecutionTarget{Model: "model"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if _, exists := observedBody["response_format"]; exists {
|
|
t.Fatalf("expected response_format omitted, got %#v", observedBody["response_format"])
|
|
}
|
|
if _, exists := observedBody["service_tier"]; exists {
|
|
t.Fatalf("expected service_tier omitted, got %#v", observedBody["service_tier"])
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientSerializesReasoningEffortAndExtraParams(t *testing.T) {
|
|
var observedBody map[string]any
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer r.Body.Close()
|
|
if err := json.NewDecoder(r.Body).Decode(&observedBody); err != nil {
|
|
t.Fatalf("failed to decode request body: %v", err)
|
|
}
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
Target: domain.ExecutionTarget{
|
|
Model: "model",
|
|
ReasoningEffort: "high",
|
|
ExtraParams: map[string]any{
|
|
"string_value": "on",
|
|
"number_value": 42,
|
|
"boolean_value": true,
|
|
"object_value": map[string]any{"nested": "value", "count": 2},
|
|
"array_value": []any{"first", 3, false},
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if observedBody["reasoning_effort"] != "high" {
|
|
t.Fatalf("expected reasoning_effort high, got %#v", observedBody["reasoning_effort"])
|
|
}
|
|
if observedBody["string_value"] != "on" {
|
|
t.Fatalf("unexpected string extra param: %#v", observedBody["string_value"])
|
|
}
|
|
if observedBody["number_value"] != float64(42) {
|
|
t.Fatalf("unexpected number extra param: %#v", observedBody["number_value"])
|
|
}
|
|
if observedBody["boolean_value"] != true {
|
|
t.Fatalf("unexpected boolean extra param: %#v", observedBody["boolean_value"])
|
|
}
|
|
objectValue, ok := observedBody["object_value"].(map[string]any)
|
|
if !ok || objectValue["nested"] != "value" || objectValue["count"] != float64(2) {
|
|
t.Fatalf("unexpected object extra param: %#v", observedBody["object_value"])
|
|
}
|
|
if _, exists := observedBody["extra_params"]; exists {
|
|
t.Fatalf("expected extra_params wrapper omitted, got %#v", observedBody["extra_params"])
|
|
}
|
|
arrayValue, ok := observedBody["array_value"].([]any)
|
|
if !ok || len(arrayValue) != 3 || arrayValue[0] != "first" || arrayValue[1] != float64(3) || arrayValue[2] != false {
|
|
t.Fatalf("unexpected array extra param: %#v", observedBody["array_value"])
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientOmitsReasoningEffortWhenUnset(t *testing.T) {
|
|
var observedBody map[string]any
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer r.Body.Close()
|
|
if err := json.NewDecoder(r.Body).Decode(&observedBody); err != nil {
|
|
t.Fatalf("failed to decode request body: %v", err)
|
|
}
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
Target: domain.ExecutionTarget{Model: "model"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if _, exists := observedBody["reasoning_effort"]; exists {
|
|
t.Fatalf("expected reasoning_effort omitted, got %#v", observedBody["reasoning_effort"])
|
|
}
|
|
if _, exists := observedBody["extra_params"]; exists {
|
|
t.Fatalf("expected extra_params wrapper omitted, got %#v", observedBody["extra_params"])
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientSerializesExplicitZeroNumericOverrides(t *testing.T) {
|
|
var observedBody map[string]any
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer r.Body.Close()
|
|
if err := json.NewDecoder(r.Body).Decode(&observedBody); err != nil {
|
|
t.Fatalf("failed to decode request body: %v", err)
|
|
}
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
Target: domain.ExecutionTarget{Model: "model"},
|
|
TargetPresence: domain.ExecutionTargetPresence{
|
|
Temperature: true,
|
|
MaxTokens: true,
|
|
TopP: true,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if observedBody["temperature"] != float64(0) {
|
|
t.Fatalf("expected explicit zero temperature, got %#v", observedBody["temperature"])
|
|
}
|
|
if observedBody["max_tokens"] != float64(0) {
|
|
t.Fatalf("expected explicit zero max_tokens, got %#v", observedBody["max_tokens"])
|
|
}
|
|
if observedBody["top_p"] != float64(0) {
|
|
t.Fatalf("expected explicit zero top_p, got %#v", observedBody["top_p"])
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientOmitsImplicitZeroNumericFields(t *testing.T) {
|
|
var observedBody map[string]any
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defer r.Body.Close()
|
|
if err := json.NewDecoder(r.Body).Decode(&observedBody); err != nil {
|
|
t.Fatalf("failed to decode request body: %v", err)
|
|
}
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
Target: domain.ExecutionTarget{Model: "model"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
for _, field := range []string{"temperature", "max_tokens", "top_p"} {
|
|
if _, exists := observedBody[field]; exists {
|
|
t.Fatalf("expected implicit zero field %q to be omitted, got body %#v", field, observedBody)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientOmittedTimeoutUsesClientTimeout(t *testing.T) {
|
|
transport := &deadlineCapturingTransport{}
|
|
clientTimeout := 5 * time.Second
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
|
BaseURL: "http://example.com/v1",
|
|
Timeout: clientTimeout,
|
|
HTTPClient: &http.Client{
|
|
Transport: transport,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
before := time.Now()
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
Target: domain.ExecutionTarget{Model: "model", TimeoutSeconds: 0},
|
|
})
|
|
after := time.Now()
|
|
if err == nil {
|
|
t.Fatal("expected transport error")
|
|
}
|
|
if !errors.Is(err, ErrRequestFailed) {
|
|
t.Fatalf("expected ErrRequestFailed, got %v", err)
|
|
}
|
|
if !errors.Is(err, errTransportStopped) {
|
|
t.Fatalf("expected transport cause, got %v", err)
|
|
}
|
|
if !transport.hasDeadline {
|
|
t.Fatal("expected client timeout to set a transport deadline")
|
|
}
|
|
assertDeadlineNear(t, transport.deadline, before, after, clientTimeout)
|
|
}
|
|
|
|
func TestOpenAICompatibleClientRejectsInvalidExtraParamsBeforeProviderCall(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
extraParams map[string]any
|
|
want string
|
|
}{
|
|
{name: "empty key", extraParams: map[string]any{"": "empty"}, want: "key must not be empty"},
|
|
{name: "unserializable value", extraParams: map[string]any{"bad": math.Inf(1)}, want: "JSON-serializable"},
|
|
}
|
|
for _, key := range []string{
|
|
"model",
|
|
"session_id",
|
|
"messages",
|
|
"temperature",
|
|
"max_tokens",
|
|
"top_p",
|
|
"service_tier",
|
|
"reasoning_effort",
|
|
"response_format",
|
|
} {
|
|
tests = append(tests, struct {
|
|
name string
|
|
extraParams map[string]any
|
|
want string
|
|
}{
|
|
name: "reserved key " + key,
|
|
extraParams: map[string]any{key: "collision"},
|
|
want: "reserved request field",
|
|
})
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
called := false
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
called = true
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
Target: domain.ExecutionTarget{Model: "model", ExtraParams: tc.extraParams},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected invalid request error")
|
|
}
|
|
if !errors.Is(err, ErrInvalidRequest) {
|
|
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), tc.want) {
|
|
t.Fatalf("expected error to contain %q, got %v", tc.want, err)
|
|
}
|
|
if called {
|
|
t.Fatal("provider should not be called for invalid extra_params")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientNoAuthorizationHeaderWhenNoAPIKey(t *testing.T) {
|
|
hadAuth := false
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hadAuth = r.Header.Get("Authorization") != ""
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
Target: domain.ExecutionTarget{Model: "model"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if hadAuth {
|
|
t.Fatal("did not expect Authorization header")
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientAPIKeyEnvMissing(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1", Model: "model"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
Target: domain.ExecutionTarget{APIKeyEnv: "PROMPTKIT_MISSING_KEY"},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected missing API key env error")
|
|
}
|
|
if !errors.Is(err, ErrInvalidRequest) {
|
|
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientModelFallbackFromConfig(t *testing.T) {
|
|
gotModel := ""
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var body map[string]any
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
if m, ok := body["model"].(string); ok {
|
|
gotModel = m
|
|
}
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1", Model: "default-model"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
Target: domain.ExecutionTarget{},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if gotModel != "default-model" {
|
|
t.Fatalf("expected default model, got %q", gotModel)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientEndpointOverride(t *testing.T) {
|
|
defaultHit := false
|
|
overrideHit := false
|
|
|
|
defaultServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
defaultHit = true
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"default"}}]}`))
|
|
}))
|
|
defer defaultServer.Close()
|
|
|
|
overrideServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
overrideHit = true
|
|
if r.URL.Path != "/v1/chat/completions" {
|
|
t.Fatalf("unexpected path: %s", r.URL.Path)
|
|
}
|
|
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"override"}}]}`))
|
|
}))
|
|
defer overrideServer.Close()
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: defaultServer.URL + "/v1", Model: "m"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
resp, err := client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
Target: domain.ExecutionTarget{Endpoint: overrideServer.URL + "/v1"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected no error, got %v", err)
|
|
}
|
|
if resp.Content != "override" {
|
|
t.Fatalf("expected override response, got %q", resp.Content)
|
|
}
|
|
if defaultHit {
|
|
t.Fatal("default endpoint should not have been called")
|
|
}
|
|
if !overrideHit {
|
|
t.Fatal("override endpoint should have been called")
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientNon2xxError(t *testing.T) {
|
|
const sensitiveBody = `provider-secret-fragment request_payload_details`
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
_, _ = w.Write([]byte(`{"error":"` + sensitiveBody + `"}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1", Model: "m"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected non-2xx error")
|
|
}
|
|
if !errors.Is(err, ErrUnexpectedStatus) {
|
|
t.Fatalf("expected ErrUnexpectedStatus, got %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "status=400") {
|
|
t.Fatalf("expected status detail, got %v", err)
|
|
}
|
|
if strings.Contains(err.Error(), sensitiveBody) {
|
|
t.Fatalf("expected provider response body to be redacted, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientMalformedResponseInvalidJSON(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{not valid json`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1", Model: "m"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected malformed response error")
|
|
}
|
|
if !errors.Is(err, ErrMalformedResponse) {
|
|
t.Fatalf("expected ErrMalformedResponse, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientMalformedResponseMissingChoices(t *testing.T) {
|
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_, _ = w.Write([]byte(`{"choices": []}`))
|
|
}))
|
|
defer ts.Close()
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{BaseURL: ts.URL + "/v1", Model: "m"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected malformed response error")
|
|
}
|
|
if !errors.Is(err, ErrMalformedResponse) {
|
|
t.Fatalf("expected ErrMalformedResponse, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientGenerationTimeoutSetsEarlierDeadline(t *testing.T) {
|
|
transport := &deadlineCapturingTransport{err: context.DeadlineExceeded}
|
|
generationTimeout := 2 * time.Second
|
|
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
|
BaseURL: "http://example.com/v1",
|
|
Model: "m",
|
|
HTTPClient: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
Transport: transport,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
before := time.Now()
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
Target: domain.ExecutionTarget{
|
|
TimeoutSeconds: int(generationTimeout / time.Second),
|
|
},
|
|
})
|
|
after := time.Now()
|
|
if err == nil {
|
|
t.Fatal("expected transport error")
|
|
}
|
|
if !errors.Is(err, ErrRequestFailed) {
|
|
t.Fatalf("expected ErrRequestFailed, got %v", err)
|
|
}
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("expected generation deadline identity, got %v", err)
|
|
}
|
|
if !transport.hasDeadline {
|
|
t.Fatal("expected generation timeout to set a transport deadline")
|
|
}
|
|
assertDeadlineNear(t, transport.deadline, before, after, generationTimeout)
|
|
}
|
|
|
|
func TestOpenAICompatibleClientCallerDeadlineTakesPrecedence(t *testing.T) {
|
|
transport := &deadlineCapturingTransport{err: context.DeadlineExceeded}
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
|
BaseURL: "http://example.com/v1",
|
|
Model: "m",
|
|
HTTPClient: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
Transport: transport,
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
callerDeadline := time.Now().Add(time.Second)
|
|
ctx, cancel := context.WithDeadline(context.Background(), callerDeadline)
|
|
defer cancel()
|
|
|
|
_, err = client.Generate(ctx, domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
Target: domain.ExecutionTarget{
|
|
TimeoutSeconds: 2,
|
|
},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected transport error")
|
|
}
|
|
if !errors.Is(err, ErrRequestFailed) {
|
|
t.Fatalf("expected ErrRequestFailed, got %v", err)
|
|
}
|
|
if !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("expected caller deadline identity, got %v", err)
|
|
}
|
|
if !transport.hasDeadline {
|
|
t.Fatal("expected caller context to set a transport deadline")
|
|
}
|
|
if !transport.deadline.Equal(callerDeadline) {
|
|
t.Fatalf("expected caller deadline %v, got %v", callerDeadline, transport.deadline)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientCancellationReturnsRequestFailure(t *testing.T) {
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
|
BaseURL: "http://example.com/v1",
|
|
Model: "m",
|
|
HTTPClient: &http.Client{
|
|
Transport: contextErrorTransport{},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
_, err = client.Generate(ctx, domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected cancellation error")
|
|
}
|
|
if !errors.Is(err, ErrRequestFailed) {
|
|
t.Fatalf("expected ErrRequestFailed, got %v", err)
|
|
}
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("expected cancellation identity, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientExpiredCallerDeadlineReturnsRequestFailure(t *testing.T) {
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
|
BaseURL: "http://example.com/v1",
|
|
Model: "m",
|
|
HTTPClient: &http.Client{
|
|
Transport: contextErrorTransport{},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ctx, cancel := context.WithDeadline(context.Background(), time.Unix(1, 0))
|
|
defer cancel()
|
|
_, err = client.Generate(ctx, domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
})
|
|
if !errors.Is(err, ErrRequestFailed) || !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("expected request failure and caller deadline identities, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientWholeRequestTimeoutReturnsRequestFailure(t *testing.T) {
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
|
BaseURL: "http://example.com/v1",
|
|
Model: "m",
|
|
HTTPClient: &http.Client{
|
|
Timeout: time.Millisecond,
|
|
Transport: waitingContextTransport{},
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
})
|
|
if !errors.Is(err, ErrRequestFailed) || !errors.Is(err, context.DeadlineExceeded) {
|
|
t.Fatalf("expected request failure and client timeout identities, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientTransportFailurePreservesCauseWithoutSensitiveText(t *testing.T) {
|
|
transportCause := errors.New("transport diagnostic")
|
|
const (
|
|
endpoint = "http://sensitive-endpoint.example/private"
|
|
apiKey = "sensitive-api-key"
|
|
content = "sensitive prompt content"
|
|
)
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
|
BaseURL: endpoint,
|
|
Model: "m",
|
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
return nil, transportCause
|
|
})},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: content}}},
|
|
Target: domain.ExecutionTarget{APIKey: apiKey},
|
|
})
|
|
if !errors.Is(err, ErrRequestFailed) || !errors.Is(err, transportCause) {
|
|
t.Fatalf("expected request failure and transport cause identities, got %v", err)
|
|
}
|
|
var requestErr *url.Error
|
|
if !errors.As(err, &requestErr) {
|
|
t.Fatalf("expected underlying http.Client.Do URL error, got %T: %v", err, err)
|
|
}
|
|
for _, sensitive := range []string{endpoint, "sensitive-endpoint.example", apiKey, content, transportCause.Error()} {
|
|
if strings.Contains(err.Error(), sensitive) {
|
|
t.Fatalf("transport error exposed %q: %v", sensitive, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientRejectsInvalidExecutionSettings(t *testing.T) {
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
|
BaseURL: "http://example.com/v1",
|
|
Model: "m",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
type testCase struct {
|
|
name string
|
|
target domain.ExecutionTarget
|
|
}
|
|
tests := []testCase{
|
|
{name: "non-finite temperature", target: domain.ExecutionTarget{Temperature: math.NaN()}},
|
|
{name: "negative max tokens", target: domain.ExecutionTarget{MaxTokens: -1}},
|
|
{name: "non-finite top p", target: domain.ExecutionTarget{TopP: math.Inf(1)}},
|
|
{name: "negative timeout", target: domain.ExecutionTarget{TimeoutSeconds: -1}},
|
|
}
|
|
if strconv.IntSize == 64 {
|
|
durationLimit := int64(math.MaxInt64 / int64(time.Second))
|
|
tests = append(tests, testCase{name: "unrepresentable timeout", target: domain.ExecutionTarget{TimeoutSeconds: int(durationLimit) + 1}})
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
Target: tt.target,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected invalid request error")
|
|
}
|
|
if !errors.Is(err, ErrInvalidRequest) {
|
|
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientAllowsEmptyConfiguredBaseURL(t *testing.T) {
|
|
var selectedURL string
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
|
BaseURL: "",
|
|
Model: "m",
|
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
selectedURL = req.URL.String()
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(
|
|
`{"choices":[{"message":{"content":"request endpoint"}}]}`,
|
|
)),
|
|
Request: req,
|
|
}, nil
|
|
})},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("expected empty configured base URL to be allowed, got %v", err)
|
|
}
|
|
|
|
response, err := client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
Target: domain.ExecutionTarget{Endpoint: "http://request-endpoint.example/v1"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("generate with request endpoint: %v", err)
|
|
}
|
|
if response.Content != "request endpoint" {
|
|
t.Fatalf("response content = %q, want request endpoint", response.Content)
|
|
}
|
|
if selectedURL != "http://request-endpoint.example/v1/chat/completions" {
|
|
t.Fatalf("selected URL = %q", selectedURL)
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientComposesCompletionURL(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
baseURL string
|
|
wantURL string
|
|
}{
|
|
{name: "HTTP host", baseURL: "http://provider.example", wantURL: "http://provider.example/chat/completions"},
|
|
{name: "HTTPS host", baseURL: "https://provider.example", wantURL: "https://provider.example/chat/completions"},
|
|
{name: "nested path", baseURL: "https://provider.example/api/openai/v1", wantURL: "https://provider.example/api/openai/v1/chat/completions"},
|
|
{name: "trailing slash", baseURL: "https://provider.example/v1/", wantURL: "https://provider.example/v1/chat/completions"},
|
|
{name: "repeated trailing slashes", baseURL: " https://provider.example/api/v1/// ", wantURL: "https://provider.example/api/v1/chat/completions"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var selectedURL string
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
|
BaseURL: tc.baseURL,
|
|
Model: "m",
|
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
|
selectedURL = req.URL.String()
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(`{"choices":[{"message":{"content":"ok"}}]}`)),
|
|
Request: req,
|
|
}, nil
|
|
})},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("construct client: %v", err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("generate: %v", err)
|
|
}
|
|
if selectedURL != tc.wantURL {
|
|
t.Fatalf("selected URL = %q, want %q", selectedURL, tc.wantURL)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientRejectsInvalidSelectedEndpointBeforeTransport(t *testing.T) {
|
|
invalidEndpoints := []string{
|
|
"/v1",
|
|
"https:///v1",
|
|
"ftp://provider.example/v1",
|
|
"https://user@provider.example/v1",
|
|
"https://provider.example/v1?mode=chat",
|
|
"https://provider.example/v1#chat",
|
|
"https://sensitive-endpoint.example/%zz",
|
|
}
|
|
for _, endpoint := range invalidEndpoints {
|
|
t.Run(endpoint, func(t *testing.T) {
|
|
transportCalls := 0
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
|
BaseURL: "https://configured.example/v1",
|
|
Model: "m",
|
|
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
transportCalls++
|
|
return nil, errors.New("transport must not be called")
|
|
})},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("construct client: %v", err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
Target: domain.ExecutionTarget{Endpoint: endpoint},
|
|
})
|
|
if !errors.Is(err, ErrInvalidRequest) {
|
|
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
|
}
|
|
if strings.Contains(err.Error(), endpoint) {
|
|
t.Fatalf("error exposed selected endpoint %q: %v", endpoint, err)
|
|
}
|
|
if transportCalls != 0 {
|
|
t.Fatalf("transport calls = %d, want 0", transportCalls)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestOpenAICompatibleClientRequiresEndpointWhenUnsetEverywhere(t *testing.T) {
|
|
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
|
|
BaseURL: "",
|
|
Model: "m",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, err = client.Generate(context.Background(), domain.GenerateRequest{
|
|
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
|
|
Target: domain.ExecutionTarget{},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected endpoint-required error")
|
|
}
|
|
if !errors.Is(err, ErrInvalidRequest) {
|
|
t.Fatalf("expected ErrInvalidRequest, got %v", err)
|
|
}
|
|
}
|