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