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