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.Reasoning == nil || wire.Reasoning.Effort != "max" {
145 t.Fatalf("reasoning = %+v", wire.Reasoning)
146 }
147}
148
149func TestResponsesToolsExplicitlyDisableStrictSchemaNormalization(t *testing.T) {
150 c := newOpenAICodexClient("token", "acct", "")
151 wire, err := c.buildRequest(Request{
152 Model: "gpt-5.6-sol",
153 Tools: []Tool{{
154 Name: "subagent_spawn",
155 Schema: json.RawMessage(`{
156 "type":"object",
157 "properties":{
158 "task":{"type":"string"},
159 "budget_ratio":{"type":"number"},
160 "budget_tokens":{"type":"integer"}
161 },
162 "required":["task"]
163 }`),
164 }},
165 })
166 if err != nil {
167 t.Fatal(err)
168 }
169 encoded, err := json.Marshal(wire.Tools)
170 if err != nil {
171 t.Fatal(err)
172 }
173 if !bytes.Contains(encoded, []byte(`"strict":false`)) {
174 t.Fatalf("responses tool omitted strict opt-out: %s", encoded)
175 }
176}
177
178func TestResponsesRequestUsesExplicitReasoningLevelMap(t *testing.T) {
179 SetLiveModels([]Model{{
180 Provider: "custom-responses",
181 ID: "reasoning-model",
182 API: APIResponses,
183 Reasoning: true,
184 ReasoningLevelMap: map[string]string{"max": "max"},
185 }})
186 t.Cleanup(func() { SetLiveModels(nil) })
187
188 named := NewOpenAIResponsesNamed("token", "https://example.test/v1", "custom-responses").(*renamedClient)
189 c := named.inner.(*codexClient)
190 wire, err := c.buildRequest(Request{Model: "reasoning-model", Reasoning: "max"})
191 if err != nil {
192 t.Fatal(err)
193 }
194 if wire.Reasoning == nil || wire.Reasoning.Effort != "max" {
195 t.Fatalf("reasoning = %+v", wire.Reasoning)
196 }
197}
198
199func TestXAIResponsesUsesNamedProviderCatalogAndEndpoint(t *testing.T) {
200 named := NewOpenAIResponsesNamed("token", "https://api.x.ai/v1", "xai").(*renamedClient)
201 c := named.inner.(*codexClient)
202
203 if c.baseURL != "https://api.x.ai/v1/responses" {
204 t.Fatalf("base URL = %q", c.baseURL)
205 }
206 for _, model := range []string{"grok-4.5", "grok-build-0.1"} {
207 if _, err := c.buildRequest(Request{Model: model}); err != nil {
208 t.Errorf("build request for %s: %v", model, err)
209 }
210 }
211}
212
213func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
214 named := NewOpenAIResponsesNamed("token", "https://api.openai.com/v1/responses", "openai").(*renamedClient)
215 ws := named.inner.(*responsesWebSocketClient)
216 c := ws.http
217 var gotReq *http.Request
218 var gotBody codexRequest
219 ws.dial = func(context.Context, Request) (*websocket.Conn, error) {
220 return nil, errors.New("test websocket unavailable")
221 }
222 c.http = openaiResponsesHTTPClient(&http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
223 gotReq = r
224 if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
225 t.Fatal(err)
226 }
227 return &http.Response{
228 StatusCode: http.StatusOK,
229 Header: http.Header{"Content-Type": []string{"text/event-stream"}},
230 Body: io.NopCloser(strings.NewReader("")),
231 }, nil
232 })})
233
234 events, err := ws.Stream(context.Background(), Request{
235 Model: "gpt-5.6-sol",
236 Context: RequestContext{CacheSessionID: "cache-root-2", ThreadID: "thread-2", TurnID: "turn-2"},
237 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
238 })
239 if err != nil {
240 t.Fatal(err)
241 }
242 for range events {
243 }
244
245 if gotReq == nil {
246 t.Fatal("request was not sent")
247 }
248 if gotReq.Header.Get("session-id") != "" {
249 t.Fatalf("session-id = %q", gotReq.Header.Get("session-id"))
250 }
251 if gotBody.PromptCacheKey != "cache-root-2" {
252 t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey)
253 }
254 if gotBody.PromptCacheOptions == nil || gotBody.PromptCacheOptions.Mode != "implicit" || len(gotBody.Input) < 2 {
255 t.Fatalf("explicit cache boundary = options=%+v input=%#v", gotBody.PromptCacheOptions, gotBody.Input)
256 }
257 boundary, ok := gotBody.Input[0].(map[string]any)
258 if !ok || boundary["role"] != "developer" {
259 t.Fatalf("stable cache boundary = %#v", gotBody.Input[0])
260 }
261 content, ok := boundary["content"].([]any)
262 if !ok || len(content) != 1 {
263 t.Fatalf("stable cache boundary content = %#v", boundary)
264 }
265 text, ok := content[0].(map[string]any)
266 if !ok || text["type"] != "input_text" {
267 t.Fatalf("stable cache text block = %#v", content)
268 }
269 breakpoint, ok := text["prompt_cache_breakpoint"].(map[string]any)
270 if !ok || breakpoint["mode"] != "explicit" {
271 t.Fatalf("stable cache breakpoint = %#v", text)
272 }
273 if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" {
274 t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header)
275 }
276}
277
278func TestCustomResponsesEndpointDoesNotInheritOpenAICacheExtensions(t *testing.T) {
279 named := NewOpenAIResponsesNamed("token", "https://example.test/v1/responses", "openai").(*renamedClient)
280 c := named.inner.(*codexClient)
281 var gotBody codexRequest
282 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
283 if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
284 t.Fatal(err)
285 }
286 return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(""))}, nil
287 })
288 events, err := c.Stream(context.Background(), Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root", ThreadID: "thread", TurnID: "turn"}})
289 if err != nil {
290 t.Fatal(err)
291 }
292 for range events {
293 }
294 if gotBody.PromptCacheKey != "" {
295 t.Fatalf("prompt_cache_key = %q, want omitted for undeclared endpoint", gotBody.PromptCacheKey)
296 }
297}
298
299func TestCodexNestedStreamError(t *testing.T) {
300 c := newOpenAICodexClient("token", "acct", "")
301 resp := &http.Response{
302 Body: io.NopCloser(strings.NewReader("data: {\"type\":\"error\",\"error\":{\"code\":\"model_not_available\",\"message\":\"limited preview\"}}\n\n")),
303 }
304 out := make(chan Event, 16)
305 go c.runStream(context.Background(), resp, Request{Model: "gpt-5.6-sol"}, out)
306
307 var got error
308 for ev := range out {
309 if done, ok := ev.(EventDone); ok {
310 got = done.Err
311 }
312 }
313 if got == nil || got.Error() != "codex error: limited preview" {
314 t.Fatalf("error = %v", got)
315 }
316}
317
318// TestCodexSubscriptionAlwaysUsesCodexCLIShape pins the ChatGPT
319// subscription backend to the Codex CLI request identity for every
320// model. The backend load-sheds unrecognized originator/user-agent
321// pairs with "Our servers are currently overloaded" stream errors
322// even when capacity is fine, so the CLI shape is required for
323// reliable service, not just for preview-model admission.
324func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {
325 c := newOpenAICodexClient("token", "acct", "https://example.test/backend-api/codex/responses")
326 var gotReq *http.Request
327 var body bytes.Buffer
328 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
329 gotReq = r
330 _, _ = body.ReadFrom(r.Body)
331 return &http.Response{
332 StatusCode: http.StatusOK,
333 Header: http.Header{"Content-Type": []string{"text/event-stream"}},
334 Body: io.NopCloser(strings.NewReader("")),
335 }, nil
336 })
337
338 events, err := c.Stream(context.Background(), Request{
339 Model: "gpt-5.6-sol",
340 Context: RequestContext{CacheSessionID: "cache-root-2", ThreadID: "thread-2", TurnID: "turn-2"},
341 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
342 })
343 if err != nil {
344 t.Fatal(err)
345 }
346 for range events {
347 }
348
349 if gotReq == nil {
350 t.Fatal("request was not sent")
351 }
352 if gotReq.Header.Get("originator") != "codex_cli_rs" {
353 t.Fatalf("originator = %q", gotReq.Header.Get("originator"))
354 }
355 if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {
356 t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))
357 }
358 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" {
359 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"))
360 }
361 if !strings.Contains(body.String(), "prompt_cache_key") {
362 t.Fatalf("request missing prompt_cache_key: %s", body.String())
363 }
364}