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