v0.0.71 9d0b1d0

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" }
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
109type codexTool struct {
110	Type        string          `json:"type"` // "function"
111	Name        string          `json:"name"`
112	Description string          `json:"description,omitempty"`
113	Parameters  json.RawMessage `json:"parameters"`
114}
115
116type codexRequest struct {
117	Model             string      `json:"model"`
118	Store             bool        `json:"store"`
119	Stream            bool        `json:"stream"`
120	Instructions      string      `json:"instructions,omitempty"`
121	Input             []any       `json:"input"`
122	Tools             []codexTool `json:"tools,omitempty"`
123	ToolChoice        string      `json:"tool_choice,omitempty"`
124	ParallelToolCalls bool        `json:"parallel_tool_calls"`
125	Include           []string    `json:"include,omitempty"`
126}
127
128// ---- Request building ----
129
130func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
131	m, err := FindModel("openai", req.Model)
132	if err != nil {
133		return nil, err
134	}
135	_ = m
136
137	body := &codexRequest{
138		Model:             req.Model,
139		Store:             false,
140		Stream:            true,
141		Instructions:      req.System,
142		ParallelToolCalls: true,
143		Include:           []string{"reasoning.encrypted_content"},
144	}
145	if len(req.Tools) > 0 {
146		body.ToolChoice = "auto"
147		for _, t := range req.Tools {
148			params := t.Schema
149			if len(params) == 0 {
150				params = json.RawMessage(`{"type":"object","properties":{}}`)
151			}
152			body.Tools = append(body.Tools, codexTool{
153				Type:        "function",
154				Name:        t.Name,
155				Description: t.Description,
156				Parameters:  params,
157			})
158		}
159	}
160
161	msgIdx := 0
162	for _, msg := range req.Messages {
163		switch msg.Role {
164		case RoleUser:
165			content := []any{}
166			for _, c := range msg.Content {
167				switch v := c.(type) {
168				case TextBlock:
169					if v.Text != "" {
170						content = append(content, codexInputText{Type: "input_text", Text: v.Text})
171					}
172				case ImageBlock:
173					url := "data:" + v.MimeType + ";base64," + base64.StdEncoding.EncodeToString(v.Data)
174					content = append(content, codexInputImage{Type: "input_image", Detail: "auto", ImageURL: url})
175				}
176			}
177			if len(content) == 0 {
178				continue
179			}
180			body.Input = append(body.Input, codexInputMessage{Role: "user", Content: content})
181		case RoleAssistant:
182			// Emit one output_message per text block and one function_call per tool call,
183			// preserving the order so model sees the same interleaving we captured.
184			for _, c := range msg.Content {
185				switch v := c.(type) {
186				case TextBlock:
187					if v.Text == "" {
188						continue
189					}
190					msgIdx++
191					body.Input = append(body.Input, codexOutputMessage{
192						Type:   "message",
193						Role:   "assistant",
194						Status: "completed",
195						ID:     fmt.Sprintf("msg_%d", msgIdx),
196						Content: []codexOutputText{
197							{Type: "output_text", Text: v.Text, Annotations: []any{}},
198						},
199					})
200				case ToolCallBlock:
201					args := string(v.Arguments)
202					if args == "" {
203						args = "{}"
204					}
205					callID, _ := splitCallID(v.ID)
206					body.Input = append(body.Input, codexFunctionCall{
207						Type:      "function_call",
208						CallID:    callID,
209						Name:      v.Name,
210						Arguments: args,
211					})
212				}
213			}
214		case RoleTool:
215			for _, c := range msg.Content {
216				if tr, ok := c.(ToolResultBlock); ok {
217					var text strings.Builder
218					for _, inner := range tr.Content {
219						if tb, ok := inner.(TextBlock); ok {
220							if text.Len() > 0 {
221								text.WriteString("\n")
222							}
223							text.WriteString(tb.Text)
224						}
225					}
226					callID, _ := splitCallID(tr.CallID)
227					body.Input = append(body.Input, codexFunctionCallOutput{
228						Type:   "function_call_output",
229						CallID: callID,
230						Output: text.String(),
231					})
232				}
233			}
234		}
235	}
236
237	return body, nil
238}
239
240func splitCallID(id string) (string, string) {
241	if i := strings.Index(id, "|"); i >= 0 {
242		return id[:i], id[i+1:]
243	}
244	return id, ""
245}
246
247// ---- Streaming ----
248
249func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
250	wire, err := c.buildRequest(req)
251	if err != nil {
252		return nil, err
253	}
254	body, err := json.Marshal(wire)
255	if err != nil {
256		return nil, err
257	}
258
259	httpReq, err := http.NewRequestWithContext(ctx, "POST", c.baseURL, bytes.NewReader(body))
260	if err != nil {
261		return nil, err
262	}
263	httpReq.Header.Set("content-type", "application/json")
264	httpReq.Header.Set("accept", "text/event-stream")
265	httpReq.Header.Set("authorization", "Bearer "+c.token)
266	httpReq.Header.Set("chatgpt-account-id", c.accountID)
267	httpReq.Header.Set("openai-beta", "responses=experimental")
268	httpReq.Header.Set("originator", "zot")
269	httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
270
271	resp, err := c.http.Do(httpReq)
272	if err != nil {
273		return nil, fmt.Errorf("openai-codex: %w", err)
274	}
275	if resp.StatusCode != http.StatusOK {
276		b, _ := io.ReadAll(resp.Body)
277		resp.Body.Close()
278		return nil, fmt.Errorf("openai-codex: http %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
279	}
280
281	out := make(chan Event, 16)
282	go c.runStream(ctx, resp, req, out)
283	return out, nil
284}
285
286func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Request, out chan<- Event) {
287	defer close(out)
288	defer resp.Body.Close()
289
290	model, _ := FindModel("openai", req.Model)
291	out <- EventStart{Model: req.Model, Provider: "openai"}
292
293	raw := make(chan sseEvent, 16)
294	go readSSE(resp.Body, raw)
295
296	// Accumulators. The Responses API emits output_items in order; each
297	// item is either a "message" (text) or a "function_call". We track
298	// the in-flight item by its index.
299	type itemState struct {
300		kind      string // "message" | "function_call"
301		callID    string
302		name      string
303		argsBuf   strings.Builder
304		textBuf   strings.Builder
305		announced bool
306	}
307	var (
308		items    = map[int]*itemState{}
309		order    []int
310		usage    Usage
311		stop     StopReason = StopEnd
312		finalErr error
313	)
314
315	assemble := func() Message {
316		content := []Content{}
317		for _, idx := range order {
318			it := items[idx]
319			switch it.kind {
320			case "message":
321				if it.textBuf.Len() > 0 {
322					content = append(content, TextBlock{Text: it.textBuf.String()})
323				}
324			case "function_call":
325				args := it.argsBuf.String()
326				if args == "" {
327					args = "{}"
328				}
329				content = append(content, ToolCallBlock{
330					ID: it.callID, Name: it.name, Arguments: json.RawMessage(args),
331				})
332			}
333		}
334		return Message{Role: RoleAssistant, Content: content, Time: time.Now()}
335	}
336
337	sendDone := func() {
338		usage.CostUSD = ComputeCost(model, usage)
339		out <- EventUsage{Usage: usage}
340		out <- EventDone{Stop: stop, Err: finalErr, Message: assemble()}
341	}
342
343	for {
344		select {
345		case <-ctx.Done():
346			stop = StopAborted
347			finalErr = ctx.Err()
348			sendDone()
349			return
350		case ev, ok := <-raw:
351			if !ok {
352				sendDone()
353				return
354			}
355			var head struct {
356				Type string `json:"type"`
357			}
358			if err := json.Unmarshal([]byte(ev.Data), &head); err != nil {
359				continue
360			}
361
362			switch head.Type {
363			case "response.output_item.added":
364				var p struct {
365					OutputIndex int `json:"output_index"`
366					Item        struct {
367						Type   string `json:"type"` // "message" | "function_call"
368						ID     string `json:"id"`
369						CallID string `json:"call_id"`
370						Name   string `json:"name"`
371					} `json:"item"`
372				}
373				_ = json.Unmarshal([]byte(ev.Data), &p)
374				it := &itemState{}
375				switch p.Item.Type {
376				case "message":
377					it.kind = "message"
378				case "function_call":
379					it.kind = "function_call"
380					it.callID = p.Item.CallID
381					it.name = p.Item.Name
382					if !it.announced {
383						it.announced = true
384						out <- EventToolStart{ID: it.callID, Name: it.name}
385					}
386				default:
387					continue
388				}
389				items[p.OutputIndex] = it
390				order = append(order, p.OutputIndex)
391			case "response.output_text.delta":
392				var p struct {
393					OutputIndex int    `json:"output_index"`
394					Delta       string `json:"delta"`
395				}
396				_ = json.Unmarshal([]byte(ev.Data), &p)
397				if it, ok := items[p.OutputIndex]; ok && it.kind == "message" {
398					it.textBuf.WriteString(p.Delta)
399					out <- EventTextDelta{Delta: p.Delta}
400				}
401			case "response.function_call_arguments.delta":
402				var p struct {
403					OutputIndex int    `json:"output_index"`
404					Delta       string `json:"delta"`
405				}
406				_ = json.Unmarshal([]byte(ev.Data), &p)
407				if it, ok := items[p.OutputIndex]; ok && it.kind == "function_call" {
408					it.argsBuf.WriteString(p.Delta)
409					out <- EventToolArgs{ID: it.callID, Delta: p.Delta}
410				}
411			case "response.output_item.done":
412				var p struct {
413					OutputIndex int `json:"output_index"`
414				}
415				_ = json.Unmarshal([]byte(ev.Data), &p)
416				if it, ok := items[p.OutputIndex]; ok && it.kind == "function_call" {
417					out <- EventToolEnd{ID: it.callID}
418				}
419			case "response.completed", "response.done":
420				var p struct {
421					Response struct {
422						Usage struct {
423							InputTokens        int `json:"input_tokens"`
424							OutputTokens       int `json:"output_tokens"`
425							InputTokensDetails struct {
426								CachedTokens int `json:"cached_tokens"`
427							} `json:"input_tokens_details"`
428						} `json:"usage"`
429						Status string `json:"status"`
430					} `json:"response"`
431				}
432				_ = json.Unmarshal([]byte(ev.Data), &p)
433				usage.InputTokens = p.Response.Usage.InputTokens - p.Response.Usage.InputTokensDetails.CachedTokens
434				if usage.InputTokens < 0 {
435					usage.InputTokens = p.Response.Usage.InputTokens
436				}
437				usage.OutputTokens = p.Response.Usage.OutputTokens
438				usage.CacheReadTokens = p.Response.Usage.InputTokensDetails.CachedTokens
439
440				hadTool := false
441				for _, it := range items {
442					if it.kind == "function_call" {
443						hadTool = true
444						break
445					}
446				}
447				if hadTool {
448					stop = StopToolUse
449				} else {
450					stop = StopEnd
451				}
452				sendDone()
453				return
454			case "response.failed":
455				var p struct {
456					Response struct {
457						Error struct {
458							Message string `json:"message"`
459						} `json:"error"`
460					} `json:"response"`
461				}
462				_ = json.Unmarshal([]byte(ev.Data), &p)
463				stop = StopError
464				finalErr = fmt.Errorf("codex: %s", p.Response.Error.Message)
465				sendDone()
466				return
467			case "error":
468				var p struct {
469					Message string `json:"message"`
470					Code    string `json:"code"`
471				}
472				_ = json.Unmarshal([]byte(ev.Data), &p)
473				msg := p.Message
474				if msg == "" {
475					msg = p.Code
476				}
477				stop = StopError
478				finalErr = fmt.Errorf("codex error: %s", msg)
479				sendDone()
480				return
481			}
482		}
483	}
484}

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