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" }
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 codexRequest struct {
135 Model string `json:"model"`
136 Store bool `json:"store"`
137 Stream bool `json:"stream"`
138 Instructions string `json:"instructions,omitempty"`
139 Input []any `json:"input"`
140 Tools []codexTool `json:"tools,omitempty"`
141 ToolChoice string `json:"tool_choice,omitempty"`
142 ParallelToolCalls bool `json:"parallel_tool_calls"`
143 Include []string `json:"include,omitempty"`
144}
145
146// ---- Request building ----
147
148func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
149 m, err := FindModel("openai", req.Model)
150 if err != nil {
151 return nil, err
152 }
153 _ = m
154
155 body := &codexRequest{
156 Model: req.Model,
157 Store: false,
158 Stream: true,
159 Instructions: req.System,
160 ParallelToolCalls: true,
161 Include: []string{"reasoning.encrypted_content"},
162 }
163 if len(req.Tools) > 0 {
164 body.ToolChoice = "auto"
165 for _, t := range req.Tools {
166 params := t.Schema
167 if len(params) == 0 {
168 params = json.RawMessage(`{"type":"object","properties":{}}`)
169 }
170 body.Tools = append(body.Tools, codexTool{
171 Type: "function",
172 Name: t.Name,
173 Description: t.Description,
174 Parameters: params,
175 })
176 }
177 }
178
179 msgIdx := 0
180 req.Messages = RepairOrphanedToolResults(req.Messages)
181 for _, msg := range req.Messages {
182 switch msg.Role {
183 case RoleUser:
184 content := []any{}
185 for _, c := range msg.Content {
186 switch v := c.(type) {
187 case TextBlock:
188 if v.Text != "" {
189 content = append(content, codexInputText{Type: "input_text", Text: v.Text})
190 }
191 case ImageBlock:
192 url := "data:" + v.MimeType + ";base64," + base64.StdEncoding.EncodeToString(v.Data)
193 content = append(content, codexInputImage{Type: "input_image", Detail: "auto", ImageURL: url})
194 }
195 }
196 if len(content) == 0 {
197 continue
198 }
199 body.Input = append(body.Input, codexInputMessage{Role: "user", Content: content})
200 case RoleAssistant:
201 // Emit one output_message per text block, one function_call per
202 // tool call, and one reasoning item per ReasoningBlock,
203 // preserving the order so the model sees the same interleaving
204 // we captured. The reasoning replay is what keeps OpenAI
205 // Codex from rejecting follow-up tool calls with
206 // "thinking is enabled but reasoning_content is missing".
207 for _, c := range msg.Content {
208 switch v := c.(type) {
209 case ReasoningBlock:
210 item := codexReasoningItem{
211 Type: "reasoning",
212 ID: v.ID,
213 EncryptedContent: v.Encrypted,
214 Summary: []codexReasoningSummary{},
215 }
216 if v.Summary != "" {
217 item.Summary = []codexReasoningSummary{{Type: "summary_text", Text: v.Summary}}
218 }
219 body.Input = append(body.Input, item)
220 case TextBlock:
221 if v.Text == "" {
222 continue
223 }
224 msgIdx++
225 body.Input = append(body.Input, codexOutputMessage{
226 Type: "message",
227 Role: "assistant",
228 Status: "completed",
229 ID: fmt.Sprintf("msg_%d", msgIdx),
230 Content: []codexOutputText{
231 {Type: "output_text", Text: v.Text, Annotations: []any{}},
232 },
233 })
234 case ToolCallBlock:
235 args := string(v.Arguments)
236 if args == "" || !json.Valid([]byte(args)) {
237 args = "{}"
238 }
239 callID, _ := splitCallID(v.ID)
240 body.Input = append(body.Input, codexFunctionCall{
241 Type: "function_call",
242 CallID: callID,
243 Name: v.Name,
244 Arguments: args,
245 })
246 }
247 }
248 case RoleTool:
249 for _, c := range msg.Content {
250 if tr, ok := c.(ToolResultBlock); ok {
251 var text strings.Builder
252 for _, inner := range tr.Content {
253 if tb, ok := inner.(TextBlock); ok {
254 if text.Len() > 0 {
255 text.WriteString("\n")
256 }
257 text.WriteString(tb.Text)
258 }
259 }
260 callID, _ := splitCallID(tr.CallID)
261 body.Input = append(body.Input, codexFunctionCallOutput{
262 Type: "function_call_output",
263 CallID: callID,
264 Output: text.String(),
265 })
266 }
267 }
268 }
269 }
270
271 return body, nil
272}
273
274func splitCallID(id string) (string, string) {
275 if i := strings.Index(id, "|"); i >= 0 {
276 return id[:i], id[i+1:]
277 }
278 return id, ""
279}
280
281// ---- Streaming ----
282
283func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
284 wire, err := c.buildRequest(req)
285 if err != nil {
286 return nil, err
287 }
288 body, err := json.Marshal(wire)
289 if err != nil {
290 return nil, err
291 }
292
293 httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body))
294 if err != nil {
295 return nil, err
296 }
297 httpReq.Header.Set("content-type", "application/json")
298 httpReq.Header.Set("accept", "text/event-stream")
299 httpReq.Header.Set("authorization", "Bearer "+c.token)
300 httpReq.Header.Set("chatgpt-account-id", c.accountID)
301 httpReq.Header.Set("openai-beta", "responses=experimental")
302 httpReq.Header.Set("originator", "zot")
303 httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
304
305 resp, err := c.http.Do(httpReq)
306 if err != nil {
307 return nil, fmt.Errorf("openai-codex: %w", err)
308 }
309 if resp.StatusCode != http.StatusOK {
310 b, _ := io.ReadAll(resp.Body)
311 resp.Body.Close()
312 return nil, fmt.Errorf("openai-codex: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
313 }
314
315 out := make(chan Event, 16)
316 go c.runStream(ctx, resp, req, out)
317 return out, nil
318}
319
320func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
321 defer close(out)
322 defer resp.Body.Close()
323
324 model, _ := FindModel("openai", req.Model)
325 out <- EventStart{Model: req.Model, Provider: "openai"}
326
327 raw := make(chan sseEvent, 16)
328 go readSSE(resp.Body, raw)
329
330 // Accumulators. The Responses API emits output_items in order; each
331 // item is either a "message" (text) or a "function_call". We track
332 // the in-flight item by its index.
333 type itemState struct {
334 kind string // "message" | "function_call" | "reasoning"
335 callID string
336 name string
337 argsBuf strings.Builder
338 textBuf strings.Builder
339 summary strings.Builder
340 rawID string
341 encrypted string
342 announced bool
343 }
344 var (
345 items = map[int]*itemState{}
346 order []int
347 usage Usage
348 stop StopReason = StopEnd
349 finalErr error
350 )
351
352 assemble := func() Message {
353 content := []Content{}
354 for _, idx := range order {
355 it := items[idx]
356 switch it.kind {
357 case "message":
358 if it.textBuf.Len() > 0 {
359 content = append(content, TextBlock{Text: it.textBuf.String()})
360 }
361 case "function_call":
362 args := it.argsBuf.String()
363 if args == "" || !json.Valid([]byte(args)) {
364 args = "{}"
365 }
366 content = append(content, ToolCallBlock{
367 ID: it.callID, Name: it.name, Arguments: json.RawMessage(args),
368 })
369 case "reasoning":
370 if it.encrypted == "" && it.summary.Len() == 0 && it.rawID == "" {
371 continue
372 }
373 content = append(content, ReasoningBlock{
374 ID: it.rawID,
375 Summary: it.summary.String(),
376 Encrypted: it.encrypted,
377 })
378 }
379 }
380 return Message{Role: RoleAssistant, Content: content, Time: time.Now()}
381 }
382
383 sendDone := func() {
384 usage.CostUSD = ComputeCost(model, usage)
385 out <- EventUsage{Usage: usage}
386 out <- EventDone{Stop: stop, Err: finalErr, Message: assemble()}
387 }
388
389 for {
390 select {
391 case <-ctx.Done():
392 stop = StopAborted
393 finalErr = ctx.Err()
394 sendDone()
395 return
396 case ev, ok := <-raw:
397 if !ok {
398 sendDone()
399 return
400 }
401 var head struct {
402 Type string `json:"type"`
403 }
404 if err := json.Unmarshal([]byte(ev.Data), &head); err != nil {
405 continue
406 }
407
408 switch head.Type {
409 case "response.output_item.added":
410 var p struct {
411 OutputIndex int `json:"output_index"`
412 Item struct {
413 Type string `json:"type"` // "message" | "function_call" | "reasoning"
414 ID string `json:"id"`
415 CallID string `json:"call_id"`
416 Name string `json:"name"`
417 EncryptedContent string `json:"encrypted_content"`
418 } `json:"item"`
419 }
420 _ = json.Unmarshal([]byte(ev.Data), &p)
421 it := &itemState{}
422 switch p.Item.Type {
423 case "message":
424 it.kind = "message"
425 case "function_call":
426 it.kind = "function_call"
427 it.callID = p.Item.CallID
428 it.name = p.Item.Name
429 if !it.announced {
430 it.announced = true
431 out <- EventToolStart{ID: it.callID, Name: it.name}
432 }
433 case "reasoning":
434 it.kind = "reasoning"
435 it.rawID = p.Item.ID
436 it.encrypted = p.Item.EncryptedContent
437 default:
438 continue
439 }
440 items[p.OutputIndex] = it
441 order = append(order, p.OutputIndex)
442 case "response.output_text.delta":
443 var p struct {
444 OutputIndex int `json:"output_index"`
445 Delta string `json:"delta"`
446 }
447 _ = json.Unmarshal([]byte(ev.Data), &p)
448 if it, ok := items[p.OutputIndex]; ok && it.kind == "message" {
449 it.textBuf.WriteString(p.Delta)
450 out <- EventTextDelta{Delta: p.Delta}
451 }
452 case "response.reasoning_summary_text.delta":
453 var p struct {
454 OutputIndex int `json:"output_index"`
455 Delta string `json:"delta"`
456 }
457 _ = json.Unmarshal([]byte(ev.Data), &p)
458 if it, ok := items[p.OutputIndex]; ok && it.kind == "reasoning" {
459 it.summary.WriteString(p.Delta)
460 }
461 case "response.reasoning_summary_text.done":
462 // summary text already accumulated via deltas
463 case "response.function_call_arguments.delta":
464 var p struct {
465 OutputIndex int `json:"output_index"`
466 Delta string `json:"delta"`
467 }
468 _ = json.Unmarshal([]byte(ev.Data), &p)
469 if it, ok := items[p.OutputIndex]; ok && it.kind == "function_call" {
470 it.argsBuf.WriteString(p.Delta)
471 out <- EventToolArgs{ID: it.callID, Delta: p.Delta}
472 }
473 case "response.output_item.done":
474 var p struct {
475 OutputIndex int `json:"output_index"`
476 Item struct {
477 Type string `json:"type"`
478 ID string `json:"id"`
479 EncryptedContent string `json:"encrypted_content"`
480 Summary []struct {
481 Type string `json:"type"`
482 Text string `json:"text"`
483 } `json:"summary"`
484 } `json:"item"`
485 }
486 _ = json.Unmarshal([]byte(ev.Data), &p)
487 if it, ok := items[p.OutputIndex]; ok {
488 switch it.kind {
489 case "function_call":
490 out <- EventToolEnd{ID: it.callID}
491 case "reasoning":
492 if p.Item.EncryptedContent != "" {
493 it.encrypted = p.Item.EncryptedContent
494 }
495 if it.rawID == "" && p.Item.ID != "" {
496 it.rawID = p.Item.ID
497 }
498 for _, s := range p.Item.Summary {
499 if s.Text == "" {
500 continue
501 }
502 if it.summary.Len() > 0 {
503 it.summary.WriteString("\n")
504 }
505 it.summary.WriteString(s.Text)
506 }
507 }
508 }
509 case "response.completed", "response.done":
510 var p struct {
511 Response struct {
512 Usage struct {
513 InputTokens int `json:"input_tokens"`
514 OutputTokens int `json:"output_tokens"`
515 InputTokensDetails struct {
516 CachedTokens int `json:"cached_tokens"`
517 } `json:"input_tokens_details"`
518 } `json:"usage"`
519 Status string `json:"status"`
520 } `json:"response"`
521 }
522 _ = json.Unmarshal([]byte(ev.Data), &p)
523 usage.InputTokens = p.Response.Usage.InputTokens - p.Response.Usage.InputTokensDetails.CachedTokens
524 if usage.InputTokens < 0 {
525 usage.InputTokens = p.Response.Usage.InputTokens
526 }
527 usage.OutputTokens = p.Response.Usage.OutputTokens
528 usage.CacheReadTokens = p.Response.Usage.InputTokensDetails.CachedTokens
529
530 hadTool := false
531 for _, it := range items {
532 if it.kind == "function_call" {
533 hadTool = true
534 break
535 }
536 }
537 if hadTool {
538 stop = StopToolUse
539 } else {
540 stop = StopEnd
541 }
542 sendDone()
543 return
544 case "response.failed":
545 var p struct {
546 Response struct {
547 Error struct {
548 Message string `json:"message"`
549 } `json:"error"`
550 } `json:"response"`
551 }
552 _ = json.Unmarshal([]byte(ev.Data), &p)
553 stop = StopError
554 finalErr = fmt.Errorf("codex: %s", p.Response.Error.Message)
555 sendDone()
556 return
557 case "error":
558 var p struct {
559 Message string `json:"message"`
560 Code string `json:"code"`
561 }
562 _ = json.Unmarshal([]byte(ev.Data), &p)
563 msg := p.Message
564 if msg == "" {
565 msg = p.Code
566 }
567 stop = StopError
568 finalErr = fmt.Errorf("codex error: %s", msg)
569 sendDone()
570 return
571 }
572 }
573 }
574}