v0.2.8 ec5eb20

openai_codex.go RawHistoryFolder

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

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