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