v0.19.0 d7aa01a

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

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