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
16// An image-only tool result must not serialize to an empty
17// function_call_output (the Responses API may reject it) and a
18// following user-message image must serialize as input_image so the
19// model actually receives the bytes.
20func TestCodexImageToolResultMirror(t *testing.T) {
21 c := NewOpenAICodex("token", "acct", "").(*codexClient)
22
23 wire, err := c.buildRequest(Request{
24 Model: "gpt-5.5",
25 Messages: []Message{
26 {Role: RoleUser, Content: []Content{TextBlock{Text: "look at this"}}},
27 {Role: RoleAssistant, Content: []Content{
28 ToolCallBlock{ID: "call_1", Name: "read", Arguments: []byte(`{"path":"x.png"}`)},
29 }},
30 {Role: RoleTool, Content: []Content{
31 ToolResultBlock{CallID: "call_1", Content: []Content{
32 ImageBlock{MimeType: "image/png", Data: []byte("png-bytes")},
33 }},
34 }},
35 // The agent loop appends this mirror after an image tool result.
36 {Role: RoleUser, Content: []Content{
37 TextBlock{Text: "Tool output included the following image content:"},
38 ImageBlock{MimeType: "image/png", Data: []byte("png-bytes")},
39 }},
40 },
41 })
42 if err != nil {
43 t.Fatal(err)
44 }
45
46 var sawFnOutput, sawInputImage bool
47 for _, item := range wire.Input {
48 switch v := item.(type) {
49 case codexFunctionCallOutput:
50 sawFnOutput = true
51 if strings.TrimSpace(v.Output) == "" {
52 t.Fatalf("image-only tool result produced empty function_call_output")
53 }
54 if !strings.Contains(strings.ToLower(v.Output), "image") {
55 t.Fatalf("placeholder should mention image, got %q", v.Output)
56 }
57 case codexInputMessage:
58 for _, ct := range v.Content {
59 if img, ok := ct.(codexInputImage); ok && img.Type == "input_image" {
60 sawInputImage = true
61 }
62 }
63 }
64 }
65 if !sawFnOutput {
66 t.Fatalf("no function_call_output emitted")
67 }
68 if !sawInputImage {
69 t.Fatalf("mirrored user image was not serialized as input_image")
70 }
71}
72
73type roundTripFunc func(*http.Request) (*http.Response, error)
74
75func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
76
77func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
78 c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient)
79 var gotReq *http.Request
80 var gotBody codexRequest
81 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
82 gotReq = r
83 if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
84 t.Fatal(err)
85 }
86 return &http.Response{
87 StatusCode: http.StatusOK,
88 Header: http.Header{"Content-Type": []string{"text/event-stream"}},
89 Body: io.NopCloser(strings.NewReader("")),
90 }, nil
91 })
92
93 events, err := c.Stream(context.Background(), Request{
94 Model: "gpt-5.6-terra",
95 Context: RequestContext{SessionID: "logical-session-1", TurnID: "turn-1"},
96 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
97 })
98 if err != nil {
99 t.Fatal(err)
100 }
101 for range events {
102 }
103
104 if gotReq == nil {
105 t.Fatal("request was not sent")
106 }
107 if gotReq.Header.Get("originator") != "codex_cli_rs" {
108 t.Fatalf("originator = %q", gotReq.Header.Get("originator"))
109 }
110 if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {
111 t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))
112 }
113 if gotBody.PromptCacheKey != "logical-session-1" {
114 t.Fatalf("prompt_cache_key = %q, want logical session ID", gotBody.PromptCacheKey)
115 }
116 if gotReq.Header.Get("session-id") != "logical-session-1" {
117 t.Fatalf("session-id = %q, want logical session ID", gotReq.Header.Get("session-id"))
118 }
119}
120
121func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) {
122 c := NewOpenAICodex("token", "acct", "").(*codexClient)
123 wire, err := c.buildRequest(Request{Model: "gpt-5.6-sol", Reasoning: "max"})
124 if err != nil {
125 t.Fatal(err)
126 }
127 if wire.Reasoning == nil || wire.Reasoning.Effort != "max" {
128 t.Fatalf("reasoning = %+v", wire.Reasoning)
129 }
130}
131
132func TestResponsesRequestUsesExplicitReasoningLevelMap(t *testing.T) {
133 SetLiveModels([]Model{{
134 Provider: "custom-responses",
135 ID: "reasoning-model",
136 API: APIResponses,
137 Reasoning: true,
138 ReasoningLevelMap: map[string]string{"max": "max"},
139 }})
140 t.Cleanup(func() { SetLiveModels(nil) })
141
142 named := NewOpenAIResponsesNamed("token", "https://example.test/v1", "custom-responses").(*renamedClient)
143 c := named.inner.(*codexClient)
144 wire, err := c.buildRequest(Request{Model: "reasoning-model", Reasoning: "max"})
145 if err != nil {
146 t.Fatal(err)
147 }
148 if wire.Reasoning == nil || wire.Reasoning.Effort != "max" {
149 t.Fatalf("reasoning = %+v", wire.Reasoning)
150 }
151}
152
153func TestXAIResponsesUsesNamedProviderCatalogAndEndpoint(t *testing.T) {
154 named := NewOpenAIResponsesNamed("token", "https://api.x.ai/v1", "xai").(*renamedClient)
155 c := named.inner.(*codexClient)
156
157 if c.baseURL != "https://api.x.ai/v1/responses" {
158 t.Fatalf("base URL = %q", c.baseURL)
159 }
160 for _, model := range []string{"grok-4.5", "grok-build-0.1"} {
161 if _, err := c.buildRequest(Request{Model: model}); err != nil {
162 t.Errorf("build request for %s: %v", model, err)
163 }
164 }
165}
166
167func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
168 named := NewOpenAIResponsesNamed("token", "https://api.openai.com/v1/responses", "openai").(*renamedClient)
169 ws := named.inner.(*responsesWebSocketClient)
170 c := ws.http
171 var gotReq *http.Request
172 var gotBody codexRequest
173 ws.dial = func(context.Context) (*websocket.Conn, error) {
174 return nil, errors.New("test websocket unavailable")
175 }
176 c.http = openaiResponsesHTTPClient(&http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
177 gotReq = r
178 if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
179 t.Fatal(err)
180 }
181 return &http.Response{
182 StatusCode: http.StatusOK,
183 Header: http.Header{"Content-Type": []string{"text/event-stream"}},
184 Body: io.NopCloser(strings.NewReader("")),
185 }, nil
186 })})
187
188 events, err := ws.Stream(context.Background(), Request{
189 Model: "gpt-5.6-sol",
190 Context: RequestContext{SessionID: "logical-session-2", TurnID: "turn-2"},
191 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
192 })
193 if err != nil {
194 t.Fatal(err)
195 }
196 for range events {
197 }
198
199 if gotReq == nil {
200 t.Fatal("request was not sent")
201 }
202 if gotReq.Header.Get("session-id") != "" {
203 t.Fatalf("session-id = %q", gotReq.Header.Get("session-id"))
204 }
205 if gotBody.PromptCacheKey != "logical-session-2" {
206 t.Fatalf("prompt_cache_key = %q, want logical session ID", gotBody.PromptCacheKey)
207 }
208 if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" {
209 t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header)
210 }
211}
212
213func TestCustomResponsesEndpointDoesNotInheritOpenAICacheExtensions(t *testing.T) {
214 named := NewOpenAIResponsesNamed("token", "https://example.test/v1/responses", "openai").(*renamedClient)
215 c := named.inner.(*codexClient)
216 var gotBody codexRequest
217 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
218 if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
219 t.Fatal(err)
220 }
221 return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(""))}, nil
222 })
223 events, err := c.Stream(context.Background(), Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "logical-session", TurnID: "turn"}})
224 if err != nil {
225 t.Fatal(err)
226 }
227 for range events {
228 }
229 if gotBody.PromptCacheKey != "" {
230 t.Fatalf("prompt_cache_key = %q, want omitted for undeclared endpoint", gotBody.PromptCacheKey)
231 }
232}
233
234func TestCodexNestedStreamError(t *testing.T) {
235 c := NewOpenAICodex("token", "acct", "").(*codexClient)
236 resp := &http.Response{
237 Body: io.NopCloser(strings.NewReader("data: {\"type\":\"error\",\"error\":{\"code\":\"model_not_available\",\"message\":\"limited preview\"}}\n\n")),
238 }
239 out := make(chan Event, 16)
240 go c.runStream(context.Background(), resp, Request{Model: "gpt-5.6-sol"}, out)
241
242 var got error
243 for ev := range out {
244 if done, ok := ev.(EventDone); ok {
245 got = done.Err
246 }
247 }
248 if got == nil || got.Error() != "codex error: limited preview" {
249 t.Fatalf("error = %v", got)
250 }
251}
252
253// TestCodexSubscriptionAlwaysUsesCodexCLIShape pins the ChatGPT
254// subscription backend to the Codex CLI request identity for every
255// model. The backend load-sheds unrecognized originator/user-agent
256// pairs with "Our servers are currently overloaded" stream errors
257// even when capacity is fine, so the CLI shape is required for
258// reliable service, not just for preview-model admission.
259func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {
260 c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient)
261 var gotReq *http.Request
262 var body bytes.Buffer
263 c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
264 gotReq = r
265 _, _ = body.ReadFrom(r.Body)
266 return &http.Response{
267 StatusCode: http.StatusOK,
268 Header: http.Header{"Content-Type": []string{"text/event-stream"}},
269 Body: io.NopCloser(strings.NewReader("")),
270 }, nil
271 })
272
273 events, err := c.Stream(context.Background(), Request{
274 Model: "gpt-5.6-sol",
275 Context: RequestContext{SessionID: "logical-session-2", TurnID: "turn-2"},
276 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
277 })
278 if err != nil {
279 t.Fatal(err)
280 }
281 for range events {
282 }
283
284 if gotReq == nil {
285 t.Fatal("request was not sent")
286 }
287 if gotReq.Header.Get("originator") != "codex_cli_rs" {
288 t.Fatalf("originator = %q", gotReq.Header.Get("originator"))
289 }
290 if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {
291 t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))
292 }
293 if gotReq.Header.Get("session-id") != "logical-session-2" {
294 t.Fatalf("session-id = %q, want logical session ID", gotReq.Header.Get("session-id"))
295 }
296 if !strings.Contains(body.String(), "prompt_cache_key") {
297 t.Fatalf("request missing prompt_cache_key: %s", body.String())
298 }
299}