X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/1c1771140063b1b4aa5d508ced1b285e773fce04..56129a5fcbcbe51a7ac7d6b009d4551ed9f71109:/responses.go diff --git a/responses.go b/responses.go index f887e6d..3055e70 100644 --- a/responses.go +++ b/responses.go @@ -10,21 +10,36 @@ import ( "io" "net/http" "os" + "path/filepath" "strings" "sync" + "sync/atomic" "time" ) const ( + // chatgptURL is the Responses endpoint of the ChatGPT backend. + chatgptURL = "https://chatgpt.com/backend-api/codex/responses" + + // apiURL is the Responses endpoint of the OpenAI API. + apiURL = "https://api.openai.com/v1/responses" + // quotaPrefix starts the names of the quota headers of the ChatGPT // backend, in canonical form. quotaPrefix = "X-Codex-" + // turnStateHeader is an x-codex-* header that holds an opaque state of + // the backend, not quota. + turnStateHeader = "X-Codex-Turn-State" + // errorBytes is the most bytes of an error response that go into the // error. errorBytes = 4096 ) +// errCallLimit tells that a client sent its limit of calls. +var errCallLimit = errors.New("call limit reached") + // authorizer adds the credentials of a model call to the headers of its // request. type authorizer interface { @@ -37,8 +52,10 @@ type apiKey string // Client sends model calls to a Responses endpoint for one agent. The ChatGPT // backend and the OpenAI API take the same request body and differ only in the // URL and the credentials. Every call sends the fixed cache key of the agent, -// so the turns and the compactions of the agent share one prompt cache. Every -// call is recorded in the usage file. +// so the turns and the compactions of the agent share one prompt cache. The +// ChatGPT backend caches only with the key in the session_id header, so a call +// to it sends the key there and in the body. Every call is recorded in the +// usage file. // // The request never holds max_output_tokens or metadata, because the ChatGPT // backend refuses them. It sets store to false, so the client sends the whole @@ -50,17 +67,21 @@ type Client struct { cacheKey string usage *usageLog http *http.Client - clock func() time.Time + limit int64 + calls atomic.Int64 } // Request is one model call. Input holds the input items in order, such as -// userMessage values or output items of an earlier response. +// userMessage values or output items of an earlier response. Node names the +// node that a compaction builds, or is nil. It goes only to the usage file, +// never to the endpoint. type Request struct { Model string Effort string Instructions string Tools []Tool Input []any + Node *nodeKey } // Tool is the definition of a function tool. @@ -145,6 +166,23 @@ type outputItem struct { } `json:"content"` } +// modelEndpoint returns the URL and the authorizer of the endpoint in config. +// The ChatGPT backend uses the token file in dir. The OpenAI API uses the key in +// OPENAI_API_KEY, and there is no fallback from one to the other. All clients +// share the authorizer, so one refresh runs at a time. +func modelEndpoint(config Config, dir string) (string, authorizer, error) { + if config.Endpoint == endpointAPI { + key := os.Getenv("OPENAI_API_KEY") + if key == "" { + return "", nil, errors.New("endpoint api needs OPENAI_API_KEY") + } + + return apiURL, apiKey(key), nil + } + + return chatgptURL, newChatGPTAuth(filepath.Join(dir, tokenFileName), authIssuer), nil +} + // newClient returns a client for the Responses endpoint at url. It records // each call in the usage file at usagePath. func newClient(url string, auth authorizer, cacheKey, usagePath string) *Client { @@ -155,13 +193,27 @@ func newClient(url string, auth authorizer, cacheKey, usagePath string) *Client usage: &usageLog{ path: usagePath, }, - http: &http.Client{}, - clock: time.Now, + http: &http.Client{}, } } -// Create sends the call and reads its event stream to the end. +// LimitCalls makes the client refuse each call after the first limit calls. +func (c *Client) LimitCalls(limit int64) { + c.limit = limit +} + +// LimitReached tells if the client refuses further calls. +func (c *Client) LimitReached() bool { + return c.limit > 0 && c.calls.Load() >= c.limit +} + +// Create sends the call and reads its event stream to the end. Over the call +// limit it sends nothing and returns errCallLimit. func (c *Client) Create(ctx context.Context, request Request) (Response, error) { + if c.calls.Add(1) > c.limit && c.limit > 0 { + return Response{}, errCallLimit + } + body, err := json.Marshal(requestBody{ Model: request.Model, Instructions: request.Instructions, @@ -191,19 +243,31 @@ func (c *Client) Create(ctx context.Context, request Request) (Response, error) return Response{}, fmt.Errorf("authorize model call: %w", err) } + _, chatgpt := c.auth.(*chatgptAuth) + if chatgpt { + httpRequest.Header.Set("session_id", c.cacheKey) + } + + start := time.Now() record := usageRecord{ - Date: c.clock().Format(time.RFC3339Nano), + Date: start.Format(time.RFC3339Nano), Model: request.Model, } + if request.Node != nil { + record.Node = &[2]int{request.Node.level, request.Node.index} + } + response, err := c.send(httpRequest, &record) if err != nil { record.Error = err.Error() } + record.Seconds = time.Since(start).Seconds() record.InputTokens = response.Usage.Input record.CachedTokens = response.Usage.Cached record.OutputTokens = response.Usage.Output + record.TextBytes = len(response.Text) return response, errors.Join(err, c.usage.Write(record)) } @@ -239,15 +303,21 @@ type usageLog struct { path string } -// usageRecord is one line of usage.jsonl. Status is the HTTP status, or 0 when -// no response came. Quota holds the x-codex-* headers of the response. +// usageRecord is one line of usage.jsonl. Date is the start of the call, and +// Seconds its length. Node is the node [l, i] of a compaction. Status is the +// HTTP status, or 0 when no response came. TextBytes is the size of the text of +// the reply. Quota holds the x-codex-* headers of the response, without the +// turn state. type usageRecord struct { Date string `json:"date"` + Seconds float64 `json:"seconds"` Model string `json:"model"` + Node *[2]int `json:"node,omitempty"` Status int `json:"status"` InputTokens int `json:"input_tokens"` CachedTokens int `json:"cached_tokens"` OutputTokens int `json:"output_tokens"` + TextBytes int `json:"text_bytes"` Quota map[string]string `json:"quota,omitempty"` Error string `json:"error,omitempty"` } @@ -309,11 +379,12 @@ func userMessage(texts ...string) any { } } -// quotaHeaders returns the x-codex-* headers, with lowercase names. +// quotaHeaders returns the x-codex-* headers, with lowercase names, without the +// turn state. func quotaHeaders(header http.Header) map[string]string { var quota map[string]string for name, values := range header { - if !strings.HasPrefix(name, quotaPrefix) || len(values) == 0 { + if !strings.HasPrefix(name, quotaPrefix) || name == turnStateHeader || len(values) == 0 { continue }