X-Git-Url: https://git.giacomolli.uk/onechat/blobdiff_plain/59948b44c0fe2165b90e3aa0d18646f6c3d74af3..c781f79f3c8cb9a7577ee1574d196988da2719b3:/model.go diff --git a/model.go b/model.go index cc77f9a..074ff60 100644 --- a/model.go +++ b/model.go @@ -40,87 +40,6 @@ const ( // 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, ModelAuthorizer, error) { - if config.Endpoint == configEndpointAPI { - key := os.Getenv("OPENAI_API_KEY") - if key == "" { - return "", nil, errors.New("endpoint api needs OPENAI_API_KEY") - } - - return modelAPIURL, ModelAPIKey(key), nil - } - - return modelChatGPTURL, newChatGPTAuth(filepath.Join(dir, chatgptTokenFileName), chatgptIssuer), nil -} - -// newModelClient returns a client for the Responses endpoint at url. It records -// each call in the usage file at usagePath. -func newModelClient(url string, auth ModelAuthorizer, cacheKey, usagePath string) *ModelClient { - return &ModelClient{ - url: url, - auth: auth, - cacheKey: cacheKey, - 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 { - content := make([]ModelInputPart, len(texts)) - for n, text := range texts { - content[n] = ModelInputPart{ - Type: "input_text", - Text: text, - } - } - - return modelUserParts(content) -} - -// modelUserParts keeps text and image parts in one user message. -func modelUserParts(content []ModelInputPart) any { - return struct { - Role string `json:"role"` - Content []ModelInputPart `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 { @@ -130,13 +49,6 @@ type ModelAuthorizer interface { // 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 @@ -161,6 +73,163 @@ type ModelClient struct { calls atomic.Int64 } +// 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 +} + +// 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"` +} + +// ModelInputPart is a text or image part in a user message or tool result. +type ModelInputPart struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + ImageURL string `json:"image_url,omitempty"` +} + +// 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. Agent names the subagent that makes the call, or is +// empty for a call of the agent itself. Node, Turn, and Agent go to the usage +// file, never to the endpoint as fields. Agent also gives the call the cache +// key of its subagent. +type ModelRequest struct { + Model string + Effort string + Instructions string + Tools []ModelTool + Input []any + Node *NodeKey + Turn string + Agent string +} + +// 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"` +} + +// 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. Agent names the subagent +// of the call. 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"` + Agent string `json:"agent,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"` +} + +// newModelClient returns a client for the Responses endpoint at url. It records +// each call in the usage file at usagePath. +func newModelClient(url string, auth ModelAuthorizer, cacheKey, usagePath string) *ModelClient { + return &ModelClient{ + url: url, + auth: auth, + cacheKey: cacheKey, + usage: &ModelUsageLog{ + path: usagePath, + }, + http: &http.Client{}, + } +} + +// 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() +} + // LimitCalls makes the client refuse each call after the first limit calls. func (c *ModelClient) LimitCalls(limit int64) { c.limit = limit @@ -278,13 +347,6 @@ func (c *ModelClient) send(httpRequest *http.Request, record *ModelUsageRecord) return modelReadStream(httpResponse.Body) } -// 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 -} - // Write appends record to the file with one write. func (u *ModelUsageLog) Write(record ModelUsageRecord) (err error) { line, err := json.Marshal(record) @@ -312,133 +374,71 @@ func (u *ModelUsageLog) Write(record ModelUsageRecord) (err error) { return nil } -// 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"` -} - -// ModelInputPart is a text or image part in a user message or tool result. -type ModelInputPart struct { - Type string `json:"type"` - Text string `json:"text,omitempty"` - ImageURL string `json:"image_url,omitempty"` -} +// 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, ModelAuthorizer, error) { + if config.Endpoint == configEndpointAPI { + key := os.Getenv("OPENAI_API_KEY") + if key == "" { + return "", nil, errors.New("endpoint api needs OPENAI_API_KEY") + } -// 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. Agent names the subagent that makes the call, or is -// empty for a call of the agent itself. Node, Turn, and Agent go to the usage -// file, never to the endpoint as fields. Agent also gives the call the cache -// key of its subagent. -type ModelRequest struct { - Model string - Effort string - Instructions string - Tools []ModelTool - Input []any - Node *NodeKey - Turn string - Agent string -} + return modelAPIURL, ModelAPIKey(key), nil + } -// 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 + return modelChatGPTURL, newChatGPTAuth(filepath.Join(dir, chatgptTokenFileName), chatgptIssuer), nil } -// 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"` -} +// modelUserMessage returns a user input item with one text part for each text. +func modelUserMessage(texts ...string) any { + content := make([]ModelInputPart, len(texts)) + for n, text := range texts { + content[n] = ModelInputPart{ + Type: "input_text", + Text: text, + } + } -type ModelReasoning struct { - Effort string `json:"effort"` + return modelUserParts(content) } -// 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. Agent names the subagent -// of the call. 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"` - Agent string `json:"agent,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"` +// modelUserParts keeps text and image parts in one user message. +func modelUserParts(content []ModelInputPart) any { + return struct { + Role string `json:"role"` + Content []ModelInputPart `json:"content"` + }{ + Role: "user", + Content: content, + } } -// 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"` -} +// modelOutputText returns the text of the message items of output. +func modelOutputText(output []json.RawMessage) (string, error) { + var text strings.Builder -// 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 -} + for _, raw := range output { + var item ModelOutputItem -// 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"` -} + err := json.Unmarshal(raw, &item) + if err != nil { + return "", fmt.Errorf("decode output item: %w", err) + } -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"` -} + if item.Type != "message" { + continue + } -type ModelWireUsage struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - InputTokensDetails struct { - CachedTokens int `json:"cached_tokens"` - } `json:"input_tokens_details"` -} + for _, part := range item.Content { + if part.Type == "output_text" { + text.WriteString(part.Text) + } + } + } -type ModelWireError struct { - Code string `json:"code"` - Message string `json:"message"` + return text.String(), nil } // modelQuotaHeaders returns the x-codex-* headers, with lowercase names,