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