]> git.giacomolli.uk - onechat/blob - responses.go
Revert the block parts of the compaction view
[onechat] / responses.go
1 package main
2
3 import (
4 "bufio"
5 "bytes"
6 "context"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "net/http"
12 "os"
13 "path/filepath"
14 "strings"
15 "sync"
16 "sync/atomic"
17 "time"
18 )
19
20 const (
21 // chatgptURL is the Responses endpoint of the ChatGPT backend.
22 chatgptURL = "https://chatgpt.com/backend-api/codex/responses"
23
24 // apiURL is the Responses endpoint of the OpenAI API.
25 apiURL = "https://api.openai.com/v1/responses"
26
27 // quotaPrefix starts the names of the quota headers of the ChatGPT
28 // backend, in canonical form.
29 quotaPrefix = "X-Codex-"
30
31 // turnStateHeader is an x-codex-* header that holds an opaque state of
32 // the backend, not quota.
33 turnStateHeader = "X-Codex-Turn-State"
34
35 // errorBytes is the most bytes of an error response that go into the
36 // error.
37 errorBytes = 4096
38 )
39
40 // errCallLimit tells that a client sent its limit of calls.
41 var errCallLimit = errors.New("call limit reached")
42
43 // authorizer adds the credentials of a model call to the headers of its
44 // request.
45 type authorizer interface {
46 Authorize(ctx context.Context, header http.Header) error
47 }
48
49 // apiKey authorizes calls to the OpenAI API with a key.
50 type apiKey string
51
52 // Client sends model calls to a Responses endpoint for one agent. The ChatGPT
53 // backend and the OpenAI API take the same request body and differ only in the
54 // URL and the credentials. Every call sends the fixed cache key of the agent,
55 // so the turns and the compactions of the agent share one prompt cache. The
56 // ChatGPT backend caches only with the key in the session_id header, so a call
57 // to it sends the key there and in the body. Every call is recorded in the
58 // usage file.
59 //
60 // The request never holds max_output_tokens or metadata, because the ChatGPT
61 // backend refuses them. It sets store to false, so the client sends the whole
62 // input on each call, and it asks for the encrypted reasoning, so a later call
63 // can send the reasoning items back.
64 type Client struct {
65 url string
66 auth authorizer
67 cacheKey string
68 usage *usageLog
69 http *http.Client
70 limit int64
71 calls atomic.Int64
72 }
73
74 // Request is one model call. Input holds the input items in order, such as
75 // userMessage values or output items of an earlier response. Node names the
76 // node that a compaction builds, or is nil. It goes only to the usage file,
77 // never to the endpoint.
78 type Request struct {
79 Model string
80 Effort string
81 Instructions string
82 Tools []Tool
83 Input []any
84 Node *nodeKey
85 }
86
87 // Tool is the definition of a function tool.
88 type Tool struct {
89 Type string `json:"type"`
90 Name string `json:"name"`
91 Description string `json:"description"`
92 Parameters any `json:"parameters"`
93 Strict bool `json:"strict"`
94 }
95
96 // Response is the result of a model call. Output holds the output items as the
97 // endpoint sent them. Text is the text of the message items.
98 type Response struct {
99 Output []json.RawMessage
100 Text string
101 Usage Usage
102 }
103
104 // Usage gives the token counts of a call. Cached is the part of the input
105 // tokens that came from the prompt cache.
106 type Usage struct {
107 Input int
108 Cached int
109 Output int
110 }
111
112 // requestBody is the JSON body of a call.
113 type requestBody struct {
114 Model string `json:"model"`
115 Instructions string `json:"instructions"`
116 Input []any `json:"input"`
117 Tools []Tool `json:"tools"`
118 Reasoning reasoning `json:"reasoning"`
119 Store bool `json:"store"`
120 Stream bool `json:"stream"`
121 Include []string `json:"include"`
122 PromptCacheKey string `json:"prompt_cache_key"`
123 }
124
125 type reasoning struct {
126 Effort string `json:"effort"`
127 }
128
129 // streamEvent holds the fields of the stream events that the client reads.
130 type streamEvent struct {
131 Type string `json:"type"`
132 Item json.RawMessage `json:"item"`
133 Response *eventResponse `json:"response"`
134 Code string `json:"code"`
135 Message string `json:"message"`
136 }
137
138 type eventResponse struct {
139 Output []json.RawMessage `json:"output"`
140 Usage *wireUsage `json:"usage"`
141 Error *wireError `json:"error"`
142 IncompleteDetails *struct {
143 Reason string `json:"reason"`
144 } `json:"incomplete_details"`
145 }
146
147 type wireUsage struct {
148 InputTokens int `json:"input_tokens"`
149 OutputTokens int `json:"output_tokens"`
150 InputTokensDetails struct {
151 CachedTokens int `json:"cached_tokens"`
152 } `json:"input_tokens_details"`
153 }
154
155 type wireError struct {
156 Code string `json:"code"`
157 Message string `json:"message"`
158 }
159
160 // outputItem holds the fields of an output item that give its text.
161 type outputItem struct {
162 Type string `json:"type"`
163 Content []struct {
164 Type string `json:"type"`
165 Text string `json:"text"`
166 } `json:"content"`
167 }
168
169 // modelEndpoint returns the URL and the authorizer of the endpoint in config.
170 // The ChatGPT backend uses the token file in dir. The OpenAI API uses the key in
171 // OPENAI_API_KEY, and there is no fallback from one to the other. All clients
172 // share the authorizer, so one refresh runs at a time.
173 func modelEndpoint(config Config, dir string) (string, authorizer, error) {
174 if config.Endpoint == endpointAPI {
175 key := os.Getenv("OPENAI_API_KEY")
176 if key == "" {
177 return "", nil, errors.New("endpoint api needs OPENAI_API_KEY")
178 }
179
180 return apiURL, apiKey(key), nil
181 }
182
183 return chatgptURL, newChatGPTAuth(filepath.Join(dir, tokenFileName), authIssuer), nil
184 }
185
186 // newClient returns a client for the Responses endpoint at url. It records
187 // each call in the usage file at usagePath.
188 func newClient(url string, auth authorizer, cacheKey, usagePath string) *Client {
189 return &Client{
190 url: url,
191 auth: auth,
192 cacheKey: cacheKey,
193 usage: &usageLog{
194 path: usagePath,
195 },
196 http: &http.Client{},
197 }
198 }
199
200 // LimitCalls makes the client refuse each call after the first limit calls.
201 func (c *Client) LimitCalls(limit int64) {
202 c.limit = limit
203 }
204
205 // LimitReached tells if the client refuses further calls.
206 func (c *Client) LimitReached() bool {
207 return c.limit > 0 && c.calls.Load() >= c.limit
208 }
209
210 // Create sends the call and reads its event stream to the end. Over the call
211 // limit it sends nothing and returns errCallLimit.
212 func (c *Client) Create(ctx context.Context, request Request) (Response, error) {
213 if c.calls.Add(1) > c.limit && c.limit > 0 {
214 return Response{}, errCallLimit
215 }
216
217 body, err := json.Marshal(requestBody{
218 Model: request.Model,
219 Instructions: request.Instructions,
220 Input: request.Input,
221 Tools: request.Tools,
222 Reasoning: reasoning{
223 Effort: request.Effort,
224 },
225 Store: false,
226 Stream: true,
227 Include: []string{"reasoning.encrypted_content"},
228 PromptCacheKey: c.cacheKey,
229 })
230 if err != nil {
231 return Response{}, fmt.Errorf("encode request: %w", err)
232 }
233
234 httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body))
235 if err != nil {
236 return Response{}, fmt.Errorf("create request: %w", err)
237 }
238
239 httpRequest.Header.Set("Content-Type", "application/json")
240 httpRequest.Header.Set("Accept", "text/event-stream")
241 err = c.auth.Authorize(ctx, httpRequest.Header)
242 if err != nil {
243 return Response{}, fmt.Errorf("authorize model call: %w", err)
244 }
245
246 _, chatgpt := c.auth.(*chatgptAuth)
247 if chatgpt {
248 httpRequest.Header.Set("session_id", c.cacheKey)
249 }
250
251 start := time.Now()
252 record := usageRecord{
253 Date: start.Format(time.RFC3339Nano),
254 Model: request.Model,
255 }
256
257 if request.Node != nil {
258 record.Node = &[2]int{request.Node.level, request.Node.index}
259 }
260
261 response, err := c.send(httpRequest, &record)
262 if err != nil {
263 record.Error = err.Error()
264 }
265
266 record.Seconds = time.Since(start).Seconds()
267 record.InputTokens = response.Usage.Input
268 record.CachedTokens = response.Usage.Cached
269 record.OutputTokens = response.Usage.Output
270 record.TextBytes = len(response.Text)
271
272 return response, errors.Join(err, c.usage.Write(record))
273 }
274
275 // send makes the HTTP call and reads its stream. It puts the status and the
276 // quota headers into record.
277 func (c *Client) send(httpRequest *http.Request, record *usageRecord) (response Response, err error) {
278 httpResponse, err := c.http.Do(httpRequest)
279 if err != nil {
280 return Response{}, fmt.Errorf("send model call: %w", err)
281 }
282
283 defer func() {
284 err = errors.Join(err, httpResponse.Body.Close())
285 }()
286
287 record.Status = httpResponse.StatusCode
288 record.Quota = quotaHeaders(httpResponse.Header)
289 if httpResponse.StatusCode != http.StatusOK {
290 text, readErr := io.ReadAll(io.LimitReader(httpResponse.Body, errorBytes))
291 err = fmt.Errorf("model call: HTTP %d: %s", httpResponse.StatusCode, strings.TrimSpace(string(text)))
292
293 return Response{}, errors.Join(err, readErr)
294 }
295
296 return readStream(httpResponse.Body)
297 }
298
299 // usageLog appends one JSON line for each model call to usage.jsonl. Calls can
300 // run at once, so a mutex serializes the writes.
301 type usageLog struct {
302 mutex sync.Mutex
303 path string
304 }
305
306 // usageRecord is one line of usage.jsonl. Date is the start of the call, and
307 // Seconds its length. Node is the node [l, i] of a compaction. Status is the
308 // HTTP status, or 0 when no response came. TextBytes is the size of the text of
309 // the reply. Quota holds the x-codex-* headers of the response, without the
310 // turn state.
311 type usageRecord struct {
312 Date string `json:"date"`
313 Seconds float64 `json:"seconds"`
314 Model string `json:"model"`
315 Node *[2]int `json:"node,omitempty"`
316 Status int `json:"status"`
317 InputTokens int `json:"input_tokens"`
318 CachedTokens int `json:"cached_tokens"`
319 OutputTokens int `json:"output_tokens"`
320 TextBytes int `json:"text_bytes"`
321 Quota map[string]string `json:"quota,omitempty"`
322 Error string `json:"error,omitempty"`
323 }
324
325 // Write appends record to the file with one write.
326 func (u *usageLog) Write(record usageRecord) (err error) {
327 line, err := json.Marshal(record)
328 if err != nil {
329 return fmt.Errorf("encode usage: %w", err)
330 }
331
332 u.mutex.Lock()
333 defer u.mutex.Unlock()
334
335 file, err := os.OpenFile(u.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
336 if err != nil {
337 return fmt.Errorf("open usage file: %w", err)
338 }
339
340 defer func() {
341 err = errors.Join(err, file.Close())
342 }()
343
344 _, err = file.Write(append(line, '\n'))
345 if err != nil {
346 return fmt.Errorf("write usage: %w", err)
347 }
348
349 return nil
350 }
351
352 // Authorize sets the key as the bearer token.
353 func (k apiKey) Authorize(ctx context.Context, header http.Header) error {
354 header.Set("Authorization", "Bearer "+string(k))
355 return ctx.Err()
356 }
357
358 // userMessage returns a user input item with one text part for each text.
359 func userMessage(texts ...string) any {
360 type part struct {
361 Type string `json:"type"`
362 Text string `json:"text"`
363 }
364
365 content := make([]part, len(texts))
366 for n, text := range texts {
367 content[n] = part{
368 Type: "input_text",
369 Text: text,
370 }
371 }
372
373 return struct {
374 Role string `json:"role"`
375 Content []part `json:"content"`
376 }{
377 Role: "user",
378 Content: content,
379 }
380 }
381
382 // quotaHeaders returns the x-codex-* headers, with lowercase names, without the
383 // turn state.
384 func quotaHeaders(header http.Header) map[string]string {
385 var quota map[string]string
386 for name, values := range header {
387 if !strings.HasPrefix(name, quotaPrefix) || name == turnStateHeader || len(values) == 0 {
388 continue
389 }
390
391 if quota == nil {
392 quota = make(map[string]string)
393 }
394
395 quota[strings.ToLower(name)] = values[0]
396 }
397
398 return quota
399 }
400
401 // readStream reads the server-sent events of a call until the response
402 // completes. The output items come from the output_item.done events, or from
403 // the completed response when the stream sent none.
404 func readStream(stream io.Reader) (Response, error) {
405 var response Response
406 reader := bufio.NewReader(stream)
407 var data bytes.Buffer
408 for {
409 line, err := reader.ReadBytes('\n')
410 if err != nil && !errors.Is(err, io.EOF) {
411 return response, fmt.Errorf("read event stream: %w", err)
412 }
413
414 line = bytes.TrimRight(line, "\r\n")
415 if len(line) > 0 {
416 value, found := bytes.CutPrefix(line, []byte("data:"))
417 if found {
418 data.Write(bytes.TrimPrefix(value, []byte(" ")))
419 data.WriteByte('\n')
420 }
421 } else if data.Len() > 0 {
422 done, eventErr := readEvent(&response, data.Bytes())
423 data.Reset()
424 if eventErr != nil || done {
425 return response, eventErr
426 }
427 }
428
429 if errors.Is(err, io.EOF) {
430 return response, errors.New("event stream ended before the response completed")
431 }
432 }
433 }
434
435 // readEvent adds one stream event to response, and tells if it ends the
436 // stream.
437 func readEvent(response *Response, data []byte) (bool, error) {
438 var event streamEvent
439 err := json.Unmarshal(data, &event)
440 if err != nil {
441 return true, fmt.Errorf("decode stream event: %w", err)
442 }
443
444 switch event.Type {
445 case "response.output_item.done":
446 response.Output = append(response.Output, event.Item)
447 case "response.completed":
448 if event.Response == nil {
449 return true, errors.New("completed event without a response")
450 }
451
452 if len(response.Output) == 0 {
453 response.Output = event.Response.Output
454 }
455
456 if event.Response.Usage != nil {
457 response.Usage = Usage{
458 Input: event.Response.Usage.InputTokens,
459 Cached: event.Response.Usage.InputTokensDetails.CachedTokens,
460 Output: event.Response.Usage.OutputTokens,
461 }
462 }
463
464 response.Text, err = outputText(response.Output)
465
466 return true, err
467 case "response.incomplete":
468 reason := "unknown"
469 if event.Response != nil && event.Response.IncompleteDetails != nil {
470 reason = event.Response.IncompleteDetails.Reason
471 }
472
473 return true, fmt.Errorf("response incomplete: %s", reason)
474 case "response.failed":
475 message := "no details"
476 if event.Response != nil && event.Response.Error != nil {
477 message = event.Response.Error.Code + ": " + event.Response.Error.Message
478 }
479
480 return true, fmt.Errorf("response failed: %s", message)
481 case "error":
482 return true, fmt.Errorf("stream error: %s: %s", event.Code, event.Message)
483 }
484
485 return false, nil
486 }
487
488 // outputText returns the text of the message items of output.
489 func outputText(output []json.RawMessage) (string, error) {
490 var text strings.Builder
491 for _, raw := range output {
492 var item outputItem
493 err := json.Unmarshal(raw, &item)
494 if err != nil {
495 return "", fmt.Errorf("decode output item: %w", err)
496 }
497
498 if item.Type != "message" {
499 continue
500 }
501
502 for _, part := range item.Content {
503 if part.Type == "output_text" {
504 text.WriteString(part.Text)
505 }
506 }
507 }
508
509 return text.String(), nil
510 }