X-Git-Url: http://git.giacomolli.uk/onechat/blobdiff_plain/09cde69fdffe2721434392036f98dc241177f5a1:/responses_test.go..02cdaff8be27f37f44db8892bb12554f2d702323:/model_test.go 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, + } +}