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