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 (required legacy value)
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 errorLabel string
43 providerName string
44 modelName func(string) string
45 disableCLIRouting bool
46 cliRoutingAll bool
47 capabilities responsesCapabilities
48 http *http.Client
49}
50
51// responsesCapabilities records extensions verified for the exact provider
52// client, rather than assuming every Responses-shaped endpoint accepts them.
53type responsesCapabilities struct {
54 StablePromptCacheKey bool
55 ExplicitPromptCacheBreakpoint bool
56 SessionHeader bool
57 ThreadHeader bool
58 ClientRequestHeader bool
59}
60
61// NewOpenAICodex creates a Responses client for ChatGPT's Codex endpoint
62// using a subscription OAuth access token and the user's ChatGPT account ID.
63// It prefers WebSocket mode and falls back to HTTP/SSE before visible output.
64// baseURL may be empty to use the default.
65func NewOpenAICodex(token, accountID, baseURL string) Client {
66 return newResponsesWebSocketClient(newOpenAICodexClient(token, accountID, baseURL))
67}
68
69func newOpenAICodexClient(token, accountID, baseURL string) *codexClient {
70 if baseURL == "" {
71 baseURL = codexDefaultBaseURL
72 }
73 return &codexClient{
74 token: token,
75 accountID: accountID,
76 baseURL: strings.TrimRight(baseURL, "/"),
77 errorLabel: "codex",
78 providerName: "openai-codex",
79 // The ChatGPT Codex backend load-sheds requests from client
80 // identities it does not recognize: unknown originator/user-agent
81 // pairs receive "Our servers are currently overloaded" stream
82 // errors near-deterministically while Codex CLI requests succeed.
83 // Send the Codex CLI request shape for every model on this
84 // backend.
85 cliRoutingAll: true,
86 capabilities: responsesCapabilities{
87 StablePromptCacheKey: true,
88 SessionHeader: true,
89 ThreadHeader: true,
90 ClientRequestHeader: true,
91 },
92 http: &http.Client{Timeout: 0},
93 }
94}
95
96func (c *codexClient) Name() string { return "openai-codex" }
97
98// ---- Responses API wire types (subset needed for zut's surface) ----
99
100type codexInputText struct {
101 Type string `json:"type"` // "input_text"
102 Text string `json:"text"`
103 PromptCacheBreakpoint *codexPromptCacheBreakpoint `json:"prompt_cache_breakpoint,omitempty"`
104}
105
106type codexInputImage struct {
107 Type string `json:"type"` // "input_image"
108 Detail string `json:"detail"`
109 ImageURL string `json:"image_url"`
110}
111
112type codexOutputText struct {
113 Type string `json:"type"` // "output_text"
114 Text string `json:"text"`
115 Annotations []any `json:"annotations"`
116}
117
118type codexPromptCacheOptions struct {
119 Mode string `json:"mode"`
120}
121
122type codexPromptCacheBreakpoint struct {
123 Mode string `json:"mode"`
124}
125
126type codexInputMessage struct {
127 Type string `json:"type,omitempty"` // "message" (optional for input)
128 Role string `json:"role"`
129 Content []any `json:"content"`
130}
131
132type codexOutputMessage struct {
133 Type string `json:"type"` // "message"
134 Role string `json:"role"`
135 Status string `json:"status,omitempty"`
136 ID string `json:"id,omitempty"`
137 Content []codexOutputText `json:"content"`
138}
139
140type codexFunctionCall struct {
141 Type string `json:"type"` // "function_call"
142 ID string `json:"id,omitempty"`
143 CallID string `json:"call_id"`
144 Name string `json:"name"`
145 Arguments string `json:"arguments"` // JSON string
146}
147
148type codexFunctionCallOutput struct {
149 Type string `json:"type"` // "function_call_output"
150 CallID string `json:"call_id"`
151 Output string `json:"output"` // string (or ResponseFunctionCallOutputItemList for images; v1 only uses string)
152}
153
154// codexReasoningItem mirrors the Responses API "reasoning" output item.
155// We capture it on incoming streams and replay it verbatim on follow-up
156// requests: the API rejects assistant tool-call replays without it when
157// thinking is enabled.
158type codexReasoningItem struct {
159 Type string `json:"type"` // "reasoning"
160 ID string `json:"id,omitempty"`
161 EncryptedContent string `json:"encrypted_content,omitempty"`
162 // Summary is required by the Responses API even when no summary text
163 // was streamed; encode an empty array rather than omitting the field.
164 Summary []codexReasoningSummary `json:"summary"`
165}
166
167type codexReasoningSummary struct {
168 Type string `json:"type"` // "summary_text"
169 Text string `json:"text"`
170}
171
172type codexTool struct {
173 Type string `json:"type"` // "function"
174 Name string `json:"name"`
175 Description string `json:"description,omitempty"`
176 Parameters json.RawMessage `json:"parameters"`
177 // Responses otherwise attempts to normalize schemas into strict mode,
178 // which makes non-nullable optional properties required model output.
179 Strict bool `json:"strict"`
180}
181
182type codexReasoningConfig struct {
183 Effort string `json:"effort,omitempty"`
184}
185
186type codexRequest struct {
187 Model string `json:"model"`
188 Store bool `json:"store"`
189 Stream bool `json:"stream,omitempty"`
190 Instructions string `json:"instructions,omitempty"`
191 Input []any `json:"input"`
192 Tools []codexTool `json:"tools,omitempty"`
193 ToolChoice string `json:"tool_choice,omitempty"`
194 ParallelToolCalls bool `json:"parallel_tool_calls"`
195 Include []string `json:"include,omitempty"`
196 Reasoning *codexReasoningConfig `json:"reasoning,omitempty"`
197 PromptCacheKey string `json:"prompt_cache_key,omitempty"`
198 PromptCacheOptions *codexPromptCacheOptions `json:"prompt_cache_options,omitempty"`
199 ServiceTier string `json:"service_tier,omitempty"`
200 PreviousResponseID string `json:"previous_response_id,omitempty"`
201}
202
203// ---- Request building ----
204
205func (c *codexClient) findModel(id string) (Model, error) {
206 if c.providerName != "" && c.providerName != "openai-codex" {
207 if m, err := FindModel(c.providerName, id); err == nil {
208 return m, nil
209 }
210 }
211 if m, err := FindModel("openai-codex", id); err == nil {
212 return m, nil
213 }
214 return FindModel("openai", id)
215}
216
217func isGPT56Model(model string) bool {
218 return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "gpt-5.6-")
219}
220
221func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
222 providerName := c.providerName
223 if providerName == "" {
224 providerName = "openai-codex"
225 }
226 if err := ValidateFastMode(providerName, req.FastMode); err != nil {
227 return nil, err
228 }
229 m, err := c.findModel(req.Model)
230 if err != nil {
231 return nil, err
232 }
233 reasoning := ClampReasoningForModel(m, req.Reasoning)
234
235 body := &codexRequest{
236 Model: req.Model,
237 Store: false,
238 Stream: true,
239 Instructions: req.SystemPrompt(),
240 ParallelToolCalls: true,
241 Include: []string{"reasoning.encrypted_content"},
242 }
243 if req.FastMode {
244 body.ServiceTier = fastModeServiceTier
245 }
246 if c.capabilities.ExplicitPromptCacheBreakpoint && isGPT56Model(req.Model) {
247 // Keep implicit mode so OpenAI can retain later conversation
248 // checkpoints as well as this stable instruction/tool boundary.
249 body.PromptCacheOptions = &codexPromptCacheOptions{Mode: "implicit"}
250 body.Input = append(body.Input, codexInputMessage{
251 Role: "developer",
252 Content: []any{codexInputText{
253 Type: "input_text",
254 Text: "[Stable prompt boundary]",
255 PromptCacheBreakpoint: &codexPromptCacheBreakpoint{Mode: "explicit"},
256 }},
257 })
258 }
259 if m.Reasoning {
260 effort := OpenAICodexReasoningEffort(reasoning, req.Model)
261 if hasReasoningLevelOverride(m, req.Reasoning) {
262 effort = reasoning
263 }
264 if effort != "" {
265 body.Reasoning = &codexReasoningConfig{Effort: effort}
266 }
267 }
268 activeTools := activeToolDefinitions(req.Tools, req.Messages)
269 if len(activeTools) > 0 {
270 body.ToolChoice = "auto"
271 for _, t := range activeTools {
272 params := t.Schema
273 if len(params) == 0 {
274 params = json.RawMessage(`{"type":"object","properties":{}}`)
275 }
276 body.Tools = append(body.Tools, codexTool{
277 Type: "function",
278 Name: t.Name,
279 Description: t.Description,
280 Parameters: params,
281 })
282 }
283 }
284
285 msgIdx := 0
286 req.Messages = RepairOrphanedToolResults(req.Messages)
287 for _, msg := range req.Messages {
288 switch msg.Role {
289 case RoleDeveloper, RoleUser:
290 content := []any{}
291 for _, c := range msg.Content {
292 switch v := c.(type) {
293 case TextBlock:
294 if v.Text != "" {
295 content = append(content, codexInputText{Type: "input_text", Text: v.Text})
296 }
297 case ImageBlock:
298 url := "data:" + v.MimeType + ";base64," + base64.StdEncoding.EncodeToString(v.Data)
299 content = append(content, codexInputImage{Type: "input_image", Detail: "auto", ImageURL: url})
300 }
301 }
302 if len(content) == 0 {
303 continue
304 }
305 role := "user"
306 if msg.Role == RoleDeveloper {
307 role = "developer"
308 }
309 body.Input = append(body.Input, codexInputMessage{Role: role, Content: content})
310 case RoleAssistant:
311 // Emit one output_message per text block, one function_call per
312 // tool call, and one reasoning item per ReasoningBlock,
313 // preserving the order so the model sees the same interleaving
314 // we captured. The reasoning replay is what keeps OpenAI
315 // Codex from rejecting follow-up tool calls with
316 // "thinking is enabled but reasoning_content is missing".
317 for _, c := range msg.Content {
318 switch v := c.(type) {
319 case ReasoningBlock:
320 item := codexReasoningItem{
321 Type: "reasoning",
322 ID: v.ID,
323 EncryptedContent: v.Encrypted,
324 Summary: []codexReasoningSummary{},
325 }
326 if v.Summary != "" {
327 item.Summary = []codexReasoningSummary{{Type: "summary_text", Text: v.Summary}}
328 }
329 body.Input = append(body.Input, item)
330 case TextBlock:
331 if v.Text == "" {
332 continue
333 }
334 msgIdx++
335 body.Input = append(body.Input, codexOutputMessage{
336 Type: "message",
337 Role: "assistant",
338 Status: "completed",
339 ID: fmt.Sprintf("msg_%d", msgIdx),
340 Content: []codexOutputText{
341 {Type: "output_text", Text: v.Text, Annotations: []any{}},
342 },
343 })
344 case ToolCallBlock:
345 args := string(v.Arguments)
346 if args == "" || !json.Valid([]byte(args)) {
347 args = "{}"
348 }
349 callID, _ := splitCallID(v.ID)
350 body.Input = append(body.Input, codexFunctionCall{
351 Type: "function_call",
352 CallID: callID,
353 Name: v.Name,
354 Arguments: args,
355 })
356 }
357 }
358 case RoleTool:
359 for _, c := range msg.Content {
360 if tr, ok := c.(ToolResultBlock); ok {
361 var text strings.Builder
362 imageCount := 0
363 for _, inner := range tr.Content {
364 switch v := inner.(type) {
365 case TextBlock:
366 if text.Len() > 0 {
367 text.WriteString("\n")
368 }
369 text.WriteString(v.Text)
370 case ImageBlock:
371 imageCount++
372 }
373 }
374 // The Responses API function_call_output only carries a
375 // string, so image bytes cannot ride along here. The agent
376 // loop mirrors any tool-result images into the following
377 // user message (which this client does serialize as
378 // input_image). Leave a short text note so an image-only
379 // result is not an empty output the API may reject, and so
380 // the model knows the image arrives next.
381 out := text.String()
382 if out == "" && imageCount > 0 {
383 if imageCount == 1 {
384 out = "[image returned; see the following message]"
385 } else {
386 out = fmt.Sprintf("[%d images returned; see the following message]", imageCount)
387 }
388 }
389 callID, _ := splitCallID(tr.CallID)
390 body.Input = append(body.Input, codexFunctionCallOutput{
391 Type: "function_call_output",
392 CallID: callID,
393 Output: out,
394 })
395 }
396 }
397 }
398 }
399
400 return body, nil
401}
402
403func splitCallID(id string) (string, string) {
404 if i := strings.Index(id, "|"); i >= 0 {
405 return id[:i], id[i+1:]
406 }
407 return id, ""
408}
409
410const minOpenAIPromptCacheTokens = 1024
411
412func responsesCacheDiagnostics(wire *codexRequest, transport, continuation string) CacheDiagnostics {
413 diagnostics := CacheDiagnostics{Mode: "none", Transport: transport, Continuation: continuation}
414 if wire == nil || wire.PromptCacheOptions == nil {
415 return diagnostics
416 }
417 diagnostics.Mode = wire.PromptCacheOptions.Mode
418 diagnostics.Eligible = estimatedOpenAIPromptTokens(wire) >= minOpenAIPromptCacheTokens
419 return diagnostics
420}
421
422// estimatedOpenAIPromptTokens supplies a deliberately conservative local
423// eligibility signal. OpenAI makes the final tokenization and cache decision;
424// this estimate never leaves the process and is not persisted in diagnostics.
425func estimatedOpenAIPromptTokens(wire *codexRequest) int {
426 encoded, err := json.Marshal(wire)
427 if err != nil {
428 return 0
429 }
430 return len(encoded) / 4
431}
432
433func usesCodexCLIRouting(model string) bool {
434 switch model {
435 case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro":
436 return true
437 default:
438 return false
439 }
440}
441
442// ---- Streaming ----
443
444func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
445 wire, err := c.buildRequest(req)
446 if err != nil {
447 return nil, err
448 }
449 ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "http_sse", "full"))
450 if c.modelName != nil {
451 wire.Model = c.modelName(wire.Model)
452 }
453 useCodexCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model))
454 cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID)
455 if c.capabilities.StablePromptCacheKey && cacheSessionID != "" {
456 wire.PromptCacheKey = cacheSessionID
457 }
458 body, err := json.Marshal(wire)
459 if err != nil {
460 return nil, err
461 }
462
463 newReq := func() (*http.Request, error) {
464 httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body))
465 if err != nil {
466 return nil, err
467 }
468 httpReq.Header = c.responsesHeaders(req, useCodexCLIRouting, false)
469 httpReq.Header.Set("content-type", "application/json")
470 httpReq.Header.Set("accept", "text/event-stream")
471 return httpReq, nil
472 }
473
474 resp, err := doStreamWithRetry(ctx, c.http, newReq, req.Lifecycle)
475 if err != nil {
476 return nil, fmt.Errorf("openai-codex: %w", err)
477 }
478 if resp.StatusCode != http.StatusOK {
479 b, _ := io.ReadAll(resp.Body)
480 resp.Body.Close()
481 return nil, fmt.Errorf("openai-codex: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
482 }
483
484 out := make(chan Event, 16)
485 go c.runStream(ctx, resp, req, out)
486 return out, nil
487}
488
489func (c *codexClient) responsesHeaders(req Request, useCodexCLIRouting, websocketMode bool) http.Header {
490 headers := make(http.Header)
491 headers.Set("authorization", "Bearer "+c.token)
492 if !websocketMode || c.accountID != "" {
493 headers.Set("openai-beta", "responses=experimental")
494 }
495 if c.accountID != "" {
496 headers.Set("chatgpt-account-id", c.accountID)
497 }
498
499 cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID)
500 threadID := strings.TrimSpace(req.Context.ThreadID)
501 if useCodexCLIRouting {
502 // The ChatGPT Codex backend only admits (and reliably serves)
503 // requests that follow Codex CLI routing metadata; other client
504 // identities are load-shed even when capacity is available.
505 headers.Set("originator", "codex_cli_rs")
506 if c.capabilities.SessionHeader && cacheSessionID != "" {
507 headers.Set("session-id", cacheSessionID)
508 }
509 if c.capabilities.ThreadHeader && threadID != "" {
510 headers.Set("thread-id", threadID)
511 }
512 if c.capabilities.ClientRequestHeader && threadID != "" {
513 headers.Set("x-client-request-id", threadID)
514 }
515 headers.Set("user-agent", "codex_cli_rs/0.0.0")
516 return headers
517 }
518
519 // ChatGPT's backend recognizes these established values. They are
520 // external provider metadata, not zut product identifiers.
521 if c.accountID != "" {
522 headers.Set("originator", "zot")
523 headers.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
524 }
525 return headers
526}
527
528func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
529 defer close(out)
530 defer resp.Body.Close()
531
532 raw := make(chan sseEvent, 16)
533 go readSSE(resp.Body, raw)
534 c.runResponseEvents(ctx, req, out, raw, nil)
535}
536
537// runResponseEvents translates the Responses streaming event protocol into
538// provider events. Both HTTP/SSE and Responses WebSocket mode use the same
539// event vocabulary, so keeping the state machine here prevents the transports
540// from drifting in tool-call, reasoning, and usage handling.
541func (c *codexClient) runResponseEvents(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, completed func(string)) {
542 var observe func(string, Message)
543 if completed != nil {
544 observe = func(responseID string, _ Message) { completed(responseID) }
545 }
546 c.runResponseEventsWithFirst(ctx, req, out, raw, nil, observe)
547}
548
549func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, first *sseEvent, completed func(string, Message)) {
550 model, _ := c.findModel(req.Model)
551 providerName := c.providerName
552 if providerName == "" {
553 providerName = "openai-codex"
554 }
555 out <- EventStart{Model: req.Model, Provider: providerName}
556
557 // Accumulators. The Responses API emits output_items in order; each
558 // item is either a "message" (text) or a "function_call". We track
559 // the in-flight item by its index.
560 type itemState struct {
561 kind string // "message" | "function_call" | "reasoning"
562 callID string
563 name string
564 argsBuf strings.Builder
565 textBuf strings.Builder
566 summary strings.Builder
567 rawID string
568 encrypted string
569 announced bool
570 }
571 var (
572 items = map[int]*itemState{}
573 order []int
574 usage Usage
575 stop = StopEnd
576 finalErr error
577 )
578
579 assemble := func() Message {
580 content := []Content{}
581 for _, idx := range order {
582 it := items[idx]
583 switch it.kind {
584 case "message":
585 if it.textBuf.Len() > 0 {
586 content = append(content, TextBlock{Text: it.textBuf.String()})
587 }
588 case "function_call":
589 args := it.argsBuf.String()
590 if args == "" || !json.Valid([]byte(args)) {
591 args = "{}"
592 }
593 content = append(content, ToolCallBlock{
594 ID: it.callID, Name: it.name, Arguments: json.RawMessage(args),
595 })
596 case "reasoning":
597 if it.encrypted == "" && it.summary.Len() == 0 && it.rawID == "" {
598 continue
599 }
600 content = append(content, ReasoningBlock{
601 ID: it.rawID,
602 Summary: it.summary.String(),
603 Encrypted: it.encrypted,
604 })
605 }
606 }
607 return Message{Role: RoleAssistant, Content: content, Time: time.Now()}
608 }
609
610 sendDone := func() {
611 usage.CostUSD = ComputeCost(model, usage)
612 out <- EventUsage{Usage: usage}
613 out <- EventDone{Stop: stop, Err: finalErr, Message: assemble()}
614 }
615
616 for {
617 var ev sseEvent
618 if first != nil {
619 ev = *first
620 first = nil
621 } else {
622 select {
623 case <-ctx.Done():
624 stop = StopAborted
625 finalErr = ctx.Err()
626 sendDone()
627 return
628 case next, ok := <-raw:
629 if !ok {
630 sendDone()
631 return
632 }
633 ev = next
634 }
635 }
636 if ev.Err != nil {
637 stop = StopError
638 finalErr = fmt.Errorf("read SSE: %w", ev.Err)
639 sendDone()
640 return
641 }
642 var head struct {
643 Type string `json:"type"`
644 }
645 if err := json.Unmarshal([]byte(ev.Data), &head); err != nil {
646 continue
647 }
648
649 switch head.Type {
650 case "response.output_item.added":
651 var p struct {
652 OutputIndex int `json:"output_index"`
653 Item struct {
654 Type string `json:"type"` // "message" | "function_call" | "reasoning"
655 ID string `json:"id"`
656 CallID string `json:"call_id"`
657 Name string `json:"name"`
658 EncryptedContent string `json:"encrypted_content"`
659 } `json:"item"`
660 }
661 _ = json.Unmarshal([]byte(ev.Data), &p)
662 it := &itemState{}
663 switch p.Item.Type {
664 case "message":
665 it.kind = "message"
666 case "function_call":
667 it.kind = "function_call"
668 it.callID = p.Item.CallID
669 it.name = p.Item.Name
670 if !it.announced {
671 it.announced = true
672 out <- EventToolStart{ID: it.callID, Name: it.name}
673 }
674 case "reasoning":
675 it.kind = "reasoning"
676 it.rawID = p.Item.ID
677 it.encrypted = p.Item.EncryptedContent
678 default:
679 continue
680 }
681 items[p.OutputIndex] = it
682 order = append(order, p.OutputIndex)
683 case "response.output_text.delta":
684 var p struct {
685 OutputIndex int `json:"output_index"`
686 Delta string `json:"delta"`
687 }
688 _ = json.Unmarshal([]byte(ev.Data), &p)
689 if it, ok := items[p.OutputIndex]; ok && it.kind == "message" {
690 it.textBuf.WriteString(p.Delta)
691 out <- EventTextDelta{Delta: p.Delta}
692 }
693 case "response.reasoning_summary_text.delta":
694 var p struct {
695 OutputIndex int `json:"output_index"`
696 Delta string `json:"delta"`
697 }
698 _ = json.Unmarshal([]byte(ev.Data), &p)
699 if it, ok := items[p.OutputIndex]; ok && it.kind == "reasoning" {
700 it.summary.WriteString(p.Delta)
701 }
702 case "response.reasoning_summary_text.done":
703 // summary text already accumulated via deltas
704 case "response.function_call_arguments.delta":
705 var p struct {
706 OutputIndex int `json:"output_index"`
707 Delta string `json:"delta"`
708 }
709 _ = json.Unmarshal([]byte(ev.Data), &p)
710 if it, ok := items[p.OutputIndex]; ok && it.kind == "function_call" {
711 it.argsBuf.WriteString(p.Delta)
712 out <- EventToolArgs{ID: it.callID, Delta: p.Delta}
713 }
714 case "response.output_item.done":
715 var p struct {
716 OutputIndex int `json:"output_index"`
717 Item struct {
718 Type string `json:"type"`
719 ID string `json:"id"`
720 EncryptedContent string `json:"encrypted_content"`
721 Summary []struct {
722 Type string `json:"type"`
723 Text string `json:"text"`
724 } `json:"summary"`
725 } `json:"item"`
726 }
727 _ = json.Unmarshal([]byte(ev.Data), &p)
728 if it, ok := items[p.OutputIndex]; ok {
729 switch it.kind {
730 case "function_call":
731 out <- EventToolEnd{ID: it.callID}
732 case "reasoning":
733 if p.Item.EncryptedContent != "" {
734 it.encrypted = p.Item.EncryptedContent
735 }
736 if it.rawID == "" && p.Item.ID != "" {
737 it.rawID = p.Item.ID
738 }
739 for _, s := range p.Item.Summary {
740 if s.Text == "" {
741 continue
742 }
743 if it.summary.Len() > 0 {
744 it.summary.WriteString("\n")
745 }
746 it.summary.WriteString(s.Text)
747 }
748 }
749 }
750 case "response.completed", "response.done":
751 var p struct {
752 Response struct {
753 ID string `json:"id"`
754 Usage struct {
755 InputTokens int `json:"input_tokens"`
756 OutputTokens int `json:"output_tokens"`
757 InputTokensDetails *openAIInputTokensDetails `json:"input_tokens_details"`
758 OutputTokensDetails *struct {
759 ReasoningTokens int `json:"reasoning_tokens"`
760 } `json:"output_tokens_details"`
761 } `json:"usage"`
762 Status string `json:"status"`
763 } `json:"response"`
764 }
765 _ = json.Unmarshal([]byte(ev.Data), &p)
766 usage = normalizeOpenAIUsage(p.Response.Usage.InputTokens, p.Response.Usage.OutputTokens, p.Response.Usage.InputTokensDetails)
767 if details := p.Response.Usage.OutputTokensDetails; details != nil {
768 usage.ReasoningTokens = details.ReasoningTokens
769 usage.ReasoningTokensKnown = true
770 }
771
772 hadTool := false
773 for _, it := range items {
774 if it.kind == "function_call" {
775 hadTool = true
776 break
777 }
778 }
779 if hadTool {
780 stop = StopToolUse
781 } else {
782 stop = StopEnd
783 }
784 if completed != nil && p.Response.ID != "" {
785 completed(p.Response.ID, assemble())
786 }
787 sendDone()
788 return
789 case "response.failed":
790 var p struct {
791 Response struct {
792 Error struct {
793 Message string `json:"message"`
794 } `json:"error"`
795 } `json:"response"`
796 }
797 _ = json.Unmarshal([]byte(ev.Data), &p)
798 stop = StopError
799 finalErr = fmt.Errorf("codex: %s", p.Response.Error.Message)
800 sendDone()
801 return
802 case "error":
803 var p struct {
804 Message string `json:"message"`
805 Code string `json:"code"`
806 Error struct {
807 Message string `json:"message"`
808 Code string `json:"code"`
809 } `json:"error"`
810 }
811 _ = json.Unmarshal([]byte(ev.Data), &p)
812 msg := p.Message
813 if msg == "" {
814 msg = p.Error.Message
815 }
816 if msg == "" {
817 msg = p.Code
818 }
819 if msg == "" {
820 msg = p.Error.Code
821 }
822 if msg == "" {
823 msg = strings.TrimSpace(ev.Data)
824 }
825 stop = StopError
826 label := c.errorLabel
827 if label == "" {
828 label = "codex"
829 }
830 finalErr = fmt.Errorf("%s error: %s", label, msg)
831 sendDone()
832 return
833 }
834 }
835}