v0.31.0 9467429

openai_codex.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close