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) } }