1package provider
2
3import (
4 "bytes"
5 "context"
6 "crypto/rand"
7 "encoding/base64"
8 "encoding/hex"
9 "encoding/json"
10 "fmt"
11 "io"
12 "net/http"
13 "runtime"
14 "strings"
15 "time"
16)
17
18// Codex (ChatGPT subscription) client. Uses OpenAI's Responses API via
19// chatgpt.com/backend-api with the chatgpt-account-id handshake.
20//
21// Wire protocol notes:
22// - Endpoint: POST https://chatgpt.com/backend-api/codex/responses
23// - Headers: Authorization: Bearer <access_token>, chatgpt-account-id: <id>,
24// OpenAI-Beta: responses=experimental, originator: zot
25// - Body: OpenAI Responses API shape (not chat/completions).
26// input: [{role, content: [{type: "input_text" | "input_image" | ... }]}]
27// instructions: <system prompt>
28// tools: [{type:"function", name, description, parameters, strict}]
29// stream: true
30// - SSE events: response.output_item.added, response.output_text.delta,
31// response.function_call_arguments.delta, response.output_item.done,
32// response.completed, response.failed, error
33//
34// The access_token comes from the OpenAI OAuth flow (auth.openai.com).
35// The accountID is parsed from the id_token JWT's `chatgpt_account_id`
36// claim; see auth/oauth.go.
37
38const codexDefaultBaseURL = "https://chatgpt.com/backend-api/codex/responses"
39
40type codexClient struct {
41 token string
42 accountID string
43 baseURL string
44 http *http.Client
45}
46
47// NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint
48// using a subscription OAuth access token and the user's ChatGPT
49// account id. baseURL may be empty to use the default.
50func NewOpenAICodex(token, accountID, baseURL string) Client {
51 if baseURL == "" {
52 baseURL = codexDefaultBaseURL
53 }
54 return &codexClient{
55 token: token,
56 accountID: accountID,
57 baseURL: strings.TrimRight(baseURL, "/"),
58 http: &http.Client{Timeout: 0},
59 }
60}
61
62func (c *codexClient) Name() string { return "openai-codex" }
63
64// ---- Responses API wire types (subset needed for zot's surface) ----
65
66type codexInputText struct {
67 Type string `json:"type"` // "input_text"
68 Text string `json:"text"`
69}
70
71type codexInputImage struct {
72 Type string `json:"type"` // "input_image"
73 Detail string `json:"detail"`
74 ImageURL string `json:"image_url"`
75}
76
77type codexOutputText struct {
78 Type string `json:"type"` // "output_text"
79 Text string `json:"text"`
80 Annotations []any `json:"annotations"`
81}
82
83type codexInputMessage struct {
84 Type string `json:"type,omitempty"` // "message" (optional for input)
85 Role string `json:"role"`
86 Content []any `json:"content"`
87}
88
89type codexOutputMessage struct {
90 Type string `json:"type"` // "message"
91 Role string `json:"role"`
92 Status string `json:"status,omitempty"`
93 ID string `json:"id,omitempty"`
94 Content []codexOutputText `json:"content"`
95}
96
97type codexFunctionCall struct {
98 Type string `json:"type"` // "function_call"
99 ID string `json:"id,omitempty"`
100 CallID string `json:"call_id"`
101 Name string `json:"name"`
102 Arguments string `json:"arguments"` // JSON string
103}
104
105type codexFunctionCallOutput struct {
106 Type string `json:"type"` // "function_call_output"
107 CallID string `json:"call_id"`
108 Output string `json:"output"` // string (or ResponseFunctionCallOutputItemList for images; v1 only uses string)
109}
110
111// codexReasoningItem mirrors the Responses API "reasoning" output item.
112// We capture it on incoming streams and replay it verbatim on follow-up
113// requests: the API rejects assistant tool-call replays without it when
114// thinking is enabled.
115type codexReasoningItem struct {
116 Type string `json:"type"` // "reasoning"
117 ID string `json:"id,omitempty"`
118 EncryptedContent string `json:"encrypted_content,omitempty"`
119 // Summary is required by the Responses API even when no summary text
120 // was streamed; encode an empty array rather than omitting the field.
121 Summary []codexReasoningSummary `json:"summary"`
122}
123
124type codexReasoningSummary struct {
125 Type string `json:"type"` // "summary_text"
126 Text string `json:"text"`
127}
128
129type codexTool struct {
130 Type string `json:"type"` // "function"
131 Name string `json:"name"`
132 Description string `json:"description,omitempty"`
133 Parameters json.RawMessage `json:"parameters"`
134}
135
136type codexReasoningConfig struct {
137 Effort string `json:"effort,omitempty"`
138}
139
140type codexRequest struct {
141 Model string `json:"model"`
142 Store bool `json:"store"`
143 Stream bool `json:"stream"`
144 Instructions string `json:"instructions,omitempty"`
145 Input []any `json:"input"`
146 Tools []codexTool `json:"tools,omitempty"`
147 ToolChoice string `json:"tool_choice,omitempty"`
148 ParallelToolCalls bool `json:"parallel_tool_calls"`
149 Include []string `json:"include,omitempty"`
150 Reasoning *codexReasoningConfig `json:"reasoning,omitempty"`
151 PromptCacheKey string `json:"prompt_cache_key,omitempty"`
152}
153
154// ---- Request building ----
155
156func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
157 m, err := FindModel("openai-codex", req.Model)
158 if err != nil {
159 m, err = FindModel("openai", req.Model)
160 }
161 if err != nil {
162 return nil, err
163 }
164 _ = m
165
166 body := &codexRequest{
167 Model: req.Model,
168 Store: false,
169 Stream: true,
170 Instructions: req.System,
171 ParallelToolCalls: true,
172 Include: []string{"reasoning.encrypted_content"},
173 }
174 if m.Reasoning {
175 if effort := OpenAICodexReasoningEffort(req.Reasoning); effort != "" {
176 body.Reasoning = &codexReasoningConfig{Effort: effort}
177 }
178 }
179 if len(req.Tools) > 0 {
180 body.ToolChoice = "auto"
181 for _, t := range req.Tools {
182 params := t.Schema
183 if len(params) == 0 {
184 params = json.RawMessage(`{"type":"object","properties":{}}`)
185 }
186 body.Tools = append(body.Tools, codexTool{
187 Type: "function",
188 Name: t.Name,
189 Description: t.Description,
190 Parameters: params,
191 })
192 }
193 }
194
195 msgIdx := 0
196 req.Messages = RepairOrphanedToolResults(req.Messages)
197 for _, msg := range req.Messages {
198 switch msg.Role {
199 case RoleUser:
200 content := []any{}
201 for _, c := range msg.Content {
202 switch v := c.(type) {
203 case TextBlock:
204 if v.Text != "" {
205 content = append(content, codexInputText{Type: "input_text", Text: v.Text})
206 }
207 case ImageBlock:
208 url := "data:" + v.MimeType + ";base64," + base64.StdEncoding.EncodeToString(v.Data)
209 content = append(content, codexInputImage{Type: "input_image", Detail: "auto", ImageURL: url})
210 }
211 }
212 if len(content) == 0 {
213 continue
214 }
215 body.Input = append(body.Input, codexInputMessage{Role: "user", Content: content})
216 case RoleAssistant:
217 // Emit one output_message per text block, one function_call per
218 // tool call, and one reasoning item per ReasoningBlock,
219 // preserving the order so the model sees the same interleaving
220 // we captured. The reasoning replay is what keeps OpenAI
221 // Codex from rejecting follow-up tool calls with
222 // "thinking is enabled but reasoning_content is missing".
223 for _, c := range msg.Content {
224 switch v := c.(type) {
225 case ReasoningBlock:
226 item := codexReasoningItem{
227 Type: "reasoning",
228 ID: v.ID,
229 EncryptedContent: v.Encrypted,
230 Summary: []codexReasoningSummary{},
231 }
232 if v.Summary != "" {
233 item.Summary = []codexReasoningSummary{{Type: "summary_text", Text: v.Summary}}
234 }
235 body.Input = append(body.Input, item)
236 case TextBlock:
237 if v.Text == "" {
238 continue
239 }
240 msgIdx++
241 body.Input = append(body.Input, codexOutputMessage{
242 Type: "message",
243 Role: "assistant",
244 Status: "completed",
245 ID: fmt.Sprintf("msg_%d", msgIdx),
246 Content: []codexOutputText{
247 {Type: "output_text", Text: v.Text, Annotations: []any{}},
248 },
249 })
250 case ToolCallBlock:
251 args := string(v.Arguments)
252 if args == "" || !json.Valid([]byte(args)) {
253 args = "{}"
254 }
255 callID, _ := splitCallID(v.ID)
256 body.Input = append(body.Input, codexFunctionCall{
257 Type: "function_call",
258 CallID: callID,
259 Name: v.Name,
260 Arguments: args,
261 })
262 }
263 }
264 case RoleTool:
265 for _, c := range msg.Content {
266 if tr, ok := c.(ToolResultBlock); ok {
267 var text strings.Builder
268 imageCount := 0
269 for _, inner := range tr.Content {
270 switch v := inner.(type) {
271 case TextBlock:
272 if text.Len() > 0 {
273 text.WriteString("\n")
274 }
275 text.WriteString(v.Text)
276 case ImageBlock:
277 imageCount++
278 }
279 }
280 // The Responses API function_call_output only carries a
281 // string, so image bytes cannot ride along here. The agent
282 // loop mirrors any tool-result images into the following
283 // user message (which this client does serialize as
284 // input_image). Leave a short text note so an image-only
285 // result is not an empty output the API may reject, and so
286 // the model knows the image arrives next.
287 out := text.String()
288 if out == "" && imageCount > 0 {
289 if imageCount == 1 {
290 out = "[image returned; see the following message]"
291 } else {
292 out = fmt.Sprintf("[%d images returned; see the following message]", imageCount)
293 }
294 }
295 callID, _ := splitCallID(tr.CallID)
296 body.Input = append(body.Input, codexFunctionCallOutput{
297 Type: "function_call_output",
298 CallID: callID,
299 Output: out,
300 })
301 }
302 }
303 }
304 }
305
306 return body, nil
307}
308
309func splitCallID(id string) (string, string) {
310 if i := strings.Index(id, "|"); i >= 0 {
311 return id[:i], id[i+1:]
312 }
313 return id, ""
314}
315
316func usesCodexCLIRouting(model string) bool {
317 switch model {
318 case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro":
319 return true
320 default:
321 return false
322 }
323}
324
325func newCodexSessionID() string {
326 var b [16]byte
327 if _, err := rand.Read(b[:]); err != nil {
328 return fmt.Sprintf("zot-%d", time.Now().UnixNano())
329 }
330 return strings.Join([]string{
331 hex.EncodeToString(b[0:4]),
332 hex.EncodeToString(b[4:6]),
333 hex.EncodeToString(b[6:8]),
334 hex.EncodeToString(b[8:10]),
335 hex.EncodeToString(b[10:16]),
336 }, "-")
337}
338
339// ---- Streaming ----
340
341func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
342 wire, err := c.buildRequest(req)
343 if err != nil {
344 return nil, err
345 }
346 var codexCLISessionID string
347 if usesCodexCLIRouting(wire.Model) {
348 codexCLISessionID = newCodexSessionID()
349 wire.PromptCacheKey = codexCLISessionID
350 }
351 body, err := json.Marshal(wire)
352 if err != nil {
353 return nil, err
354 }
355
356 newReq := func() (*http.Request, error) {
357 httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body))
358 if err != nil {
359 return nil, err
360 }
361 httpReq.Header.Set("content-type", "application/json")
362 httpReq.Header.Set("accept", "text/event-stream")
363 httpReq.Header.Set("authorization", "Bearer "+c.token)
364 httpReq.Header.Set("chatgpt-account-id", c.accountID)
365 httpReq.Header.Set("openai-beta", "responses=experimental")
366 if codexCLISessionID != "" {
367 // Some preview models are only admitted or reliably served by the
368 // ChatGPT Codex backend when the request follows Codex CLI routing
369 // metadata. Keep this narrow so Sol retains zot's proven shape.
370 httpReq.Header.Set("originator", "codex_cli_rs")
371 httpReq.Header.Set("session-id", codexCLISessionID)
372 httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")
373 } else {
374 httpReq.Header.Set("originator", "zot")
375 httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
376 }
377 return httpReq, nil
378 }
379
380 resp, err := doStreamWithRetry(ctx, c.http, newReq)
381 if err != nil {
382 return nil, fmt.Errorf("openai-codex: %w", err)
383 }
384 if resp.StatusCode != http.StatusOK {
385 b, _ := io.ReadAll(resp.Body)
386 resp.Body.Close()
387 return nil, fmt.Errorf("openai-codex: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
388 }
389
390 out := make(chan Event, 16)
391 go c.runStream(ctx, resp, req, out)
392 return out, nil
393}
394
395func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
396 defer close(out)
397 defer resp.Body.Close()
398
399 model, _ := FindModel("openai-codex", req.Model)
400 if model.ID == "" {
401 model, _ = FindModel("openai", req.Model)
402 }
403 out <- EventStart{Model: req.Model, Provider: "openai-codex"}
404
405 raw := make(chan sseEvent, 16)
406 go readSSE(resp.Body, raw)
407
408 // Accumulators. The Responses API emits output_items in order; each
409 // item is either a "message" (text) or a "function_call". We track
410 // the in-flight item by its index.
411 type itemState struct {
412 kind string // "message" | "function_call" | "reasoning"
413 callID string
414 name string
415 argsBuf strings.Builder
416 textBuf strings.Builder
417 summary strings.Builder
418 rawID string
419 encrypted string
420 announced bool
421 }
422 var (
423 items = map[int]*itemState{}
424 order []int
425 usage Usage
426 stop StopReason = StopEnd
427 finalErr error
428 )
429
430 assemble := func() Message {
431 content := []Content{}
432 for _, idx := range order {
433 it := items[idx]
434 switch it.kind {
435 case "message":
436 if it.textBuf.Len() > 0 {
437 content = append(content, TextBlock{Text: it.textBuf.String()})
438 }
439 case "function_call":
440 args := it.argsBuf.String()
441 if args == "" || !json.Valid([]byte(args)) {
442 args = "{}"
443 }
444 content = append(content, ToolCallBlock{
445 ID: it.callID, Name: it.name, Arguments: json.RawMessage(args),
446 })
447 case "reasoning":
448 if it.encrypted == "" && it.summary.Len() == 0 && it.rawID == "" {
449 continue
450 }
451 content = append(content, ReasoningBlock{
452 ID: it.rawID,
453 Summary: it.summary.String(),
454 Encrypted: it.encrypted,
455 })
456 }
457 }
458 return Message{Role: RoleAssistant, Content: content, Time: time.Now()}
459 }
460
461 sendDone := func() {
462 usage.CostUSD = ComputeCost(model, usage)
463 out <- EventUsage{Usage: usage}
464 out <- EventDone{Stop: stop, Err: finalErr, Message: assemble()}
465 }
466
467 for {
468 select {
469 case <-ctx.Done():
470 stop = StopAborted
471 finalErr = ctx.Err()
472 sendDone()
473 return
474 case ev, ok := <-raw:
475 if !ok {
476 sendDone()
477 return
478 }
479 var head struct {
480 Type string `json:"type"`
481 }
482 if err := json.Unmarshal([]byte(ev.Data), &head); err != nil {
483 continue
484 }
485
486 switch head.Type {
487 case "response.output_item.added":
488 var p struct {
489 OutputIndex int `json:"output_index"`
490 Item struct {
491 Type string `json:"type"` // "message" | "function_call" | "reasoning"
492 ID string `json:"id"`
493 CallID string `json:"call_id"`
494 Name string `json:"name"`
495 EncryptedContent string `json:"encrypted_content"`
496 } `json:"item"`
497 }
498 _ = json.Unmarshal([]byte(ev.Data), &p)
499 it := &itemState{}
500 switch p.Item.Type {
501 case "message":
502 it.kind = "message"
503 case "function_call":
504 it.kind = "function_call"
505 it.callID = p.Item.CallID
506 it.name = p.Item.Name
507 if !it.announced {
508 it.announced = true
509 out <- EventToolStart{ID: it.callID, Name: it.name}
510 }
511 case "reasoning":
512 it.kind = "reasoning"
513 it.rawID = p.Item.ID
514 it.encrypted = p.Item.EncryptedContent
515 default:
516 continue
517 }
518 items[p.OutputIndex] = it
519 order = append(order, p.OutputIndex)
520 case "response.output_text.delta":
521 var p struct {
522 OutputIndex int `json:"output_index"`
523 Delta string `json:"delta"`
524 }
525 _ = json.Unmarshal([]byte(ev.Data), &p)
526 if it, ok := items[p.OutputIndex]; ok && it.kind == "message" {
527 it.textBuf.WriteString(p.Delta)
528 out <- EventTextDelta{Delta: p.Delta}
529 }
530 case "response.reasoning_summary_text.delta":
531 var p struct {
532 OutputIndex int `json:"output_index"`
533 Delta string `json:"delta"`
534 }
535 _ = json.Unmarshal([]byte(ev.Data), &p)
536 if it, ok := items[p.OutputIndex]; ok && it.kind == "reasoning" {
537 it.summary.WriteString(p.Delta)
538 }
539 case "response.reasoning_summary_text.done":
540 // summary text already accumulated via deltas
541 case "response.function_call_arguments.delta":
542 var p struct {
543 OutputIndex int `json:"output_index"`
544 Delta string `json:"delta"`
545 }
546 _ = json.Unmarshal([]byte(ev.Data), &p)
547 if it, ok := items[p.OutputIndex]; ok && it.kind == "function_call" {
548 it.argsBuf.WriteString(p.Delta)
549 out <- EventToolArgs{ID: it.callID, Delta: p.Delta}
550 }
551 case "response.output_item.done":
552 var p struct {
553 OutputIndex int `json:"output_index"`
554 Item struct {
555 Type string `json:"type"`
556 ID string `json:"id"`
557 EncryptedContent string `json:"encrypted_content"`
558 Summary []struct {
559 Type string `json:"type"`
560 Text string `json:"text"`
561 } `json:"summary"`
562 } `json:"item"`
563 }
564 _ = json.Unmarshal([]byte(ev.Data), &p)
565 if it, ok := items[p.OutputIndex]; ok {
566 switch it.kind {
567 case "function_call":
568 out <- EventToolEnd{ID: it.callID}
569 case "reasoning":
570 if p.Item.EncryptedContent != "" {
571 it.encrypted = p.Item.EncryptedContent
572 }
573 if it.rawID == "" && p.Item.ID != "" {
574 it.rawID = p.Item.ID
575 }
576 for _, s := range p.Item.Summary {
577 if s.Text == "" {
578 continue
579 }
580 if it.summary.Len() > 0 {
581 it.summary.WriteString("\n")
582 }
583 it.summary.WriteString(s.Text)
584 }
585 }
586 }
587 case "response.completed", "response.done":
588 var p struct {
589 Response struct {
590 Usage struct {
591 InputTokens int `json:"input_tokens"`
592 OutputTokens int `json:"output_tokens"`
593 InputTokensDetails struct {
594 CachedTokens int `json:"cached_tokens"`
595 } `json:"input_tokens_details"`
596 } `json:"usage"`
597 Status string `json:"status"`
598 } `json:"response"`
599 }
600 _ = json.Unmarshal([]byte(ev.Data), &p)
601 usage.InputTokens = p.Response.Usage.InputTokens - p.Response.Usage.InputTokensDetails.CachedTokens
602 if usage.InputTokens < 0 {
603 usage.InputTokens = p.Response.Usage.InputTokens
604 }
605 usage.OutputTokens = p.Response.Usage.OutputTokens
606 usage.CacheReadTokens = p.Response.Usage.InputTokensDetails.CachedTokens
607
608 hadTool := false
609 for _, it := range items {
610 if it.kind == "function_call" {
611 hadTool = true
612 break
613 }
614 }
615 if hadTool {
616 stop = StopToolUse
617 } else {
618 stop = StopEnd
619 }
620 sendDone()
621 return
622 case "response.failed":
623 var p struct {
624 Response struct {
625 Error struct {
626 Message string `json:"message"`
627 } `json:"error"`
628 } `json:"response"`
629 }
630 _ = json.Unmarshal([]byte(ev.Data), &p)
631 stop = StopError
632 finalErr = fmt.Errorf("codex: %s", p.Response.Error.Message)
633 sendDone()
634 return
635 case "error":
636 var p struct {
637 Message string `json:"message"`
638 Code string `json:"code"`
639 }
640 _ = json.Unmarshal([]byte(ev.Data), &p)
641 msg := p.Message
642 if msg == "" {
643 msg = p.Code
644 }
645 stop = StopError
646 finalErr = fmt.Errorf("codex error: %s", msg)
647 sendDone()
648 return
649 }
650 }
651 }
652}