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