diff --git a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts index cbb2a91333..ec87e29ab6 100644 --- a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts +++ b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts @@ -6,6 +6,7 @@ import OpenAI from "openai" import type { ModelInfo } from "@roo-code/types" import { BaseOpenAiCompatibleProvider } from "../base-openai-compatible-provider" +import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" // Create mock functions const mockCreate = vi.fn() @@ -61,33 +62,16 @@ describe("BaseOpenAiCompatibleProvider", () => { describe("TagMatcher reasoning tags", () => { it("should handle reasoning tags () from stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: "Let me think" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: " about this" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: "The answer is 42" } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { choices: [{ delta: { content: "Let me think" } }] }, + { choices: [{ delta: { content: " about this" } }] }, + { choices: [{ delta: { content: "The answer is 42" } }] }, + ]), + ) const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(stream) // TagMatcher yields chunks as they're processed expect(chunks).toEqual([ @@ -98,32 +82,15 @@ describe("BaseOpenAiCompatibleProvider", () => { }) it("should handle reasoning tags () from stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: "Deep thought" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: " here" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: "Result: 42" } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { choices: [{ delta: { content: "Deep thought" } }] }, + { choices: [{ delta: { content: " here" } }] }, + { choices: [{ delta: { content: "Result: 42" } }] }, + ]), + ) const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(stream) expect(chunks).toEqual([ { type: "reasoning", text: "Deep thought" }, { type: "reasoning", text: " here" }, @@ -132,32 +99,15 @@ describe("BaseOpenAiCompatibleProvider", () => { }) it("should not close tag with tag", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: "Thinking" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: " but closing with wrong tag" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: " still thinking" } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { choices: [{ delta: { content: "Thinking" } }] }, + { choices: [{ delta: { content: " but closing with wrong tag" } }] }, + { choices: [{ delta: { content: " still thinking" } }] }, + ]), + ) const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(stream) // The tag should be treated as text since it doesn't match the active tag expect(chunks).toEqual([ { type: "reasoning", text: "Thinking" }, @@ -167,33 +117,16 @@ describe("BaseOpenAiCompatibleProvider", () => { }) it("should handle complete tag in a single chunk", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: "Regular text before " } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: "Complete thought" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: " regular text after" } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { choices: [{ delta: { content: "Regular text before " } }] }, + { choices: [{ delta: { content: "Complete thought" } }] }, + { choices: [{ delta: { content: " regular text after" } }] }, + ]), + ) const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(stream) // When a complete tag arrives in one chunk, TagMatcher may not parse it // This test documents the actual behavior @@ -202,54 +135,27 @@ describe("BaseOpenAiCompatibleProvider", () => { }) it("should handle incomplete tag at end of stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: "Incomplete thought" } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([{ choices: [{ delta: { content: "Incomplete thought" } }] }]), + ) const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(stream) // TagMatcher should flush incomplete reasoning content on stream end expect(chunks).toContainEqual({ type: "reasoning", text: "Incomplete thought" }) }) it("should handle text without any tags", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: "Just regular text" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: " without reasoning" } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { choices: [{ delta: { content: "Just regular text" } }] }, + { choices: [{ delta: { content: " without reasoning" } }] }, + ]), + ) const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(stream) expect(chunks).toEqual([ { type: "text", text: "Just regular text" }, @@ -258,33 +164,16 @@ describe("BaseOpenAiCompatibleProvider", () => { }) it("should handle tags that start at beginning of stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: "reasoning" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: " content" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: " normal text" } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { choices: [{ delta: { content: "reasoning" } }] }, + { choices: [{ delta: { content: " content" } }] }, + { choices: [{ delta: { content: " normal text" } }] }, + ]), + ) const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(stream) expect(chunks).toEqual([ { type: "reasoning", text: "reasoning" }, @@ -296,37 +185,17 @@ describe("BaseOpenAiCompatibleProvider", () => { describe("reasoning_content field", () => { it("should preserve whitespace-only reasoning_content so streamed boundaries survive concatenation", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { reasoning_content: "\n" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { reasoning_content: " " } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { reasoning_content: "\t\n " } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: "Regular content" } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { choices: [{ delta: { reasoning_content: "\n" } }] }, + { choices: [{ delta: { reasoning_content: " " } }] }, + { choices: [{ delta: { reasoning_content: "\t\n " } }] }, + { choices: [{ delta: { content: "Regular content" } }] }, + ]), + ) const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(stream) expect(chunks).toEqual([ { type: "reasoning", text: "\n" }, @@ -337,33 +206,16 @@ describe("BaseOpenAiCompatibleProvider", () => { }) it("should yield non-empty reasoning_content", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { reasoning_content: "Thinking step 1" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { reasoning_content: "\n" } }] }, - }) - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { reasoning_content: "Thinking step 2" } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { choices: [{ delta: { reasoning_content: "Thinking step 1" } }] }, + { choices: [{ delta: { reasoning_content: "\n" } }] }, + { choices: [{ delta: { reasoning_content: "Thinking step 2" } }] }, + ]), + ) const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(stream) expect(chunks).toEqual([ { type: "reasoning", text: "Thinking step 1" }, @@ -373,25 +225,12 @@ describe("BaseOpenAiCompatibleProvider", () => { }) it("should handle reasoning_content with leading/trailing whitespace", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { reasoning_content: " content with spaces " } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([{ choices: [{ delta: { reasoning_content: " content with spaces " } }] }]), + ) const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(stream) // Should yield reasoning with spaces (only pure whitespace is filtered) expect(chunks).toEqual([{ type: "reasoning", text: " content with spaces " }]) @@ -400,15 +239,7 @@ describe("BaseOpenAiCompatibleProvider", () => { describe("Basic functionality", () => { it("should create stream with correct parameters", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } - }) + mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) const systemPrompt = "Test system prompt" const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message" }] @@ -429,22 +260,14 @@ describe("BaseOpenAiCompatibleProvider", () => { }) it("should yield usage data from stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [{ delta: {} }], - usage: { prompt_tokens: 100, completion_tokens: 50 }, - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { + choices: [{ delta: {} }], + usage: { prompt_tokens: 100, completion_tokens: 50 }, + }, + ]), + ) const stream = handler.createMessage("system prompt", []) const firstChunk = await stream.next() @@ -456,67 +279,50 @@ describe("BaseOpenAiCompatibleProvider", () => { describe("Tool call handling", () => { it("should yield tool_call_end events when finish_reason is tool_calls", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { + choices: [ + { + delta: { + tool_calls: [ { - delta: { - tool_calls: [ - { - index: 0, - id: "call_123", - function: { name: "test_tool", arguments: '{"arg":' }, - }, - ], - }, + index: 0, + id: "call_123", + function: { name: "test_tool", arguments: '{"arg":' }, }, ], }, - }) - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ + }, + ], + }, + { + choices: [ + { + delta: { + tool_calls: [ { - delta: { - tool_calls: [ - { - index: 0, - function: { arguments: '"value"}' }, - }, - ], - }, + index: 0, + function: { arguments: '"value"}' }, }, ], }, - }) - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: {}, - finish_reason: "tool_calls", - }, - ], - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) + }, + ], + }, + { + choices: [ + { + delta: {}, + finish_reason: "tool_calls", + }, + ], + }, + ]), + ) const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(stream) // Should have tool_call_partial and tool_call_end const partialChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") @@ -528,55 +334,41 @@ describe("BaseOpenAiCompatibleProvider", () => { }) it("should yield multiple tool_call_end events for parallel tool calls", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { + choices: [ + { + delta: { + tool_calls: [ { - delta: { - tool_calls: [ - { - index: 0, - id: "call_001", - function: { name: "tool_a", arguments: "{}" }, - }, - { - index: 1, - id: "call_002", - function: { name: "tool_b", arguments: "{}" }, - }, - ], - }, + index: 0, + id: "call_001", + function: { name: "tool_a", arguments: "{}" }, }, - ], - }, - }) - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ { - delta: {}, - finish_reason: "tool_calls", + index: 1, + id: "call_002", + function: { name: "tool_b", arguments: "{}" }, }, ], }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) + }, + ], + }, + { + choices: [ + { + delta: {}, + finish_reason: "tool_calls", + }, + ], + }, + ]), + ) const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(stream) const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") expect(endChunks).toHaveLength(2) @@ -584,32 +376,21 @@ describe("BaseOpenAiCompatibleProvider", () => { }) it("should not yield tool_call_end when finish_reason is not tool_calls", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: { content: "Some text response" }, - finish_reason: "stop", - }, - ], - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } - }) + mockCreate.mockImplementationOnce(() => + asyncStreamFrom([ + { + choices: [ + { + delta: { content: "Some text response" }, + finish_reason: "stop", + }, + ], + }, + ]), + ) const stream = handler.createMessage("system prompt", []) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(stream) const endChunks = chunks.filter((chunk) => chunk.type === "tool_call_end") expect(endChunks).toHaveLength(0) diff --git a/src/api/providers/__tests__/openai-usage-tracking.spec.ts b/src/api/providers/__tests__/openai-usage-tracking.spec.ts index ddb58ba1cc..15fccf5abb 100644 --- a/src/api/providers/__tests__/openai-usage-tracking.spec.ts +++ b/src/api/providers/__tests__/openai-usage-tracking.spec.ts @@ -4,6 +4,7 @@ import { Anthropic } from "@anthropic-ai/sdk" import { ApiHandlerOptions } from "../../../shared/api" import { OpenAiHandler } from "../openai" +import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" const mockCreate = vitest.fn() @@ -34,54 +35,47 @@ vitest.mock("openai", () => { } // Return a stream with multiple chunks that include usage metrics - return { - [Symbol.asyncIterator]: async function* () { - // First chunk with partial usage - yield { - choices: [ - { - delta: { content: "Test " }, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 2, - total_tokens: 12, + return asyncStreamFrom([ + { + choices: [ + { + delta: { content: "Test " }, + index: 0, }, - } - - // Second chunk with updated usage - yield { - choices: [ - { - delta: { content: "response" }, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 4, - total_tokens: 14, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 2, + total_tokens: 12, + }, + }, + { + choices: [ + { + delta: { content: "response" }, + index: 0, }, - } - - // Final chunk with complete usage - yield { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 4, + total_tokens: 14, + }, + }, + { + choices: [ + { + delta: {}, + index: 0, }, - } + ], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + }, }, - } + ]) }), }, }, @@ -120,10 +114,7 @@ describe("OpenAiHandler with usage tracking fix", () => { it("should only yield usage metrics once at the end of the stream", async () => { const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(stream) // Check we have text chunks const textChunks = chunks.filter((chunk) => chunk.type === "text") @@ -142,9 +133,7 @@ describe("OpenAiHandler with usage tracking fix", () => { // Check the usage chunk is the last one reported from the API const lastChunk = chunks[chunks.length - 1] - expect(lastChunk.type).toBe("usage") - expect(lastChunk.inputTokens).toBe(10) - expect(lastChunk.outputTokens).toBe(5) + expect(lastChunk).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5 }) }) it("should handle case where usage is only in the final chunk", async () => { @@ -158,38 +147,28 @@ describe("OpenAiHandler with usage tracking fix", () => { } } - return { - [Symbol.asyncIterator]: async function* () { - // First chunk with no usage - yield { - choices: [{ delta: { content: "Test " }, index: 0 }], - usage: null, - } - - // Second chunk with no usage - yield { - choices: [{ delta: { content: "response" }, index: 0 }], - usage: null, - } - - // Final chunk with usage data - yield { - choices: [{ delta: {}, index: 0 }], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } + return asyncStreamFrom([ + { + choices: [{ delta: { content: "Test " }, index: 0 }], + usage: null, }, - } + { + choices: [{ delta: { content: "response" }, index: 0 }], + usage: null, + }, + { + choices: [{ delta: {}, index: 0 }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + }, + }, + ]) }) const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(stream) // Check usage metrics const usageChunks = chunks.filter((chunk) => chunk.type === "usage") @@ -212,25 +191,20 @@ describe("OpenAiHandler with usage tracking fix", () => { } } - return { - [Symbol.asyncIterator]: async function* () { - yield { - choices: [{ delta: { content: "Test response" }, index: 0 }], - usage: null, - } - yield { - choices: [{ delta: {}, index: 0 }], - usage: null, - } + return asyncStreamFrom([ + { + choices: [{ delta: { content: "Test response" }, index: 0 }], + usage: null, }, - } + { + choices: [{ delta: {}, index: 0 }], + usage: null, + }, + ]) }) const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const chunks = await collectStream(stream) // Check we don't have any usage chunks const usageChunks = chunks.filter((chunk) => chunk.type === "usage") diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index cdab485080..b9a5765ba7 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -234,11 +234,6 @@ "count": 5 } }, - "api/providers/__tests__/openai-usage-tracking.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 3 - } - }, "api/providers/__tests__/openai.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 25