v0.2.71 c18646e

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

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