]> git.giacomolli.uk - onechat/blobdiff - model.go
Name and order the model client code
[onechat] / model.go
similarity index 57%
rename from responses.go
rename to model.go
index 0ba4955128b491911b1f77f7d92d2ab95c930f89..bd1258b80d586aeece9bd174e8cc1baaf2e65ba6 100644 (file)
+++ 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
-}