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