1package provider
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "io"
9 "net/http"
10 "strings"
11 "testing"
12
13 "golang.org/x/net/websocket"
14)
15
16func TestResponsesCacheDiagnosticsRequireEstimatedThreshold(t *testing.T) {
17 newWire := func(text string) *codexRequest {
18 return &codexRequest{
19 PromptCacheOptions: &codexPromptCacheOptions{Mode: "implicit"},
20 Input: []any{codexInputMessage{Role: "user", Content: []any{
21 codexInputText{Type: "input_text", Text: text},
22 }}},
23 }
24 }
25 if diagnostics := responsesCacheDiagnostics(newWire(strings.Repeat("a", 64)), "http_sse", "full"); diagnostics.Eligible {
26 t.Fatalf("short request diagnostics = %#v, want ineligible", diagnostics)
27 }
28 if diagnostics := responsesCacheDiagnostics(newWire(strings.Repeat("a", 5_000)), "http_sse", "full"); !diagnostics.Eligible {
29 t.Fatalf("long request diagnostics = %#v, want eligible", diagnostics)
30 }
31}
32
33// An image-only tool result must not serialize to an empty
34// function_call_output (the Responses API may reject it) and a
35// following user-message image must serialize as input_image so the
36// model actually receives the bytes.
37func TestCodexImageToolResultMirror(t *testing.T) {
38 c := newOpenAICodexClient("token", "acct", "")
39
40 wire, err := c.buildRequest(Request{
41 Model: "gpt-5.5",
42 Messages: []Message{
43 {Role: RoleUser, Content: []Content{TextBlock{Text: "look at this"}}},
44 {Role: RoleAssistant, Content: []Content{
45 ToolCallBlock{ID: "call_1", Name: "read", Arguments: []byte(`{"path":"x.png"}`)},
46 }},
47 {Role: RoleTool, Content: []Content{
48 ToolResultBlock{CallID: "call_1", Content: []Content{
49 ImageBlock{MimeType: "image/png", Data: []byte("png-bytes")},
50 }},
51 }},
52 // The agent loop appends this mirror after an image tool result.
53 {Role: RoleUser, Content: []Content{
54 TextBlock{Text: "Tool output included the following image content:"},
55 ImageBlock{MimeType: "image/png", Data: []byte("png-bytes")},
56 }},
57 },
58 })
59 if err != nil {
60 t.Fatal(err)
61 }
62
63 var sawFnOutput, sawInputImage bool
64 for _, item := range wire.Input {
65 switch v := item.(type) {
66 case codexFunctionCallOutput:
67 sawFnOutput = true
68 if strings.TrimSpace(v.Output) == "" {
69 t.Fatalf("image-only tool result produced empty function_call_output")
70 }
71 if !strings.Contains(strings.ToLower(v.Output), "image") {
72 t.Fatalf("placeholder should mention image, got %q", v.Output)
73 }
74 case codexInputMessage:
75 for _, ct := range v.Content {
76 if img, ok := ct.(codexInputImage); ok && img.Type == "input_image" {
77 sawInputImage = true
78 }
79 }
80 }
81 }
82 if !sawFnOutput {
83 t.Fatalf("no function_call_output emitted")
84 }
85 if !sawInputImage {
86 t.Fatalf("mirrored user image was not serialized as input_image")
87 }
88}
89
90type roundTripFunc func(*http.Request) (*http.Response, error)
91
92func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
93
94func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
95 c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses")
96 var gotReq *http.Request
97 var gotBody codexRequest
98 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
99 gotReq = r
100 if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
101 t.Fatal(err)
102 }
103 return &http.Response{
104 StatusCode: http.StatusOK,
105 Header: http.Header{"Content-Type": []string{"text/event-stream"}},
106 Body: io.NopCloser(strings.NewReader("")),
107 }, nil
108 })
109
110 events, err := c.Stream(context.Background(), Request{
111 Model: "gpt-5.6-terra",
112 Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"},
113 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
114 })
115 if err != nil {
116 t.Fatal(err)
117 }
118 for range events {
119 }
120
121 if gotReq == nil {
122 t.Fatal("request was not sent")
123 }
124 if gotReq.Header.Get("originator") != "codex_cli_rs" {
125 t.Fatalf("originator = %q", gotReq.Header.Get("originator"))
126 }
127 if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {
128 t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))
129 }
130 if gotBody.PromptCacheKey != "cache-root-1" {
131 t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey)
132 }
133 if gotReq.Header.Get("session-id") != "cache-root-1" || gotReq.Header.Get("thread-id") != "thread-1" || gotReq.Header.Get("x-client-request-id") != "thread-1" {
134 t.Fatalf("routing headers = session=%q thread=%q request=%q", gotReq.Header.Get("session-id"), gotReq.Header.Get("thread-id"), gotReq.Header.Get("x-client-request-id"))
135 }
136}
137
138func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) {
139 c := newOpenAICodexClient("token", "acct", "")
140 wire, err := c.buildRequest(Request{Model: " gpt-5.6-sol ", Reasoning: "max"})
141 if err != nil {
142 t.Fatal(err)
143 }
144 if wire.Model != "gpt-5.6-sol" {
145 t.Fatalf("model = %q, want trimmed id", wire.Model)
146 }
147 if wire.Reasoning == nil || wire.Reasoning.Effort != "max" {
148 t.Fatalf("reasoning = %+v", wire.Reasoning)
149 }
150}
151
152func TestResponsesToolsSelectStrictModeFromSchema(t *testing.T) {
153 c := newOpenAICodexClient("token", "acct", "")
154 wire, err := c.buildRequest(Request{
155 Model: "gpt-5.6-sol",
156 Tools: []Tool{
157 {
158 Name: "grep",
159 Schema: json.RawMessage(`{
160 "type":"object",
161 "properties":{"pattern":{"type":"string"},"path":{"type":"string"}},
162 "required":["pattern","path"],
163 "additionalProperties":false
164 }`),
165 },
166 {
167 Name: "subagent_spawn",
168 Schema: json.RawMessage(`{
169 "type":"object",
170 "properties":{
171 "task":{"type":"string"},
172 "budget_ratio":{"type":"number"},
173 "budget_tokens":{"type":"integer"}
174 },
175 "required":["task"]
176 }`),
177 },
178 },
179 })
180 if err != nil {
181 t.Fatal(err)
182 }
183 if len(wire.Tools) != 2 {
184 t.Fatalf("tools = %d, want 2", len(wire.Tools))
185 }
186 if !wire.Tools[0].Strict {
187 t.Fatal("closed, fully-required grep schema should use strict mode")
188 }
189 if wire.Tools[1].Strict {
190 t.Fatal("schema with optional properties should opt out of strict normalization")
191 }
192
193 c.providerName = ProviderOpenCodeGo
194 wire, err = c.buildRequest(Request{
195 Model: "gpt-5.6-sol",
196 Tools: []Tool{{
197 Name: "grep",
198 Schema: wire.Tools[0].Parameters,
199 }},
200 })
201 if err != nil {
202 t.Fatal(err)
203 }
204 if wire.Tools[0].Strict {
205 t.Fatal("non-OpenAI Responses provider should opt out of strict mode")
206 }
207}
208
209func TestResponsesRequestUsesExplicitReasoningLevelMap(t *testing.T) {
210 SetLiveModels([]Model{{
211 Provider: "custom-responses",
212 ID: "reasoning-model",
213 API: APIResponses,
214 Reasoning: true,
215 ReasoningLevelMap: map[string]string{"max": "max"},
216 }})
217 t.Cleanup(func() { SetLiveModels(nil) })
218
219 named := NewOpenAIResponsesNamed("token", "https://example.test/v1", "custom-responses").(*renamedClient)
220 c := named.inner.(*codexClient)
221 wire, err := c.buildRequest(Request{Model: "reasoning-model", Reasoning: "max"})
222 if err != nil {
223 t.Fatal(err)
224 }
225 if wire.Reasoning == nil || wire.Reasoning.Effort != "max" {
226 t.Fatalf("reasoning = %+v", wire.Reasoning)
227 }
228}
229
230func TestXAIResponsesUsesNamedProviderCatalogAndEndpoint(t *testing.T) {
231 named := NewOpenAIResponsesNamed("token", "https://api.x.ai/v1", "xai").(*renamedClient)
232 c := named.inner.(*codexClient)
233
234 if c.baseURL != "https://api.x.ai/v1/responses" {
235 t.Fatalf("base URL = %q", c.baseURL)
236 }
237 for _, model := range []string{"grok-4.5", "grok-build-0.1"} {
238 if _, err := c.buildRequest(Request{Model: model}); err != nil {
239 t.Errorf("build request for %s: %v", model, err)
240 }
241 }
242}
243
244func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
245 named := NewOpenAIResponsesNamed("token", "https://api.openai.com/v1/responses", "openai").(*renamedClient)
246 ws := named.inner.(*responsesWebSocketClient)
247 c := ws.http
248 var gotReq *http.Request
249 var gotBody codexRequest
250 ws.dial = func(context.Context, Request) (*websocket.Conn, error) {
251 return nil, errors.New("test websocket unavailable")
252 }
253 c.http = openaiResponsesHTTPClient(&http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
254 gotReq = r
255 if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
256 t.Fatal(err)
257 }
258 return &http.Response{
259 StatusCode: http.StatusOK,
260 Header: http.Header{"Content-Type": []string{"text/event-stream"}},
261 Body: io.NopCloser(strings.NewReader("")),
262 }, nil
263 })})
264
265 events, err := ws.Stream(context.Background(), Request{
266 Model: "gpt-5.6-sol",
267 Context: RequestContext{CacheSessionID: "cache-root-2", ThreadID: "thread-2", TurnID: "turn-2"},
268 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
269 })
270 if err != nil {
271 t.Fatal(err)
272 }
273 for range events {
274 }
275
276 if gotReq == nil {
277 t.Fatal("request was not sent")
278 }
279 if gotReq.Header.Get("session-id") != "" {
280 t.Fatalf("session-id = %q", gotReq.Header.Get("session-id"))
281 }
282 if gotBody.PromptCacheKey != "cache-root-2" {
283 t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey)
284 }
285 if gotBody.PromptCacheOptions == nil || gotBody.PromptCacheOptions.Mode != "implicit" || len(gotBody.Input) < 2 {
286 t.Fatalf("explicit cache boundary = options=%+v input=%#v", gotBody.PromptCacheOptions, gotBody.Input)
287 }
288 boundary, ok := gotBody.Input[0].(map[string]any)
289 if !ok || boundary["role"] != "developer" {
290 t.Fatalf("stable cache boundary = %#v", gotBody.Input[0])
291 }
292 content, ok := boundary["content"].([]any)
293 if !ok || len(content) != 1 {
294 t.Fatalf("stable cache boundary content = %#v", boundary)
295 }
296 text, ok := content[0].(map[string]any)
297 if !ok || text["type"] != "input_text" {
298 t.Fatalf("stable cache text block = %#v", content)
299 }
300 breakpoint, ok := text["prompt_cache_breakpoint"].(map[string]any)
301 if !ok || breakpoint["mode"] != "explicit" {
302 t.Fatalf("stable cache breakpoint = %#v", text)
303 }
304 if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" {
305 t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header)
306 }
307}
308
309func TestCustomResponsesEndpointDoesNotInheritOpenAICacheExtensions(t *testing.T) {
310 named := NewOpenAIResponsesNamed("token", "https://example.test/v1/responses", "openai").(*renamedClient)
311 c := named.inner.(*codexClient)
312 var gotBody codexRequest
313 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
314 if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
315 t.Fatal(err)
316 }
317 return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(""))}, nil
318 })
319 events, err := c.Stream(context.Background(), Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root", ThreadID: "thread", TurnID: "turn"}})
320 if err != nil {
321 t.Fatal(err)
322 }
323 for range events {
324 }
325 if gotBody.PromptCacheKey != "" {
326 t.Fatalf("prompt_cache_key = %q, want omitted for undeclared endpoint", gotBody.PromptCacheKey)
327 }
328}
329
330func TestCodexNestedStreamError(t *testing.T) {
331 c := newOpenAICodexClient("token", "acct", "")
332 resp := &http.Response{
333 Body: io.NopCloser(strings.NewReader("data: {\"type\":\"error\",\"error\":{\"code\":\"model_not_available\",\"message\":\"limited preview\"}}\n\n")),
334 }
335 out := make(chan Event, 16)
336 go c.runStream(context.Background(), resp, Request{Model: "gpt-5.6-sol"}, out)
337
338 var got error
339 for ev := range out {
340 if done, ok := ev.(EventDone); ok {
341 got = done.Err
342 }
343 }
344 if got == nil || got.Error() != "codex error: limited preview" {
345 t.Fatalf("error = %v", got)
346 }
347}
348
349// TestCodexSubscriptionAlwaysUsesCodexCLIShape pins the ChatGPT
350// subscription backend to the Codex CLI request identity for every
351// model. The backend load-sheds unrecognized originator/user-agent
352// pairs with "Our servers are currently overloaded" stream errors
353// even when capacity is fine, so the CLI shape is required for
354// reliable service, not just for preview-model admission.
355func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {
356 c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses")
357 var gotReq *http.Request
358 var body bytes.Buffer
359 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
360 gotReq = r
361 _, _ = body.ReadFrom(r.Body)
362 return &http.Response{
363 StatusCode: http.StatusOK,
364 Header: http.Header{"Content-Type": []string{"text/event-stream"}},
365 Body: io.NopCloser(strings.NewReader("")),
366 }, nil
367 })
368
369 events, err := c.Stream(context.Background(), Request{
370 Model: "gpt-5.6-sol",
371 Context: RequestContext{CacheSessionID: "cache-root-2", ThreadID: "thread-2", TurnID: "turn-2"},
372 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
373 })
374 if err != nil {
375 t.Fatal(err)
376 }
377 for range events {
378 }
379
380 if gotReq == nil {
381 t.Fatal("request was not sent")
382 }
383 if gotReq.Header.Get("originator") != "codex_cli_rs" {
384 t.Fatalf("originator = %q", gotReq.Header.Get("originator"))
385 }
386 if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {
387 t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))
388 }
389 if gotReq.Header.Get("session-id") != "cache-root-2" || gotReq.Header.Get("thread-id") != "thread-2" || gotReq.Header.Get("x-client-request-id") != "thread-2" {
390 t.Fatalf("routing headers = session=%q thread=%q request=%q", gotReq.Header.Get("session-id"), gotReq.Header.Get("thread-id"), gotReq.Header.Get("x-client-request-id"))
391 }
392 if !strings.Contains(body.String(), "prompt_cache_key") {
393 t.Fatalf("request missing prompt_cache_key: %s", body.String())
394 }
395}
396
397func TestCodexRetriesUnsupportedPromptCacheRetentionWithNativeIdentity(t *testing.T) {
398 c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses")
399 var headers []http.Header
400 var bodies []string
401 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
402 headers = append(headers, r.Header.Clone())
403 body, err := io.ReadAll(r.Body)
404 if err != nil {
405 t.Fatal(err)
406 }
407 bodies = append(bodies, string(body))
408 if len(headers) == 1 {
409 return &http.Response{
410 StatusCode: http.StatusBadRequest,
411 Header: make(http.Header),
412 Body: io.NopCloser(strings.NewReader(`{
413 "error": {
414 "message": "prompt_cache_retention is not supported on this model",
415 "type": "invalid_request_error",
416 "param": "prompt_cache_retention",
417 "code": "invalid_parameter"
418 }
419 }`)),
420 }, nil
421 }
422 return &http.Response{
423 StatusCode: http.StatusOK,
424 Header: http.Header{"Content-Type": []string{"text/event-stream"}},
425 Body: io.NopCloser(strings.NewReader("")),
426 }, nil
427 })
428
429 events, err := c.Stream(context.Background(), Request{
430 Model: "gpt-5.6-sol",
431 Context: RequestContext{CacheSessionID: "cache-root-3", ThreadID: "thread-3", TurnID: "turn-3"},
432 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
433 })
434 if err != nil {
435 t.Fatal(err)
436 }
437 for event := range events {
438 if done, ok := event.(EventDone); ok && done.Err != nil {
439 t.Fatalf("retry stream failed: %v", done.Err)
440 }
441 }
442
443 if len(headers) != 2 {
444 t.Fatalf("requests = %d, want exactly one retry", len(headers))
445 }
446 if got := headers[0].Get("originator"); got != "codex_cli_rs" {
447 t.Fatalf("initial originator = %q, want codex_cli_rs", got)
448 }
449 // The retry drops the Codex CLI identity and uses the native one on the
450 // same session/request correlation and body.
451 if got := headers[1].Get("originator"); got != "zot" {
452 t.Fatalf("retry originator = %q, want zot", got)
453 }
454 if headers[1].Get("session-id") != "cache-root-3" || headers[1].Get("thread-id") != "thread-3" || headers[1].Get("x-client-request-id") != "thread-3" {
455 t.Fatalf("retry correlation headers = session=%q thread=%q request=%q",
456 headers[1].Get("session-id"), headers[1].Get("thread-id"), headers[1].Get("x-client-request-id"))
457 }
458 if len(bodies) != 2 || !strings.Contains(bodies[1], `"prompt_cache_key":"cache-root-3"`) {
459 t.Fatalf("retry body did not preserve prompt cache key: %v", bodies)
460 }
461}
462
463func TestCodexDoesNotRetryUnrelatedBadRequest(t *testing.T) {
464 c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses")
465 requests := 0
466 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
467 requests++
468 return &http.Response{
469 StatusCode: http.StatusBadRequest,
470 Header: make(http.Header),
471 Body: io.NopCloser(strings.NewReader(`{
472 "error": {"message": "bad input", "param": "input", "code": "invalid_parameter"}
473 }`)),
474 }, nil
475 })
476
477 _, err := c.Stream(context.Background(), Request{
478 Model: "gpt-5.6-sol",
479 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
480 })
481 if err == nil {
482 t.Fatal("expected error, got nil")
483 }
484 if requests != 1 {
485 t.Fatalf("requests = %d, want 1 (no retry)", requests)
486 }
487 if !strings.Contains(err.Error(), "http 400") {
488 t.Fatalf("error = %v, want http 400", err)
489 }
490}
491
492func TestCodexUnsupportedRetentionSecondFailureReturns(t *testing.T) {
493 c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses")
494 requests := 0
495 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
496 requests++
497 return &http.Response{
498 StatusCode: http.StatusBadRequest,
499 Header: make(http.Header),
500 Body: io.NopCloser(strings.NewReader(`{
501 "error": {
502 "message": "prompt_cache_retention is not supported",
503 "param": "prompt_cache_retention",
504 "code": "unsupported_parameter"
505 }
506 }`)),
507 }, nil
508 })
509
510 _, err := c.Stream(context.Background(), Request{
511 Model: "gpt-5.6-sol",
512 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
513 })
514 if err == nil {
515 t.Fatal("expected error, got nil")
516 }
517 if requests != 2 {
518 t.Fatalf("requests = %d, want exactly one retry", requests)
519 }
520 if !strings.Contains(err.Error(), "http 400") {
521 t.Fatalf("error = %v, want second http 400", err)
522 }
523}
524
525func TestUnsupportedPromptCacheRetentionPredicate(t *testing.T) {
526 retention := func(code string) string {
527 return `{"error":{"message":"` + code + `","param":"prompt_cache_retention","code":"invalid_parameter"}}`
528 }
529 cases := []struct {
530 name string
531 status int
532 body string
533 want bool
534 }{
535 {"code match", http.StatusBadRequest, retention("anything"), true},
536 {"message match", http.StatusBadRequest, `{"error":{"message":"retention not supported here","param":"prompt_cache_retention","code":"other"}}`, true},
537 {"wrong status", http.StatusForbidden, retention("anything"), false},
538 {"wrong param", http.StatusBadRequest, `{"error":{"message":"x","param":"input","code":"invalid_parameter"}}`, false},
539 {"no signal", http.StatusBadRequest, `{"error":{"message":"x","param":"prompt_cache_retention","code":"other"}}`, false},
540 {"malformed", http.StatusBadRequest, "not json", false},
541 }
542 for _, tc := range cases {
543 t.Run(tc.name, func(t *testing.T) {
544 if got := unsupportedPromptCacheRetention(tc.status, []byte(tc.body)); got != tc.want {
545 t.Fatalf("predicate = %v, want %v", got, tc.want)
546 }
547 })
548 }
549}