v0.2.70 9e3f5d2

openai_codex.go RawHistoryFolder

1package provider
2
3import (
4	"bytes"
5	"context"
6	"crypto/rand"
7	"encoding/base64"
8	"encoding/hex"
9	"encoding/json"
10	"fmt"
11	"io"
12	"net/http"
13	"runtime"
14	"strings"
15	"time"
16)
17
18// Codex (ChatGPT subscription) client. Uses OpenAI's Responses API via
19// chatgpt.com/backend-api with the chatgpt-account-id handshake.
20//
21// Wire protocol notes:
22//   - Endpoint: POST https://chatgpt.com/backend-api/codex/responses
23//   - Headers: Authorization: Bearer <access_token>, chatgpt-account-id: <id>,
24//     OpenAI-Beta: responses=experimental, originator: zot
25//   - Body: OpenAI Responses API shape (not chat/completions).
26//     input: [{role, content: [{type: "input_text" | "input_image" | ... }]}]
27//     instructions: <system prompt>
28//     tools: [{type:"function", name, description, parameters, strict}]
29//     stream: true
30//   - SSE events: response.output_item.added, response.output_text.delta,
31//     response.function_call_arguments.delta, response.output_item.done,
32//     response.completed, response.failed, error
33//
34// The access_token comes from the OpenAI OAuth flow (auth.openai.com).
35// The accountID is parsed from the id_token JWT's `chatgpt_account_id`
36// claim; see auth/oauth.go.
37
38const codexDefaultBaseURL = "https://chatgpt.com/backend-api/codex/responses"
39
40type codexClient struct {
41	token     string
42	accountID string
43	baseURL   string
44	http      *http.Client
45}
46
47// NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint
48// using a subscription OAuth access token and the user's ChatGPT
49// account id. baseURL may be empty to use the default.
50func NewOpenAICodex(token, accountID, baseURL string) Client {
51	if baseURL == "" {
52		baseURL = codexDefaultBaseURL
53	}
54	return &codexClient{
55		token:     token,
56		accountID: accountID,
57		baseURL:   strings.TrimRight(baseURL, "/"),
58		http:      &http.Client{Timeout: 0},
59	}
60}
61
62func (c *codexClient) Name() string { return "openai-codex" }
63
64// ---- Responses API wire types (subset needed for zot's surface) ----
65
66type codexInputText struct {
67	Type string `json:"type"` // "input_text"
68	Text string `json:"text"`
69}
70
71type codexInputImage struct {
72	Type     string `json:"type"` // "input_image"
73	Detail   string `json:"detail"`
74	ImageURL string `json:"image_url"`
75}
76
77type codexOutputText struct {
78	Type        string `json:"type"` // "output_text"
79	Text        string `json:"text"`
80	Annotations []any  `json:"annotations"`
81}
82
83type codexInputMessage struct {
84	Type    string `json:"type,omitempty"` // "message" (optional for input)
85	Role    string `json:"role"`
86	Content []any  `json:"content"`
87}
88
89type codexOutputMessage struct {
90	Type    string            `json:"type"` // "message"
91	Role    string            `json:"role"`
92	Status  string            `json:"status,omitempty"`
93	ID      string            `json:"id,omitempty"`
94	Content []codexOutputText `json:"content"`
95}
96
97type codexFunctionCall struct {
98	Type      string `json:"type"` // "function_call"
99	ID        string `json:"id,omitempty"`
100	CallID    string `json:"call_id"`
101	Name      string `json:"name"`
102	Arguments string `json:"arguments"` // JSON string
103}
104
105type codexFunctionCallOutput struct {
106	Type   string `json:"type"` // "function_call_output"
107	CallID string `json:"call_id"`
108	Output string `json:"output"` // string (or ResponseFunctionCallOutputItemList for images; v1 only uses string)
109}
110
111// codexReasoningItem mirrors the Responses API "reasoning" output item.
112// We capture it on incoming streams and replay it verbatim on follow-up
113// requests: the API rejects assistant tool-call replays without it when
114// thinking is enabled.
115type codexReasoningItem struct {
116	Type             string `json:"type"` // "reasoning"
117	ID               string `json:"id,omitempty"`
118	EncryptedContent string `json:"encrypted_content,omitempty"`
119	// Summary is required by the Responses API even when no summary text
120	// was streamed; encode an empty array rather than omitting the field.
121	Summary []codexReasoningSummary `json:"summary"`
122}
123
124type codexReasoningSummary struct {
125	Type string `json:"type"` // "summary_text"
126	Text string `json:"text"`
127}
128
129type codexTool struct {
130	Type        string          `json:"type"` // "function"
131	Name        string          `json:"name"`
132	Description string          `json:"description,omitempty"`
133	Parameters  json.RawMessage `json:"parameters"`
134}
135
136type codexReasoningConfig struct {
137	Effort string `json:"effort,omitempty"`
138}
139
140type codexRequest struct {
141	Model             string                `json:"model"`
142	Store             bool                  `json:"store"`
143	Stream            bool                  `json:"stream"`
144	Instructions      string                `json:"instructions,omitempty"`
145	Input             []any                 `json:"input"`
146	Tools             []codexTool           `json:"tools,omitempty"`
147	ToolChoice        string                `json:"tool_choice,omitempty"`
148	ParallelToolCalls bool                  `json:"parallel_tool_calls"`
149	Include           []string              `json:"include,omitempty"`
150	Reasoning         *codexReasoningConfig `json:"reasoning,omitempty"`
151	PromptCacheKey    string                `json:"prompt_cache_key,omitempty"`
152}
153
154// ---- Request building ----
155
156func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
157	m, err := FindModel("openai-codex", req.Model)
158	if err != nil {
159		m, err = FindModel("openai", req.Model)
160	}
161	if err != nil {
162		return nil, err
163	}
164	_ = m
165
166	body := &codexRequest{
167		Model:             req.Model,
168		Store:             false,
169		Stream:            true,
170		Instructions:      req.System,
171		ParallelToolCalls: true,
172		Include:           []string{"reasoning.encrypted_content"},
173	}
174	if m.Reasoning {
175		if effort := OpenAICodexReasoningEffort(req.Reasoning); effort != "" {
176			body.Reasoning = &codexReasoningConfig{Effort: effort}
177		}
178	}
179	if len(req.Tools) > 0 {
180		body.ToolChoice = "auto"
181		for _, t := range req.Tools {
182			params := t.Schema
183			if len(params) == 0 {
184				params = json.RawMessage(`{"type":"object","properties":{}}`)
185			}
186			body.Tools = append(body.Tools, codexTool{
187				Type:        "function",
188				Name:        t.Name,
189				Description: t.Description,
190				Parameters:  params,
191			})
192		}
193	}
194
195	msgIdx := 0
196	req.Messages = RepairOrphanedToolResults(req.Messages)
197	for _, msg := range req.Messages {
198		switch msg.Role {
199		case RoleUser:
200			content := []any{}
201			for _, c := range msg.Content {
202				switch v := c.(type) {
203				case TextBlock:
204					if v.Text != "" {
205						content = append(content, codexInputText{Type: "input_text", Text: v.Text})
206					}
207				case ImageBlock:
208					url := "data:" + v.MimeType + ";base64," + base64.StdEncoding.EncodeToString(v.Data)
209					content = append(content, codexInputImage{Type: "input_image", Detail: "auto", ImageURL: url})
210				}
211			}
212			if len(content) == 0 {
213				continue
214			}
215			body.Input = append(body.Input, codexInputMessage{Role: "user", Content: content})
216		case RoleAssistant:
217			// Emit one output_message per text block, one function_call per
218			// tool call, and one reasoning item per ReasoningBlock,
219			// preserving the order so the model sees the same interleaving
220			// we captured. The reasoning replay is what keeps OpenAI
221			// Codex from rejecting follow-up tool calls with
222			// "thinking is enabled but reasoning_content is missing".
223			for _, c := range msg.Content {
224				switch v := c.(type) {
225				case ReasoningBlock:
226					item := codexReasoningItem{
227						Type:             "reasoning",
228						ID:               v.ID,
229						EncryptedContent: v.Encrypted,
230						Summary:          []codexReasoningSummary{},
231					}
232					if v.Summary != "" {
233						item.Summary = []codexReasoningSummary{{Type: "summary_text", Text: v.Summary}}
234					}
235					body.Input = append(body.Input, item)
236				case TextBlock:
237					if v.Text == "" {
238						continue
239					}
240					msgIdx++
241					body.Input = append(body.Input, codexOutputMessage{
242						Type:   "message",
243						Role:   "assistant",
244						Status: "completed",
245						ID:     fmt.Sprintf("msg_%d", msgIdx),
246						Content: []codexOutputText{
247							{Type: "output_text", Text: v.Text, Annotations: []any{}},
248						},
249					})
250				case ToolCallBlock:
251					args := string(v.Arguments)
252					if args == "" || !json.Valid([]byte(args)) {
253						args = "{}"
254					}
255					callID, _ := splitCallID(v.ID)
256					body.Input = append(body.Input, codexFunctionCall{
257						Type:      "function_call",
258						CallID:    callID,
259						Name:      v.Name,
260						Arguments: args,
261					})
262				}
263			}
264		case RoleTool:
265			for _, c := range msg.Content {
266				if tr, ok := c.(ToolResultBlock); ok {
267					var text strings.Builder
268					imageCount := 0
269					for _, inner := range tr.Content {
270						switch v := inner.(type) {
271						case TextBlock:
272							if text.Len() > 0 {
273								text.WriteString("\n")
274							}
275							text.WriteString(v.Text)
276						case ImageBlock:
277							imageCount++
278						}
279					}
280					// The Responses API function_call_output only carries a
281					// string, so image bytes cannot ride along here. The agent
282					// loop mirrors any tool-result images into the following
283					// user message (which this client does serialize as
284					// input_image). Leave a short text note so an image-only
285					// result is not an empty output the API may reject, and so
286					// the model knows the image arrives next.
287					out := text.String()
288					if out == "" && imageCount > 0 {
289						if imageCount == 1 {
290							out = "[image returned; see the following message]"
291						} else {
292							out = fmt.Sprintf("[%d images returned; see the following message]", imageCount)
293						}
294					}
295					callID, _ := splitCallID(tr.CallID)
296					body.Input = append(body.Input, codexFunctionCallOutput{
297						Type:   "function_call_output",
298						CallID: callID,
299						Output: out,
300					})
301				}
302			}
303		}
304	}
305
306	return body, nil
307}
308
309func splitCallID(id string) (string, string) {
310	if i := strings.Index(id, "|"); i >= 0 {
311		return id[:i], id[i+1:]
312	}
313	return id, ""
314}
315
316func usesCodexCLIRouting(model string) bool {
317	switch model {
318	case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro":
319		return true
320	default:
321		return false
322	}
323}
324
325func newCodexSessionID() string {
326	var b [16]byte
327	if _, err := rand.Read(b[:]); err != nil {
328		return fmt.Sprintf("zot-%d", time.Now().UnixNano())
329	}
330	return strings.Join([]string{
331		hex.EncodeToString(b[0:4]),
332		hex.EncodeToString(b[4:6]),
333		hex.EncodeToString(b[6:8]),
334		hex.EncodeToString(b[8:10]),
335		hex.EncodeToString(b[10:16]),
336	}, "-")
337}
338
339// ---- Streaming ----
340
341func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
342	wire, err := c.buildRequest(req)
343	if err != nil {
344		return nil, err
345	}
346	var codexCLISessionID string
347	if usesCodexCLIRouting(wire.Model) {
348		codexCLISessionID = newCodexSessionID()
349		wire.PromptCacheKey = codexCLISessionID
350	}
351	body, err := json.Marshal(wire)
352	if err != nil {
353		return nil, err
354	}
355
356	newReq := func() (*http.Request, error) {
357		httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body))
358		if err != nil {
359			return nil, err
360		}
361		httpReq.Header.Set("content-type", "application/json")
362		httpReq.Header.Set("accept", "text/event-stream")
363		httpReq.Header.Set("authorization", "Bearer "+c.token)
364		httpReq.Header.Set("chatgpt-account-id", c.accountID)
365		httpReq.Header.Set("openai-beta", "responses=experimental")
366		if codexCLISessionID != "" {
367			// Some preview models are only admitted or reliably served by the
368			// ChatGPT Codex backend when the request follows Codex CLI routing
369			// metadata. Keep this narrow so Sol retains zot's proven shape.
370			httpReq.Header.Set("originator", "codex_cli_rs")
371			httpReq.Header.Set("session-id", codexCLISessionID)
372			httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")
373		} else {
374			httpReq.Header.Set("originator", "zot")
375			httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
376		}
377		return httpReq, nil
378	}
379
380	resp, err := doStreamWithRetry(ctx, c.http, newReq)
381	if err != nil {
382		return nil, fmt.Errorf("openai-codex: %w", err)
383	}
384	if resp.StatusCode != http.StatusOK {
385		b, _ := io.ReadAll(resp.Body)
386		resp.Body.Close()
387		return nil, fmt.Errorf("openai-codex: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
388	}
389
390	out := make(chan Event, 16)
391	go c.runStream(ctx, resp, req, out)
392	return out, nil
393}
394
395func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
396	defer close(out)
397	defer resp.Body.Close()
398
399	model, _ := FindModel("openai-codex", req.Model)
400	if model.ID == "" {
401		model, _ = FindModel("openai", req.Model)
402	}
403	out <- EventStart{Model: req.Model, Provider: "openai-codex"}
404
405	raw := make(chan sseEvent, 16)
406	go readSSE(resp.Body, raw)
407
408	// Accumulators. The Responses API emits output_items in order; each
409	// item is either a "message" (text) or a "function_call". We track
410	// the in-flight item by its index.
411	type itemState struct {
412		kind      string // "message" | "function_call" | "reasoning"
413		callID    string
414		name      string
415		argsBuf   strings.Builder
416		textBuf   strings.Builder
417		summary   strings.Builder
418		rawID     string
419		encrypted string
420		announced bool
421	}
422	var (
423		items    = map[int]*itemState{}
424		order    []int
425		usage    Usage
426		stop     StopReason = StopEnd
427		finalErr error
428	)
429
430	assemble := func() Message {
431		content := []Content{}
432		for _, idx := range order {
433			it := items[idx]
434			switch it.kind {
435			case "message":
436				if it.textBuf.Len() > 0 {
437					content = append(content, TextBlock{Text: it.textBuf.String()})
438				}
439			case "function_call":
440				args := it.argsBuf.String()
441				if args == "" || !json.Valid([]byte(args)) {
442					args = "{}"
443				}
444				content = append(content, ToolCallBlock{
445					ID: it.callID, Name: it.name, Arguments: json.RawMessage(args),
446				})
447			case "reasoning":
448				if it.encrypted == "" && it.summary.Len() == 0 && it.rawID == "" {
449					continue
450				}
451				content = append(content, ReasoningBlock{
452					ID:        it.rawID,
453					Summary:   it.summary.String(),
454					Encrypted: it.encrypted,
455				})
456			}
457		}
458		return Message{Role: RoleAssistant, Content: content, Time: time.Now()}
459	}
460
461	sendDone := func() {
462		usage.CostUSD = ComputeCost(model, usage)
463		out <- EventUsage{Usage: usage}
464		out <- EventDone{Stop: stop, Err: finalErr, Message: assemble()}
465	}
466
467	for {
468		select {
469		case <-ctx.Done():
470			stop = StopAborted
471			finalErr = ctx.Err()
472			sendDone()
473			return
474		case ev, ok := <-raw:
475			if !ok {
476				sendDone()
477				return
478			}
479			var head struct {
480				Type string `json:"type"`
481			}
482			if err := json.Unmarshal([]byte(ev.Data), &head); err != nil {
483				continue
484			}
485
486			switch head.Type {
487			case "response.output_item.added":
488				var p struct {
489					OutputIndex int `json:"output_index"`
490					Item        struct {
491						Type             string `json:"type"` // "message" | "function_call" | "reasoning"
492						ID               string `json:"id"`
493						CallID           string `json:"call_id"`
494						Name             string `json:"name"`
495						EncryptedContent string `json:"encrypted_content"`
496					} `json:"item"`
497				}
498				_ = json.Unmarshal([]byte(ev.Data), &p)
499				it := &itemState{}
500				switch p.Item.Type {
501				case "message":
502					it.kind = "message"
503				case "function_call":
504					it.kind = "function_call"
505					it.callID = p.Item.CallID
506					it.name = p.Item.Name
507					if !it.announced {
508						it.announced = true
509						out <- EventToolStart{ID: it.callID, Name: it.name}
510					}
511				case "reasoning":
512					it.kind = "reasoning"
513					it.rawID = p.Item.ID
514					it.encrypted = p.Item.EncryptedContent
515				default:
516					continue
517				}
518				items[p.OutputIndex] = it
519				order = append(order, p.OutputIndex)
520			case "response.output_text.delta":
521				var p struct {
522					OutputIndex int    `json:"output_index"`
523					Delta       string `json:"delta"`
524				}
525				_ = json.Unmarshal([]byte(ev.Data), &p)
526				if it, ok := items[p.OutputIndex]; ok && it.kind == "message" {
527					it.textBuf.WriteString(p.Delta)
528					out <- EventTextDelta{Delta: p.Delta}
529				}
530			case "response.reasoning_summary_text.delta":
531				var p struct {
532					OutputIndex int    `json:"output_index"`
533					Delta       string `json:"delta"`
534				}
535				_ = json.Unmarshal([]byte(ev.Data), &p)
536				if it, ok := items[p.OutputIndex]; ok && it.kind == "reasoning" {
537					it.summary.WriteString(p.Delta)
538				}
539			case "response.reasoning_summary_text.done":
540				// summary text already accumulated via deltas
541			case "response.function_call_arguments.delta":
542				var p struct {
543					OutputIndex int    `json:"output_index"`
544					Delta       string `json:"delta"`
545				}
546				_ = json.Unmarshal([]byte(ev.Data), &p)
547				if it, ok := items[p.OutputIndex]; ok && it.kind == "function_call" {
548					it.argsBuf.WriteString(p.Delta)
549					out <- EventToolArgs{ID: it.callID, Delta: p.Delta}
550				}
551			case "response.output_item.done":
552				var p struct {
553					OutputIndex int `json:"output_index"`
554					Item        struct {
555						Type             string `json:"type"`
556						ID               string `json:"id"`
557						EncryptedContent string `json:"encrypted_content"`
558						Summary          []struct {
559							Type string `json:"type"`
560							Text string `json:"text"`
561						} `json:"summary"`
562					} `json:"item"`
563				}
564				_ = json.Unmarshal([]byte(ev.Data), &p)
565				if it, ok := items[p.OutputIndex]; ok {
566					switch it.kind {
567					case "function_call":
568						out <- EventToolEnd{ID: it.callID}
569					case "reasoning":
570						if p.Item.EncryptedContent != "" {
571							it.encrypted = p.Item.EncryptedContent
572						}
573						if it.rawID == "" && p.Item.ID != "" {
574							it.rawID = p.Item.ID
575						}
576						for _, s := range p.Item.Summary {
577							if s.Text == "" {
578								continue
579							}
580							if it.summary.Len() > 0 {
581								it.summary.WriteString("\n")
582							}
583							it.summary.WriteString(s.Text)
584						}
585					}
586				}
587			case "response.completed", "response.done":
588				var p struct {
589					Response struct {
590						Usage struct {
591							InputTokens        int `json:"input_tokens"`
592							OutputTokens       int `json:"output_tokens"`
593							InputTokensDetails struct {
594								CachedTokens int `json:"cached_tokens"`
595							} `json:"input_tokens_details"`
596						} `json:"usage"`
597						Status string `json:"status"`
598					} `json:"response"`
599				}
600				_ = json.Unmarshal([]byte(ev.Data), &p)
601				usage.InputTokens = p.Response.Usage.InputTokens - p.Response.Usage.InputTokensDetails.CachedTokens
602				if usage.InputTokens < 0 {
603					usage.InputTokens = p.Response.Usage.InputTokens
604				}
605				usage.OutputTokens = p.Response.Usage.OutputTokens
606				usage.CacheReadTokens = p.Response.Usage.InputTokensDetails.CachedTokens
607
608				hadTool := false
609				for _, it := range items {
610					if it.kind == "function_call" {
611						hadTool = true
612						break
613					}
614				}
615				if hadTool {
616					stop = StopToolUse
617				} else {
618					stop = StopEnd
619				}
620				sendDone()
621				return
622			case "response.failed":
623				var p struct {
624					Response struct {
625						Error struct {
626							Message string `json:"message"`
627						} `json:"error"`
628					} `json:"response"`
629				}
630				_ = json.Unmarshal([]byte(ev.Data), &p)
631				stop = StopError
632				finalErr = fmt.Errorf("codex: %s", p.Response.Error.Message)
633				sendDone()
634				return
635			case "error":
636				var p struct {
637					Message string `json:"message"`
638					Code    string `json:"code"`
639				}
640				_ = json.Unmarshal([]byte(ev.Data), &p)
641				msg := p.Message
642				if msg == "" {
643					msg = p.Code
644				}
645				stop = StopError
646				finalErr = fmt.Errorf("codex error: %s", msg)
647				sendDone()
648				return
649			}
650		}
651	}
652}

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 476

↑↓ moveenter openesc close