X-Git-Url: http://git.giacomolli.uk/onechat/blobdiff_plain/09cde69fdffe2721434392036f98dc241177f5a1:/responses.go..02cdaff8be27f37f44db8892bb12554f2d702323:/model.go?ds=inline diff --git a/responses.go b/model.go similarity index 57% rename from responses.go rename to model.go index 0ba4955..bd1258b 100644 --- a/responses.go +++ b/model.go @@ -18,210 +18,167 @@ import ( ) const ( - // chatgptURL is the Responses endpoint of the ChatGPT backend. - chatgptURL = "https://chatgpt.com/backend-api/codex/responses" + // modelChatGPTURL is the Responses endpoint of the ChatGPT backend. + modelChatGPTURL = "https://chatgpt.com/backend-api/codex/responses" - // apiURL is the Responses endpoint of the OpenAI API. - apiURL = "https://api.openai.com/v1/responses" + // modelAPIURL is the Responses endpoint of the OpenAI API. + modelAPIURL = "https://api.openai.com/v1/responses" - // quotaPrefix starts the names of the quota headers of the ChatGPT + // modelQuotaPrefix starts the names of the quota headers of the ChatGPT // backend, in canonical form. - quotaPrefix = "X-Codex-" + modelQuotaPrefix = "X-Codex-" - // turnStateHeader is an x-codex-* header that holds an opaque state of - // the backend, not quota. - turnStateHeader = "X-Codex-Turn-State" + // modelTurnStateHeader is an x-codex-* header that holds an opaque + // state of the backend, not quota. + modelTurnStateHeader = "X-Codex-Turn-State" - // errorBytes is the most bytes of an error response that go into the - // error. - errorBytes = 4096 + // modelErrorBytes is the most bytes of an error response that go into + // the error. + modelErrorBytes = 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 { - Authorize(ctx context.Context, header http.Header) error -} - -// apiKey authorizes calls to the OpenAI API with a key. -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. 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 -// input on each call, and it asks for the encrypted reasoning, so a later call -// can send the reasoning items back. -type Client struct { - url string - auth authorizer - cacheKey string - usage *usageLog - http *http.Client - 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. Node names the -// node that a compaction builds, or is nil. Turn names the request of a turn: -// first or next. Node and Turn go only to the usage file, never to the -// endpoint. -type Request struct { - Model string - Effort string - Instructions string - Tools []Tool - Input []any - Node *nodeKey - Turn string -} - -// Tool is the definition of a function tool. -type Tool struct { - Type string `json:"type"` - Name string `json:"name"` - Description string `json:"description"` - Parameters any `json:"parameters"` - Strict bool `json:"strict"` -} - -// Response is the result of a model call. Output holds the output items as the -// endpoint sent them. Text is the text of the message items. -type Response struct { - Output []json.RawMessage - Text string - Usage Usage -} - -// Usage gives the token counts of a call. Cached is the part of the input -// tokens that came from the prompt cache. -type Usage struct { - Input int - Cached int - Output int -} - -// requestBody is the JSON body of a call. -type requestBody struct { - Model string `json:"model"` - Instructions string `json:"instructions"` - Input []any `json:"input"` - Tools []Tool `json:"tools"` - Reasoning reasoning `json:"reasoning"` - Store bool `json:"store"` - Stream bool `json:"stream"` - Include []string `json:"include"` - PromptCacheKey string `json:"prompt_cache_key"` -} - -type reasoning struct { - Effort string `json:"effort"` -} - -// streamEvent holds the fields of the stream events that the client reads. -type streamEvent struct { - Type string `json:"type"` - Item json.RawMessage `json:"item"` - Response *eventResponse `json:"response"` - Code string `json:"code"` - Message string `json:"message"` -} - -type eventResponse struct { - Output []json.RawMessage `json:"output"` - Usage *wireUsage `json:"usage"` - Error *wireError `json:"error"` - IncompleteDetails *struct { - Reason string `json:"reason"` - } `json:"incomplete_details"` -} - -type wireUsage struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - InputTokensDetails struct { - CachedTokens int `json:"cached_tokens"` - } `json:"input_tokens_details"` -} - -type wireError struct { - Code string `json:"code"` - Message string `json:"message"` -} - -// outputItem holds the fields of an output item that give its text. -type outputItem struct { - Type string `json:"type"` - Content []struct { - Type string `json:"type"` - Text string `json:"text"` - } `json:"content"` -} +// errModelCallLimit tells that a client sent its limit of calls. +var errModelCallLimit = errors.New("call limit reached") // 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) { +func modelEndpoint(config Config, dir string) (string, ModelAuthorizer, 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 modelAPIURL, ModelAPIKey(key), nil } - return chatgptURL, newChatGPTAuth(filepath.Join(dir, tokenFileName), authIssuer), nil + return modelChatGPTURL, newChatGPTAuth(filepath.Join(dir, tokenFileName), authIssuer), nil } -// newClient returns a client for the Responses endpoint at url. It records +// newModelClient 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 { - return &Client{ +func newModelClient(url string, auth ModelAuthorizer, cacheKey, usagePath string) *ModelClient { + return &ModelClient{ url: url, auth: auth, cacheKey: cacheKey, - usage: &usageLog{ + usage: &ModelUsageLog{ path: usagePath, }, http: &http.Client{}, } } +// modelUserMessage returns a user input item with one text part for each text. +func modelUserMessage(texts ...string) any { + type part struct { + Type string `json:"type"` + Text string `json:"text"` + } + + content := make([]part, len(texts)) + for n, text := range texts { + content[n] = part{ + Type: "input_text", + Text: text, + } + } + + return struct { + Role string `json:"role"` + Content []part `json:"content"` + }{ + Role: "user", + Content: content, + } +} + +// modelOutputText returns the text of the message items of output. +func modelOutputText(output []json.RawMessage) (string, error) { + var text strings.Builder + for _, raw := range output { + var item ModelOutputItem + err := json.Unmarshal(raw, &item) + if err != nil { + return "", fmt.Errorf("decode output item: %w", err) + } + + if item.Type != "message" { + continue + } + + for _, part := range item.Content { + if part.Type == "output_text" { + text.WriteString(part.Text) + } + } + } + + return text.String(), nil +} + +// ModelAuthorizer adds the credentials of a model call to the headers of its +// request. +type ModelAuthorizer interface { + Authorize(ctx context.Context, header http.Header) error +} + +// ModelAPIKey authorizes calls to the OpenAI API with a key. +type ModelAPIKey string + +// Authorize sets the key as the bearer token. +func (k ModelAPIKey) Authorize(ctx context.Context, header http.Header) error { + header.Set("Authorization", "Bearer "+string(k)) + return ctx.Err() +} + +// ModelClient 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. +// 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 +// input on each call, and it asks for the encrypted reasoning, so a later call +// can send the reasoning items back. +type ModelClient struct { + url string + auth ModelAuthorizer + cacheKey string + usage *ModelUsageLog + http *http.Client + limit int64 + calls atomic.Int64 +} + // LimitCalls makes the client refuse each call after the first limit calls. -func (c *Client) LimitCalls(limit int64) { +func (c *ModelClient) LimitCalls(limit int64) { c.limit = limit } // LimitReached tells if the client refuses further calls. -func (c *Client) LimitReached() bool { +func (c *ModelClient) 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) { +// limit it sends nothing and returns errModelCallLimit. +func (c *ModelClient) Create(ctx context.Context, request ModelRequest) (ModelResponse, error) { if c.calls.Add(1) > c.limit && c.limit > 0 { - return Response{}, errCallLimit + return ModelResponse{}, errModelCallLimit } - body, err := json.Marshal(requestBody{ + body, err := json.Marshal(ModelRequestBody{ Model: request.Model, Instructions: request.Instructions, Input: request.Input, Tools: request.Tools, - Reasoning: reasoning{ + Reasoning: ModelReasoning{ Effort: request.Effort, }, Store: false, @@ -230,19 +187,19 @@ func (c *Client) Create(ctx context.Context, request Request) (Response, error) PromptCacheKey: c.cacheKey, }) if err != nil { - return Response{}, fmt.Errorf("encode request: %w", err) + return ModelResponse{}, fmt.Errorf("encode request: %w", err) } httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body)) if err != nil { - return Response{}, fmt.Errorf("create request: %w", err) + return ModelResponse{}, fmt.Errorf("create request: %w", err) } httpRequest.Header.Set("Content-Type", "application/json") httpRequest.Header.Set("Accept", "text/event-stream") err = c.auth.Authorize(ctx, httpRequest.Header) if err != nil { - return Response{}, fmt.Errorf("authorize model call: %w", err) + return ModelResponse{}, fmt.Errorf("authorize model call: %w", err) } _, chatgpt := c.auth.(*chatgptAuth) @@ -251,7 +208,7 @@ func (c *Client) Create(ctx context.Context, request Request) (Response, error) } start := time.Now() - record := usageRecord{ + record := ModelUsageRecord{ Date: start.Format(time.RFC3339Nano), Model: request.Model, } @@ -277,10 +234,10 @@ func (c *Client) Create(ctx context.Context, request Request) (Response, error) // send makes the HTTP call and reads its stream. It puts the status and the // quota headers into record. -func (c *Client) send(httpRequest *http.Request, record *usageRecord) (response Response, err error) { +func (c *ModelClient) send(httpRequest *http.Request, record *ModelUsageRecord) (response ModelResponse, err error) { httpResponse, err := c.http.Do(httpRequest) if err != nil { - return Response{}, fmt.Errorf("send model call: %w", err) + return ModelResponse{}, fmt.Errorf("send model call: %w", err) } defer func() { @@ -288,46 +245,26 @@ func (c *Client) send(httpRequest *http.Request, record *usageRecord) (response }() record.Status = httpResponse.StatusCode - record.Quota = quotaHeaders(httpResponse.Header) + record.Quota = modelQuotaHeaders(httpResponse.Header) if httpResponse.StatusCode != http.StatusOK { - text, readErr := io.ReadAll(io.LimitReader(httpResponse.Body, errorBytes)) + text, readErr := io.ReadAll(io.LimitReader(httpResponse.Body, modelErrorBytes)) err = fmt.Errorf("model call: HTTP %d: %s", httpResponse.StatusCode, strings.TrimSpace(string(text))) - return Response{}, errors.Join(err, readErr) + return ModelResponse{}, errors.Join(err, readErr) } - return readStream(httpResponse.Body) + return modelReadStream(httpResponse.Body) } -// usageLog appends one JSON line for each model call to usage.jsonl. Calls can -// run at once, so a mutex serializes the writes. -type usageLog struct { +// ModelUsageLog appends one JSON line for each model call to usage.jsonl. Calls +// can run at once, so a mutex serializes the writes. +type ModelUsageLog struct { mutex sync.Mutex path string } -// 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. Turn names the request of a turn. 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"` - Turn string `json:"turn,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"` -} - // Write appends record to the file with one write. -func (u *usageLog) Write(record usageRecord) (err error) { +func (u *ModelUsageLog) Write(record ModelUsageRecord) (err error) { line, err := json.Marshal(record) if err != nil { return fmt.Errorf("encode usage: %w", err) @@ -353,42 +290,129 @@ func (u *usageLog) Write(record usageRecord) (err error) { return nil } -// Authorize sets the key as the bearer token. -func (k apiKey) Authorize(ctx context.Context, header http.Header) error { - header.Set("Authorization", "Bearer "+string(k)) - return ctx.Err() -} - -// userMessage returns a user input item with one text part for each text. -func userMessage(texts ...string) any { - type part struct { +// ModelOutputItem holds the fields of an output item that give its text. +type ModelOutputItem struct { + Type string `json:"type"` + Content []struct { Type string `json:"type"` Text string `json:"text"` - } + } `json:"content"` +} - content := make([]part, len(texts)) - for n, text := range texts { - content[n] = part{ - Type: "input_text", - Text: text, - } - } +// ModelRequest is one model call. Input holds the input items in order, such as +// modelUserMessage values or output items of an earlier response. Node names +// the node that a compaction builds, or is nil. Turn names the request of a +// turn: first or next. Node and Turn go only to the usage file, never to the +// endpoint. +type ModelRequest struct { + Model string + Effort string + Instructions string + Tools []ModelTool + Input []any + Node *nodeKey + Turn string +} - return struct { - Role string `json:"role"` - Content []part `json:"content"` - }{ - Role: "user", - Content: content, - } +// ModelResponse is the result of a model call. Output holds the output items as +// the endpoint sent them. Text is the text of the message items. +type ModelResponse struct { + Output []json.RawMessage + Text string + Usage ModelUsage +} + +// ModelRequestBody is the JSON body of a call. +type ModelRequestBody struct { + Model string `json:"model"` + Instructions string `json:"instructions"` + Input []any `json:"input"` + Tools []ModelTool `json:"tools"` + Reasoning ModelReasoning `json:"reasoning"` + Store bool `json:"store"` + Stream bool `json:"stream"` + Include []string `json:"include"` + PromptCacheKey string `json:"prompt_cache_key"` +} + +type ModelReasoning struct { + Effort string `json:"effort"` } -// quotaHeaders returns the x-codex-* headers, with lowercase names, without the -// turn state. -func quotaHeaders(header http.Header) map[string]string { +// ModelUsageRecord 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. Turn names the request of a turn. Quota holds the +// x-codex-* headers of the response, without the turn state. +type ModelUsageRecord struct { + Date string `json:"date"` + Seconds float64 `json:"seconds"` + Model string `json:"model"` + Node *[2]int `json:"node,omitempty"` + Turn string `json:"turn,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"` +} + +// ModelTool is the definition of a function tool. +type ModelTool struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + Parameters any `json:"parameters"` + Strict bool `json:"strict"` +} + +// ModelUsage gives the token counts of a call. Cached is the part of the input +// tokens that came from the prompt cache. +type ModelUsage struct { + Input int + Cached int + Output int +} + +// ModelStreamEvent holds the fields of the stream events that the client reads. +type ModelStreamEvent struct { + Type string `json:"type"` + Item json.RawMessage `json:"item"` + Response *ModelEventResponse `json:"response"` + Code string `json:"code"` + Message string `json:"message"` +} + +type ModelEventResponse struct { + Output []json.RawMessage `json:"output"` + Usage *ModelWireUsage `json:"usage"` + Error *ModelWireError `json:"error"` + IncompleteDetails *struct { + Reason string `json:"reason"` + } `json:"incomplete_details"` +} + +type ModelWireUsage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + InputTokensDetails struct { + CachedTokens int `json:"cached_tokens"` + } `json:"input_tokens_details"` +} + +type ModelWireError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// modelQuotaHeaders returns the x-codex-* headers, with lowercase names, +// without the turn state. +func modelQuotaHeaders(header http.Header) map[string]string { var quota map[string]string for name, values := range header { - if !strings.HasPrefix(name, quotaPrefix) || name == turnStateHeader || len(values) == 0 { + if !strings.HasPrefix(name, modelQuotaPrefix) || name == modelTurnStateHeader || len(values) == 0 { continue } @@ -402,11 +426,11 @@ func quotaHeaders(header http.Header) map[string]string { return quota } -// readStream reads the server-sent events of a call until the response +// modelReadStream reads the server-sent events of a call until the response // completes. The output items come from the output_item.done events, or from // the completed response when the stream sent none. -func readStream(stream io.Reader) (Response, error) { - var response Response +func modelReadStream(stream io.Reader) (ModelResponse, error) { + var response ModelResponse reader := bufio.NewReader(stream) var data bytes.Buffer for { @@ -423,7 +447,7 @@ func readStream(stream io.Reader) (Response, error) { data.WriteByte('\n') } } else if data.Len() > 0 { - done, eventErr := readEvent(&response, data.Bytes()) + done, eventErr := modelReadEvent(&response, data.Bytes()) data.Reset() if eventErr != nil || done { return response, eventErr @@ -436,10 +460,10 @@ func readStream(stream io.Reader) (Response, error) { } } -// readEvent adds one stream event to response, and tells if it ends the +// modelReadEvent adds one stream event to response, and tells if it ends the // stream. -func readEvent(response *Response, data []byte) (bool, error) { - var event streamEvent +func modelReadEvent(response *ModelResponse, data []byte) (bool, error) { + var event ModelStreamEvent err := json.Unmarshal(data, &event) if err != nil { return true, fmt.Errorf("decode stream event: %w", err) @@ -458,14 +482,14 @@ func readEvent(response *Response, data []byte) (bool, error) { } if event.Response.Usage != nil { - response.Usage = Usage{ + response.Usage = ModelUsage{ Input: event.Response.Usage.InputTokens, Cached: event.Response.Usage.InputTokensDetails.CachedTokens, Output: event.Response.Usage.OutputTokens, } } - response.Text, err = outputText(response.Output) + response.Text, err = modelOutputText(response.Output) return true, err case "response.incomplete": @@ -488,27 +512,3 @@ func readEvent(response *Response, data []byte) (bool, error) { return false, nil } - -// outputText returns the text of the message items of output. -func outputText(output []json.RawMessage) (string, error) { - var text strings.Builder - for _, raw := range output { - var item outputItem - err := json.Unmarshal(raw, &item) - if err != nil { - return "", fmt.Errorf("decode output item: %w", err) - } - - if item.Type != "message" { - continue - } - - for _, part := range item.Content { - if part.Type == "output_text" { - text.WriteString(part.Text) - } - } - } - - return text.String(), nil -}