Use resolved targets for client routing

This commit is contained in:
2026-11-05 19:52:48 +00:00
parent de46935a0e
commit 448e64dc40
15 changed files with 157 additions and 396 deletions

View File

@@ -123,26 +123,19 @@ func (p *recordingProvider) lastRequest(t *testing.T) recordedProviderRequest {
return p.requests[len(p.requests)-1]
}
func newProviderClient(t *testing.T, provider *recordingProvider, config OpenAICompatibleConfig) *OpenAICompatibleClient {
t.Helper()
if config.BaseURL == "" {
config.BaseURL = provider.endpoint("/v1")
}
client, err := NewOpenAICompatibleClient(config)
if err != nil {
t.Fatalf("construct client: %v", err)
}
return client
}
func ordinaryGenerateRequest() domain.GenerateRequest {
return domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
Target: domain.ExecutionTarget{Model: "model"},
Target: domain.ExecutionTarget{Endpoint: "https://provider.example/v1", Model: "model"},
}
}
func (p *recordingProvider) generateRequest() domain.GenerateRequest {
req := ordinaryGenerateRequest()
req.Target.Endpoint = p.endpoint("/v1")
return req
}
type deadlineCapturingTransport struct {
deadline time.Time
hasDeadline bool
@@ -253,35 +246,13 @@ func assertDeadlineNear(t *testing.T, deadline, before, after time.Time, duratio
}
}
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{
client := 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)
@@ -305,13 +276,10 @@ func TestNewOpenAICompatibleClientDoesNotMutateSuppliedNonzeroTimeoutClient(t *t
Transport: transport,
}
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
client := 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)
@@ -335,13 +303,10 @@ func TestNewOpenAICompatibleClientTreatsSuppliedNegativeTimeoutAsUnset(t *testin
}
configuredTimeout := 23 * time.Second
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
client := 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)
@@ -370,7 +335,6 @@ func TestOpenAICompatibleClientRequestMapping(t *testing.T) {
{name: "optional response fields", run: checkOptionalResponseFieldOmission},
{name: "reasoning and extra parameters", run: checkReasoningAndExtraParameterMapping},
{name: "optional request field presence", run: checkRequestFieldPresence},
{name: "configured model fallback", run: checkConfiguredModelFallback},
}
for _, tc := range tests {
@@ -380,14 +344,14 @@ func TestOpenAICompatibleClientRequestMapping(t *testing.T) {
func checkCanonicalRoleAndExactContentMapping(t *testing.T) {
provider := newRecordingProvider(t)
client := newProviderClient(t, provider, OpenAICompatibleConfig{})
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{})
const content = " exact content\nwith whitespace "
_, err := client.Generate(context.Background(), domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{
Role: domain.RoleDeveloper,
Content: content,
}}},
Target: domain.ExecutionTarget{Model: "model"},
Target: domain.ExecutionTarget{Endpoint: provider.endpoint("/v1"), Model: "model"},
})
if err != nil {
t.Fatalf("Generate() error = %v", err)
@@ -413,7 +377,7 @@ func checkCompleteRequestAndResponseMapping(t *testing.T) {
"usage": {"prompt_tokens": 11, "completion_tokens": 22, "total_tokens": 33}
}`)
client := newProviderClient(t, provider, OpenAICompatibleConfig{
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{
Timeout: 2 * time.Second,
})
t.Setenv("PROMPTKIT_TEST_API_KEY", "secret-key")
@@ -424,6 +388,7 @@ func checkCompleteRequestAndResponseMapping(t *testing.T) {
{Role: "user", Content: "Say hello"},
}},
Target: domain.ExecutionTarget{
Endpoint: provider.endpoint("/v1"),
Model: "gpt-test",
Temperature: 0.4,
MaxTokens: 123,
@@ -594,9 +559,11 @@ func TestOpenAICompatibleClientAuthentication(t *testing.T) {
tc.configureEnv(t)
}
provider := newRecordingProvider(t)
client := newProviderClient(t, provider, OpenAICompatibleConfig{Model: "model"})
request := ordinaryGenerateRequest()
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{})
request := provider.generateRequest()
request.Target = tc.target
request.Target.Endpoint = provider.endpoint("/v1")
request.Target.Model = "model"
_, err := client.Generate(context.Background(), request)
if tc.wantError != nil {
@@ -625,7 +592,7 @@ func TestOpenAICompatibleClientAuthentication(t *testing.T) {
func checkCacheControlledMessageMapping(t *testing.T) {
provider := newRecordingProvider(t)
client := newProviderClient(t, provider, OpenAICompatibleConfig{})
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{})
_, err := client.Generate(context.Background(), domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{
@@ -639,7 +606,7 @@ func checkCacheControlledMessageMapping(t *testing.T) {
},
{Role: "user", Content: "Dynamic request."},
}},
Target: domain.ExecutionTarget{Model: "model"},
Target: domain.ExecutionTarget{Endpoint: provider.endpoint("/v1"), Model: "model"},
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
@@ -691,7 +658,7 @@ func checkCacheControlledMessageMapping(t *testing.T) {
func checkEmptyCacheControlTTLOmission(t *testing.T) {
provider := newRecordingProvider(t)
client := newProviderClient(t, provider, OpenAICompatibleConfig{})
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{})
_, err := client.Generate(context.Background(), domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{
@@ -703,7 +670,7 @@ func checkEmptyCacheControlTTLOmission(t *testing.T) {
},
},
}},
Target: domain.ExecutionTarget{Model: "model"},
Target: domain.ExecutionTarget{Endpoint: provider.endpoint("/v1"), Model: "model"},
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
@@ -745,8 +712,8 @@ func checkSessionIDMapping(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
provider := newRecordingProvider(t)
client := newProviderClient(t, provider, OpenAICompatibleConfig{})
request := ordinaryGenerateRequest()
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{})
request := provider.generateRequest()
request.Prompt.SessionID = tc.sessionID
if _, err := client.Generate(context.Background(), request); err != nil {
@@ -766,19 +733,14 @@ func checkSessionIDMapping(t *testing.T) {
}
func TestOpenAICompatibleClientRejectsTooLongSessionID(t *testing.T) {
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "http://example.com/v1",
Model: "model",
})
if err != nil {
t.Fatal(err)
}
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{})
_, err = client.Generate(context.Background(), domain.GenerateRequest{
_, err := client.Generate(context.Background(), domain.GenerateRequest{
Prompt: domain.RenderedPrompt{
SessionID: strings.Repeat("x", domain.SessionIDMaxLength+1),
Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}},
},
Target: domain.ExecutionTarget{Endpoint: "http://example.com/v1", Model: "model"},
})
if err == nil {
t.Fatal("expected invalid request error")
@@ -828,9 +790,9 @@ func checkContentPresence(t *testing.T) {
"cache_write_tokens": 5
}
}`)
client := newProviderClient(t, provider, OpenAICompatibleConfig{Model: "model"})
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{})
response, err := client.Generate(context.Background(), ordinaryGenerateRequest())
response, err := client.Generate(context.Background(), provider.generateRequest())
if err != nil {
t.Fatalf("generate: %v", err)
}
@@ -865,10 +827,11 @@ func checkCacheUsageMapping(t *testing.T) {
"cache_write_tokens": 60
}
}`)
client := newProviderClient(t, provider, OpenAICompatibleConfig{Model: "model"})
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{})
resp, err := client.Generate(context.Background(), domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
Target: domain.ExecutionTarget{Endpoint: provider.endpoint("/v1"), Model: "model"},
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
@@ -901,8 +864,8 @@ func TestOpenAICompatibleUsagePresence(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
provider := newRecordingProvider(t)
provider.respond(http.StatusOK, `{`+tc.usage+`"choices":[{"message":{"content":"ok"}}]}`)
client := newProviderClient(t, provider, OpenAICompatibleConfig{Model: "model"})
resp, err := client.Generate(context.Background(), ordinaryGenerateRequest())
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{})
resp, err := client.Generate(context.Background(), provider.generateRequest())
if err != nil {
t.Fatal(err)
}
@@ -913,17 +876,17 @@ func TestOpenAICompatibleUsagePresence(t *testing.T) {
}
provider := newRecordingProvider(t)
provider.respond(http.StatusOK, `{"usage":{"prompt_tokens":"seven"},"choices":[{"message":{"content":"ok"}}]}`)
client := newProviderClient(t, provider, OpenAICompatibleConfig{Model: "model"})
if resp, err := client.Generate(context.Background(), ordinaryGenerateRequest()); resp != nil || !errors.Is(err, ErrMalformedResponse) {
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{})
if resp, err := client.Generate(context.Background(), provider.generateRequest()); resp != nil || !errors.Is(err, ErrMalformedResponse) {
t.Fatalf("invalid numeric usage response=%+v, error=%v", resp, err)
}
}
func checkOptionalResponseFieldOmission(t *testing.T) {
provider := newRecordingProvider(t)
client := newProviderClient(t, provider, OpenAICompatibleConfig{})
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{})
_, err := client.Generate(context.Background(), ordinaryGenerateRequest())
_, err := client.Generate(context.Background(), provider.generateRequest())
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
@@ -938,11 +901,12 @@ func checkOptionalResponseFieldOmission(t *testing.T) {
func checkReasoningAndExtraParameterMapping(t *testing.T) {
provider := newRecordingProvider(t)
client := newProviderClient(t, provider, OpenAICompatibleConfig{})
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{})
_, err := client.Generate(context.Background(), domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
Target: domain.ExecutionTarget{
Endpoint: provider.endpoint("/v1"),
Model: "model",
ReasoningEffort: "high",
ExtraParams: map[string]any{
@@ -1020,8 +984,8 @@ func checkRequestFieldPresence(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
provider := newRecordingProvider(t)
client := newProviderClient(t, provider, OpenAICompatibleConfig{})
request := ordinaryGenerateRequest()
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{})
request := provider.generateRequest()
request.TargetPresence = tc.presence
if _, err := client.Generate(context.Background(), request); err != nil {
@@ -1073,21 +1037,17 @@ func checkOmittedGenerationTimeout(t *testing.T) {
transport := &deadlineCapturingTransport{}
clientTimeout := 5 * time.Second
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "http://example.com/v1",
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{
Timeout: clientTimeout,
HTTPClient: &http.Client{
Transport: transport,
},
})
if err != nil {
t.Fatal(err)
}
before := time.Now()
_, err = client.Generate(context.Background(), domain.GenerateRequest{
_, err := client.Generate(context.Background(), domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
Target: domain.ExecutionTarget{Model: "model", TimeoutSeconds: 0},
Target: domain.ExecutionTarget{Endpoint: "http://example.com/v1", Model: "model", TimeoutSeconds: 0},
})
after := time.Now()
if err == nil {
@@ -1146,11 +1106,11 @@ func TestOpenAICompatibleClientRejectsInvalidExtraParamsBeforeProviderCall(t *te
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
provider := newRecordingProvider(t)
client := newProviderClient(t, provider, OpenAICompatibleConfig{})
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{})
_, 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},
Target: domain.ExecutionTarget{Endpoint: provider.endpoint("/v1"), Model: "model", ExtraParams: tc.extraParams},
})
if err == nil {
t.Fatal("expected invalid request error")
@@ -1192,8 +1152,8 @@ func TestOpenAICompatibleClientClassifiesSchemaEncodingFailures(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
provider := newRecordingProvider(t)
client := newProviderClient(t, provider, OpenAICompatibleConfig{})
request := ordinaryGenerateRequest()
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{})
request := provider.generateRequest()
request.Target.ExtraParams = tc.extraParams
request.StructuredOutput = &domain.StructuredOutputSpec{
Type: domain.StructuredOutputJSONSchema,
@@ -1218,30 +1178,12 @@ func TestOpenAICompatibleClientClassifiesSchemaEncodingFailures(t *testing.T) {
}
}
func checkConfiguredModelFallback(t *testing.T) {
provider := newRecordingProvider(t)
client := newProviderClient(t, provider, OpenAICompatibleConfig{Model: "default-model"})
_, 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)
}
var model string
if !provider.lastRequest(t).decodeField(t, "model", &model) || model != "default-model" {
t.Fatalf("model = %q, want default-model", model)
}
}
func TestOpenAICompatibleClientEndpointComposition(t *testing.T) {
tests := []struct {
name string
run func(*testing.T)
}{
{name: "request endpoint overrides configured endpoint", run: checkEndpointOverride},
{name: "request endpoint works without configured endpoint", run: checkEmptyConfiguredEndpoint},
{name: "request endpoint", run: checkSelectedEndpoint},
{name: "completion URL preserves valid base paths", run: checkCompletionURLComposition},
{name: "invalid selected endpoint fails before transport", run: checkInvalidSelectedEndpointRejection},
{name: "an endpoint is required", run: checkRequiredEndpoint},
@@ -1252,35 +1194,6 @@ func TestOpenAICompatibleClientEndpointComposition(t *testing.T) {
}
}
func checkEndpointOverride(t *testing.T) {
configuredProvider := newRecordingProvider(t)
configuredProvider.respond(http.StatusOK, `{"choices":[{"message":{"content":"default"}}]}`)
selectedProvider := newRecordingProvider(t)
selectedProvider.respond(http.StatusOK, `{"choices":[{"message":{"content":"override"}}]}`)
client := newProviderClient(t, configuredProvider, OpenAICompatibleConfig{Model: "m"})
resp, err := client.Generate(context.Background(), domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
Target: domain.ExecutionTarget{Endpoint: selectedProvider.endpoint("/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 got := configuredProvider.requestCount(); got != 0 {
t.Fatalf("configured provider calls = %d, want 0", got)
}
if got := selectedProvider.requestCount(); got != 1 {
t.Fatalf("selected provider calls = %d, want 1", got)
}
if got := selectedProvider.lastRequest(t).url; got != "/v1/chat/completions" {
t.Fatalf("selected URL = %q, want /v1/chat/completions", got)
}
}
func checkCommonResponseFailures(t *testing.T) {
const sensitiveBody = `provider-secret-fragment request_payload_details`
tests := []struct {
@@ -1310,9 +1223,9 @@ func checkCommonResponseFailures(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
provider := newRecordingProvider(t)
provider.respond(tc.statusCode, tc.body)
client := newProviderClient(t, provider, OpenAICompatibleConfig{Model: "m"})
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{})
response, err := client.Generate(context.Background(), ordinaryGenerateRequest())
response, err := client.Generate(context.Background(), provider.generateRequest())
if response != nil {
t.Fatalf("expected no partial response, got %+v", response)
}
@@ -1357,9 +1270,7 @@ func checkSuccessfulResponseByteBoundary(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
body := sizedSuccessResponseBody(tc.size)
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "https://provider.example/v1",
Model: "m",
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
@@ -1370,12 +1281,10 @@ func checkSuccessfulResponseByteBoundary(t *testing.T) {
}, nil
})},
})
if err != nil {
t.Fatalf("construct client: %v", err)
}
response, err := client.Generate(context.Background(), domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
Target: domain.ExecutionTarget{Endpoint: "https://provider.example/v1", Model: "m"},
})
if tc.wantErr {
if response != nil {
@@ -1425,9 +1334,7 @@ func checkContinuingOversizedResponse(t *testing.T) {
strings.NewReader(prefix),
continuation,
)}
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "https://provider.example/v1",
Model: "m",
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
@@ -1438,9 +1345,6 @@ func checkContinuingOversizedResponse(t *testing.T) {
}, nil
})},
})
if err != nil {
t.Fatalf("construct client: %v", err)
}
type outcome struct {
response *domain.GenerateResponse
@@ -1450,6 +1354,7 @@ func checkContinuingOversizedResponse(t *testing.T) {
go func() {
response, generateErr := client.Generate(context.Background(), domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
Target: domain.ExecutionTarget{Endpoint: "https://provider.example/v1", Model: "m"},
})
done <- outcome{response: response, err: generateErr}
}()
@@ -1497,9 +1402,7 @@ func checkSingleResponseDocument(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
body := &countingReadCloser{reader: strings.NewReader(tc.body)}
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "https://provider.example/v1",
Model: "m",
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
@@ -1510,12 +1413,10 @@ func checkSingleResponseDocument(t *testing.T) {
}, nil
})},
})
if err != nil {
t.Fatalf("construct client: %v", err)
}
response, err := client.Generate(context.Background(), domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
Target: domain.ExecutionTarget{Endpoint: "https://provider.example/v1", Model: "m"},
})
if tc.wantErr {
if response != nil {
@@ -1541,22 +1442,19 @@ func checkGenerationTimeoutPrecedence(t *testing.T) {
transport := &deadlineCapturingTransport{err: context.DeadlineExceeded}
generationTimeout := 2 * time.Second
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "http://example.com/v1",
Model: "m",
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{
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{
_, err := client.Generate(context.Background(), domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
Target: domain.ExecutionTarget{
Endpoint: "http://example.com/v1",
Model: "m",
TimeoutSeconds: int(generationTimeout / time.Second),
},
})
@@ -1578,25 +1476,22 @@ func checkGenerationTimeoutPrecedence(t *testing.T) {
func checkCallerDeadlinePrecedence(t *testing.T) {
transport := &deadlineCapturingTransport{err: context.DeadlineExceeded}
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "http://example.com/v1",
Model: "m",
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{
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{
_, err := client.Generate(ctx, domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
Target: domain.ExecutionTarget{
Endpoint: "http://example.com/v1",
Model: "m",
TimeoutSeconds: 2,
},
})
@@ -1618,22 +1513,18 @@ func checkCallerDeadlinePrecedence(t *testing.T) {
}
func checkCallerCancellationIdentity(t *testing.T) {
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "http://example.com/v1",
Model: "m",
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{
HTTPClient: &http.Client{
Transport: contextErrorTransport{},
},
})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err = client.Generate(ctx, domain.GenerateRequest{
_, err := client.Generate(ctx, domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
Target: domain.ExecutionTarget{Endpoint: "http://example.com/v1", Model: "m"},
})
if err == nil {
t.Fatal("expected cancellation error")
@@ -1647,21 +1538,17 @@ func checkCallerCancellationIdentity(t *testing.T) {
}
func checkExpiredCallerDeadlineIdentity(t *testing.T) {
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "http://example.com/v1",
Model: "m",
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{
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{
_, err := client.Generate(ctx, domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
Target: domain.ExecutionTarget{Endpoint: "http://example.com/v1", Model: "m"},
})
if !errors.Is(err, ErrRequestFailed) || !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected request failure and caller deadline identities, got %v", err)
@@ -1669,20 +1556,16 @@ func checkExpiredCallerDeadlineIdentity(t *testing.T) {
}
func checkWholeRequestTimeoutIdentity(t *testing.T) {
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "http://example.com/v1",
Model: "m",
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{
HTTPClient: &http.Client{
Timeout: time.Millisecond,
Transport: waitingContextTransport{},
},
})
if err != nil {
t.Fatal(err)
}
_, err = client.Generate(context.Background(), domain.GenerateRequest{
_, err := client.Generate(context.Background(), domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
Target: domain.ExecutionTarget{Endpoint: "http://example.com/v1", Model: "m"},
})
if !errors.Is(err, ErrRequestFailed) || !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected request failure and client timeout identities, got %v", err)
@@ -1696,20 +1579,15 @@ func checkTransportFailureIdentityAndRedaction(t *testing.T) {
apiKey = "sensitive-api-key"
content = "sensitive prompt content"
)
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: endpoint,
Model: "m",
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{
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{
_, err := client.Generate(context.Background(), domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: content}}},
Target: domain.ExecutionTarget{APIKey: apiKey},
Target: domain.ExecutionTarget{Endpoint: endpoint, Model: "m", APIKey: apiKey},
})
if !errors.Is(err, ErrRequestFailed) || !errors.Is(err, transportCause) {
t.Fatalf("expected request failure and transport cause identities, got %v", err)
@@ -1726,13 +1604,7 @@ func checkTransportFailureIdentityAndRedaction(t *testing.T) {
}
func TestOpenAICompatibleClientRejectsInvalidExecutionSettings(t *testing.T) {
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "http://example.com/v1",
Model: "m",
})
if err != nil {
t.Fatal(err)
}
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{})
type testCase struct {
name string
@@ -1765,11 +1637,9 @@ func TestOpenAICompatibleClientRejectsInvalidExecutionSettings(t *testing.T) {
}
}
func checkEmptyConfiguredEndpoint(t *testing.T) {
func checkSelectedEndpoint(t *testing.T) {
var selectedURL string
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "",
Model: "m",
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
selectedURL = req.URL.String()
return &http.Response{
@@ -1782,13 +1652,10 @@ func checkEmptyConfiguredEndpoint(t *testing.T) {
}, 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"},
Target: domain.ExecutionTarget{Endpoint: "http://request-endpoint.example/v1", Model: "m"},
})
if err != nil {
t.Fatalf("generate with request endpoint: %v", err)
@@ -1817,9 +1684,7 @@ func checkCompletionURLComposition(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var selectedURL string
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: tc.baseURL,
Model: "m",
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{
HTTPClient: &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
selectedURL = req.URL.String()
return &http.Response{
@@ -1830,12 +1695,10 @@ func checkCompletionURLComposition(t *testing.T) {
}, nil
})},
})
if err != nil {
t.Fatalf("construct client: %v", err)
}
_, err = client.Generate(context.Background(), domain.GenerateRequest{
_, err := client.Generate(context.Background(), domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
Target: domain.ExecutionTarget{Endpoint: tc.baseURL, Model: "m"},
})
if err != nil {
t.Fatalf("generate: %v", err)
@@ -1860,21 +1723,16 @@ func checkInvalidSelectedEndpointRejection(t *testing.T) {
for _, endpoint := range invalidEndpoints {
t.Run(endpoint, func(t *testing.T) {
transportCalls := 0
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "https://configured.example/v1",
Model: "m",
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{
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{
_, err := client.Generate(context.Background(), domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
Target: domain.ExecutionTarget{Endpoint: endpoint},
Target: domain.ExecutionTarget{Endpoint: endpoint, Model: "m"},
})
if !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("expected ErrInvalidRequest, got %v", err)
@@ -1890,17 +1748,11 @@ func checkInvalidSelectedEndpointRejection(t *testing.T) {
}
func checkRequiredEndpoint(t *testing.T) {
client, err := NewOpenAICompatibleClient(OpenAICompatibleConfig{
BaseURL: "",
Model: "m",
})
if err != nil {
t.Fatal(err)
}
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{})
_, err = client.Generate(context.Background(), domain.GenerateRequest{
_, err := client.Generate(context.Background(), domain.GenerateRequest{
Prompt: domain.RenderedPrompt{Messages: []domain.RenderedMessage{{Role: "user", Content: "hi"}}},
Target: domain.ExecutionTarget{},
Target: domain.ExecutionTarget{Model: "m"},
})
if err == nil {
t.Fatal("expected endpoint-required error")
@@ -1909,3 +1761,13 @@ func checkRequiredEndpoint(t *testing.T) {
t.Fatalf("expected ErrInvalidRequest, got %v", err)
}
}
func TestOpenAICompatibleClientRequiresModel(t *testing.T) {
client := NewOpenAICompatibleClient(OpenAICompatibleConfig{})
_, err := client.PrepareGeneration(domain.GenerateRequest{
Target: domain.ExecutionTarget{Endpoint: "https://provider.example/v1"},
})
if !errors.Is(err, ErrInvalidRequest) {
t.Fatalf("missing model error = %v, want invalid request", err)
}
}