From: Anderson A. Giacomolli Date: Sat, 7 Nov 2026 16:25:00 +0000 (-0300) Subject: Name and order the model client code X-Git-Url: http://git.giacomolli.uk/onechat/commitdiff_plain/02cdaff8be27f37f44db8892bb12554f2d702323?ds=inline Name and order the model client code Every package-level name of the client that sends the model calls starts with model, and its file is model.go. The client talks to the ChatGPT backend or to the OpenAI API, so the name tells its role and not one of the endpoints. The interface of the turn loop that the client satisfies is TurnClient, so it does not take the name of the client. The fake output items of the tests are in model_test.go, next to the other fakes of the endpoint. The declarations of the model files follow the order of the go-code skill. The behavior does not change. Issue: 947bddb --- diff --git a/auth_test.go b/auth_test.go index ccc7e66..0621605 100644 --- a/auth_test.go +++ b/auth_test.go @@ -169,9 +169,9 @@ func TestForcedRefreshLetsTheNextCallWork(t *testing.T) { auth := newTestAuth(t, server) writeTestTokens(t, auth, server.token(-time.Hour), "refresh-0") - responses := newFakeResponses(t) - client := newClient(responses.URL, auth, "agent-key", filepath.Join(t.TempDir(), "usage.jsonl")) - _, err := client.Create(t.Context(), testRequest()) + responses := newModelFake(t) + client := newModelClient(responses.URL, auth, "agent-key", filepath.Join(t.TempDir(), "usage.jsonl")) + _, err := client.Create(t.Context(), modelTestRequest()) require.NoError(t, err) require.Equal(t, 1, server.refreshes) @@ -185,18 +185,18 @@ func TestModelEndpointFollowsTheConfig(t *testing.T) { dir := t.TempDir() config := defaultConfig() - t.Setenv("OPENAI_API_KEY", testKey) + t.Setenv("OPENAI_API_KEY", modelTestKey) address, auth, err := modelEndpoint(config, dir) require.NoError(t, err) - require.Equal(t, chatgptURL, address) + require.Equal(t, modelChatGPTURL, address) require.IsType(t, &chatgptAuth{}, auth) require.Equal(t, filepath.Join(dir, tokenFileName), auth.(*chatgptAuth).path) config.Endpoint = endpointAPI address, auth, err = modelEndpoint(config, dir) require.NoError(t, err) - require.Equal(t, apiURL, address) - require.Equal(t, apiKey(testKey), auth) + require.Equal(t, modelAPIURL, address) + require.Equal(t, ModelAPIKey(modelTestKey), auth) t.Setenv("OPENAI_API_KEY", "") _, _, err = modelEndpoint(config, dir) diff --git a/compactor.go b/compactor.go index e899c09..48b855b 100644 --- a/compactor.go +++ b/compactor.go @@ -28,16 +28,16 @@ var ruler = strings.Repeat("-", nodeBytes) // because the view measures the real sizes. A call that fails before any line // fails the build, and the core tries it again at the next message. type Compactor struct { - client *Client + client *ModelClient model ModelConfig instructions func() (string, error) - tools []Tool + tools []ModelTool } // newCompactor returns the compactor of an agent, which sends its calls through // client with model. instructions gives the system prompt of the agent at each // build, so a build sends the same prompt as the turns of that time. -func newCompactor(client *Client, instructions func() (string, error), model ModelConfig) *Compactor { +func newCompactor(client *ModelClient, instructions func() (string, error), model ModelConfig) *Compactor { return &Compactor{ client: client, model: model, @@ -67,13 +67,13 @@ func (c *Compactor) Summarize(ctx context.Context, task Task) (string, error) { parts = append(parts, note) } - var response Response - response, err = c.client.Create(ctx, Request{ + var response ModelResponse + response, err = c.client.Create(ctx, ModelRequest{ Model: c.model.Model, Effort: c.model.Effort, Instructions: instructions, Tools: c.tools, - Input: []any{userMessage(parts...)}, + Input: []any{modelUserMessage(parts...)}, Node: &nodeKey{task.Level, task.Index}, }) if err != nil { diff --git a/compactor_test.go b/compactor_test.go index 4573fdd..8b14c81 100644 --- a/compactor_test.go +++ b/compactor_test.go @@ -14,7 +14,7 @@ import ( ) func TestCompactorSendsThePromptToolsAndTask(t *testing.T) { - server := newFakeResponses(t) + server := newModelFake(t) compactor := newTestCompactor(t, server) line, err := compactor.Summarize(t.Context(), Task{ @@ -67,9 +67,9 @@ func TestCompactorWritesTheMergeTask(t *testing.T) { func TestCompactorRetriesALongLineWithTheNewestNote(t *testing.T) { long := []string{strings.Repeat("a", 600), strings.Repeat("b", 550), strings.Repeat("c", 400)} - server := newFakeResponses(t) - server.reply = func(w http.ResponseWriter, call fakeCall) { - writeEvents(w, completedEvent([]any{messageItem(long[call.number])}, 100, 0, 5)) + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(long[call.number])}, 100, 0, 5)) } compactor := newTestCompactor(t, server) @@ -78,7 +78,7 @@ func TestCompactorRetriesALongLineWithTheNewestNote(t *testing.T) { require.Equal(t, long[2], line) require.Equal(t, 3, server.Calls()) - records := readUsage(t, compactor.client.usage.path) + records := modelReadUsage(t, compactor.client.usage.path) require.Len(t, records, 3) for n, record := range records { require.Equal(t, &[2]int{0, 0}, record.Node) @@ -106,9 +106,9 @@ func TestCompactorRetriesALongLineWithTheNewestNote(t *testing.T) { func TestCompactorKeepsTheShortestOfFiveLines(t *testing.T) { sizes := []int{700, 530, 600, 520, 560, 513} - server := newFakeResponses(t) - server.reply = func(w http.ResponseWriter, call fakeCall) { - writeEvents(w, completedEvent([]any{messageItem(strings.Repeat("z", sizes[call.number]))}, 100, 0, 5)) + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(strings.Repeat("z", sizes[call.number]))}, 100, 0, 5)) } line, err := newTestCompactor(t, server).Summarize(t.Context(), leafTask()) @@ -120,12 +120,12 @@ func TestCompactorKeepsTheShortestOfFiveLines(t *testing.T) { func TestCompactorFailsWithoutALine(t *testing.T) { testCases := []struct { name string - reply func(http.ResponseWriter, fakeCall) + reply func(http.ResponseWriter, ModelFakeCall) want string }{ { name: "failed call", - reply: func(w http.ResponseWriter, call fakeCall) { + reply: func(w http.ResponseWriter, call ModelFakeCall) { w.WriteHeader(http.StatusInternalServerError) _, _ = io.WriteString(w, "down") }, @@ -133,8 +133,8 @@ func TestCompactorFailsWithoutALine(t *testing.T) { }, { name: "tool call and no text", - reply: func(w http.ResponseWriter, call fakeCall) { - writeEvents(w, completedEvent([]any{map[string]any{"type": "function_call", "name": "zoom", "arguments": "{}"}}, 100, 0, 5)) + reply: func(w http.ResponseWriter, call ModelFakeCall) { + modelWriteEvents(w, modelCompletedEvent([]any{map[string]any{"type": "function_call", "name": "zoom", "arguments": "{}"}}, 100, 0, 5)) }, want: "no line", }, @@ -142,7 +142,7 @@ func TestCompactorFailsWithoutALine(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - server := newFakeResponses(t) + server := newModelFake(t) server.reply = tc.reply _, err := newTestCompactor(t, server).Summarize(t.Context(), leafTask()) @@ -161,14 +161,14 @@ func TestCutLineKeepsWholeCharacters(t *testing.T) { } func TestCompactorBuildsNodesInTheCore(t *testing.T) { - server := newFakeResponses(t) - server.reply = func(w http.ResponseWriter, call fakeCall) { + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { if call.number == 0 { w.WriteHeader(http.StatusServiceUnavailable) return } - writeEvents(w, completedEvent([]any{messageItem("summary of the long output")}, 100, 0, 5)) + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("summary of the long output")}, 100, 0, 5)) } memory, err := openMemory(t.Context(), t.TempDir(), newTestClock().Now, newTestCompactor(t, server)) @@ -193,10 +193,10 @@ func TestCompactorBuildsNodesInTheCore(t *testing.T) { require.Equal(t, 2, server.Calls()) } -func newTestCompactor(t *testing.T, server *fakeResponses) *Compactor { +func newTestCompactor(t *testing.T, server *ModelFake) *Compactor { t.Helper() - client := newClient(server.URL, apiKey(testKey), "agent-key", filepath.Join(t.TempDir(), "usage.jsonl")) + client := newModelClient(server.URL, ModelAPIKey(modelTestKey), "agent-key", filepath.Join(t.TempDir(), "usage.jsonl")) return newCompactor(client, adaPrompt, ModelConfig{ Model: "gpt-test", @@ -219,7 +219,7 @@ func leafTask() Task { } // inputParts returns the texts of the one user message of call. -func inputParts(t *testing.T, call fakeCall) []string { +func inputParts(t *testing.T, call ModelFakeCall) []string { t.Helper() var body struct { diff --git a/daemon.go b/daemon.go index 5486843..cedb45e 100644 --- a/daemon.go +++ b/daemon.go @@ -98,7 +98,7 @@ func daemon(ctx context.Context, out io.Writer) (err error) { return err } - client := newClient(address, auth, agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl")) + client := newModelClient(address, auth, agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl")) memory, err := openMemory(ctx, agent.dir, time.Now, newCompactor(client, agent.Prompt, config.Compactor)) if err != nil { return err diff --git a/daemon_test.go b/daemon_test.go index 4bcc05a..d1f1bc5 100644 --- a/daemon_test.go +++ b/daemon_test.go @@ -17,14 +17,14 @@ import ( ) func TestClientsSeeTheTurnAsItHappens(t *testing.T) { - server := newFakeResponses(t) - server.reply = func(w http.ResponseWriter, call fakeCall) { + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { if call.number == 0 { - writeEvents(w, completedEvent([]any{functionCallItem("fc_1", "call_1", "shell", `{"command":"echo hi"}`)}, 100, 0, 5)) + modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"echo hi"}`)}, 100, 0, 5)) return } - writeEvents(w, completedEvent([]any{messageItem("It said hi.")}, 100, 0, 5)) + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It said hi.")}, 100, 0, 5)) } socket := startTestDaemon(t, server) @@ -46,14 +46,14 @@ func TestClientsSeeTheTurnAsItHappens(t *testing.T) { } func TestStopInterruptsTheTurn(t *testing.T) { - server := newFakeResponses(t) + server := newModelFake(t) arrived := make(chan struct{}) release := make(chan struct{}) t.Cleanup(func() { close(release) }) - server.reply = func(w http.ResponseWriter, call fakeCall) { + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { close(arrived) <-release } @@ -239,14 +239,14 @@ func (b *syncBuffer) String() string { // startTestDaemon serves a new agent on a socket in a temporary directory and // returns the socket path. The commands of the agent run in a temporary // directory. -func startTestDaemon(t *testing.T, server *fakeResponses) string { +func startTestDaemon(t *testing.T, server *ModelFake) string { t.Helper() return startTestDaemonWithBot(t, server, nil) } // startTestDaemonWithBot is startTestDaemon with bot, which can be nil. -func startTestDaemonWithBot(t *testing.T, server *fakeResponses, bot *TelegramBot) string { +func startTestDaemonWithBot(t *testing.T, server *ModelFake, bot *TelegramBot) string { t.Helper() dir := t.TempDir() @@ -262,7 +262,7 @@ func startTestDaemonWithBot(t *testing.T, server *fakeResponses, bot *TelegramBo listener, err := listenSocket(socket) require.NoError(t, err) - client := newClient(server.URL, apiKey(testKey), agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl")) + client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), filepath.Join(agent.dir, "usage.jsonl")) ctx, cancel := context.WithCancel(t.Context()) done := make(chan error) go func() { 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 -} diff --git a/responses_test.go b/model_test.go similarity index 66% rename from responses_test.go rename to model_test.go index 9a1355a..b90eadd 100644 --- a/responses_test.go +++ b/model_test.go @@ -16,28 +16,28 @@ import ( "github.com/stretchr/testify/require" ) -// testKey is a fake API key. -const testKey = "sk-test-not-a-real-key" +// modelTestKey is a fake API key. +const modelTestKey = "sk-test-not-a-real-key" func TestClientSendsTheFixedRequestFields(t *testing.T) { - server := newFakeResponses(t) - client := newTestClient(t, server) + server := newModelFake(t) + client := newTestModelClient(t, server) - _, err := client.Create(t.Context(), Request{ + _, err := client.Create(t.Context(), ModelRequest{ Model: "gpt-test", Effort: "high", Instructions: "You are a test.", - Tools: []Tool{{ + Tools: []ModelTool{{ Type: "function", Name: "date", Parameters: map[string]any{"type": "object"}, }}, - Input: []any{userMessage("\n\n", "hello")}, + Input: []any{modelUserMessage("\n\n", "hello")}, }) require.NoError(t, err) call := server.Call(0) - require.Equal(t, "Bearer "+testKey, call.header.Get("Authorization")) + require.Equal(t, "Bearer "+modelTestKey, call.header.Get("Authorization")) require.Equal(t, "text/event-stream", call.header.Get("Accept")) require.Equal(t, "application/json", call.header.Get("Content-Type")) @@ -68,7 +68,7 @@ func TestClientSendsTheSessionHeaderToChatGPT(t *testing.T) { testCases := []struct { name string - auth authorizer + auth ModelAuthorizer want string }{ { @@ -78,16 +78,16 @@ func TestClientSendsTheSessionHeaderToChatGPT(t *testing.T) { }, { name: "OpenAI API", - auth: apiKey(testKey), + auth: ModelAPIKey(modelTestKey), want: "", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - server := newFakeResponses(t) - client := newClient(server.URL, tc.auth, "agent-key", filepath.Join(t.TempDir(), "usage.jsonl")) - _, err := client.Create(t.Context(), testRequest()) + server := newModelFake(t) + client := newModelClient(server.URL, tc.auth, "agent-key", filepath.Join(t.TempDir(), "usage.jsonl")) + _, err := client.Create(t.Context(), modelTestRequest()) require.NoError(t, err) call := server.Call(0) @@ -98,24 +98,24 @@ func TestClientSendsTheSessionHeaderToChatGPT(t *testing.T) { } func TestClientReadsTheStream(t *testing.T) { - server := newFakeResponses(t) - server.reply = func(w http.ResponseWriter, call fakeCall) { - writeEvents(w, + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { + modelWriteEvents(w, map[string]any{"type": "response.created", "response": map[string]any{}}, map[string]any{"type": "response.output_item.done", "item": map[string]any{"type": "reasoning", "encrypted_content": "opaque"}}, map[string]any{"type": "response.output_text.delta", "delta": "Hel"}, - map[string]any{"type": "response.output_item.done", "item": messageItem("Hello")}, - map[string]any{"type": "response.output_item.done", "item": messageItem(" world")}, - completedEvent(nil, 1200, 1024, 30), + map[string]any{"type": "response.output_item.done", "item": modelMessageItem("Hello")}, + map[string]any{"type": "response.output_item.done", "item": modelMessageItem(" world")}, + modelCompletedEvent(nil, 1200, 1024, 30), ) } - response, err := newTestClient(t, server).Create(t.Context(), testRequest()) + response, err := newTestModelClient(t, server).Create(t.Context(), modelTestRequest()) require.NoError(t, err) require.Equal(t, "Hello world", response.Text) require.Len(t, response.Output, 3) require.JSONEq(t, `{"type": "reasoning", "encrypted_content": "opaque"}`, string(response.Output[0])) - require.Equal(t, Usage{ + require.Equal(t, ModelUsage{ Input: 1200, Cached: 1024, Output: 30, @@ -123,19 +123,19 @@ func TestClientReadsTheStream(t *testing.T) { } func TestClientTakesTheOutputOfTheCompletedResponse(t *testing.T) { - server := newFakeResponses(t) - server.reply = func(w http.ResponseWriter, call fakeCall) { - writeEvents(w, completedEvent([]any{messageItem("only here")}, 10, 0, 2)) + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("only here")}, 10, 0, 2)) } - response, err := newTestClient(t, server).Create(t.Context(), testRequest()) + response, err := newTestModelClient(t, server).Create(t.Context(), modelTestRequest()) require.NoError(t, err) require.Equal(t, "only here", response.Text) } func TestClientRecordsEachCall(t *testing.T) { - server := newFakeResponses(t) - server.reply = func(w http.ResponseWriter, call fakeCall) { + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { w.Header().Set("X-Codex-Primary-Used-Percent", "12") w.Header().Set("X-Codex-Plan-Type", "plus") w.Header().Set("X-Other", "not quota") @@ -147,22 +147,22 @@ func TestClientRecordsEachCall(t *testing.T) { return } - writeEvents(w, completedEvent([]any{messageItem("ok")}, 500, 400, 7)) + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 500, 400, 7)) } - client := newTestClient(t, server) - request := testRequest() + client := newTestModelClient(t, server) + request := modelTestRequest() request.Node = &nodeKey{2, 5} _, err := client.Create(t.Context(), request) require.NoError(t, err) require.NotContains(t, server.Call(0).body, "node") require.NotContains(t, string(server.Call(0).raw), "[2,5]") - _, err = client.Create(t.Context(), testRequest()) + _, err = client.Create(t.Context(), modelTestRequest()) require.ErrorContains(t, err, "HTTP 429") require.ErrorContains(t, err, "slow down") - records := readUsage(t, client.usage.path) + records := modelReadUsage(t, client.usage.path) require.Len(t, records, 2) quota := map[string]string{ @@ -190,7 +190,7 @@ func TestClientRecordsEachCall(t *testing.T) { data, err := os.ReadFile(client.usage.path) require.NoError(t, err) - require.NotContains(t, string(data), testKey) + require.NotContains(t, string(data), modelTestKey) info, err := os.Stat(client.usage.path) require.NoError(t, err) @@ -198,21 +198,21 @@ func TestClientRecordsEachCall(t *testing.T) { } func TestClientRefusesCallsOverTheLimit(t *testing.T) { - server := newFakeResponses(t) - client := newTestClient(t, server) + server := newModelFake(t) + client := newTestModelClient(t, server) client.LimitCalls(2) for range 2 { require.False(t, client.LimitReached()) - _, err := client.Create(t.Context(), testRequest()) + _, err := client.Create(t.Context(), modelTestRequest()) require.NoError(t, err) } require.True(t, client.LimitReached()) - _, err := client.Create(t.Context(), testRequest()) - require.ErrorIs(t, err, errCallLimit) + _, err := client.Create(t.Context(), modelTestRequest()) + require.ErrorIs(t, err, errModelCallLimit) require.Equal(t, 2, server.Calls()) - require.Len(t, readUsage(t, client.usage.path), 2) + require.Len(t, modelReadUsage(t, client.usage.path), 2) } func TestClientFailsWhenTheResponseFails(t *testing.T) { @@ -238,69 +238,70 @@ func TestClientFailsWhenTheResponseFails(t *testing.T) { }, { name: "stream ends early", - events: []any{map[string]any{"type": "response.output_item.done", "item": messageItem("cut")}}, + events: []any{map[string]any{"type": "response.output_item.done", "item": modelMessageItem("cut")}}, want: "ended before the response completed", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - server := newFakeResponses(t) - server.reply = func(w http.ResponseWriter, call fakeCall) { - writeEvents(w, tc.events...) + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { + modelWriteEvents(w, tc.events...) } - client := newTestClient(t, server) - _, err := client.Create(t.Context(), testRequest()) + client := newTestModelClient(t, server) + _, err := client.Create(t.Context(), modelTestRequest()) require.ErrorContains(t, err, tc.want) - records := readUsage(t, client.usage.path) + records := modelReadUsage(t, client.usage.path) require.Len(t, records, 1) require.Contains(t, records[0].Error, tc.want) }) } } -// fakeResponses is an httptest Responses endpoint. It keeps each call, and -// reply writes the response. The default reply is one short message. -type fakeResponses struct { - *httptest.Server - mutex sync.Mutex - calls []fakeCall - reply func(http.ResponseWriter, fakeCall) -} - -// fakeCall is one call to a fake endpoint, numbered from 0. -type fakeCall struct { +// ModelFakeCall is one call to a fake endpoint, numbered from 0. +type ModelFakeCall struct { number int header http.Header raw []byte body map[string]any } -func newFakeResponses(t *testing.T) *fakeResponses { - t.Helper() +// ModelFake is an httptest Responses endpoint. It keeps each call, and reply +// writes the response. The default reply is one short message. +type ModelFake struct { + *httptest.Server + mutex sync.Mutex + calls []ModelFakeCall + reply func(http.ResponseWriter, ModelFakeCall) +} - f := &fakeResponses{ - reply: func(w http.ResponseWriter, call fakeCall) { - writeEvents(w, completedEvent([]any{messageItem("a line")}, 100, 0, 5)) - }, - } +// Call returns call number n. +func (f *ModelFake) Call(n int) ModelFakeCall { + f.mutex.Lock() + defer f.mutex.Unlock() - f.Server = httptest.NewServer(http.HandlerFunc(f.serve)) - t.Cleanup(f.Close) + return f.calls[n] +} - return f +// Calls returns the number of calls. +func (f *ModelFake) Calls() int { + f.mutex.Lock() + defer f.mutex.Unlock() + + return len(f.calls) } -func (f *fakeResponses) serve(w http.ResponseWriter, r *http.Request) { +func (f *ModelFake) serve(w http.ResponseWriter, r *http.Request) { raw, err := io.ReadAll(r.Body) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - call := fakeCall{ + call := ModelFakeCall{ header: r.Header.Clone(), raw: raw, } @@ -320,38 +321,37 @@ func (f *fakeResponses) serve(w http.ResponseWriter, r *http.Request) { reply(w, call) } -// Call returns call number n. -func (f *fakeResponses) Call(n int) fakeCall { - f.mutex.Lock() - defer f.mutex.Unlock() +func newModelFake(t *testing.T) *ModelFake { + t.Helper() - return f.calls[n] -} + f := &ModelFake{ + reply: func(w http.ResponseWriter, call ModelFakeCall) { + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("a line")}, 100, 0, 5)) + }, + } -// Calls returns the number of calls. -func (f *fakeResponses) Calls() int { - f.mutex.Lock() - defer f.mutex.Unlock() + f.Server = httptest.NewServer(http.HandlerFunc(f.serve)) + t.Cleanup(f.Close) - return len(f.calls) + return f } -func newTestClient(t *testing.T, server *fakeResponses) *Client { +func newTestModelClient(t *testing.T, server *ModelFake) *ModelClient { t.Helper() - return newClient(server.URL, apiKey(testKey), "agent-key", filepath.Join(t.TempDir(), "usage.jsonl")) + return newModelClient(server.URL, ModelAPIKey(modelTestKey), "agent-key", filepath.Join(t.TempDir(), "usage.jsonl")) } -func testRequest() Request { - return Request{ +func modelTestRequest() ModelRequest { + return ModelRequest{ Model: "gpt-test", Effort: "low", - Input: []any{userMessage("hello")}, + Input: []any{modelUserMessage("hello")}, } } -// writeEvents writes events as a server-sent event stream. -func writeEvents(w http.ResponseWriter, events ...any) { +// modelWriteEvents writes events as a server-sent event stream. +func modelWriteEvents(w http.ResponseWriter, events ...any) { w.Header().Set("Content-Type", "text/event-stream") for _, event := range events { data, err := json.Marshal(event) @@ -366,7 +366,7 @@ func writeEvents(w http.ResponseWriter, events ...any) { } } -func messageItem(text string) map[string]any { +func modelMessageItem(text string) map[string]any { return map[string]any{ "type": "message", "role": "assistant", @@ -376,7 +376,7 @@ func messageItem(text string) map[string]any { } } -func completedEvent(output []any, input, cached, out int) map[string]any { +func modelCompletedEvent(output []any, input, cached, out int) map[string]any { if output == nil { output = []any{} } @@ -394,7 +394,7 @@ func completedEvent(output []any, input, cached, out int) map[string]any { } } -func readUsage(t *testing.T, path string) []usageRecord { +func modelReadUsage(t *testing.T, path string) []ModelUsageRecord { t.Helper() file, err := os.Open(path) @@ -404,10 +404,10 @@ func readUsage(t *testing.T, path string) []usageRecord { require.NoError(t, file.Close()) }() - var records []usageRecord + var records []ModelUsageRecord scanner := bufio.NewScanner(file) for scanner.Scan() { - var record usageRecord + var record ModelUsageRecord require.NoError(t, json.Unmarshal(scanner.Bytes(), &record)) records = append(records, record) } @@ -416,3 +416,22 @@ func readUsage(t *testing.T, path string) []usageRecord { return records } + +func modelReasoningItem(id, encrypted string) map[string]any { + return map[string]any{ + "type": "reasoning", + "id": id, + "encrypted_content": encrypted, + "summary": []any{}, + } +} + +func modelFunctionCallItem(id, callID, name, arguments string) map[string]any { + return map[string]any{ + "type": "function_call", + "id": id, + "call_id": callID, + "name": name, + "arguments": arguments, + } +} diff --git a/probe.go b/probe.go index 9d272ec..32a3ba9 100644 --- a/probe.go +++ b/probe.go @@ -98,7 +98,7 @@ func probe(ctx context.Context, args []string, out io.Writer) (err error) { } usagePath := filepath.Join(root, "usage.jsonl") - client := newClient(address, auth, "onechat-"+probeName, usagePath) + client := newModelClient(address, auth, "onechat-"+probeName, usagePath) client.LimitCalls(probeCallLimit) summarizer := slotSummarizer{ inner: newCompactor(client, probePrompt, config.Compactor), @@ -140,7 +140,7 @@ func probe(ctx context.Context, args []string, out io.Writer) (err error) { // by the rules of probeStop. type probeRun struct { memory *Memory - client *Client + client *ModelClient usagePath string } @@ -295,7 +295,7 @@ func readProbeLog(path string, count int) (entries []probeEntry, err error) { // probeStop returns why a probe run must stop: a failed call, or a quota window // whose used percent grew by more than probeQuotaLimit points since the first // call. It returns "" when the run can go on. -func probeStop(records []usageRecord) string { +func probeStop(records []ModelUsageRecord) string { for _, record := range records { if record.Error != "" { return "call failed: " + record.Error @@ -375,7 +375,7 @@ func builtByModel(memory *Memory, key nodeKey) (bool, error) { // readUsageFile reads the records of usage.jsonl at path, in the order of the // start of their calls. The file has them in the order of the end of their // calls, which differs when calls run at once. -func readUsageFile(path string) (records []usageRecord, err error) { +func readUsageFile(path string) (records []ModelUsageRecord, err error) { file, err := os.Open(path) if errors.Is(err, os.ErrNotExist) { return nil, nil @@ -392,7 +392,7 @@ func readUsageFile(path string) (records []usageRecord, err error) { scanner := bufio.NewScanner(file) scanner.Buffer(make([]byte, 64*1024), probeLineBytes) for scanner.Scan() { - var record usageRecord + var record ModelUsageRecord err = json.Unmarshal(scanner.Bytes(), &record) if err != nil { return nil, fmt.Errorf("decode usage: %w", err) @@ -406,7 +406,7 @@ func readUsageFile(path string) (records []usageRecord, err error) { return nil, fmt.Errorf("read usage file: %w", err) } - slices.SortStableFunc(records, func(a, b usageRecord) int { + slices.SortStableFunc(records, func(a, b ModelUsageRecord) int { return callStart(a).Compare(callStart(b)) }) @@ -415,7 +415,7 @@ func readUsageFile(path string) (records []usageRecord, err error) { // callStart returns the start of the call of record, or the zero time when its // date is not valid. -func callStart(record usageRecord) time.Time { +func callStart(record ModelUsageRecord) time.Time { start, err := time.Parse(time.RFC3339Nano, record.Date) if err != nil { return time.Time{} @@ -426,7 +426,7 @@ func callStart(record usageRecord) time.Time { // usedPercents returns the used percent of each quota window of record, by // header name. -func usedPercents(record usageRecord) map[string]float64 { +func usedPercents(record ModelUsageRecord) map[string]float64 { used := make(map[string]float64) for name, value := range record.Quota { if !strings.HasSuffix(name, "-used-percent") { @@ -443,7 +443,7 @@ func usedPercents(record usageRecord) map[string]float64 { } // writeProbeReport writes the result of a probe run. -func writeProbeReport(out io.Writer, appended int, stop string, records []usageRecord, sizes []int) error { +func writeProbeReport(out io.Writer, appended int, stop string, records []ModelUsageRecord, sizes []int) error { var input, cached, output, failed int for _, record := range records { input += record.InputTokens @@ -502,7 +502,7 @@ func sizeText(sizes []int) string { // timeText describes the time per call and how many calls ran at once. A call // runs from its date for its seconds. -func timeText(records []usageRecord) string { +func timeText(records []ModelUsageRecord) string { type event struct { at time.Time delta int @@ -550,7 +550,7 @@ func timeText(records []usageRecord) string { } // quotaText returns the quota headers of record, sorted by name. -func quotaText(record usageRecord) string { +func quotaText(record ModelUsageRecord) string { names := slices.Sorted(maps.Keys(record.Quota)) parts := make([]string, len(names)) for n, name := range names { diff --git a/probe_test.go b/probe_test.go index f791084..afc6e2f 100644 --- a/probe_test.go +++ b/probe_test.go @@ -70,8 +70,8 @@ func TestProbeLogHasTheSyntheticMix(t *testing.T) { } func TestProbeStopRules(t *testing.T) { - quota := func(primary, secondary string) usageRecord { - return usageRecord{ + quota := func(primary, secondary string) ModelUsageRecord { + return ModelUsageRecord{ Quota: map[string]string{ "x-codex-primary-used-percent": primary, "x-codex-secondary-used-percent": secondary, @@ -82,7 +82,7 @@ func TestProbeStopRules(t *testing.T) { testCases := []struct { name string - records []usageRecord + records []ModelUsageRecord want string }{ { @@ -92,17 +92,17 @@ func TestProbeStopRules(t *testing.T) { }, { name: "growth within the limit", - records: []usageRecord{quota("10", "40"), quota("25", "41")}, + records: []ModelUsageRecord{quota("10", "40"), quota("25", "41")}, want: "", }, { name: "growth over the limit", - records: []usageRecord{quota("10", "40"), quota("12", "55.5")}, + records: []ModelUsageRecord{quota("10", "40"), quota("12", "55.5")}, want: "x-codex-secondary-used-percent grew from 40 to 55.5", }, { name: "failed call", - records: []usageRecord{quota("10", "40"), { + records: []ModelUsageRecord{quota("10", "40"), { Status: 400, Error: "model call: HTTP 400", }}, @@ -118,10 +118,10 @@ func TestProbeStopRules(t *testing.T) { } func TestProbeRunStopsWhenTheQuotaGrows(t *testing.T) { - server := newFakeResponses(t) - server.reply = func(w http.ResponseWriter, call fakeCall) { + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { w.Header().Set("X-Codex-Primary-Used-Percent", strconv.Itoa(10+call.number)) - writeEvents(w, completedEvent([]any{messageItem(strings.Repeat("s", 400))}, 1000, 800, 50)) + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(strings.Repeat("s", 400))}, 1000, 800, 50)) } run := newTestProbeRun(t, server) @@ -148,7 +148,7 @@ func TestProbeRunStopsWhenTheQuotaGrows(t *testing.T) { } func TestProbeRunStopsAtTheCallLimit(t *testing.T) { - server := newFakeResponses(t) + server := newModelFake(t) run := newTestProbeRun(t, server) run.client.LimitCalls(5) @@ -170,8 +170,8 @@ func TestProbeRunWaitsOnlyBeforeUserMessages(t *testing.T) { gate := make(chan struct{}) var mutex sync.Mutex running, most := 0, 0 - server := newFakeResponses(t) - server.reply = func(w http.ResponseWriter, call fakeCall) { + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { mutex.Lock() running++ most = max(most, running) @@ -183,7 +183,7 @@ func TestProbeRunWaitsOnlyBeforeUserMessages(t *testing.T) { running-- mutex.Unlock() - writeEvents(w, completedEvent([]any{messageItem("a line")}, 100, 0, 5)) + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("a line")}, 100, 0, 5)) } run := newTestProbeRun(t, server) @@ -264,12 +264,12 @@ func TestSlotSummarizerLimitsTheBuildsAtOnce(t *testing.T) { func TestReadUsageFileSortsByCallStart(t *testing.T) { path := filepath.Join(t.TempDir(), "usage.jsonl") - usage := usageLog{ + usage := ModelUsageLog{ path: path, } for _, date := range []string{"2026-01-01T12:00:02Z", "2026-01-01T12:00:00.5Z", "2026-01-01T12:00:01.25Z"} { - require.NoError(t, usage.Write(usageRecord{ + require.NoError(t, usage.Write(ModelUsageRecord{ Date: date, })) } @@ -281,14 +281,14 @@ func TestReadUsageFileSortsByCallStart(t *testing.T) { func TestTimeTextCountsTheCallsAtOnce(t *testing.T) { start := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) - record := func(offset, seconds float64) usageRecord { - return usageRecord{ + record := func(offset, seconds float64) ModelUsageRecord { + return ModelUsageRecord{ Date: start.Add(time.Duration(offset * float64(time.Second))).Format(time.RFC3339Nano), Seconds: seconds, } } - text := timeText([]usageRecord{record(0, 10), record(2, 4), record(3, 2), record(10, 10)}) + text := timeText([]ModelUsageRecord{record(0, 10), record(2, 4), record(3, 2), record(10, 10)}) require.Equal(t, "time: 20 s in all, 12.0 calls per minute, 6.5 s per call (median 10.0, max 10.0), calls at once: at most 3, 1.3 on average", text) } @@ -413,12 +413,12 @@ func (g *probeWriter) pick(words []string) string { return words[g.random.IntN(len(words))] } -func newTestProbeRun(t *testing.T, server *fakeResponses) *probeRun { +func newTestProbeRun(t *testing.T, server *ModelFake) *probeRun { t.Helper() root := t.TempDir() usagePath := filepath.Join(root, "usage.jsonl") - client := newClient(server.URL, apiKey(testKey), "onechat-"+probeName, usagePath) + client := newModelClient(server.URL, ModelAPIKey(modelTestKey), "onechat-"+probeName, usagePath) memory, err := openMemory(t.Context(), root, newTestClock().Now, newCompactor(client, probePrompt, defaultConfig().Compactor)) require.NoError(t, err) diff --git a/prompt.go b/prompt.go index 373aab6..546a86b 100644 --- a/prompt.go +++ b/prompt.go @@ -134,8 +134,8 @@ func prompt(name, instructions string) string { // agentTools returns the function tools of every call: zoom, date, shell, // read, write, and edit. A compaction sends them too and never calls them, // because a change of the tools ends the shared prompt cache. -func agentTools() []Tool { - return []Tool{ +func agentTools() []ModelTool { + return []ModelTool{ { Type: "function", Name: "zoom", diff --git a/telegram_test.go b/telegram_test.go index 9111d13..2f944fa 100644 --- a/telegram_test.go +++ b/telegram_test.go @@ -14,9 +14,9 @@ import ( ) func TestOwnerTalksToTheAgentThroughTelegram(t *testing.T) { - server := newFakeResponses(t) - server.reply = func(w http.ResponseWriter, call fakeCall) { - writeEvents(w, completedEvent([]any{messageItem("Hello from the agent.")}, 100, 0, 5)) + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("Hello from the agent.")}, 100, 0, 5)) } telegram := newTelegramFake(t) @@ -43,7 +43,7 @@ func TestOwnerTalksToTheAgentThroughTelegram(t *testing.T) { func TestBotIgnoresEveryoneButTheOwner(t *testing.T) { telegram := newTelegramFake(t) bot, logs := telegramStartTestBot(t, telegram) - socket := startTestDaemonWithBot(t, newFakeResponses(t), bot) + socket := startTestDaemonWithBot(t, newModelFake(t), bot) terminal := startTestClient(t, socket) terminal.WaitFor(t, "[connected to OneChat]") @@ -81,7 +81,7 @@ func TestBotIgnoresEveryoneButTheOwner(t *testing.T) { func TestBotAnswersThatOnlyTextIsSupported(t *testing.T) { telegram := newTelegramFake(t) bot, _ := telegramStartTestBot(t, telegram) - startTestDaemonWithBot(t, newFakeResponses(t), bot) + startTestDaemonWithBot(t, newModelFake(t), bot) telegram.Add(TelegramUpdate{ UpdateID: 1, @@ -92,14 +92,14 @@ func TestBotAnswersThatOnlyTextIsSupported(t *testing.T) { } func TestStopFromTelegramInterruptsTheTurn(t *testing.T) { - server := newFakeResponses(t) + server := newModelFake(t) arrived := make(chan struct{}) release := make(chan struct{}) t.Cleanup(func() { close(release) }) - server.reply = func(w http.ResponseWriter, call fakeCall) { + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { close(arrived) <-release } @@ -130,9 +130,9 @@ func TestStopFromTelegramInterruptsTheTurn(t *testing.T) { } func TestBotStartsAtTheStoredOffset(t *testing.T) { - server := newFakeResponses(t) - server.reply = func(w http.ResponseWriter, call fakeCall) { - writeEvents(w, completedEvent([]any{messageItem("ok")}, 100, 0, 5)) + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5)) } telegram := newTelegramFake(t) @@ -164,9 +164,9 @@ func TestBotStartsAtTheStoredOffset(t *testing.T) { } func TestBotTriesAgainAfterFailures(t *testing.T) { - server := newFakeResponses(t) - server.reply = func(w http.ResponseWriter, call fakeCall) { - writeEvents(w, completedEvent([]any{messageItem("ok")}, 100, 0, 5)) + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("ok")}, 100, 0, 5)) } telegram := newTelegramFake(t) @@ -229,9 +229,9 @@ func TestDeliverTriesAgainAfterATimeout(t *testing.T) { func TestRepliesGoAsRichMessages(t *testing.T) { reply := "**Done.** The table:\n\n| Name | Size |\n|:-----|-----:|\n| `a.go` | 10 |\n\n- one\n- two\n\n```go\nx := 1\n```" - server := newFakeResponses(t) - server.reply = func(w http.ResponseWriter, call fakeCall) { - writeEvents(w, completedEvent([]any{messageItem(reply)}, 100, 0, 5)) + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(reply)}, 100, 0, 5)) } telegram := newTelegramFake(t) @@ -423,7 +423,7 @@ func TestBotLogsNoTokenWhenTheNetworkFails(t *testing.T) { telegram.Close() bot, logs := telegramStartTestBot(t, telegram) - startTestDaemonWithBot(t, newFakeResponses(t), bot) + startTestDaemonWithBot(t, newModelFake(t), bot) require.Eventually(t, func() bool { return strings.Count(logs.String(), "no valid answer") >= 2 diff --git a/turn.go b/turn.go index 0b2b29e..029f24e 100644 --- a/turn.go +++ b/turn.go @@ -11,9 +11,9 @@ import ( "time" ) -// modelClient sends model calls. -type modelClient interface { - Create(ctx context.Context, request Request) (Response, error) +// TurnClient sends model calls. +type TurnClient interface { + Create(ctx context.Context, request ModelRequest) (ModelResponse, error) } // Event tells the clients what the agent does as it happens. Kind is the kind @@ -47,7 +47,7 @@ type Event struct { type loop struct { agent *Agent memory *Memory - client modelClient + client TurnClient tools *toolbox layout *viewLayout clock func() time.Time @@ -76,7 +76,7 @@ type functionCallOutput struct { // newLoop returns the loop of agent with its memory. Its calls go through // client, and notify gets each event. The clock gives the date of a turn. -func newLoop(agent *Agent, memory *Memory, client modelClient, clock func() time.Time, notify func(Event)) (*loop, error) { +func newLoop(agent *Agent, memory *Memory, client TurnClient, clock func() time.Time, notify func(Event)) (*loop, error) { layout, err := loadLayout(filepath.Join(agent.dir, layoutFileName)) if err != nil { return nil, err @@ -283,7 +283,7 @@ func (l *loop) chain(ctx context.Context) error { input := turnInput(view, l.state(), messages) for turn := "first"; ; turn = "next" { - response, err := l.client.Create(ctx, Request{ + response, err := l.client.Create(ctx, ModelRequest{ Model: l.agent.config.Model, Effort: l.agent.config.Effort, Instructions: instructions, @@ -310,7 +310,7 @@ func (l *loop) chain(ctx context.Context) error { // input of the next request: input, the output items, the tool results, and // the user messages that came in the meantime. After the final answer it // returns no input. -func (l *loop) handle(ctx context.Context, input []any, response Response) ([]any, error) { +func (l *loop) handle(ctx context.Context, input []any, response ModelResponse) ([]any, error) { var outputs []any for _, raw := range response.Output { item, err := inputItem(raw) @@ -328,7 +328,7 @@ func (l *loop) handle(ctx context.Context, input []any, response Response) ([]an switch call.Type { case "message": - text, err := outputText([]json.RawMessage{raw}) + text, err := modelOutputText([]json.RawMessage{raw}) if err != nil { return nil, err } @@ -362,7 +362,7 @@ func (l *loop) handle(ctx context.Context, input []any, response Response) ([]an input = append(input, outputs...) for _, message := range l.take() { - input = append(input, userMessage(message.Text)) + input = append(input, modelUserMessage(message.Text)) } return input, nil @@ -381,12 +381,12 @@ func (l *loop) state() string { func turnInput(view []string, state string, messages []Message) []any { input := make([]any, 0, len(view)+1+len(messages)) for _, text := range view { - input = append(input, userMessage(text)) + input = append(input, modelUserMessage(text)) } - input = append(input, userMessage(state)) + input = append(input, modelUserMessage(state)) for _, message := range messages { - input = append(input, userMessage(message.Text)) + input = append(input, modelUserMessage(message.Text)) } return input diff --git a/turn_test.go b/turn_test.go index 0de7dd7..c378469 100644 --- a/turn_test.go +++ b/turn_test.go @@ -15,18 +15,18 @@ import ( ) func TestTurnRunsToolsUntilTheFinalAnswer(t *testing.T) { - server := newFakeResponses(t) - server.reply = func(w http.ResponseWriter, call fakeCall) { + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { if call.number == 0 { - writeEvents(w, completedEvent([]any{ - reasoningItem("rs_1", "opaque thought"), - functionCallItem("fc_1", "call_1", "shell", `{"command":"echo hi"}`), + modelWriteEvents(w, modelCompletedEvent([]any{ + modelReasoningItem("rs_1", "opaque thought"), + modelFunctionCallItem("fc_1", "call_1", "shell", `{"command":"echo hi"}`), }, 100, 0, 5)) return } - writeEvents(w, completedEvent([]any{messageItem("It said hi.")}, 120, 100, 5)) + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("It said hi.")}, 120, 100, 5)) } test := newTestLoop(t, server) @@ -61,7 +61,7 @@ func TestTurnRunsToolsUntilTheFinalAnswer(t *testing.T) { require.Equal(t, []string{"user", "busy", "tool", "echo", "reply", "idle"}, test.events.Kinds()) var turns []string - for _, record := range readUsage(t, test.usagePath) { + for _, record := range modelReadUsage(t, test.usagePath) { turns = append(turns, record.Turn) } @@ -69,7 +69,7 @@ func TestTurnRunsToolsUntilTheFinalAnswer(t *testing.T) { } func TestFirstRequestHoldsTheViewTheStateAndTheMessage(t *testing.T) { - server := newFakeResponses(t) + server := newModelFake(t) test := newTestLoop(t, server) _, err := test.memory.Append("user", "an earlier message") require.NoError(t, err) @@ -86,19 +86,19 @@ func TestFirstRequestHoldsTheViewTheStateAndTheMessage(t *testing.T) { } func TestMessageDuringATurnJoinsTheNextRequest(t *testing.T) { - server := newFakeResponses(t) + server := newModelFake(t) arrived := make(chan struct{}) release := make(chan struct{}) - server.reply = func(w http.ResponseWriter, call fakeCall) { + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { if call.number == 0 { close(arrived) <-release - writeEvents(w, completedEvent([]any{functionCallItem("fc_1", "call_1", "date", `{"id":0}`)}, 100, 0, 5)) + modelWriteEvents(w, modelCompletedEvent([]any{modelFunctionCallItem("fc_1", "call_1", "date", `{"id":0}`)}, 100, 0, 5)) return } - writeEvents(w, completedEvent([]any{messageItem("done")}, 100, 0, 5)) + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("done")}, 100, 0, 5)) } test := newTestLoop(t, server) @@ -123,16 +123,16 @@ func TestMessageDuringATurnJoinsTheNextRequest(t *testing.T) { } func TestMessageDuringTheFinalRequestStartsANewTurn(t *testing.T) { - server := newFakeResponses(t) + server := newModelFake(t) arrived := make(chan struct{}) release := make(chan struct{}) - server.reply = func(w http.ResponseWriter, call fakeCall) { + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { if call.number == 0 { close(arrived) <-release } - writeEvents(w, completedEvent([]any{messageItem("answer")}, 100, 0, 5)) + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("answer")}, 100, 0, 5)) } test := newTestLoop(t, server) @@ -153,7 +153,7 @@ func TestMessageDuringTheFinalRequestStartsANewTurn(t *testing.T) { } func TestTurnWaitsForTheSummaries(t *testing.T) { - server := newFakeResponses(t) + server := newModelFake(t) summarizer := newFakeSummarizer() summarizer.gate = make(chan struct{}) test := newTestLoopWith(t, server, summarizer) @@ -174,16 +174,16 @@ func TestTurnWaitsForTheSummaries(t *testing.T) { } func TestInterruptEndsTheTurn(t *testing.T) { - server := newFakeResponses(t) + server := newModelFake(t) arrived := make(chan struct{}) release := make(chan struct{}) - server.reply = func(w http.ResponseWriter, call fakeCall) { + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { if call.number == 0 { close(arrived) <-release } - writeEvents(w, completedEvent([]any{messageItem("answer")}, 100, 0, 5)) + modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("answer")}, 100, 0, 5)) } test := newTestLoop(t, server) @@ -206,8 +206,8 @@ func TestInterruptEndsTheTurn(t *testing.T) { } func TestFailedCallEndsTheTurnWithoutARetry(t *testing.T) { - server := newFakeResponses(t) - server.reply = func(w http.ResponseWriter, call fakeCall) { + server := newModelFake(t) + server.reply = func(w http.ResponseWriter, call ModelFakeCall) { http.Error(w, "busy", http.StatusServiceUnavailable) } @@ -222,7 +222,7 @@ func TestFailedCallEndsTheTurnWithoutARetry(t *testing.T) { } func TestViewMessagesOnlyGrowBetweenTurns(t *testing.T) { - server := newFakeResponses(t) + server := newModelFake(t) test := newTestLoop(t, server) for n, text := range []string{"one", "two", "three"} { _, err := test.loop.Submit(terminalSource, text) @@ -327,7 +327,7 @@ func (r *eventRecorder) WaitFor(t *testing.T, kind string, count int) { }, 10*time.Second, time.Millisecond) } -func newTestLoop(t *testing.T, server *fakeResponses) *testLoop { +func newTestLoop(t *testing.T, server *ModelFake) *testLoop { t.Helper() return newTestLoopWith(t, server, newFakeSummarizer()) @@ -335,7 +335,7 @@ func newTestLoop(t *testing.T, server *fakeResponses) *testLoop { // newTestLoopWith returns a running loop on a new agent whose builds use // summarizer and whose commands run in a temporary directory. -func newTestLoopWith(t *testing.T, server *fakeResponses, summarizer Summarizer) *testLoop { +func newTestLoopWith(t *testing.T, server *ModelFake, summarizer Summarizer) *testLoop { t.Helper() agent, err := openAgent(t.TempDir()) @@ -353,7 +353,7 @@ func newTestLoopWith(t *testing.T, server *fakeResponses, summarizer Summarizer) usagePath: filepath.Join(t.TempDir(), "usage.jsonl"), } - client := newClient(server.URL, apiKey(testKey), agent.CacheKey(), test.usagePath) + client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), test.usagePath) test.loop, err = newLoop(agent, memory, client, clock.Now, test.events.Add) require.NoError(t, err) ctx, cancel := context.WithCancel(t.Context()) @@ -372,7 +372,7 @@ func newTestLoopWith(t *testing.T, server *fakeResponses, summarizer Summarizer) } // inputTexts returns the text of each user message of the input of call. -func inputTexts(t *testing.T, call fakeCall) []string { +func inputTexts(t *testing.T, call ModelFakeCall) []string { t.Helper() var body struct { @@ -402,22 +402,3 @@ func inputTexts(t *testing.T, call fakeCall) []string { return texts } - -func reasoningItem(id, encrypted string) map[string]any { - return map[string]any{ - "type": "reasoning", - "id": id, - "encrypted_content": encrypted, - "summary": []any{}, - } -} - -func functionCallItem(id, callID, name, arguments string) map[string]any { - return map[string]any{ - "type": "function_call", - "id": id, - "call_id": callID, - "name": name, - "arguments": arguments, - } -}