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