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