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