v0.2.72 1fa7669

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

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