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