From a512a6eeec8bb5dc9c5693fd0e6f4b6e25147fc5 Mon Sep 17 00:00:00 2001 From: Roomote Date: Sun, 2 Aug 2026 16:03:42 +0000 Subject: [PATCH] refactor: roll out stream helpers to friendli opencode mimo specs --- src/api/providers/__tests__/friendli.spec.ts | 163 +++------ src/api/providers/__tests__/mimo.spec.ts | 278 ++++++---------- .../providers/__tests__/opencode-go.spec.ts | 309 +++++++----------- src/eslint-suppressions.json | 4 +- 4 files changed, 279 insertions(+), 475 deletions(-) diff --git a/src/api/providers/__tests__/friendli.spec.ts b/src/api/providers/__tests__/friendli.spec.ts index c8fd81ad19..7c31c754e7 100644 --- a/src/api/providers/__tests__/friendli.spec.ts +++ b/src/api/providers/__tests__/friendli.spec.ts @@ -8,6 +8,7 @@ import { friendliDefaultModelId, friendliModels } from "@roo-code/types" import { buildApiHandler } from "../../index" import { getModelMaxOutputTokens } from "../../../shared/api" import { FriendliHandler } from "../friendli" +import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" // Create mock functions const mockCreate = vi.fn() @@ -31,9 +32,9 @@ describe("FriendliHandler", () => { beforeEach(() => { vi.clearAllMocks() // Set up default mock implementation - mockCreate.mockImplementation(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { + mockCreate.mockImplementation(async () => + asyncStreamFrom([ + { choices: [ { delta: { content: "Test response" }, @@ -41,8 +42,8 @@ describe("FriendliHandler", () => { }, ], usage: null, - } - yield { + }, + { choices: [ { delta: {}, @@ -54,9 +55,9 @@ describe("FriendliHandler", () => { completion_tokens: 5, total_tokens: 15, }, - } - }, - })) + }, + ]), + ) handler = new FriendliHandler({ friendliApiKey: "test-key" }) }) @@ -189,19 +190,7 @@ describe("FriendliHandler", () => { it("createMessage should yield text content from stream", async () => { const testContent = "This is test content from Friendli stream" - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: testContent } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) + mockCreate.mockImplementationOnce(() => asyncStreamFrom([{ choices: [{ delta: { content: testContent } }] }])) const stream = handler.createMessage("system prompt", []) const firstChunk = await stream.next() @@ -211,19 +200,9 @@ describe("FriendliHandler", () => { }) it("createMessage should yield usage data from stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([{ choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } }]), + ) const stream = handler.createMessage("system prompt", []) const firstChunk = await stream.next() @@ -240,15 +219,7 @@ describe("FriendliHandler", () => { friendliApiKey: "test-friendli-api-key", }) - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } - }) + mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) const systemPrompt = "Test system prompt for Friendli" const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for Friendli" }] @@ -276,13 +247,7 @@ describe("FriendliHandler", () => { modelTemperature: 0.3, }) - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - })) + mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) const messageGenerator = handlerWithModel.createMessage("system", []) await messageGenerator.next() @@ -308,9 +273,9 @@ describe("FriendliHandler", () => { }) it("createMessage should handle stream with multiple chunks", async () => { - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [ { delta: { content: "Hello" }, @@ -318,8 +283,8 @@ describe("FriendliHandler", () => { }, ], usage: null, - } - yield { + }, + { choices: [ { delta: { content: " world" }, @@ -327,8 +292,8 @@ describe("FriendliHandler", () => { }, ], usage: null, - } - yield { + }, + { choices: [ { delta: {}, @@ -340,18 +305,15 @@ describe("FriendliHandler", () => { completion_tokens: 10, total_tokens: 15, }, - } - }, - })) + }, + ]), + ) const systemPrompt = "You are a helpful assistant." const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] const stream = handler.createMessage(systemPrompt, messages) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(stream) expect(chunks[0]).toEqual({ type: "text", text: "Hello" }) expect(chunks[1]).toEqual({ type: "text", text: " world" }) @@ -417,13 +379,7 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { reasoningEffort: "high", }) - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - })) + mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) await handler.createMessage("system", []).next() @@ -446,13 +402,7 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { enableReasoningEffort: false, }) - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - })) + mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) await handler.createMessage("system", []).next() @@ -471,13 +421,7 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { reasoningEffort: "none", }) - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - })) + mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) await handler.createMessage("system", []).next() @@ -496,13 +440,7 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { reasoningEffort: "disable", }) - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - })) + mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) await handler.createMessage("system", []).next() @@ -520,13 +458,7 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { // No enableReasoningEffort or reasoningEffort — model default "high" kicks in }) - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - })) + mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) await handler.createMessage("system", []).next() @@ -545,13 +477,7 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { reasoningEffort: "high", }) - mockCreate.mockImplementationOnce(() => ({ - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - })) + mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) await handler.createMessage("system", []).next() @@ -569,28 +495,25 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { reasoningEffort: "high", }) - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [{ delta: { reasoning_content: "Let me think..." } }], usage: null, - } - yield { + }, + { choices: [{ delta: { content: "The answer is 42" } }], usage: null, - } - yield { + }, + { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, - } - }, - })) + }, + ]), + ) const stream = handler.createMessage("system", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(stream) expect(chunks).toContainEqual({ type: "reasoning", text: "Let me think..." }) expect(chunks).toContainEqual({ type: "text", text: "The answer is 42" }) diff --git a/src/api/providers/__tests__/mimo.spec.ts b/src/api/providers/__tests__/mimo.spec.ts index 7da1c84463..357bbf6861 100644 --- a/src/api/providers/__tests__/mimo.spec.ts +++ b/src/api/providers/__tests__/mimo.spec.ts @@ -1,4 +1,5 @@ const mockCreate = vi.fn() +import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" vi.mock("openai", () => { return { __esModule: true, @@ -6,25 +7,23 @@ vi.mock("openai", () => { return { chat: { completions: { - create: mockCreate.mockImplementation(async (options) => { - return { - [Symbol.asyncIterator]: async function* () { - yield { - choices: [{ delta: { content: "Test response" }, index: 0 }], - usage: null, - } - yield { - choices: [{ delta: {}, index: 0, finish_reason: "stop" }], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - prompt_tokens_details: { cached_tokens: 2 }, - }, - } + create: mockCreate.mockImplementation(async (options) => + asyncStreamFrom([ + { + choices: [{ delta: { content: "Test response" }, index: 0 }], + usage: null, + }, + { + choices: [{ delta: {}, index: 0, finish_reason: "stop" }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + prompt_tokens_details: { cached_tokens: 2 }, + }, }, - } - }), + ]), + ), }, }, } @@ -368,9 +367,7 @@ describe("MimoHandler", () => { const stream = handler.createMessage("System prompt", messages) // Consume the stream - for await (const _chunk of stream) { - // drain - } + await collectStream(stream) expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ @@ -385,9 +382,7 @@ describe("MimoHandler", () => { ] const stream = handler.createMessage("System prompt", messages) - for await (const _chunk of stream) { - // drain - } + await collectStream(stream) const params = mockCreate.mock.calls[0][0] expect(params.parallel_tool_calls).toBeUndefined() @@ -400,9 +395,7 @@ describe("MimoHandler", () => { ] const stream = handler.createMessage("System prompt", messages) - for await (const _chunk of stream) { - // drain - } + await collectStream(stream) const params = mockCreate.mock.calls[0][0] expect(params.stream_options).toEqual({ include_usage: true }) @@ -428,9 +421,7 @@ describe("MimoHandler", () => { ] const stream = handler.createMessage("System prompt", messages, { tools } as any) - for await (const _chunk of stream) { - // drain - } + await collectStream(stream) const params = mockCreate.mock.calls[0][0] expect(params.tools).toHaveLength(1) @@ -442,11 +433,7 @@ describe("MimoHandler", () => { { role: "user", content: [{ type: "text", text: "Hello" }] }, ] - const chunks: any[] = [] - const stream = handler.createMessage("System prompt", messages) - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(handler.createMessage("System prompt", messages)) const textChunks = chunks.filter((c) => c.type === "text") expect(textChunks.length).toBeGreaterThan(0) @@ -458,11 +445,7 @@ describe("MimoHandler", () => { { role: "user", content: [{ type: "text", text: "Hello" }] }, ] - const chunks: any[] = [] - const stream = handler.createMessage("System prompt", messages) - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(handler.createMessage("System prompt", messages)) const usageChunks = chunks.filter((c) => c.type === "usage") expect(usageChunks).toHaveLength(1) @@ -471,56 +454,50 @@ describe("MimoHandler", () => { }) it("streams reasoning chunks from delta.reasoning_content", async () => { - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { choices: [{ delta: { reasoning_content: "thinking..." }, index: 0 }] } - yield { choices: [{ delta: { content: "answer" }, index: 0 }] } - yield { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [{ delta: { reasoning_content: "thinking..." }, index: 0 }] }, + { choices: [{ delta: { content: "answer" }, index: 0 }] }, + { choices: [{ delta: {}, index: 0 }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - } - }, - })) + }, + ]), + ) const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text", text: "Hello" }] }, ] - const chunks: any[] = [] - for await (const chunk of handler.createMessage("System prompt", messages)) { - chunks.push(chunk) - } + const chunks = await collectStream(handler.createMessage("System prompt", messages)) expect(chunks).toContainEqual({ type: "reasoning", text: "thinking..." }) }) it("falls back to delta.reasoning when reasoning_content is absent", async () => { - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { choices: [{ delta: { reasoning: "router-style thought" }, index: 0 }] } - yield { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [{ delta: { reasoning: "router-style thought" }, index: 0 }] }, + { choices: [{ delta: {}, index: 0 }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - } - }, - })) + }, + ]), + ) const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text", text: "Hello" }] }, ] - const chunks: any[] = [] - for await (const chunk of handler.createMessage("System prompt", messages)) { - chunks.push(chunk) - } + const chunks = await collectStream(handler.createMessage("System prompt", messages)) expect(chunks).toContainEqual({ type: "reasoning", text: "router-style thought" }) }) it("prefers delta.reasoning_content over delta.reasoning when both are present", async () => { - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [ { delta: { @@ -530,31 +507,28 @@ describe("MimoHandler", () => { index: 0, }, ], - } - yield { + }, + { choices: [{ delta: {}, index: 0 }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - } - }, - })) + }, + ]), + ) const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text", text: "Hello" }] }, ] - const chunks: any[] = [] - for await (const chunk of handler.createMessage("System prompt", messages)) { - chunks.push(chunk) - } + const chunks = await collectStream(handler.createMessage("System prompt", messages)) const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") expect(reasoningChunks).toEqual([{ type: "reasoning", text: "primary thought" }]) }) it("should yield tool_call_partial chunks from stream", async () => { - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [ { delta: { @@ -570,8 +544,8 @@ describe("MimoHandler", () => { }, ], usage: null, - } - yield { + }, + { choices: [ { delta: { @@ -586,23 +560,19 @@ describe("MimoHandler", () => { }, ], usage: null, - } - yield { + }, + { choices: [{ delta: {}, index: 0, finish_reason: "tool_calls" }], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - } - }, - })) + }, + ]), + ) const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text", text: "Read test.ts" }] }, ] - const chunks: any[] = [] - const stream = handler.createMessage("System prompt", messages) - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(handler.createMessage("System prompt", messages)) const toolChunks = chunks.filter((c) => c.type === "tool_call_partial") expect(toolChunks).toHaveLength(2) @@ -613,13 +583,13 @@ describe("MimoHandler", () => { }) it("should yield usage with cache tokens", async () => { - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [{ delta: { content: "Hi" }, index: 0 }], usage: null, - } - yield { + }, + { choices: [{ delta: {}, index: 0, finish_reason: "stop" }], usage: { prompt_tokens: 100, @@ -630,19 +600,15 @@ describe("MimoHandler", () => { cached_tokens: 30, }, }, - } - }, - })) + }, + ]), + ) const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text", text: "Hello" }] }, ] - const chunks: any[] = [] - const stream = handler.createMessage("System prompt", messages) - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(handler.createMessage("System prompt", messages)) const usageChunks = chunks.filter((c) => c.type === "usage") expect(usageChunks).toHaveLength(1) @@ -661,10 +627,7 @@ describe("MimoHandler", () => { ] await expect(async () => { - const stream = handler.createMessage("System prompt", messages) - for await (const _chunk of stream) { - // drain - } + await collectStream(handler.createMessage("System prompt", messages)) }).rejects.toThrow() }) @@ -699,9 +662,7 @@ describe("MimoHandler", () => { ] const stream = handler.createMessage("System prompt", messages) - for await (const _chunk of stream) { - // drain - } + await collectStream(stream) const params = mockCreate.mock.calls[0][0] expect(params.messages).toHaveLength(4) // system + user + assistant + tool @@ -721,44 +682,38 @@ describe("MimoHandler", () => { ] const stream = handler.createMessage("System prompt", messages) - for await (const _chunk of stream) { - // drain - } + await collectStream(stream) const params = mockCreate.mock.calls[0][0] expect(params.tools).toBeUndefined() }) it("should handle empty delta chunks without errors", async () => { - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { choices: [{}], usage: null } - yield { choices: [{ delta: {} }], usage: null } - yield { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [{}], usage: null }, + { choices: [{ delta: {} }], usage: null }, + { choices: [{ delta: {}, index: 0, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - } - }, - })) + }, + ]), + ) const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text", text: "Hello" }] }, ] - const chunks: any[] = [] - const stream = handler.createMessage("System prompt", messages) - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(handler.createMessage("System prompt", messages)) const textChunks = chunks.filter((c) => c.type === "text") expect(textChunks).toHaveLength(0) }) it("should handle multiple tool calls in single response", async () => { - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [ { delta: { @@ -779,8 +734,8 @@ describe("MimoHandler", () => { }, ], usage: null, - } - yield { + }, + { choices: [ { delta: { @@ -793,13 +748,13 @@ describe("MimoHandler", () => { }, ], usage: null, - } - yield { + }, + { choices: [{ delta: {}, index: 0, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, - } - }, - })) + }, + ]), + ) const tools: any[] = [ { @@ -816,11 +771,7 @@ describe("MimoHandler", () => { { role: "user", content: [{ type: "text", text: "Hello" }] }, ] - const chunks: any[] = [] - const stream = handler.createMessage("System", messages, { taskId: "test", tools }) - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(handler.createMessage("System", messages, { taskId: "test", tools })) const toolChunks = chunks.filter((c) => c.type === "tool_call_partial") const readChunks = toolChunks.filter((c) => c.name === "read_file") @@ -830,25 +781,20 @@ describe("MimoHandler", () => { }) it("should handle stream interruption gracefully", async () => { - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [{ delta: { content: "Partial " }, index: 0 }], usage: null, - } - // Stream ends without finish_reason (connection dropped) - }, - })) + }, + ]), + ) const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", content: [{ type: "text", text: "Hello" }] }, ] - const chunks: any[] = [] - const stream = handler.createMessage("System", messages) - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(handler.createMessage("System", messages)) const textChunks = chunks.filter((c) => c.type === "text") expect(textChunks).toHaveLength(1) @@ -859,9 +805,9 @@ describe("MimoHandler", () => { }) it("should sanitize tool call IDs with invalid characters", async () => { - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [ { delta: { @@ -877,13 +823,13 @@ describe("MimoHandler", () => { }, ], usage: null, - } - yield { + }, + { choices: [{ delta: {}, index: 0, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - } - }, - })) + }, + ]), + ) const tools: any[] = [ { @@ -896,11 +842,7 @@ describe("MimoHandler", () => { { role: "user", content: [{ type: "text", text: "Hello" }] }, ] - const chunks: any[] = [] - const stream = handler.createMessage("System", messages, { taskId: "test", tools }) - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(handler.createMessage("System", messages, { taskId: "test", tools })) const toolChunks = chunks.filter((c) => c.type === "tool_call_partial") expect(toolChunks.length).toBeGreaterThan(0) @@ -914,9 +856,7 @@ describe("MimoHandler", () => { ] const stream = handler.createMessage("You are a helpful assistant", userMessages) - for await (const _chunk of stream) { - // drain - } + await collectStream(stream) const params = mockCreate.mock.calls[0][0] expect(params.messages[0].role).toBe("system") diff --git a/src/api/providers/__tests__/opencode-go.spec.ts b/src/api/providers/__tests__/opencode-go.spec.ts index 38be399c9d..0c81cbc75c 100644 --- a/src/api/providers/__tests__/opencode-go.spec.ts +++ b/src/api/providers/__tests__/opencode-go.spec.ts @@ -17,6 +17,7 @@ import { opencodeGoDefaultModelId, opencodeGoModels, isOpencodeGoAnthropicFormat import { OpencodeGoHandler } from "../opencode-go" import { getModels } from "../fetchers/modelCache" import { ApiHandlerOptions } from "../../../shared/api" +import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" vitest.mock("openai") vitest.mock("delay", () => ({ @@ -114,9 +115,9 @@ describe("OpencodeGoHandler", () => { describe("createMessage", () => { beforeEach(() => { - mockCreate.mockImplementation(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { + mockCreate.mockImplementation(async () => + asyncStreamFrom([ + { choices: [ { delta: { @@ -134,8 +135,8 @@ describe("OpencodeGoHandler", () => { }, ], usage: null, - } - yield { + }, + { choices: [{ delta: {}, index: 0 }], usage: { prompt_tokens: 12, @@ -143,19 +144,16 @@ describe("OpencodeGoHandler", () => { total_tokens: 19, prompt_tokens_details: { cached_tokens: 4 }, }, - } - }, - })) + }, + ]), + ) }) it("streams text, reasoning, tool-call and usage chunks", async () => { const handler = new OpencodeGoHandler(mockOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - const chunks = [] - for await (const chunk of handler.createMessage("You are helpful.", messages)) { - chunks.push(chunk) - } + const chunks = await collectStream(handler.createMessage("You are helpful.", messages)) expect(chunks).toContainEqual({ type: "text", text: "Hello" }) expect(chunks).toContainEqual({ type: "reasoning", text: "thinking…" }) @@ -177,9 +175,7 @@ describe("OpencodeGoHandler", () => { it("requests a streaming completion with usage included and native max tokens", async () => { const handler = new OpencodeGoHandler(mockOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - for await (const _chunk of handler.createMessage("sys", messages)) { - void _chunk // drain - } + await collectStream(handler.createMessage("sys", messages)) expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ @@ -197,9 +193,7 @@ describe("OpencodeGoHandler", () => { it("forwards the model's default reasoning_effort for reasoning-capable models", async () => { const handler = new OpencodeGoHandler(mockOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - for await (const _chunk of handler.createMessage("sys", messages)) { - void _chunk // drain - } + await collectStream(handler.createMessage("sys", messages)) // glm-5.1 advertises supportsReasoningEffort with a default of "medium". expect(mockCreate).toHaveBeenCalledWith( @@ -213,9 +207,7 @@ describe("OpencodeGoHandler", () => { it("omits reasoning_effort when the user disables reasoning", async () => { const handler = new OpencodeGoHandler({ ...mockOptions, reasoningEffort: "disable" }) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - for await (const _chunk of handler.createMessage("sys", messages)) { - void _chunk // drain - } + await collectStream(handler.createMessage("sys", messages)) const callArgs = mockCreate.mock.calls[0][0] as Record expect(callArgs.reasoning_effort).toBeUndefined() @@ -229,9 +221,7 @@ describe("OpencodeGoHandler", () => { content: [{ type: "text", text: "Hi" }], }, ] - for await (const _chunk of handler.createMessage("sys", messages)) { - void _chunk // drain - } + await collectStream(handler.createMessage("sys", messages)) const callArgs = mockCreate.mock.calls[0][0] as { messages: Array<{ role: string }> } // The system prompt is prepended, then the R1-converted user message. @@ -241,54 +231,48 @@ describe("OpencodeGoHandler", () => { }) it("streams reasoning chunks from delta.reasoning_content", async () => { - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { choices: [{ delta: { reasoning_content: "thinking..." }, index: 0 }] } - yield { choices: [{ delta: { content: "answer" }, index: 0 }] } - yield { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [{ delta: { reasoning_content: "thinking..." }, index: 0 }] }, + { choices: [{ delta: { content: "answer" }, index: 0 }] }, + { choices: [{ delta: {}, index: 0 }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - } - }, - })) + }, + ]), + ) const handler = new OpencodeGoHandler(mockOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - const chunks: any[] = [] - for await (const chunk of handler.createMessage("sys", messages)) { - chunks.push(chunk) - } + const chunks = await collectStream(handler.createMessage("sys", messages)) expect(chunks).toContainEqual({ type: "reasoning", text: "thinking..." }) }) it("falls back to delta.reasoning when reasoning_content is absent", async () => { - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { choices: [{ delta: { reasoning: "router-style thought" }, index: 0 }] } - yield { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [{ delta: { reasoning: "router-style thought" }, index: 0 }] }, + { choices: [{ delta: {}, index: 0 }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - } - }, - })) + }, + ]), + ) const handler = new OpencodeGoHandler(mockOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - const chunks: any[] = [] - for await (const chunk of handler.createMessage("sys", messages)) { - chunks.push(chunk) - } + const chunks = await collectStream(handler.createMessage("sys", messages)) expect(chunks).toContainEqual({ type: "reasoning", text: "router-style thought" }) }) it("prefers delta.reasoning_content over delta.reasoning when both are present", async () => { - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [ { delta: { @@ -298,21 +282,18 @@ describe("OpencodeGoHandler", () => { index: 0, }, ], - } - yield { + }, + { choices: [{ delta: {}, index: 0 }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - } - }, - })) + }, + ]), + ) const handler = new OpencodeGoHandler(mockOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - const chunks: any[] = [] - for await (const chunk of handler.createMessage("sys", messages)) { - chunks.push(chunk) - } + const chunks = await collectStream(handler.createMessage("sys", messages)) const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") expect(reasoningChunks).toEqual([{ type: "reasoning", text: "primary thought" }]) @@ -324,22 +305,20 @@ describe("OpencodeGoHandler", () => { vitest.mocked(getModels).mockImplementationOnce(async () => ({ "kimi-k2.6": { ...opencodeGoModels["kimi-k2.6"] }, })) - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { choices: [{ delta: { content: "Hi" }, index: 0 }] } - yield { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [{ delta: { content: "Hi" }, index: 0 }] }, + { choices: [{ delta: {}, index: 0 }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - } - }, - })) + }, + ]), + ) const handler = new OpencodeGoHandler({ ...mockOptions, opencodeGoModelId: "kimi-k2.6" }) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - for await (const _chunk of handler.createMessage("sys", messages)) { - void _chunk - } + await collectStream(handler.createMessage("sys", messages)) const callArgs = mockCreate.mock.calls[0][0] as { messages: Array<{ role: string }> } expect(callArgs.messages[0]).toEqual({ role: "system", content: "sys" }) @@ -348,23 +327,20 @@ describe("OpencodeGoHandler", () => { }) it("emits a usage chunk with zeroed tokens when the stream reports no usage", async () => { - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { choices: [{ delta: { content: "Hi" }, index: 0 }] } - yield { + mockCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { choices: [{ delta: { content: "Hi" }, index: 0 }] }, + { choices: [{ delta: {}, index: 0 }], usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, - } - }, - })) + }, + ]), + ) const handler = new OpencodeGoHandler(mockOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - const chunks: any[] = [] - for await (const chunk of handler.createMessage("sys", messages)) { - chunks.push(chunk) - } + const chunks = await collectStream(handler.createMessage("sys", messages)) expect(chunks).toContainEqual({ type: "usage", inputTokens: 0, outputTokens: 0 }) }) @@ -373,9 +349,7 @@ describe("OpencodeGoHandler", () => { const handler = new OpencodeGoHandler({ ...mockOptions, includeMaxTokens: true, modelMaxTokens: 999 }) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - for await (const _chunk of handler.createMessage("sys", messages)) { - void _chunk - } + await collectStream(handler.createMessage("sys", messages)) expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ max_completion_tokens: 999 })) }) @@ -433,9 +407,9 @@ describe("OpencodeGoHandler", () => { } beforeEach(() => { - mockAnthropicCreate.mockImplementation(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { + mockAnthropicCreate.mockImplementation(async () => + asyncStreamFrom([ + { type: "message_start", message: { usage: { @@ -445,37 +419,35 @@ describe("OpencodeGoHandler", () => { cache_read_input_tokens: 3, }, }, - } - yield { + }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" }, - } - yield { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } } - yield { + }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } }, + { type: "content_block_start", index: 1, content_block: { type: "tool_use", id: "toolu_1", name: "read_file", input: {} }, - } - yield { + }, + { type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: '{"path":' }, - } - yield { type: "content_block_stop", index: 1 } - yield { type: "message_delta", usage: { output_tokens: 5 } } - yield { type: "message_stop" } - }, - })) + }, + { type: "content_block_stop", index: 1 }, + { type: "message_delta", usage: { output_tokens: 5 } }, + { type: "message_stop" }, + ]), + ) }) it("routes the request through the Anthropic /v1/messages client, not chat completions", async () => { const handler = new OpencodeGoHandler(anthropicOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - for await (const _chunk of handler.createMessage("sys", messages)) { - void _chunk // drain - } + await collectStream(handler.createMessage("sys", messages)) expect(mockAnthropicCreate).toHaveBeenCalledWith( expect.objectContaining({ @@ -492,10 +464,7 @@ describe("OpencodeGoHandler", () => { const handler = new OpencodeGoHandler(anthropicOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - const chunks: any[] = [] - for await (const chunk of handler.createMessage("sys", messages)) { - chunks.push(chunk) - } + const chunks = await collectStream(handler.createMessage("sys", messages)) expect(chunks).toContainEqual({ type: "text", text: "Hello" }) expect(chunks).toContainEqual({ @@ -539,9 +508,7 @@ describe("OpencodeGoHandler", () => { { role: "user", content: "second" }, ] - for await (const _chunk of handler.createMessage("sys", messages)) { - void _chunk // drain - } + await collectStream(handler.createMessage("sys", messages)) const callArgs = mockAnthropicCreate.mock.calls[0][0] as { system: Array<{ cache_control?: unknown }> @@ -603,9 +570,7 @@ describe("OpencodeGoHandler", () => { const handler = new OpencodeGoHandler(anthropicOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - for await (const _chunk of handler.createMessage("sys", messages)) { - void _chunk - } + await collectStream(handler.createMessage("sys", messages)) const callArgs = mockAnthropicCreate.mock.calls[0][0] as Record // Disable-tools path: with no tools, neither field is sent so the @@ -628,9 +593,7 @@ describe("OpencodeGoHandler", () => { }, ] - for await (const _chunk of handler.createMessage("sys", messages, { taskId: "test-task", tools })) { - void _chunk - } + await collectStream(handler.createMessage("sys", messages, { taskId: "test-task", tools })) const callArgs = mockAnthropicCreate.mock.calls[0][0] as Record expect(Array.isArray(callArgs.tools)).toBe(true) @@ -650,9 +613,7 @@ describe("OpencodeGoHandler", () => { { role: "user", content: "second" }, ] - for await (const _chunk of handler.createMessage("sys", messages)) { - void _chunk - } + await collectStream(handler.createMessage("sys", messages)) const callArgs = mockAnthropicCreate.mock.calls[0][0] as { system: Array<{ cache_control?: unknown }> @@ -676,9 +637,7 @@ describe("OpencodeGoHandler", () => { }, ] - for await (const _chunk of handler.createMessage("sys", messages)) { - void _chunk - } + await collectStream(handler.createMessage("sys", messages)) const callArgs = mockAnthropicCreate.mock.calls[0][0] as { messages: Array<{ content: any }> } const lastUserMsg = callArgs.messages[callArgs.messages.length - 1] @@ -692,9 +651,7 @@ describe("OpencodeGoHandler", () => { const handler = new OpencodeGoHandler(anthropicOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "assistant", content: "only assistant" }] - for await (const _chunk of handler.createMessage("sys", messages)) { - void _chunk - } + await collectStream(handler.createMessage("sys", messages)) const callArgs = mockAnthropicCreate.mock.calls[0][0] as { messages: Array<{ cache_control?: unknown }> @@ -703,41 +660,35 @@ describe("OpencodeGoHandler", () => { }) it("streams thinking content blocks and thinking deltas", async () => { - mockAnthropicCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { type: "message_start", message: { usage: { input_tokens: 5, output_tokens: 0 } } } - // index 0: thinking block (no leading newline at index 0). - yield { + mockAnthropicCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { type: "message_start", message: { usage: { input_tokens: 5, output_tokens: 0 } } }, + { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "initial thought" }, - } - yield { + }, + { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: " more" }, - } - // index 1: text block gets a leading newline separator. - yield { type: "content_block_start", index: 1, content_block: { type: "text", text: "" } } - yield { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "answer" } } - // index 2: a second thinking block also gets a newline separator. - yield { + }, + { type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "answer" } }, + { type: "content_block_start", index: 2, content_block: { type: "thinking", thinking: "second thought" }, - } - yield { type: "message_delta", usage: { output_tokens: 3 } } - yield { type: "message_stop" } - }, - })) + }, + { type: "message_delta", usage: { output_tokens: 3 } }, + { type: "message_stop" }, + ]), + ) const handler = new OpencodeGoHandler(anthropicOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - const chunks: any[] = [] - for await (const chunk of handler.createMessage("sys", messages)) { - chunks.push(chunk) - } + const chunks = await collectStream(handler.createMessage("sys", messages)) // index 0 thinking block (no leading newline separator at index 0). expect(chunks).toContainEqual({ type: "reasoning", text: "initial thought" }) @@ -758,9 +709,7 @@ describe("OpencodeGoHandler", () => { }) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - for await (const _chunk of handler.createMessage("sys", messages)) { - void _chunk - } + await collectStream(handler.createMessage("sys", messages)) expect(mockAnthropicCreate).toHaveBeenCalledWith(expect.objectContaining({ max_tokens: 8192 })) }) @@ -769,36 +718,33 @@ describe("OpencodeGoHandler", () => { const handler = new OpencodeGoHandler({ ...anthropicOptions, includeMaxTokens: true }) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - for await (const _chunk of handler.createMessage("sys", messages)) { - void _chunk - } + await collectStream(handler.createMessage("sys", messages)) // qwen3.7-max maxTokens (65_536) clamped to 20% of 1M context => 65_536. expect(mockAnthropicCreate).toHaveBeenCalledWith(expect.objectContaining({ max_tokens: 65_536 })) }) it("accumulates output tokens across message_delta events into the final cost", async () => { - mockAnthropicCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { type: "message_start", message: { usage: { input_tokens: 10, output_tokens: 0 } } } - yield { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } - yield { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hi" } } - yield { type: "message_delta", usage: { output_tokens: 4 } } - yield { type: "message_delta", usage: { output_tokens: 6 } } - yield { type: "message_stop" } - }, - })) + mockAnthropicCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { type: "message_start", message: { usage: { input_tokens: 10, output_tokens: 0 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hi" } }, + { type: "message_delta", usage: { output_tokens: 4 } }, + { type: "message_delta", usage: { output_tokens: 6 } }, + { type: "message_stop" }, + ]), + ) const handler = new OpencodeGoHandler(anthropicOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - const chunks: any[] = [] - for await (const chunk of handler.createMessage("sys", messages)) { - chunks.push(chunk) - } + const chunks = await collectStream(handler.createMessage("sys", messages)) - const costChunk = chunks.find((c) => c.type === "usage" && c.totalCost !== undefined) - expect(costChunk).toBeDefined() + const costChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c && c.totalCost !== undefined) + if (!costChunk || costChunk.type !== "usage") { + throw new Error("Expected usage chunk with cost") + } // qwen3.7-max: input $2.5/M, output $7.5/M. Accumulated output // tokens (4 + 6 = 10) must feed the cost calc — without the // accumulation fix this would only reflect the 10 input tokens @@ -807,23 +753,20 @@ describe("OpencodeGoHandler", () => { }) it("does not yield a cost chunk when the stream reports no token usage", async () => { - mockAnthropicCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { type: "message_start", message: { usage: { input_tokens: 0, output_tokens: 0 } } } - yield { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } - yield { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hi" } } - yield { type: "message_delta", usage: { output_tokens: 0 } } - yield { type: "message_stop" } - }, - })) + mockAnthropicCreate.mockImplementationOnce(async () => + asyncStreamFrom([ + { type: "message_start", message: { usage: { input_tokens: 0, output_tokens: 0 } } }, + { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, + { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hi" } }, + { type: "message_delta", usage: { output_tokens: 0 } }, + { type: "message_stop" }, + ]), + ) const handler = new OpencodeGoHandler(anthropicOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - const chunks: any[] = [] - for await (const chunk of handler.createMessage("sys", messages)) { - chunks.push(chunk) - } + const chunks = await collectStream(handler.createMessage("sys", messages)) expect(chunks.some((c) => c.type === "usage" && c.totalCost !== undefined)).toBe(false) }) @@ -842,9 +785,7 @@ describe("OpencodeGoHandler", () => { const handler = new OpencodeGoHandler(anthropicOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] await expect(async () => { - for await (const _chunk of handler.createMessage("sys", messages)) { - void _chunk - } + await collectStream(handler.createMessage("sys", messages)) }).rejects.toThrow("Opencode Go completion error: rate limited") }) }) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 7558fb6d57..53ea19fefb 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -186,7 +186,7 @@ }, "api/providers/__tests__/mimo.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 29 + "count": 18 } }, "api/providers/__tests__/minimax.spec.ts": { @@ -241,7 +241,7 @@ }, "api/providers/__tests__/opencode-go.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 11 + "count": 3 } }, "api/providers/__tests__/openrouter.spec.ts": {