)
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,
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)
}
start := time.Now()
- record := usageRecord{
+ record := ModelUsageRecord{
Date: start.Format(time.RFC3339Nano),
Model: request.Model,
}
// 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() {
}()
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)
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
}
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 {
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
}
}
-// 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)
}
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":
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
-}