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