v0.3.4 fc39bd9

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

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