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