From b8ef352808fff9acf0a236833981b2314f6390c0 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:27:58 -0500 Subject: [PATCH] feat: migrate OpenAI Native provider to @ai-sdk/openai (#11330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: migrate OpenAI Native provider to @ai-sdk/openai Replace the raw OpenAI SDK (openai) usage in OpenAiNativeHandler with @ai-sdk/openai and AI SDK's streamText/generateText, following the same pattern used by other migrated providers (Groq, xAI, Fireworks, etc.). Key changes: - Use createOpenAI from @ai-sdk/openai with provider.responses() for the Responses API - Use streamText/generateText from ai for streaming and completions - Pass OpenAI-specific features via providerOptions.openai (store, reasoningEffort, reasoningSummary, textVerbosity, serviceTier, promptCacheRetention, parallelToolCalls, include) - Capture responseId, serviceTier, and encrypted reasoning content from providerMetadata after streaming - Preserve getEncryptedContent() and getResponseId() for Task.ts - Preserve service tier pricing adjustment in cost calculation - Mark as isAiSdkProvider: true - Eliminate ~1100 lines of manual SSE parsing, raw fetch fallback, and event handling code - Rewrite all 3 test files to use AI SDK mocking pattern * fix: remove non-existent cacheWriteTokens from providerMetadata The OpenAI Responses API does not report cache write tokens separately. Remove the reference to providerMetadata?.openai?.cacheWriteTokens which does not exist in the @ai-sdk/openai provider metadata schema. * fix: filter standalone encrypted reasoning items from messages Task.ts buildCleanConversationHistory injects standalone reasoning items with { type: 'reasoning', encrypted_content: '...' } into the messages array. These have no 'role' property and would be silently dropped by convertToAiSdkMessages. Filter them explicitly to prevent confusion. Note: Encrypted reasoning content round-tripping for stateless continuity is a known limitation of the AI SDK migration. The @ai-sdk/openai provider does not support injecting raw Responses API reasoning items. Plain-text reasoning round-tripping works correctly via isAiSdkProvider(). * fix: restore reasoning round-trip for OpenAI Responses API via AI SDK - Strip plain-text reasoning blocks from assistant messages before convertToAiSdkMessages() to eliminate 'Non-OpenAI reasoning parts' warnings from @ai-sdk/openai Responses provider - Re-inject encrypted reasoning items as AI SDK reasoning parts with providerOptions.openai.itemId and reasoningEncryptedContent, restoring reasoning continuity that was silently broken after the migration - Restructure createMessage() into a 5-step pipeline: collect → filter → strip → convert → inject - Add 21 new tests for both plain-text stripping and encrypted reasoning injection --------- Co-authored-by: Hannes Rudolph --- pnpm-lock.yaml | 17 +- .../__tests__/openai-native-reasoning.spec.ts | 565 +++++ .../__tests__/openai-native-tools.spec.ts | 372 ++- .../__tests__/openai-native-usage.spec.ts | 671 +++--- .../providers/__tests__/openai-native.spec.ts | 2064 ++++++----------- src/api/providers/openai-native.ts | 1844 ++++----------- src/package.json | 1 + 7 files changed, 2216 insertions(+), 3318 deletions(-) create mode 100644 src/api/providers/__tests__/openai-native-reasoning.spec.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 734c32fca4..a9a0c08650 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -770,6 +770,9 @@ importers: '@ai-sdk/mistral': specifier: ^3.0.19 version: 3.0.19(zod@3.25.76) + '@ai-sdk/openai': + specifier: ^3.0.26 + version: 3.0.26(zod@3.25.76) '@ai-sdk/xai': specifier: ^3.0.48 version: 3.0.48(zod@3.25.76) @@ -1486,6 +1489,12 @@ packages: peerDependencies: zod: 3.25.76 + '@ai-sdk/openai@3.0.26': + resolution: {integrity: sha512-W/hiwxIfG29IO0Fob1HwWpFssMsNrxWoX8A7DwNGOtKArDBmJNuGzQeU/k0Fnh8WyvZEnfxkjO4oXkSXfVBayg==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + '@ai-sdk/provider-utils@3.0.5': resolution: {integrity: sha512-HliwB/yzufw3iwczbFVE2Fiwf1XqROB/I6ng8EKUsPM5+2wnIa8f4VbljZcDx+grhFrPV+PnRZH7zBqi8WZM7Q==} engines: {node: '>=18'} @@ -11107,6 +11116,12 @@ snapshots: '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) zod: 3.25.76 + '@ai-sdk/openai@3.0.26(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + zod: 3.25.76 + '@ai-sdk/provider-utils@3.0.5(zod@3.25.76)': dependencies: '@ai-sdk/provider': 2.0.0 @@ -14930,7 +14945,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: diff --git a/src/api/providers/__tests__/openai-native-reasoning.spec.ts b/src/api/providers/__tests__/openai-native-reasoning.spec.ts new file mode 100644 index 0000000000..ebad23ee11 --- /dev/null +++ b/src/api/providers/__tests__/openai-native-reasoning.spec.ts @@ -0,0 +1,565 @@ +// npx vitest run api/providers/__tests__/openai-native-reasoning.spec.ts + +import type { Anthropic } from "@anthropic-ai/sdk" +import type { ModelMessage } from "ai" + +import { + stripPlainTextReasoningBlocks, + collectEncryptedReasoningItems, + injectEncryptedReasoning, + type EncryptedReasoningItem, +} from "../openai-native" + +describe("OpenAI Native reasoning helpers", () => { + // ─────────────────────────────────────────────────────────── + // stripPlainTextReasoningBlocks + // ─────────────────────────────────────────────────────────── + describe("stripPlainTextReasoningBlocks", () => { + it("passes through user messages unchanged", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + const result = stripPlainTextReasoningBlocks(messages) + expect(result).toEqual(messages) + }) + + it("passes through assistant messages with only text blocks", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "assistant", content: [{ type: "text", text: "Hi there" }] }, + ] + const result = stripPlainTextReasoningBlocks(messages) + expect(result).toEqual(messages) + }) + + it("passes through string-content assistant messages", () => { + const messages: Anthropic.Messages.MessageParam[] = [{ role: "assistant", content: "Hello" }] + const result = stripPlainTextReasoningBlocks(messages) + expect(result).toEqual(messages) + }) + + it("strips plain-text reasoning blocks from assistant content", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Let me think...", + } as unknown as Anthropic.Messages.ContentBlockParam, + { type: "text", text: "The answer is 42" }, + ], + }, + ] + const result = stripPlainTextReasoningBlocks(messages) + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([{ type: "text", text: "The answer is 42" }]) + }) + + it("removes assistant messages whose content becomes empty after filtering", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "Thinking only...", + } as unknown as Anthropic.Messages.ContentBlockParam, + ], + }, + ] + const result = stripPlainTextReasoningBlocks(messages) + expect(result).toHaveLength(0) + }) + + it("preserves tool_use blocks alongside stripped reasoning", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "reasoning", text: "Thinking..." } as unknown as Anthropic.Messages.ContentBlockParam, + { type: "tool_use", id: "call_1", name: "read_file", input: { path: "a.ts" } }, + ], + }, + ] + const result = stripPlainTextReasoningBlocks(messages) + expect(result).toHaveLength(1) + expect(result[0].content).toEqual([ + { type: "tool_use", id: "call_1", name: "read_file", input: { path: "a.ts" } }, + ]) + }) + + it("does NOT strip blocks that have encrypted_content (those are not plain-text reasoning)", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "summary", + encrypted_content: "abc123", + } as unknown as Anthropic.Messages.ContentBlockParam, + { type: "text", text: "Response" }, + ], + }, + ] + const result = stripPlainTextReasoningBlocks(messages) + expect(result).toHaveLength(1) + // Both blocks should remain + expect(result[0].content).toHaveLength(2) + }) + + it("handles multiple messages correctly", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Q1" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "Think1" } as unknown as Anthropic.Messages.ContentBlockParam, + { type: "text", text: "A1" }, + ], + }, + { role: "user", content: [{ type: "text", text: "Q2" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "Think2" } as unknown as Anthropic.Messages.ContentBlockParam, + { type: "text", text: "A2" }, + ], + }, + ] + const result = stripPlainTextReasoningBlocks(messages) + expect(result).toHaveLength(4) + expect(result[1].content).toEqual([{ type: "text", text: "A1" }]) + expect(result[3].content).toEqual([{ type: "text", text: "A2" }]) + }) + }) + + // ─────────────────────────────────────────────────────────── + // collectEncryptedReasoningItems + // ─────────────────────────────────────────────────────────── + describe("collectEncryptedReasoningItems", () => { + it("returns empty array when no encrypted reasoning items exist", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + { role: "assistant", content: [{ type: "text", text: "Hi" }] }, + ] + const result = collectEncryptedReasoningItems(messages) + expect(result).toEqual([]) + }) + + it("collects a single encrypted reasoning item", () => { + const messages = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + { + type: "reasoning", + id: "rs_abc", + encrypted_content: "encrypted_data_1", + summary: [{ type: "summary_text", text: "I thought about it" }], + }, + { role: "assistant", content: [{ type: "text", text: "Hi" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const result = collectEncryptedReasoningItems(messages) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + id: "rs_abc", + encrypted_content: "encrypted_data_1", + summary: [{ type: "summary_text", text: "I thought about it" }], + originalIndex: 1, + }) + }) + + it("collects multiple encrypted reasoning items with correct indices", () => { + const messages = [ + { role: "user", content: [{ type: "text", text: "Q1" }] }, + { + type: "reasoning", + id: "rs_1", + encrypted_content: "enc_1", + summary: [{ type: "summary_text", text: "Summary 1" }], + }, + { role: "assistant", content: [{ type: "text", text: "A1" }] }, + { role: "user", content: [{ type: "text", text: "Q2" }] }, + { + type: "reasoning", + id: "rs_2", + encrypted_content: "enc_2", + summary: [{ type: "summary_text", text: "Summary 2" }], + }, + { role: "assistant", content: [{ type: "text", text: "A2" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const result = collectEncryptedReasoningItems(messages) + expect(result).toHaveLength(2) + expect(result[0].id).toBe("rs_1") + expect(result[0].originalIndex).toBe(1) + expect(result[1].id).toBe("rs_2") + expect(result[1].originalIndex).toBe(4) + }) + + it("ignores messages that have type 'reasoning' but no encrypted_content", () => { + const messages = [ + { type: "reasoning", id: "rs_x", text: "plain reasoning" }, + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const result = collectEncryptedReasoningItems(messages) + expect(result).toEqual([]) + }) + + it("handles items without summary", () => { + const messages = [ + { + type: "reasoning", + id: "rs_no_summary", + encrypted_content: "enc_data", + }, + { role: "assistant", content: [{ type: "text", text: "Hi" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const result = collectEncryptedReasoningItems(messages) + expect(result).toHaveLength(1) + expect(result[0].summary).toBeUndefined() + }) + }) + + // ─────────────────────────────────────────────────────────── + // injectEncryptedReasoning + // ─────────────────────────────────────────────────────────── + describe("injectEncryptedReasoning", () => { + it("does nothing when encryptedItems is empty", () => { + const aiSdkMessages: ModelMessage[] = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: [{ type: "text", text: "Hi" }] }, + ] + const original = JSON.parse(JSON.stringify(aiSdkMessages)) + injectEncryptedReasoning(aiSdkMessages, [], []) + expect(aiSdkMessages).toEqual(original) + }) + + it("injects a single encrypted reasoning part into the next assistant message", () => { + // Original messages: [user, encrypted_reasoning, assistant] + const originalMessages = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + { + type: "reasoning", + id: "rs_abc", + encrypted_content: "enc_123", + summary: [{ type: "summary_text", text: "I considered the question" }], + }, + { role: "assistant", content: [{ type: "text", text: "Hi there" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + // AI SDK messages (after filtering encrypted items + converting) + const aiSdkMessages: ModelMessage[] = [ + { role: "user", content: "Hello" }, + { role: "assistant", content: [{ type: "text", text: "Hi there" }] }, + ] + + const encryptedItems: EncryptedReasoningItem[] = [ + { + id: "rs_abc", + encrypted_content: "enc_123", + summary: [{ type: "summary_text", text: "I considered the question" }], + originalIndex: 1, + }, + ] + + injectEncryptedReasoning(aiSdkMessages, encryptedItems, originalMessages) + + const assistantMsg = aiSdkMessages[1] as Record + const content = assistantMsg.content as unknown[] + expect(content).toHaveLength(2) + + // First part should be the injected reasoning + const reasoningPart = content[0] as Record + expect(reasoningPart.type).toBe("reasoning") + expect(reasoningPart.text).toBe("I considered the question") + + const providerOptions = reasoningPart.providerOptions as Record> + expect(providerOptions.openai.itemId).toBe("rs_abc") + expect(providerOptions.openai.reasoningEncryptedContent).toBe("enc_123") + + // Second part should be the original text + const textPart = content[1] as Record + expect(textPart.type).toBe("text") + expect(textPart.text).toBe("Hi there") + }) + + it("handles multiple encrypted reasoning items across different assistant messages", () => { + const originalMessages = [ + { role: "user", content: [{ type: "text", text: "Q1" }] }, + { + type: "reasoning", + id: "rs_1", + encrypted_content: "enc_1", + summary: [{ type: "summary_text", text: "Thought 1" }], + }, + { role: "assistant", content: [{ type: "text", text: "A1" }] }, + { role: "user", content: [{ type: "text", text: "Q2" }] }, + { + type: "reasoning", + id: "rs_2", + encrypted_content: "enc_2", + summary: [{ type: "summary_text", text: "Thought 2" }], + }, + { role: "assistant", content: [{ type: "text", text: "A2" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const aiSdkMessages: ModelMessage[] = [ + { role: "user", content: "Q1" }, + { role: "assistant", content: [{ type: "text", text: "A1" }] }, + { role: "user", content: "Q2" }, + { role: "assistant", content: [{ type: "text", text: "A2" }] }, + ] + + const encryptedItems: EncryptedReasoningItem[] = [ + { + id: "rs_1", + encrypted_content: "enc_1", + summary: [{ type: "summary_text", text: "Thought 1" }], + originalIndex: 1, + }, + { + id: "rs_2", + encrypted_content: "enc_2", + summary: [{ type: "summary_text", text: "Thought 2" }], + originalIndex: 4, + }, + ] + + injectEncryptedReasoning(aiSdkMessages, encryptedItems, originalMessages) + + // First assistant message + const content1 = (aiSdkMessages[1] as Record).content as unknown[] + expect(content1).toHaveLength(2) + expect((content1[0] as Record).type).toBe("reasoning") + expect( + ((content1[0] as Record).providerOptions as Record>) + .openai.itemId, + ).toBe("rs_1") + + // Second assistant message + const content2 = (aiSdkMessages[3] as Record).content as unknown[] + expect(content2).toHaveLength(2) + expect((content2[0] as Record).type).toBe("reasoning") + expect( + ((content2[0] as Record).providerOptions as Record>) + .openai.itemId, + ).toBe("rs_2") + }) + + it("joins multiple summary texts with newlines", () => { + const originalMessages = [ + { role: "user", content: [{ type: "text", text: "Hi" }] }, + { + type: "reasoning", + id: "rs_multi", + encrypted_content: "enc_multi", + summary: [ + { type: "summary_text", text: "First thought" }, + { type: "summary_text", text: "Second thought" }, + ], + }, + { role: "assistant", content: [{ type: "text", text: "Response" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const aiSdkMessages: ModelMessage[] = [ + { role: "user", content: "Hi" }, + { role: "assistant", content: [{ type: "text", text: "Response" }] }, + ] + + const encryptedItems: EncryptedReasoningItem[] = [ + { + id: "rs_multi", + encrypted_content: "enc_multi", + summary: [ + { type: "summary_text", text: "First thought" }, + { type: "summary_text", text: "Second thought" }, + ], + originalIndex: 1, + }, + ] + + injectEncryptedReasoning(aiSdkMessages, encryptedItems, originalMessages) + + const content = (aiSdkMessages[1] as Record).content as unknown[] + const reasoningPart = content[0] as Record + expect(reasoningPart.text).toBe("First thought\nSecond thought") + }) + + it("uses empty string when summary is undefined", () => { + const originalMessages = [ + { role: "user", content: [{ type: "text", text: "Hi" }] }, + { + type: "reasoning", + id: "rs_nosummary", + encrypted_content: "enc_nosummary", + }, + { role: "assistant", content: [{ type: "text", text: "Response" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const aiSdkMessages: ModelMessage[] = [ + { role: "user", content: "Hi" }, + { role: "assistant", content: [{ type: "text", text: "Response" }] }, + ] + + const encryptedItems: EncryptedReasoningItem[] = [ + { + id: "rs_nosummary", + encrypted_content: "enc_nosummary", + summary: undefined, + originalIndex: 1, + }, + ] + + injectEncryptedReasoning(aiSdkMessages, encryptedItems, originalMessages) + + const content = (aiSdkMessages[1] as Record).content as unknown[] + const reasoningPart = content[0] as Record + expect(reasoningPart.text).toBe("") + }) + + it("handles consecutive encrypted items before the same assistant message", () => { + // Two encrypted reasoning items before one assistant message + const originalMessages = [ + { role: "user", content: [{ type: "text", text: "Hi" }] }, + { + type: "reasoning", + id: "rs_a", + encrypted_content: "enc_a", + summary: [{ type: "summary_text", text: "Step A" }], + }, + { + type: "reasoning", + id: "rs_b", + encrypted_content: "enc_b", + summary: [{ type: "summary_text", text: "Step B" }], + }, + { role: "assistant", content: [{ type: "text", text: "Done" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const aiSdkMessages: ModelMessage[] = [ + { role: "user", content: "Hi" }, + { role: "assistant", content: [{ type: "text", text: "Done" }] }, + ] + + const encryptedItems: EncryptedReasoningItem[] = [ + { + id: "rs_a", + encrypted_content: "enc_a", + summary: [{ type: "summary_text", text: "Step A" }], + originalIndex: 1, + }, + { + id: "rs_b", + encrypted_content: "enc_b", + summary: [{ type: "summary_text", text: "Step B" }], + originalIndex: 2, + }, + ] + + injectEncryptedReasoning(aiSdkMessages, encryptedItems, originalMessages) + + const content = (aiSdkMessages[1] as Record).content as unknown[] + // Both reasoning parts should be injected before the text + expect(content).toHaveLength(3) + expect((content[0] as Record).type).toBe("reasoning") + expect( + ((content[0] as Record).providerOptions as Record>) + .openai.itemId, + ).toBe("rs_a") + expect((content[1] as Record).type).toBe("reasoning") + expect( + ((content[1] as Record).providerOptions as Record>) + .openai.itemId, + ).toBe("rs_b") + expect((content[2] as Record).type).toBe("text") + }) + + it("handles tool messages splitting (user messages with tool_results create extra tool-role messages)", () => { + // Original: [user_with_tool_result, encrypted_reasoning, assistant] + // After filtering: [user_with_tool_result, assistant] + // AI SDK: [tool, user, assistant] (tool_result split into tool + user messages) + const originalMessages = [ + { + role: "user", + content: [ + { type: "tool_result", tool_use_id: "call_1", content: "result" }, + { type: "text", text: "Continue" }, + ], + }, + { + type: "reasoning", + id: "rs_tool", + encrypted_content: "enc_tool", + summary: [{ type: "summary_text", text: "Thought after tool" }], + }, + { role: "assistant", content: [{ type: "text", text: "OK" }] }, + ] as unknown as Anthropic.Messages.MessageParam[] + + // AI SDK messages after conversion (tool_result splits into tool + user) + const aiSdkMessages: ModelMessage[] = [ + { + role: "tool", + content: [ + { type: "tool-result", toolCallId: "call_1", toolName: "unknown_tool", result: "result" }, + ], + } as unknown as ModelMessage, + { role: "user", content: [{ type: "text", text: "Continue" }] }, + { role: "assistant", content: [{ type: "text", text: "OK" }] }, + ] + + const encryptedItems: EncryptedReasoningItem[] = [ + { + id: "rs_tool", + encrypted_content: "enc_tool", + summary: [{ type: "summary_text", text: "Thought after tool" }], + originalIndex: 1, + }, + ] + + injectEncryptedReasoning(aiSdkMessages, encryptedItems, originalMessages) + + // The assistant message (index 2) should have the reasoning injected + const content = (aiSdkMessages[2] as Record).content as unknown[] + expect(content).toHaveLength(2) + expect((content[0] as Record).type).toBe("reasoning") + expect( + ((content[0] as Record).providerOptions as Record>) + .openai.itemId, + ).toBe("rs_tool") + }) + + it("gracefully handles encrypted items with no following assistant message", () => { + const originalMessages = [ + { role: "user", content: [{ type: "text", text: "Hi" }] }, + { + type: "reasoning", + id: "rs_orphan", + encrypted_content: "enc_orphan", + }, + ] as unknown as Anthropic.Messages.MessageParam[] + + const aiSdkMessages: ModelMessage[] = [{ role: "user", content: "Hi" }] + + const encryptedItems: EncryptedReasoningItem[] = [ + { + id: "rs_orphan", + encrypted_content: "enc_orphan", + summary: undefined, + originalIndex: 1, + }, + ] + + // Should not throw + expect(() => { + injectEncryptedReasoning(aiSdkMessages, encryptedItems, originalMessages) + }).not.toThrow() + + // User message unchanged + expect(aiSdkMessages).toHaveLength(1) + expect(aiSdkMessages[0].role).toBe("user") + }) + }) +}) diff --git a/src/api/providers/__tests__/openai-native-tools.spec.ts b/src/api/providers/__tests__/openai-native-tools.spec.ts index e0746f792e..d873b7457b 100644 --- a/src/api/providers/__tests__/openai-native-tools.spec.ts +++ b/src/api/providers/__tests__/openai-native-tools.spec.ts @@ -1,8 +1,8 @@ +// npx vitest run api/providers/__tests__/openai-native-tools.spec.ts + import OpenAI from "openai" import { OpenAiHandler } from "../openai" -import { OpenAiNativeHandler } from "../openai-native" -import type { ApiHandlerOptions } from "../../../shared/api" describe("OpenAiHandler native tools", () => { it("includes tools in request when tools are provided via metadata (regression test)", async () => { @@ -68,35 +68,103 @@ describe("OpenAiHandler native tools", () => { }) }) -describe("OpenAiNativeHandler MCP tool schema handling", () => { - it("should add additionalProperties: false to MCP tools while keeping strict: false", async () => { - let capturedRequestBody: any +// Use vi.hoisted to define mock functions for AI SDK +const { mockStreamText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: vi.fn(), + } +}) + +vi.mock("@ai-sdk/openai", () => ({ + createOpenAI: vi.fn(() => { + const provider = vi.fn(() => ({ + modelId: "gpt-4o", + provider: "openai", + })) + ;(provider as any).responses = vi.fn(() => ({ + modelId: "gpt-4o", + provider: "openai.responses", + })) + return provider + }), +})) + +import { OpenAiNativeHandler } from "../openai-native" +import type { ApiHandlerOptions } from "../../../shared/api" + +describe("OpenAiNativeHandler tool handling with AI SDK", () => { + function createMockStreamReturn() { + async function* mockFullStream() { + yield { type: "text-delta", text: "test" } + } + + return { + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + } + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should pass tools through convertToolsForOpenAI and convertToolsForAiSdk to streamText", async () => { + mockStreamText.mockReturnValue(createMockStreamReturn()) const handler = new OpenAiNativeHandler({ openAiNativeApiKey: "test-key", apiModelId: "gpt-4o", } as ApiHandlerOptions) - // Mock the responses API call - const mockClient = { - responses: { - create: vi.fn().mockImplementation((body: any) => { - capturedRequestBody = body - return { - [Symbol.asyncIterator]: async function* () { - yield { - type: "response.done", - response: { - output: [{ type: "message", content: [{ type: "output_text", text: "test" }] }], - usage: { input_tokens: 10, output_tokens: 5 }, - }, - } + const tools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file from the filesystem", + parameters: { + type: "object", + properties: { + path: { type: "string", description: "File path" }, }, - } - }), + }, + }, }, + ] + + const stream = handler.createMessage("system prompt", [], { + taskId: "test-task-id", + tools, + }) + for await (const _ of stream) { + // consume } - ;(handler as any).client = mockClient + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.objectContaining({ + read_file: expect.anything(), + }), + }), + ) + }) + + it("should pass MCP tools to streamText", async () => { + mockStreamText.mockReturnValue(createMockStreamReturn()) + + const handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-4o", + } as ApiHandlerOptions) const mcpTools: OpenAI.Chat.ChatCompletionTool[] = [ { @@ -119,120 +187,36 @@ describe("OpenAiNativeHandler MCP tool schema handling", () => { taskId: "test-task-id", tools: mcpTools, }) - - // Consume the stream for await (const _ of stream) { - // Just consume + // consume } - // Verify the request body - expect(capturedRequestBody.tools).toBeDefined() - expect(capturedRequestBody.tools.length).toBe(1) - - const tool = capturedRequestBody.tools[0] - expect(tool.name).toBe("mcp--github--get_me") - expect(tool.strict).toBe(false) // MCP tools should have strict: false - expect(tool.parameters.additionalProperties).toBe(false) // Should have additionalProperties: false - expect(tool.parameters.required).toEqual(["token"]) // Should preserve original required array + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + tools: expect.objectContaining({ + "mcp--github--get_me": expect.anything(), + }), + }), + ) }) - it("should add additionalProperties: false and required array to non-MCP tools with strict: true", async () => { - let capturedRequestBody: any + it("should pass both regular and MCP tools together", async () => { + mockStreamText.mockReturnValue(createMockStreamReturn()) const handler = new OpenAiNativeHandler({ openAiNativeApiKey: "test-key", apiModelId: "gpt-4o", } as ApiHandlerOptions) - // Mock the responses API call - const mockClient = { - responses: { - create: vi.fn().mockImplementation((body: any) => { - capturedRequestBody = body - return { - [Symbol.asyncIterator]: async function* () { - yield { - type: "response.done", - response: { - output: [{ type: "message", content: [{ type: "output_text", text: "test" }] }], - usage: { input_tokens: 10, output_tokens: 5 }, - }, - } - }, - } - }), - }, - } - ;(handler as any).client = mockClient - - const regularTools: OpenAI.Chat.ChatCompletionTool[] = [ + const mixedTools: OpenAI.Chat.ChatCompletionTool[] = [ { type: "function", function: { name: "read_file", - description: "Read a file from the filesystem", - parameters: { - type: "object", - properties: { - path: { type: "string", description: "File path" }, - encoding: { type: "string", description: "File encoding" }, - }, - }, + description: "Read a file", + parameters: { type: "object", properties: { path: { type: "string" } } }, }, }, - ] - - const stream = handler.createMessage("system prompt", [], { - taskId: "test-task-id", - tools: regularTools, - }) - - // Consume the stream - for await (const _ of stream) { - // Just consume - } - - // Verify the request body - expect(capturedRequestBody.tools).toBeDefined() - expect(capturedRequestBody.tools.length).toBe(1) - - const tool = capturedRequestBody.tools[0] - expect(tool.name).toBe("read_file") - expect(tool.strict).toBe(true) // Non-MCP tools should have strict: true - expect(tool.parameters.additionalProperties).toBe(false) // Should have additionalProperties: false - expect(tool.parameters.required).toEqual(["path", "encoding"]) // Should have all properties as required - }) - - it("should recursively add additionalProperties: false to nested objects in MCP tools", async () => { - let capturedRequestBody: any - - const handler = new OpenAiNativeHandler({ - openAiNativeApiKey: "test-key", - apiModelId: "gpt-4o", - } as ApiHandlerOptions) - - // Mock the responses API call - const mockClient = { - responses: { - create: vi.fn().mockImplementation((body: any) => { - capturedRequestBody = body - return { - [Symbol.asyncIterator]: async function* () { - yield { - type: "response.done", - response: { - output: [{ type: "message", content: [{ type: "output_text", text: "test" }] }], - usage: { input_tokens: 10, output_tokens: 5 }, - }, - } - }, - } - }), - }, - } - ;(handler as any).client = mockClient - - const mcpToolsWithNestedObjects: OpenAI.Chat.ChatCompletionTool[] = [ { type: "function", function: { @@ -240,24 +224,8 @@ describe("OpenAiNativeHandler MCP tool schema handling", () => { description: "Create a Linear issue", parameters: { type: "object", - properties: { - title: { type: "string" }, - metadata: { - type: "object", - properties: { - priority: { type: "number" }, - labels: { - type: "array", - items: { - type: "object", - properties: { - name: { type: "string" }, - }, - }, - }, - }, - }, - }, + properties: { title: { type: "string" } }, + required: ["title"], }, }, }, @@ -265,72 +233,64 @@ describe("OpenAiNativeHandler MCP tool schema handling", () => { const stream = handler.createMessage("system prompt", [], { taskId: "test-task-id", - tools: mcpToolsWithNestedObjects, + tools: mixedTools, }) - - // Consume the stream for await (const _ of stream) { - // Just consume + // consume } - // Verify the request body - const tool = capturedRequestBody.tools[0] - expect(tool.strict).toBe(false) // MCP tool should have strict: false - expect(tool.parameters.additionalProperties).toBe(false) // Root level - expect(tool.parameters.properties.metadata.additionalProperties).toBe(false) // Nested object - expect(tool.parameters.properties.metadata.properties.labels.items.additionalProperties).toBe(false) // Array items + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.tools).toBeDefined() + expect(callArgs.tools.read_file).toBeDefined() + expect(callArgs.tools["mcp--linear--create_issue"]).toBeDefined() }) - it("should handle missing call_id and name in tool_call_arguments.delta by using pending tool identity", async () => { + it("should pass parallelToolCalls in provider options", async () => { + mockStreamText.mockReturnValue(createMockStreamReturn()) + const handler = new OpenAiNativeHandler({ openAiNativeApiKey: "test-key", apiModelId: "gpt-4o", } as ApiHandlerOptions) - const mockClient = { - responses: { - create: vi.fn().mockImplementation(() => { - return { - [Symbol.asyncIterator]: async function* () { - // 1. Emit output_item.added with tool identity - yield { - type: "response.output_item.added", - item: { - type: "function_call", - call_id: "call_123", - name: "read_file", - arguments: "", - }, - } - - // 2. Emit tool_call_arguments.delta WITHOUT identity (just args) - yield { - type: "response.function_call_arguments.delta", - delta: '{"path":', - } - - // 3. Emit another delta - yield { - type: "response.function_call_arguments.delta", - delta: '"/tmp/test.txt"}', - } - - // 4. Emit output_item.done - yield { - type: "response.output_item.done", - item: { - type: "function_call", - call_id: "call_123", - name: "read_file", - arguments: '{"path":"/tmp/test.txt"}', - }, - } - }, - } - }), - }, + const stream = handler.createMessage("system prompt", [], { + taskId: "test-task-id", + parallelToolCalls: false, + }) + for await (const _ of stream) { + // consume } - ;(handler as any).client = mockClient + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: expect.objectContaining({ + openai: expect.objectContaining({ + parallelToolCalls: false, + }), + }), + }), + ) + }) + + it("should handle tool call streaming events", async () => { + async function* mockFullStream() { + yield { type: "tool-input-start", id: "call_abc", toolName: "read_file" } + yield { type: "tool-input-delta", id: "call_abc", delta: '{"path":' } + yield { type: "tool-input-delta", id: "call_abc", delta: '"/tmp/test.txt"}' } + yield { type: "tool-input-end", id: "call_abc" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const handler = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-4o", + } as ApiHandlerOptions) const stream = handler.createMessage("system prompt", [], { taskId: "test-task-id", @@ -338,25 +298,19 @@ describe("OpenAiNativeHandler MCP tool schema handling", () => { const chunks: any[] = [] for await (const chunk of stream) { - if (chunk.type === "tool_call_partial") { - chunks.push(chunk) - } + chunks.push(chunk) } - expect(chunks.length).toBe(2) - expect(chunks[0]).toEqual({ - type: "tool_call_partial", - index: 0, - id: "call_123", // Should be filled from pendingToolCallId - name: "read_file", // Should be filled from pendingToolCallName - arguments: '{"path":', - }) - expect(chunks[1]).toEqual({ - type: "tool_call_partial", - index: 0, - id: "call_123", - name: "read_file", - arguments: '"/tmp/test.txt"}', - }) + const toolStart = chunks.filter((c) => c.type === "tool_call_start") + expect(toolStart).toHaveLength(1) + expect(toolStart[0].id).toBe("call_abc") + expect(toolStart[0].name).toBe("read_file") + + const toolDeltas = chunks.filter((c) => c.type === "tool_call_delta") + expect(toolDeltas).toHaveLength(2) + + const toolEnd = chunks.filter((c) => c.type === "tool_call_end") + expect(toolEnd).toHaveLength(1) + expect(toolEnd[0].id).toBe("call_abc") }) }) diff --git a/src/api/providers/__tests__/openai-native-usage.spec.ts b/src/api/providers/__tests__/openai-native-usage.spec.ts index 48e1c26877..5742d7282b 100644 --- a/src/api/providers/__tests__/openai-native-usage.spec.ts +++ b/src/api/providers/__tests__/openai-native-usage.spec.ts @@ -1,422 +1,355 @@ -import { describe, it, expect, beforeEach } from "vitest" -import { OpenAiNativeHandler } from "../openai-native" +// npx vitest run api/providers/__tests__/openai-native-usage.spec.ts + +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) + +vi.mock("@ai-sdk/openai", () => ({ + createOpenAI: vi.fn(() => { + const provider = vi.fn(() => ({ + modelId: "gpt-4.1", + provider: "openai", + })) + ;(provider as any).responses = vi.fn(() => ({ + modelId: "gpt-4.1", + provider: "openai.responses", + })) + return provider + }), +})) + +import type { Anthropic } from "@anthropic-ai/sdk" + import { openAiNativeModels } from "@roo-code/types" -describe("OpenAiNativeHandler - normalizeUsage", () => { +import { OpenAiNativeHandler } from "../openai-native" +import type { ApiHandlerOptions } from "../../../shared/api" + +describe("OpenAiNativeHandler - usage metrics", () => { let handler: OpenAiNativeHandler - const mockModel = { - id: "gpt-4o", - info: openAiNativeModels["gpt-4o"], - } + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello!" }] beforeEach(() => { handler = new OpenAiNativeHandler({ openAiNativeApiKey: "test-key", + apiModelId: "gpt-4.1", + }) + vi.clearAllMocks() + }) + + describe("basic token counts", () => { + it("should handle basic input and output tokens", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].inputTokens).toBe(100) + expect(usageChunks[0].outputTokens).toBe(50) + }) + + it("should handle zero tokens", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].inputTokens).toBe(0) + expect(usageChunks[0].outputTokens).toBe(0) }) }) - describe("detailed token shapes (Responses API)", () => { - it("should handle detailed shapes with cached and miss tokens", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, - input_tokens_details: { - cached_tokens: 30, - cache_miss_tokens: 70, - }, + describe("cache metrics", () => { + it("should handle cached input tokens from usage details", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } } - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 30, - cacheWriteTokens: 0, // miss tokens are NOT cache writes + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + details: { + cachedInputTokens: 30, + }, + }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].cacheReadTokens).toBe(30) }) - it("should derive total input tokens from details when totals are missing", () => { - const usage = { - // No input_tokens or prompt_tokens - output_tokens: 50, - input_tokens_details: { - cached_tokens: 30, - cache_miss_tokens: 70, - }, + it("should handle no cache information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } } - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, // Derived from 30 + 70 - outputTokens: 50, - cacheReadTokens: 30, - cacheWriteTokens: 0, // miss tokens are NOT cache writes + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 50, outputTokens: 25 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) - }) - it("should handle prompt_tokens_details variant", () => { - const usage = { - prompt_tokens: 100, - completion_tokens: 50, - prompt_tokens_details: { - cached_tokens: 30, - cache_miss_tokens: 70, - }, + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 30, - cacheWriteTokens: 0, // miss tokens are NOT cache writes - }) - }) - - it("should handle cache_creation_input_tokens for actual cache writes", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, - cache_creation_input_tokens: 20, - input_tokens_details: { - cached_tokens: 30, - cache_miss_tokens: 50, // 50 miss + 30 cached + 20 creation = 100 total - }, - } - - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 30, - cacheWriteTokens: 20, // Actual cache writes from cache_creation_input_tokens - }) - }) - - it("should handle reasoning tokens in output details", () => { - const usage = { - input_tokens: 100, - output_tokens: 150, - output_tokens_details: { - reasoning_tokens: 50, - }, - } - - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 150, - reasoningTokens: 50, - }) + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].cacheReadTokens).toBeUndefined() + expect(usageChunks[0].cacheWriteTokens).toBeUndefined() }) }) - describe("legacy field names", () => { - it("should handle cache_creation_input_tokens and cache_read_input_tokens", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, - cache_creation_input_tokens: 20, - cache_read_input_tokens: 30, + describe("reasoning tokens", () => { + it("should handle reasoning tokens in usage details", async () => { + async function* mockFullStream() { + yield { type: "reasoning-delta", text: "thinking..." } + yield { type: "text-delta", text: "answer" } } - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 30, - cacheWriteTokens: 20, + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + details: { + reasoningTokens: 30, + }, + }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) - }) - it("should handle cache_write_tokens and cache_read_tokens", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, - cache_write_tokens: 20, - cache_read_tokens: 30, + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 30, - cacheWriteTokens: 20, - }) + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].reasoningTokens).toBe(30) }) - it("should handle cached_tokens field", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, - cached_tokens: 30, + it("should omit reasoning tokens when not present", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "answer" } } - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 30, + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) - }) - it("should handle prompt_tokens and completion_tokens", () => { - const usage = { - prompt_tokens: 100, - completion_tokens: 50, + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 0, - cacheWriteTokens: 0, - }) - }) - }) - - describe("SSE-only events", () => { - it("should handle SSE events with minimal usage data", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, - } - - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 0, - cacheWriteTokens: 0, - }) - }) - - it("should handle SSE events with no cache information", () => { - const usage = { - prompt_tokens: 100, - completion_tokens: 50, - } - - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 0, - cacheWriteTokens: 0, - }) - }) - }) - - describe("edge cases", () => { - it("should handle undefined usage", () => { - const result = (handler as any).normalizeUsage(undefined, mockModel) - expect(result).toBeUndefined() - }) - - it("should handle null usage", () => { - const result = (handler as any).normalizeUsage(null, mockModel) - expect(result).toBeUndefined() - }) - - it("should handle empty usage object", () => { - const usage = {} - - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - }) - }) - - it("should handle missing details but with cache fields", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, - cache_read_input_tokens: 30, - // No input_tokens_details - } - - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 30, - cacheWriteTokens: 0, - }) - }) - - it("should use all available cache information with proper fallbacks", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, - cached_tokens: 20, // Legacy field (will be used as fallback) - input_tokens_details: { - cached_tokens: 30, // Detailed shape - cache_miss_tokens: 70, - }, - } - - const result = (handler as any).normalizeUsage(usage, mockModel) - - // The implementation uses nullish coalescing, so it will use the first non-nullish value: - // cache_read_input_tokens ?? cache_read_tokens ?? cached_tokens ?? cachedFromDetails - // Since none of the first two exist, it falls back to cached_tokens (20) before cachedFromDetails - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 20, // From cached_tokens (legacy field comes before details in fallback chain) - cacheWriteTokens: 0, // miss tokens are NOT cache writes - }) - }) - - it("should use detailed shapes when legacy fields are not present", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, - // No cached_tokens legacy field - input_tokens_details: { - cached_tokens: 30, - cache_miss_tokens: 70, - }, - } - - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 30, // From details since no legacy field exists - cacheWriteTokens: 0, // miss tokens are NOT cache writes - }) - }) - - it("should handle totals missing with only partial details", () => { - const usage = { - // No input_tokens or prompt_tokens - output_tokens: 50, - input_tokens_details: { - cached_tokens: 30, - // No cache_miss_tokens - }, - } - - const result = (handler as any).normalizeUsage(usage, mockModel) - - expect(result).toMatchObject({ - type: "usage", - inputTokens: 30, // Derived from cached_tokens only - outputTokens: 50, - cacheReadTokens: 30, - cacheWriteTokens: 0, - }) - }) - }) - - describe("OpenAiNativeHandler - prompt cache retention", () => { - let handler: OpenAiNativeHandler - - beforeEach(() => { - handler = new OpenAiNativeHandler({ - openAiNativeApiKey: "test-key", - }) - }) - - const buildRequestBodyForModel = (modelId: string) => { - // Force the handler to use the requested model ID - ;(handler as any).options.apiModelId = modelId - const model = handler.getModel() - // Minimal formatted input/systemPrompt/verbosity/metadata for building the body - return (handler as any).buildRequestBody(model, [], "", model.verbosity, undefined, undefined) - } - - it("should set prompt_cache_retention=24h for gpt-5.1 models that support prompt caching", () => { - const body = buildRequestBodyForModel("gpt-5.1") - expect(body.prompt_cache_retention).toBe("24h") - - const codexBody = buildRequestBodyForModel("gpt-5.1-codex") - expect(codexBody.prompt_cache_retention).toBe("24h") - - const codexMiniBody = buildRequestBodyForModel("gpt-5.1-codex-mini") - expect(codexMiniBody.prompt_cache_retention).toBe("24h") - }) - - it("should not set prompt_cache_retention for non-gpt-5.1 models even if they support prompt caching", () => { - const body = buildRequestBodyForModel("gpt-5") - expect(body.prompt_cache_retention).toBeUndefined() - - const fourOBody = buildRequestBodyForModel("gpt-4o") - expect(fourOBody.prompt_cache_retention).toBeUndefined() - }) - - it("should not set prompt_cache_retention when the model does not support prompt caching", () => { - const modelId = "codex-mini-latest" - expect(openAiNativeModels[modelId as keyof typeof openAiNativeModels].supportsPromptCache).toBe(false) - - const body = buildRequestBodyForModel(modelId) - expect(body.prompt_cache_retention).toBeUndefined() + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].reasoningTokens).toBeUndefined() }) }) describe("cost calculation", () => { - it("should pass total input tokens to calculateApiCostOpenAI", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, - cache_read_input_tokens: 30, - cache_creation_input_tokens: 20, + it("should include totalCost in usage metrics", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } } - const result = (handler as any).normalizeUsage(usage, mockModel) + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 1000, + outputTokens: 500, + }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) - expect(result).toHaveProperty("totalCost") - expect(result.totalCost).toBeGreaterThan(0) - // calculateApiCostOpenAI handles subtracting cache tokens internally - // It will compute: 100 - 30 - 20 = 50 uncached input tokens + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(typeof usageChunks[0].totalCost).toBe("number") + expect(usageChunks[0].totalCost).toBeGreaterThanOrEqual(0) }) - it("should handle cost calculation with no cache reads", () => { - const usage = { - input_tokens: 100, - output_tokens: 50, + it("should handle all details together", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } } - const result = (handler as any).normalizeUsage(usage, mockModel) + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 200, + outputTokens: 100, + details: { + cachedInputTokens: 50, + reasoningTokens: 25, + }, + }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) - expect(result).toHaveProperty("totalCost") - expect(result.totalCost).toBeGreaterThan(0) - // Cost should be calculated with full input tokens since no cache reads + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].inputTokens).toBe(200) + expect(usageChunks[0].outputTokens).toBe(100) + expect(usageChunks[0].cacheReadTokens).toBe(50) + expect(usageChunks[0].reasoningTokens).toBe(25) + expect(typeof usageChunks[0].totalCost).toBe("number") + }) + }) + + describe("prompt cache retention", () => { + it("should set promptCacheRetention=24h for gpt-5.1 models that support prompt caching", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const h = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "gpt-5.1", + }) + + const stream = h.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + const modelInfo = openAiNativeModels["gpt-5.1"] + if (modelInfo.supportsPromptCache && modelInfo.promptCacheRetention === "24h") { + expect(callArgs.providerOptions.openai.promptCacheRetention).toBe("24h") + } + }) + + it("should not set promptCacheRetention for non-gpt-5.1 models", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions.openai.promptCacheRetention).toBeUndefined() + }) + + it("should not set promptCacheRetention when the model does not support prompt caching", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + // o3-mini doesn't support prompt caching + const h = new OpenAiNativeHandler({ + openAiNativeApiKey: "test-key", + apiModelId: "o3-mini-high", + }) + + const stream = h.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions.openai.promptCacheRetention).toBeUndefined() }) }) }) diff --git a/src/api/providers/__tests__/openai-native.spec.ts b/src/api/providers/__tests__/openai-native.spec.ts index ac50e6b0a1..e7981520c3 100644 --- a/src/api/providers/__tests__/openai-native.spec.ts +++ b/src/api/providers/__tests__/openai-native.spec.ts @@ -1,37 +1,42 @@ // npx vitest run api/providers/__tests__/openai-native.spec.ts -const mockCaptureException = vitest.fn() - -vitest.mock("@roo-code/telemetry", () => ({ - TelemetryService: { - instance: { - captureException: (...args: unknown[]) => mockCaptureException(...args), - }, - }, +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), })) -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" - -import { ApiProviderError } from "@roo-code/types" - -import { OpenAiNativeHandler } from "../openai-native" -import { ApiHandlerOptions } from "../../../shared/api" - -// Mock OpenAI client - now everything uses Responses API -const mockResponsesCreate = vitest.fn() - -vitest.mock("openai", () => { +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - __esModule: true, - default: vitest.fn().mockImplementation(() => ({ - responses: { - create: mockResponsesCreate, - }, - })), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) +vi.mock("@ai-sdk/openai", () => ({ + createOpenAI: vi.fn(() => { + const provider = vi.fn(() => ({ + modelId: "gpt-4.1", + provider: "openai", + })) + // Add .responses() method that returns the same mock model + ;(provider as any).responses = vi.fn(() => ({ + modelId: "gpt-4.1", + provider: "openai.responses", + })) + return provider + }), +})) + +import type { Anthropic } from "@anthropic-ai/sdk" + +import { openAiNativeDefaultModelId, openAiNativeModels } from "@roo-code/types" + +import { OpenAiNativeHandler } from "../openai-native" +import type { ApiHandlerOptions } from "../../../shared/api" + describe("OpenAiNativeHandler", () => { let handler: OpenAiNativeHandler let mockOptions: ApiHandlerOptions @@ -39,7 +44,12 @@ describe("OpenAiNativeHandler", () => { const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", - content: "Hello!", + content: [ + { + type: "text" as const, + text: "Hello!", + }, + ], }, ] @@ -49,19 +59,7 @@ describe("OpenAiNativeHandler", () => { openAiNativeApiKey: "test-api-key", } handler = new OpenAiNativeHandler(mockOptions) - mockResponsesCreate.mockClear() - mockCaptureException.mockClear() - // Clear fetch mock if it exists - if ((global as any).fetch) { - delete (global as any).fetch - } - }) - - afterEach(() => { - // Clean up fetch mock - if ((global as any).fetch) { - delete (global as any).fetch - } + vi.clearAllMocks() }) describe("constructor", () => { @@ -78,215 +76,82 @@ describe("OpenAiNativeHandler", () => { expect(handlerWithoutKey).toBeInstanceOf(OpenAiNativeHandler) }) - it("should pass undefined baseURL when openAiNativeBaseUrl is empty string", () => { - ;(OpenAI as unknown as ReturnType).mockClear() - new OpenAiNativeHandler({ + it("should default enableResponsesReasoningSummary to true", () => { + const opts: ApiHandlerOptions = { apiModelId: "gpt-4.1", openAiNativeApiKey: "test-key", - openAiNativeBaseUrl: "", - }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: undefined })) - }) - - it("should pass custom baseURL when openAiNativeBaseUrl is a valid URL", () => { - ;(OpenAI as unknown as ReturnType).mockClear() - new OpenAiNativeHandler({ - apiModelId: "gpt-4.1", - openAiNativeApiKey: "test-key", - openAiNativeBaseUrl: "https://custom-openai.example.com/v1", - }) - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ baseURL: "https://custom-openai.example.com/v1" }), - ) - }) - }) - - describe("createMessage", () => { - it("should handle streaming responses via Responses API", async () => { - // Mock fetch for Responses API fallback - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode('data: {"type":"response.text.delta","delta":"Test"}\n\n'), - ) - controller.enqueue( - new TextEncoder().encode('data: {"type":"response.text.delta","delta":" response"}\n\n'), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.done","response":{"usage":{"prompt_tokens":10,"completion_tokens":2}}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail so it falls back to fetch - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) } - - expect(chunks.length).toBeGreaterThan(0) - const textChunks = chunks.filter((chunk) => chunk.type === "text") - expect(textChunks).toHaveLength(2) - expect(textChunks[0].text).toBe("Test") - expect(textChunks[1].text).toBe(" response") + const h = new OpenAiNativeHandler(opts) + expect(h).toBeInstanceOf(OpenAiNativeHandler) + // enableResponsesReasoningSummary should have been set to true in constructor + expect(opts.enableResponsesReasoningSummary).toBe(true) }) - it("should handle API errors", async () => { - // Mock fetch to return error - const mockFetch = vitest.fn().mockResolvedValue({ - ok: false, - status: 500, - text: async () => "Internal Server Error", - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - const stream = handler.createMessage(systemPrompt, messages) - await expect(async () => { - for await (const _chunk of stream) { - // Should not reach here - } - }).rejects.toThrow("OpenAI service error") - }) - }) - - describe("completePrompt", () => { - it("should handle non-streaming completion using Responses API", async () => { - // Mock the responses.create method to return a non-streaming response - mockResponsesCreate.mockResolvedValue({ - output: [ - { - type: "message", - content: [ - { - type: "output_text", - text: "This is the completion response", - }, - ], - }, - ], - }) - - const result = await handler.completePrompt("Test prompt") - - expect(result).toBe("This is the completion response") - expect(mockResponsesCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: "gpt-4.1", - stream: false, - store: false, - input: [ - { - role: "user", - content: [{ type: "input_text", text: "Test prompt" }], - }, - ], - }), - expect.objectContaining({ - signal: expect.any(Object), - }), - ) - }) - - it("should handle SDK errors in completePrompt", async () => { - // Mock SDK to throw an error - mockResponsesCreate.mockRejectedValue(new Error("API Error")) - - await expect(handler.completePrompt("Test prompt")).rejects.toThrow( - "OpenAI Native completion error: API Error", - ) - }) - - it("should return empty string when no text in response", async () => { - // Mock the responses.create method to return a response without text - mockResponsesCreate.mockResolvedValue({ - output: [ - { - type: "message", - content: [], - }, - ], - }) - - const result = await handler.completePrompt("Test prompt") - - expect(result).toBe("") + it("should preserve explicit enableResponsesReasoningSummary=false", () => { + const opts: ApiHandlerOptions = { + apiModelId: "gpt-4.1", + openAiNativeApiKey: "test-key", + enableResponsesReasoningSummary: false, + } + new OpenAiNativeHandler(opts) + expect(opts.enableResponsesReasoningSummary).toBe(false) }) }) describe("getModel", () => { - it("should return model info", () => { + it("should return model info for gpt-4.1", () => { const modelInfo = handler.getModel() - expect(modelInfo.id).toBe(mockOptions.apiModelId) + expect(modelInfo.id).toBe("gpt-4.1") expect(modelInfo.info).toBeDefined() expect(modelInfo.info.maxTokens).toBe(32768) expect(modelInfo.info.contextWindow).toBe(1047576) }) - it("should handle undefined model ID", () => { + it("should handle undefined model ID and return default", () => { const handlerWithoutModel = new OpenAiNativeHandler({ openAiNativeApiKey: "test-api-key", }) const modelInfo = handlerWithoutModel.getModel() - expect(modelInfo.id).toBe("gpt-5.1-codex-max") // Default model + expect(modelInfo.id).toBe(openAiNativeDefaultModelId) expect(modelInfo.info).toBeDefined() }) + + it("should fall back to default model for invalid model ID", () => { + const handlerWithInvalidModel = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "invalid-model", + }) + const model = handlerWithInvalidModel.getModel() + expect(model.id).toBe(openAiNativeDefaultModelId) + }) + + it("should strip o3-mini suffix from model ID", () => { + const handlerO3 = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "o3-mini-high", + }) + const model = handlerO3.getModel() + expect(model.id).toBe("o3-mini") + }) + + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("maxTokens") + }) }) - describe("GPT-5 models", () => { - it("should handle GPT-5 model with Responses API", async () => { - // Mock fetch for Responses API - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - // Simulate actual GPT-5 Responses API SSE stream format - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.created","response":{"id":"test","status":"in_progress"}}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"Hello"}}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":" world"}}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.done","response":{"usage":{"prompt_tokens":10,"completion_tokens":2}}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any + describe("createMessage", () => { + it("should handle streaming responses", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + yield { type: "text-delta", text: " response" } + } - // Mock SDK to fail so it uses fetch - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1", + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 2 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) const stream = handler.createMessage(systemPrompt, messages) @@ -295,67 +160,22 @@ describe("OpenAiNativeHandler", () => { chunks.push(chunk) } - // Verify Responses API is called with correct parameters - expect(mockFetch).toHaveBeenCalledWith( - "https://api.openai.com/v1/responses", - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - "Content-Type": "application/json", - Authorization: "Bearer test-api-key", - }), - body: expect.any(String), - }), - ) - const body1 = (mockFetch.mock.calls[0][1] as any).body as string - const parsedBody = JSON.parse(body1) - expect(parsedBody.model).toBe("gpt-5.1") - expect(parsedBody.instructions).toBe("You are a helpful assistant.") - // Now using structured format with content arrays (no system prompt in input; it's provided via `instructions`) - expect(parsedBody.input).toEqual([ - { - role: "user", - content: [{ type: "input_text", text: "Hello!" }], - }, - ]) - expect(parsedBody.reasoning?.effort).toBe("medium") - expect(parsedBody.reasoning?.summary).toBe("auto") - expect(parsedBody.text?.verbosity).toBe("medium") - // GPT-5 models don't include temperature - expect(parsedBody.temperature).toBeUndefined() - expect(parsedBody.max_output_tokens).toBeDefined() - - // Verify the streamed content const textChunks = chunks.filter((c) => c.type === "text") expect(textChunks).toHaveLength(2) - expect(textChunks[0].text).toBe("Hello") - expect(textChunks[1].text).toBe(" world") + expect(textChunks[0].text).toBe("Test") + expect(textChunks[1].text).toBe(" response") }) - it("should handle GPT-5-mini model with Responses API", async () => { - // Mock fetch for Responses API - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"Response"}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5-mini-2025-08-07", + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) const stream = handler.createMessage(systemPrompt, messages) @@ -364,39 +184,29 @@ describe("OpenAiNativeHandler", () => { chunks.push(chunk) } - // Verify correct model and default parameters - expect(mockFetch).toHaveBeenCalledWith( - "https://api.openai.com/v1/responses", - expect.objectContaining({ - body: expect.stringContaining('"model":"gpt-5-mini-2025-08-07"'), - }), - ) + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(20) }) - it("should handle GPT-5-nano model with Responses API", async () => { - // Mock fetch for Responses API - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"Nano response"}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() + it("should handle cached tokens in usage details", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + details: { + cachedInputTokens: 30, + reasoningTokens: 10, }, }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5-nano-2025-08-07", + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) const stream = handler.createMessage(systemPrompt, messages) @@ -405,83 +215,25 @@ describe("OpenAiNativeHandler", () => { chunks.push(chunk) } - // Verify correct model - expect(mockFetch).toHaveBeenCalledWith( - "https://api.openai.com/v1/responses", - expect.objectContaining({ - body: expect.stringContaining('"model":"gpt-5-nano-2025-08-07"'), - }), - ) + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].inputTokens).toBe(100) + expect(usageChunks[0].outputTokens).toBe(50) + expect(usageChunks[0].cacheReadTokens).toBe(30) + expect(usageChunks[0].reasoningTokens).toBe(10) }) - it("should support verbosity control for GPT-5", async () => { - // Mock fetch for Responses API - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"Low verbosity"}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1", - verbosity: "low", // Set verbosity through options - }) - - // Create a message to verify verbosity is passed - const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) + it("should handle reasoning stream parts", async () => { + async function* mockFullStream() { + yield { type: "reasoning-delta", text: "thinking..." } + yield { type: "text-delta", text: "answer" } } - // Verify that verbosity is passed in the request - expect(mockFetch).toHaveBeenCalledWith( - "https://api.openai.com/v1/responses", - expect.objectContaining({ - body: expect.stringContaining('"verbosity":"low"'), - }), - ) - }) - - it("should support minimal reasoning effort for GPT-5", async () => { - // Mock fetch for Responses API - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"Minimal effort"}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1", - reasoningEffort: "minimal" as any, // GPT-5 supports minimal + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) const stream = handler.createMessage(systemPrompt, messages) @@ -490,1093 +242,693 @@ describe("OpenAiNativeHandler", () => { chunks.push(chunk) } - // With minimal reasoning effort, the model should pass it through - expect(mockFetch).toHaveBeenCalledWith( - "https://api.openai.com/v1/responses", - expect.objectContaining({ - body: expect.stringContaining('"effort":"minimal"'), - }), - ) + const reasoningChunks = chunks.filter((c) => c.type === "reasoning") + expect(reasoningChunks).toHaveLength(1) + expect(reasoningChunks[0].text).toBe("thinking...") + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("answer") }) - it("should support xhigh reasoning effort for GPT-5.1 Codex Max", async () => { - // Mock fetch for Responses API - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"XHigh effort"}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any + it("should handle tool calls in stream", async () => { + async function* mockFullStream() { + yield { type: "tool-input-start", id: "call_1", toolName: "test_tool" } + yield { type: "tool-input-delta", id: "call_1", delta: '{"arg":"val"}' } + yield { type: "tool-input-end", id: "call_1" } + } - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1-codex-max", - reasoningEffort: "xhigh", + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) const stream = handler.createMessage(systemPrompt, messages) - for await (const _chunk of stream) { - // drain + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - expect(mockFetch).toHaveBeenCalledWith( - "https://api.openai.com/v1/responses", - expect.objectContaining({ - body: expect.stringContaining('"effort":"xhigh"'), - }), - ) + expect(chunks.some((c) => c.type === "tool_call_start")).toBe(true) + expect(chunks.some((c) => c.type === "tool_call_delta")).toBe(true) + expect(chunks.some((c) => c.type === "tool_call_end")).toBe(true) }) - it("should omit reasoning when selection is 'disable'", async () => { - // Mock fetch for Responses API - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"No reasoning"}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), + it("should handle API errors", async () => { + const error = new Error("API Error") + ;(error as any).name = "AI_APICallError" + ;(error as any).status = 500 + + // Suppress unhandled rejection warnings for dangling promises + const rejectedUsage = Promise.reject(error) + const rejectedMeta = Promise.reject(error) + const rejectedContent = Promise.reject(error) + rejectedUsage.catch(() => {}) + rejectedMeta.catch(() => {}) + rejectedContent.catch(() => {}) + + async function* errorStream() { + yield { type: "text-delta", text: "" } + throw error + } + + mockStreamText.mockReturnValue({ + fullStream: errorStream(), + usage: rejectedUsage, + providerMetadata: rejectedMeta, + content: rejectedContent, }) - global.fetch = mockFetch as any - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) + const stream = handler.createMessage(systemPrompt, messages) + await expect(async () => { + for await (const _chunk of stream) { + // drain + } + }).rejects.toThrow("OpenAI Native") + }) - const handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1", - reasoningEffort: "disable" as any, + it("should pass system prompt to streamText", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) const stream = handler.createMessage(systemPrompt, messages) for await (const _ of stream) { - // drain + // consume } - const bodyStr = (mockFetch.mock.calls[0][1] as any).body as string - const parsed = JSON.parse(bodyStr) - expect(parsed.reasoning).toBeUndefined() - expect(parsed.include).toBeUndefined() - }) - - it("should support low reasoning effort for GPT-5", async () => { - // Mock fetch for Responses API - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"Low effort response"}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1", - reasoningEffort: "low", - }) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Should use Responses API with low reasoning effort - expect(mockFetch).toHaveBeenCalledWith( - "https://api.openai.com/v1/responses", + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - body: expect.any(String), + system: systemPrompt, }), ) - const body2 = (mockFetch.mock.calls[0][1] as any).body as string - const parsedBody = JSON.parse(body2) - expect(parsedBody.model).toBe("gpt-5.1") - expect(parsedBody.reasoning?.effort).toBe("low") - expect(parsedBody.reasoning?.summary).toBe("auto") - expect(parsedBody.text?.verbosity).toBe("medium") - // GPT-5 models don't include temperature - expect(parsedBody.temperature).toBeUndefined() - expect(parsedBody.max_output_tokens).toBeDefined() }) - it("should support both verbosity and reasoning effort together for GPT-5", async () => { - // Mock fetch for Responses API - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"High verbosity minimal effort"}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any + it("should pass temperature when model supports it", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1", - verbosity: "high", - reasoningEffort: "minimal" as any, + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) + for await (const _ of stream) { + // consume } - // Should use Responses API with both parameters - expect(mockFetch).toHaveBeenCalledWith( - "https://api.openai.com/v1/responses", + // gpt-4.1 supports temperature + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - body: expect.any(String), + temperature: expect.any(Number), }), ) - const body3 = (mockFetch.mock.calls[0][1] as any).body as string - const parsedBody = JSON.parse(body3) - expect(parsedBody.model).toBe("gpt-5.1") - expect(parsedBody.reasoning?.effort).toBe("minimal") - expect(parsedBody.reasoning?.summary).toBe("auto") - expect(parsedBody.text?.verbosity).toBe("high") - // GPT-5 models don't include temperature - expect(parsedBody.temperature).toBeUndefined() - expect(parsedBody.max_output_tokens).toBeDefined() }) - it("should handle actual GPT-5 Responses API format", async () => { - // Mock fetch with actual response format from GPT-5 - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - // Test actual GPT-5 response format - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.created","response":{"id":"test","status":"in_progress"}}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.in_progress","response":{"status":"in_progress"}}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"First text"}}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":" Second text"}}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"reasoning","text":"Some reasoning"}}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.done","response":{"usage":{"prompt_tokens":100,"completion_tokens":20}}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), + it("should use user-specified temperature", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) - global.fetch = mockFetch as any - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ + const handlerWithTemp = new OpenAiNativeHandler({ ...mockOptions, - apiModelId: "gpt-5.1", + modelTemperature: 0.7, + }) + + const stream = handlerWithTemp.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.7, + }), + ) + }) + + it("should pass store: false in provider options", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) + for await (const _ of stream) { + // consume } - // Should handle the actual format correctly - const textChunks = chunks.filter((c) => c.type === "text") - const reasoningChunks = chunks.filter((c) => c.type === "reasoning") - - expect(textChunks).toHaveLength(2) - expect(textChunks[0].text).toBe("First text") - expect(textChunks[1].text).toBe(" Second text") - - expect(reasoningChunks).toHaveLength(1) - expect(reasoningChunks[0].text).toBe("Some reasoning") - - // Should also have usage information with cost - const usageChunks = chunks.filter((c) => c.type === "usage") - expect(usageChunks).toHaveLength(1) - expect(usageChunks[0]).toMatchObject({ - type: "usage", - inputTokens: 100, - outputTokens: 20, - totalCost: expect.any(Number), - }) - - // Verify cost calculation (GPT-5 pricing: input $1.25/M, output $10/M) - const expectedInputCost = (100 / 1_000_000) * 1.25 - const expectedOutputCost = (20 / 1_000_000) * 10.0 - const expectedTotalCost = expectedInputCost + expectedOutputCost - expect(usageChunks[0].totalCost).toBeCloseTo(expectedTotalCost, 10) + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: expect.objectContaining({ + openai: expect.objectContaining({ + store: false, + }), + }), + }), + ) }) - it("should handle Responses API with no content gracefully", async () => { - // Mock fetch with empty response - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode('data: {"someField":"value"}\n\n')) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() + it("should capture responseId from provider metadata", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({ + openai: { + responseId: "resp_test123", + serviceTier: "default", }, }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1", + content: Promise.resolve([]), }) const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - - // Should not throw, just warn - for await (const chunk of stream) { - chunks.push(chunk) + for await (const _ of stream) { + // consume } - // Should have no content chunks when stream is empty - const contentChunks = chunks.filter((c) => c.type === "text" || c.type === "reasoning") - - expect(contentChunks).toHaveLength(0) + expect(handler.getResponseId()).toBe("resp_test123") }) - it("should handle unhandled stream events gracefully", async () => { - // Mock fetch for the fallback SSE path - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"Hello"}}\n\n', - ), - ) - // This event is not handled, so it should be ignored - controller.enqueue( - new TextEncoder().encode('data: {"type":"response.audio.delta","delta":"..."}\n\n'), - ) - controller.enqueue(new TextEncoder().encode('data: {"type":"response.done","response":{}}\n\n')) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, + it("should capture encrypted content from reasoning parts", async () => { + async function* mockFullStream() { + yield { type: "reasoning-delta", text: "thinking" } + yield { type: "text-delta", text: "answer" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({ + openai: { responseId: "resp_test" }, }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1", + content: Promise.resolve([ + { + type: "reasoning", + text: "thinking", + providerMetadata: { + openai: { + reasoningEncryptedContent: "encrypted_payload", + itemId: "item_123", + }, + }, + }, + { + type: "text", + text: "answer", + }, + ]), }) const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - const errors: any[] = [] - - try { - for await (const chunk of stream) { - chunks.push(chunk) - } - } catch (error) { - errors.push(error) + for await (const _ of stream) { + // consume } - expect(errors.length).toBe(0) - const textChunks = chunks.filter((c) => c.type === "text") - expect(textChunks.length).toBeGreaterThan(0) - expect(textChunks[0].text).toBe("Hello") + const encrypted = handler.getEncryptedContent() + expect(encrypted).toBeDefined() + expect(encrypted!.encrypted_content).toBe("encrypted_payload") + expect(encrypted!.id).toBe("item_123") }) - it("should format full conversation correctly", async () => { - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"Response"}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, + it("should reset state between requests", async () => { + // First request with metadata + async function* mockFullStream1() { + yield { type: "text-delta", text: "first" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream1(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({ + openai: { responseId: "resp_1" }, }), + content: Promise.resolve([]), + }) + + let stream = handler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + expect(handler.getResponseId()).toBe("resp_1") + + // Second request should reset state + async function* mockFullStream2() { + yield { type: "text-delta", text: "second" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream2(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + stream = handler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + // Should be reset since second request had no responseId + expect(handler.getResponseId()).toBeUndefined() + expect(handler.getEncryptedContent()).toBeUndefined() + }) + }) + + describe("GPT-5 models", () => { + it("should pass reasoning effort in provider options for GPT-5", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) - global.fetch = mockFetch as any - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) const gpt5Handler = new OpenAiNativeHandler({ ...mockOptions, apiModelId: "gpt-5.1", }) - const stream = gpt5Handler.createMessage(systemPrompt, messages, { - taskId: "task1", - }) - for await (const chunk of stream) { + const stream = gpt5Handler.createMessage(systemPrompt, messages) + for await (const _ of stream) { // consume } - const callBody = JSON.parse(mockFetch.mock.calls[0][1].body) - expect(callBody.input).toEqual([ - { - role: "user", - content: [{ type: "input_text", text: "Hello!" }], - }, - ]) - expect(callBody.previous_response_id).toBeUndefined() + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: expect.objectContaining({ + openai: expect.objectContaining({ + reasoningEffort: expect.any(String), + reasoningSummary: "auto", + include: ["reasoning.encrypted_content"], + }), + }), + }), + ) }) - it("should provide helpful error messages for different error codes", async () => { - const testCases = [ - { status: 400, expectedMessage: "Invalid request to Responses API" }, - { status: 401, expectedMessage: "Authentication failed" }, - { status: 403, expectedMessage: "Access denied" }, - { status: 404, expectedMessage: "Responses API endpoint not found" }, - { status: 429, expectedMessage: "Rate limit exceeded" }, - { status: 500, expectedMessage: "OpenAI service error" }, - ] + it("should pass verbosity in provider options for models that support it", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } - for (const { status, expectedMessage } of testCases) { - // Mock fetch with error response - const mockFetch = vitest.fn().mockResolvedValue({ - ok: false, - status, - statusText: "Error", - text: async () => JSON.stringify({ error: { message: "Test error" } }), - }) - global.fetch = mockFetch as any + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) + const gpt5Handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1", + verbosity: "low", + }) - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "gpt-5.1", - }) + const stream = gpt5Handler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } - const stream = handler.createMessage(systemPrompt, messages) + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: expect.objectContaining({ + openai: expect.objectContaining({ + textVerbosity: "low", + }), + }), + }), + ) + }) - await expect(async () => { - for await (const chunk of stream) { - // Should throw before yielding anything - } - }).rejects.toThrow(expectedMessage) + it("should support xhigh reasoning effort for GPT-5.1 Codex Max", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } - // Clean up - delete (global as any).fetch + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const codexHandler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1-codex-max", + reasoningEffort: "xhigh", + }) + + const stream = codexHandler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + providerOptions: expect.objectContaining({ + openai: expect.objectContaining({ + reasoningEffort: "xhigh", + }), + }), + }), + ) + }) + + it("should omit reasoning when selection is 'disable'", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "No reasoning" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const h = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1", + reasoningEffort: "disable" as any, + }) + + const stream = h.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions.openai.reasoningEffort).toBeUndefined() + expect(callArgs.providerOptions.openai.include).toBeUndefined() + expect(callArgs.providerOptions.openai.reasoningSummary).toBeUndefined() + }) + + it("should not pass temperature for models that don't support it", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const gpt5Handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1", + }) + + const stream = gpt5Handler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + // GPT-5 models have supportsTemperature: false + const gpt51Info = openAiNativeModels["gpt-5.1"] + if (gpt51Info.supportsTemperature === false) { + expect(callArgs.temperature).toBeUndefined() } }) - }) - describe("error telemetry", () => { - const errorMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello", - }, - ] + it("should not include verbosity for non-GPT-5 models", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } - const errorSystemPrompt = "You are a helpful assistant" - - beforeEach(() => { - mockCaptureException.mockClear() - }) - - it("should capture telemetry on createMessage error", async () => { - // Mock fetch to return error - const mockFetch = vitest.fn().mockResolvedValue({ - ok: false, - status: 500, - text: async () => "Internal Server Error", - }) - global.fetch = mockFetch as any - - // Mock SDK to fail so it falls back to fetch - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - const stream = handler.createMessage(errorSystemPrompt, errorMessages) - - await expect(async () => { - for await (const _chunk of stream) { - // Should throw before yielding any chunks - } - }).rejects.toThrow() - - // Verify telemetry was captured - expect(mockCaptureException).toHaveBeenCalledTimes(1) - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: expect.stringContaining("OpenAI service error"), - provider: "OpenAI Native", - modelId: "gpt-4.1", - operation: "createMessage", - }), - ) - - // Verify it's an ApiProviderError - const capturedError = mockCaptureException.mock.calls[0][0] - expect(capturedError).toBeInstanceOf(ApiProviderError) - }) - - it("should capture telemetry on stream processing error", async () => { - // Mock fetch to return a stream with an error event - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.error","error":{"message":"Model overloaded"}}\n\n', - ), - ) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail so it falls back to fetch - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - const stream = handler.createMessage(errorSystemPrompt, errorMessages) - - await expect(async () => { - for await (const _chunk of stream) { - // Should throw when encountering error event - } - }).rejects.toThrow() - - // Verify telemetry was captured (may be called multiple times due to error propagation) - expect(mockCaptureException).toHaveBeenCalled() - - // Find the call with the stream error message - const streamErrorCall = mockCaptureException.mock.calls.find((call: any[]) => - call[0]?.message?.includes("Model overloaded"), - ) - expect(streamErrorCall).toBeDefined() - expect(streamErrorCall![0]).toMatchObject({ - provider: "OpenAI Native", - modelId: "gpt-4.1", - operation: "createMessage", + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) - // Verify it's an ApiProviderError - expect(streamErrorCall![0]).toBeInstanceOf(ApiProviderError) - }) - - it("should capture telemetry on completePrompt error", async () => { - // Mock SDK to throw an error - mockResponsesCreate.mockRejectedValue(new Error("API Error")) - - await expect(handler.completePrompt("Test prompt")).rejects.toThrow() - - // Verify telemetry was captured - expect(mockCaptureException).toHaveBeenCalledTimes(1) - expect(mockCaptureException).toHaveBeenCalledWith( - expect.objectContaining({ - message: "API Error", - provider: "OpenAI Native", - modelId: "gpt-4.1", - operation: "completePrompt", - }), - ) - - // Verify it's an ApiProviderError - const capturedError = mockCaptureException.mock.calls[0][0] - expect(capturedError).toBeInstanceOf(ApiProviderError) - }) - - it("should still throw the error after capturing telemetry", async () => { - // Mock fetch to return error - const mockFetch = vitest.fn().mockResolvedValue({ - ok: false, - status: 500, - text: async () => "Internal Server Error", - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - const stream = handler.createMessage(errorSystemPrompt, errorMessages) - - // Verify the error is still thrown - await expect(async () => { - for await (const _chunk of stream) { - // Should throw - } - }).rejects.toThrow() - - // Telemetry should have been captured before the error was thrown - expect(mockCaptureException).toHaveBeenCalled() - }) - }) -}) - -// Additional tests for GPT-5 streaming event coverage -describe("GPT-5 streaming event coverage (additional)", () => { - afterEach(() => { - if ((global as any).fetch) { - delete (global as any).fetch - } - }) - - it("should handle reasoning delta events for GPT-5", async () => { - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.reasoning.delta","delta":"Thinking about the problem..."}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode('data: {"type":"response.text.delta","delta":"The answer is..."}\n\n'), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - const handler = new OpenAiNativeHandler({ - apiModelId: "gpt-5.1", - openAiNativeApiKey: "test-api-key", - }) - - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello!" }] - const stream = handler.createMessage(systemPrompt, messages) - - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - const reasoningChunks = chunks.filter((c) => c.type === "reasoning") - const textChunks = chunks.filter((c) => c.type === "text") - - expect(reasoningChunks).toHaveLength(1) - expect(reasoningChunks[0].text).toBe("Thinking about the problem...") - expect(textChunks).toHaveLength(1) - expect(textChunks[0].text).toBe("The answer is...") - }) - - it("should handle refusal delta events for GPT-5 and prefix output", async () => { - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.refusal.delta","delta":"I cannot comply with this request."}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - const handler = new OpenAiNativeHandler({ - apiModelId: "gpt-5.1", - openAiNativeApiKey: "test-api-key", - }) - - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Do something disallowed" }] - const stream = handler.createMessage(systemPrompt, messages) - - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - const textChunks = chunks.filter((c) => c.type === "text") - expect(textChunks).toHaveLength(1) - expect(textChunks[0].text).toBe("[Refusal] I cannot comply with this request.") - }) - - it("should ignore malformed JSON lines in SSE stream", async () => { - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"Before"}}\n\n', - ), - ) - // Malformed JSON line - controller.enqueue( - new TextEncoder().encode('data: {"type":"response.text.delta","delta":"Bad"\n\n'), - ) - // Valid line after malformed - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_item.added","item":{"type":"text","text":"After"}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - const handler = new OpenAiNativeHandler({ - apiModelId: "gpt-5.1", - openAiNativeApiKey: "test-api-key", - }) - - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello!" }] - const stream = handler.createMessage(systemPrompt, messages) - - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // It should not throw and still capture the valid texts around the malformed line - const textChunks = chunks.filter((c) => c.type === "text") - expect(textChunks.map((c: any) => c.text)).toEqual(["Before", "After"]) - }) - - describe("Codex Mini Model", () => { - let handler: OpenAiNativeHandler - const mockOptions: ApiHandlerOptions = { - openAiNativeApiKey: "test-api-key", - apiModelId: "codex-mini-latest", - } - - it("should handle codex-mini-latest streaming response", async () => { - // Mock fetch for Codex Mini responses API - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - // Codex Mini uses the same responses API format - controller.enqueue( - new TextEncoder().encode('data: {"type":"response.output_text.delta","delta":"Hello"}\n\n'), - ) - controller.enqueue( - new TextEncoder().encode('data: {"type":"response.output_text.delta","delta":" from"}\n\n'), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_text.delta","delta":" Codex"}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_text.delta","delta":" Mini!"}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.done","response":{"usage":{"prompt_tokens":50,"completion_tokens":10}}}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "codex-mini-latest", - }) - - const systemPrompt = "You are a helpful coding assistant." - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Write a hello world function" }, - ] - + // gpt-4.1 does not support verbosity const stream = handler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions.openai.textVerbosity).toBeUndefined() + }) + + it("should handle GPT-5 models with multiple stream chunks", async () => { + async function* mockFullStream() { + yield { type: "reasoning-delta", text: "reasoning step 1" } + yield { type: "reasoning-delta", text: " step 2" } + yield { type: "text-delta", text: "Hello" } + yield { type: "text-delta", text: " world" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 100, + outputTokens: 50, + details: { reasoningTokens: 20 }, + }), + providerMetadata: Promise.resolve({ + openai: { responseId: "resp_gpt5_test" }, + }), + content: Promise.resolve([]), + }) + + const gpt5Handler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1", + }) + + const stream = gpt5Handler.createMessage(systemPrompt, messages) const chunks: any[] = [] for await (const chunk of stream) { chunks.push(chunk) } - // Verify text chunks - const textChunks = chunks.filter((c) => c.type === "text") - expect(textChunks).toHaveLength(4) - expect(textChunks.map((c) => c.text).join("")).toBe("Hello from Codex Mini!") + const reasoning = chunks.filter((c) => c.type === "reasoning") + expect(reasoning).toHaveLength(2) - // Verify usage data from API + const text = chunks.filter((c) => c.type === "text") + expect(text).toHaveLength(2) + + const usage = chunks.filter((c) => c.type === "usage") + expect(usage).toHaveLength(1) + expect(usage[0].reasoningTokens).toBe(20) + }) + }) + + describe("service tier", () => { + it("should pass service tier in provider options when supported", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), + }) + + const tierHandler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1", + openAiNativeServiceTier: "flex", + }) + + const stream = tierHandler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + const callArgs = mockStreamText.mock.calls[0][0] + // Tier should be passed when model supports it + const model = tierHandler.getModel() + const allowedTiers = new Set(model.info.tiers?.map((t) => t.name).filter(Boolean) || []) + if (allowedTiers.has("flex")) { + expect(callArgs.providerOptions.openai.serviceTier).toBe("flex") + } + }) + + it("should capture service tier from provider metadata", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({ + openai: { + responseId: "resp_123", + serviceTier: "flex", + }, + }), + content: Promise.resolve([]), + }) + + const tierHandler = new OpenAiNativeHandler({ + ...mockOptions, + apiModelId: "gpt-5.1", + openAiNativeServiceTier: "flex", + }) + + const stream = tierHandler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Usage should include totalCost (calculated with tier pricing) const usageChunks = chunks.filter((c) => c.type === "usage") expect(usageChunks).toHaveLength(1) - expect(usageChunks[0]).toMatchObject({ - type: "usage", - inputTokens: 50, - outputTokens: 10, - totalCost: expect.any(Number), // Codex Mini has pricing: $1.5/M input, $6/M output - }) - - // Verify cost is calculated correctly based on API usage data - const expectedCost = (50 / 1_000_000) * 1.5 + (10 / 1_000_000) * 6 - expect(usageChunks[0].totalCost).toBeCloseTo(expectedCost, 10) - - // Verify the request was made with correct parameters - expect(mockFetch).toHaveBeenCalledWith( - "https://api.openai.com/v1/responses", - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - "Content-Type": "application/json", - Authorization: "Bearer test-api-key", - }), - body: expect.any(String), - }), - ) - - const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body) - expect(requestBody).toMatchObject({ - model: "codex-mini-latest", - instructions: "You are a helpful coding assistant.", - input: [ - { - role: "user", - content: [{ type: "input_text", text: "Write a hello world function" }], - }, - ], - stream: true, - }) + expect(typeof usageChunks[0].totalCost).toBe("number") }) + }) - it("should handle codex-mini-latest non-streaming completion", async () => { - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "codex-mini-latest", - }) - - // Mock the responses.create method to return a non-streaming response - mockResponsesCreate.mockResolvedValue({ - output: [ - { - type: "message", - content: [ - { - type: "output_text", - text: "def hello_world():\n print('Hello, World!')", - }, - ], - }, - ], - }) - - const result = await handler.completePrompt("Write a hello world function in Python") - - expect(result).toBe("def hello_world():\n print('Hello, World!')") - expect(mockResponsesCreate).toHaveBeenCalledWith( - expect.objectContaining({ - model: "codex-mini-latest", - stream: false, - store: false, - }), - expect.objectContaining({ - signal: expect.any(Object), - }), - ) - }) - - it("should handle codex-mini-latest API errors", async () => { - // Mock fetch with error response - const mockFetch = vitest.fn().mockResolvedValue({ - ok: false, - status: 429, - statusText: "Too Many Requests", - text: async () => "Rate limit exceeded", - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "codex-mini-latest", - }) - - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] - - const stream = handler.createMessage(systemPrompt, messages) - - // Should throw an error (using the same error format as GPT-5) - await expect(async () => { - for await (const chunk of stream) { - // consume stream - } - }).rejects.toThrow("Rate limit exceeded") - }) - - it("should handle codex-mini-latest with multiple user messages", async () => { - // Mock fetch for streaming response - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_text.delta","delta":"Combined response"}\n\n', - ), - ) - controller.enqueue(new TextEncoder().encode('data: {"type":"response.completed"}\n\n')) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - global.fetch = mockFetch as any - - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ - ...mockOptions, - apiModelId: "codex-mini-latest", - }) - - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "First question" }, - { role: "assistant", content: "First answer" }, - { role: "user", content: "Second question" }, - ] - - const stream = handler.createMessage(systemPrompt, messages) - const chunks: any[] = [] - for await (const chunk of stream) { - chunks.push(chunk) + describe("prompt cache retention", () => { + it("should pass promptCacheRetention for models that support it", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } } - // Verify the request body includes full conversation in structured format (without embedding system prompt) - const requestBody = JSON.parse(mockFetch.mock.calls[0][1].body) - expect(requestBody.instructions).toBe("You are a helpful assistant.") - expect(requestBody.input).toEqual([ - { - role: "user", - content: [{ type: "input_text", text: "First question" }], - }, - { - role: "assistant", - content: [{ type: "output_text", text: "First answer" }], - }, - { - role: "user", - content: [{ type: "input_text", text: "Second question" }], - }, - ]) - }) - - it("should handle codex-mini-latest stream error events", async () => { - // Mock fetch with error event in stream - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.output_text.delta","delta":"Partial"}\n\n', - ), - ) - controller.enqueue( - new TextEncoder().encode( - 'data: {"type":"response.error","error":{"message":"Model overloaded"}}\n\n', - ), - ) - // The error handler will throw, but we still need to close the stream - controller.close() - }, - }), + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) - global.fetch = mockFetch as any - // Mock SDK to fail - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - handler = new OpenAiNativeHandler({ + const h = new OpenAiNativeHandler({ ...mockOptions, - apiModelId: "codex-mini-latest", + apiModelId: "gpt-5.1", }) - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] + const stream = h.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } - const stream = handler.createMessage(systemPrompt, messages) - - // Should throw an error when encountering error event - await expect(async () => { - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - }).rejects.toThrow("Responses API error: Model overloaded") + const callArgs = mockStreamText.mock.calls[0][0] + const modelInfo = openAiNativeModels["gpt-5.1"] + if (modelInfo.supportsPromptCache && modelInfo.promptCacheRetention === "24h") { + expect(callArgs.providerOptions.openai.promptCacheRetention).toBe("24h") + } }) - // New tests: ensure text.verbosity is omitted for models without supportsVerbosity - describe("Verbosity gating for non-GPT-5 models", () => { - it("should omit text.verbosity for gpt-4.1", async () => { - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode('data: {"type":"response.done","response":{}}\n\n'), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - ;(global as any).fetch = mockFetch as any + it("should not pass promptCacheRetention for models without support", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } - // Force SDK path to fail so we use fetch fallback - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) - - const handler = new OpenAiNativeHandler({ - apiModelId: "gpt-4.1", - openAiNativeApiKey: "test-api-key", - verbosity: "high", - }) - - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello!" }] - const stream = handler.createMessage(systemPrompt, messages) - - for await (const _ of stream) { - // drain - } - - const bodyStr = (mockFetch.mock.calls[0][1] as any).body as string - const parsedBody = JSON.parse(bodyStr) - expect(parsedBody.model).toBe("gpt-4.1") - expect(parsedBody.text).toBeUndefined() - expect(bodyStr).not.toContain('"verbosity"') + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + content: Promise.resolve([]), }) - it("should omit text.verbosity for gpt-4o", async () => { - const mockFetch = vitest.fn().mockResolvedValue({ - ok: true, - body: new ReadableStream({ - start(controller) { - controller.enqueue( - new TextEncoder().encode('data: {"type":"response.done","response":{}}\n\n'), - ) - controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) - controller.close() - }, - }), - }) - ;(global as any).fetch = mockFetch as any + // gpt-4.1 doesn't have promptCacheRetention: "24h" + const stream = handler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } - // Force SDK path to fail so we use fetch fallback - mockResponsesCreate.mockRejectedValue(new Error("SDK not available")) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions.openai.promptCacheRetention).toBeUndefined() + }) + }) - const handler = new OpenAiNativeHandler({ - apiModelId: "gpt-4o", - openAiNativeApiKey: "test-api-key", - verbosity: "low", - }) - - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello!" }] - const stream = handler.createMessage(systemPrompt, messages) - - for await (const _ of stream) { - // drain - } - - const bodyStr = (mockFetch.mock.calls[0][1] as any).body as string - const parsedBody = JSON.parse(bodyStr) - expect(parsedBody.model).toBe("gpt-4o") - expect(parsedBody.text).toBeUndefined() - expect(bodyStr).not.toContain('"verbosity"') + describe("completePrompt", () => { + it("should complete prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "This is the completion response", + usage: { inputTokens: 10, outputTokens: 5 }, }) + + const result = await handler.completePrompt("Test prompt") + expect(result).toBe("This is the completion response") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + providerOptions: expect.objectContaining({ + openai: expect.objectContaining({ + store: false, + }), + }), + }), + ) + }) + + it("should handle errors in completePrompt", async () => { + mockGenerateText.mockRejectedValue(new Error("API Error")) + + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("OpenAI Native") + }) + + it("should return empty string when no text in response", async () => { + mockGenerateText.mockResolvedValue({ + text: "", + usage: { inputTokens: 10, outputTokens: 0 }, + }) + + const result = await handler.completePrompt("Test prompt") + expect(result).toBe("") + }) + }) + + describe("isAiSdkProvider", () => { + it("should return true", () => { + expect(handler.isAiSdkProvider()).toBe(true) + }) + }) + + describe("getEncryptedContent", () => { + it("should return undefined when no encrypted content has been captured", () => { + expect(handler.getEncryptedContent()).toBeUndefined() + }) + }) + + describe("getResponseId", () => { + it("should return undefined when no response ID has been captured", () => { + expect(handler.getResponseId()).toBeUndefined() }) }) }) diff --git a/src/api/providers/openai-native.ts b/src/api/providers/openai-native.ts index d7c60c5daf..4779db8340 100644 --- a/src/api/providers/openai-native.ts +++ b/src/api/providers/openai-native.ts @@ -1,7 +1,8 @@ import * as os from "os" import { v7 as uuidv7 } from "uuid" import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import { createOpenAI } from "@ai-sdk/openai" +import { streamText, generateText, ToolSet, type ModelMessage } from "ai" import { Package } from "../../shared/package" import { @@ -10,86 +11,221 @@ import { OpenAiNativeModelId, openAiNativeModels, OPENAI_NATIVE_DEFAULT_TEMPERATURE, - type ReasoningEffort, type VerbosityLevel, type ReasoningEffortExtended, type ServiceTier, - ApiProviderError, } from "@roo-code/types" -import { TelemetryService } from "@roo-code/telemetry" import type { ApiHandlerOptions } from "../../shared/api" - import { calculateApiCostOpenAI } from "../../shared/cost" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" import { getModelParams } from "../transform/model-params" import { BaseProvider } from "./base-provider" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import { isMcpTool } from "../../utils/mcp-name" -import { sanitizeOpenAiCallId } from "../../utils/tool-id" export type OpenAiNativeModel = ReturnType +/** + * An encrypted reasoning item extracted from the conversation history. + * These are standalone items injected by `buildCleanConversationHistory` with + * `{ type: "reasoning", encrypted_content: "...", id: "...", summary: [...] }`. + */ +export interface EncryptedReasoningItem { + id: string + encrypted_content: string + summary?: Array<{ type: string; text: string }> + originalIndex: number +} + +/** + * Strip plain-text reasoning blocks from assistant message content arrays. + * + * Plain-text reasoning blocks (`{ type: "reasoning", text: "..." }`) inside + * assistant content arrays would be converted by `convertToAiSdkMessages` + * into AI SDK reasoning parts WITHOUT `providerOptions.openai.itemId`. + * The `@ai-sdk/openai` Responses provider rejects those with console warnings. + * + * This function removes them BEFORE conversion. If an assistant message's + * content becomes empty after filtering, the message is removed entirely. + */ +export function stripPlainTextReasoningBlocks( + messages: Anthropic.Messages.MessageParam[], +): Anthropic.Messages.MessageParam[] { + return messages.reduce((acc, msg) => { + if (msg.role !== "assistant" || typeof msg.content === "string") { + acc.push(msg) + return acc + } + + const filteredContent = msg.content.filter((block) => { + const b = block as unknown as Record + // Remove blocks that are plain-text reasoning: + // type === "reasoning" AND has "text" AND does NOT have "encrypted_content" + if (b.type === "reasoning" && typeof b.text === "string" && !b.encrypted_content) { + return false + } + return true + }) + + // Only include the message if it still has content + if (filteredContent.length > 0) { + acc.push({ ...msg, content: filteredContent }) + } + + return acc + }, []) +} + +/** + * Collect encrypted reasoning items from the messages array. + * + * These are standalone items with `type: "reasoning"` and `encrypted_content`, + * injected by `buildCleanConversationHistory` for OpenAI Responses API + * reasoning continuity. + */ +export function collectEncryptedReasoningItems(messages: Anthropic.Messages.MessageParam[]): EncryptedReasoningItem[] { + const items: EncryptedReasoningItem[] = [] + messages.forEach((msg, index) => { + const m = msg as unknown as Record + if (m.type === "reasoning" && m.encrypted_content) { + items.push({ + id: m.id as string, + encrypted_content: m.encrypted_content as string, + summary: m.summary as Array<{ type: string; text: string }> | undefined, + originalIndex: index, + }) + } + }) + return items +} + +/** + * Inject encrypted reasoning parts into AI SDK messages. + * + * For each encrypted reasoning item, a reasoning part (with + * `providerOptions.openai.itemId` and `reasoningEncryptedContent`) is injected + * at the **beginning** of the next assistant message's content in the AI SDK + * messages array. + * + * @param aiSdkMessages - The converted AI SDK messages (mutated in place). + * @param encryptedItems - Encrypted reasoning items with their original indices. + * @param originalMessages - The original (unfiltered) messages array, used to + * determine which assistant message each encrypted item precedes. + */ +export function injectEncryptedReasoning( + aiSdkMessages: ModelMessage[], + encryptedItems: EncryptedReasoningItem[], + originalMessages: Anthropic.Messages.MessageParam[], +): void { + if (encryptedItems.length === 0) return + + // Map: original-array index of an assistant message -> encrypted items that precede it. + const itemsByAssistantOrigIdx = new Map() + + for (const item of encryptedItems) { + // Walk forward from the encrypted item to find its corresponding assistant message, + // skipping over any other encrypted reasoning items. + for (let i = item.originalIndex + 1; i < originalMessages.length; i++) { + const msg = originalMessages[i] as unknown as Record + if (msg.type === "reasoning" && msg.encrypted_content) continue + if ((msg as { role?: string }).role === "assistant") { + const existing = itemsByAssistantOrigIdx.get(i) || [] + existing.push(item) + itemsByAssistantOrigIdx.set(i, existing) + break + } + // Non-assistant, non-encrypted message — keep searching + } + } + + if (itemsByAssistantOrigIdx.size === 0) return + + // Collect the original indices of assistant messages that remain after + // encrypted reasoning items have been filtered out (order preserved). + const standardAssistantOriginalIndices: number[] = [] + for (let i = 0; i < originalMessages.length; i++) { + const msg = originalMessages[i] as unknown as Record + if (msg.type === "reasoning" && msg.encrypted_content) continue + if ((msg as { role?: string }).role === "assistant") { + standardAssistantOriginalIndices.push(i) + } + } + + // Collect assistant-role indices in the AI SDK messages array. + const aiSdkAssistantIndices: number[] = [] + for (let i = 0; i < aiSdkMessages.length; i++) { + if (aiSdkMessages[i].role === "assistant") { + aiSdkAssistantIndices.push(i) + } + } + + // Match: Nth standard assistant (by original index) -> Nth AI SDK assistant. + for (let n = 0; n < standardAssistantOriginalIndices.length && n < aiSdkAssistantIndices.length; n++) { + const origIdx = standardAssistantOriginalIndices[n] + const items = itemsByAssistantOrigIdx.get(origIdx) + if (!items || items.length === 0) continue + + const aiIdx = aiSdkAssistantIndices[n] + const msg = aiSdkMessages[aiIdx] as Record + const content = Array.isArray(msg.content) ? (msg.content as unknown[]) : [] + + const reasoningParts = items.map((item) => ({ + type: "reasoning" as const, + text: item.summary?.map((s) => s.text).join("\n") || "", + providerOptions: { + openai: { + itemId: item.id, + reasoningEncryptedContent: item.encrypted_content, + }, + }, + })) + + msg.content = [...reasoningParts, ...content] + } +} + +/** + * OpenAI Native provider using the dedicated @ai-sdk/openai package. + * Uses the OpenAI Responses API by default (AI SDK 5+). + * Supports reasoning models, service tiers, verbosity control, + * encrypted reasoning content, and prompt cache retention. + */ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletionHandler { protected options: ApiHandlerOptions - private client: OpenAI + protected provider: ReturnType private readonly providerName = "OpenAI Native" - // Session ID for request tracking (persists for the lifetime of the handler) private readonly sessionId: string - /** - * Some Responses streams emit tool-call argument deltas without stable call id/name. - * Track the last observed tool identity from output_item events so we can still - * emit `tool_call_partial` chunks (tool-call-only streams). - */ - private pendingToolCallId: string | undefined - private pendingToolCallName: string | undefined - // Resolved service tier from Responses API (actual tier used by OpenAI) - private lastServiceTier: ServiceTier | undefined - // Complete response output array (includes reasoning items with encrypted_content) - private lastResponseOutput: any[] | undefined - // Last top-level response id from Responses API (for troubleshooting) - private lastResponseId: string | undefined - // Abort controller for cancelling ongoing requests - private abortController?: AbortController - // Event types handled by the shared event processor to avoid duplication - private readonly coreHandledEventTypes = new Set([ - "response.text.delta", - "response.output_text.delta", - "response.reasoning.delta", - "response.reasoning_text.delta", - "response.reasoning_summary.delta", - "response.reasoning_summary_text.delta", - "response.refusal.delta", - "response.output_item.added", - "response.output_item.done", - "response.done", - "response.completed", - "response.tool_call_arguments.delta", - "response.function_call_arguments.delta", - "response.tool_call_arguments.done", - "response.function_call_arguments.done", - ]) + private lastResponseId: string | undefined + private lastEncryptedContent: { encrypted_content: string; id?: string } | undefined + private lastServiceTier: ServiceTier | undefined constructor(options: ApiHandlerOptions) { super() this.options = options - // Generate a session ID for request tracking this.sessionId = uuidv7() - // Default to including reasoning.summary: "auto" for models that support Responses API - // reasoning summaries unless explicitly disabled. + if (this.options.enableResponsesReasoningSummary === undefined) { this.options.enableResponsesReasoningSummary = true } + const apiKey = this.options.openAiNativeApiKey ?? "not-provided" - // Include originator, session_id, and User-Agent headers for API tracking and debugging + const baseURL = this.options.openAiNativeBaseUrl || undefined const userAgent = `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}` - this.client = new OpenAI({ - baseURL: this.options.openAiNativeBaseUrl || undefined, + + this.provider = createOpenAI({ apiKey, - defaultHeaders: { + baseURL, + headers: { originator: "roo-code", session_id: this.sessionId, "User-Agent": userAgent, @@ -97,1233 +233,10 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio }) } - private normalizeUsage(usage: any, model: OpenAiNativeModel): ApiStreamUsageChunk | undefined { - if (!usage) return undefined - - // Prefer detailed shapes when available (Responses API) - const inputDetails = usage.input_tokens_details ?? usage.prompt_tokens_details - - // Extract cache information from details with better readability - const hasCachedTokens = typeof inputDetails?.cached_tokens === "number" - const hasCacheMissTokens = typeof inputDetails?.cache_miss_tokens === "number" - const cachedFromDetails = hasCachedTokens ? inputDetails.cached_tokens : 0 - const missFromDetails = hasCacheMissTokens ? inputDetails.cache_miss_tokens : 0 - - // If total input tokens are missing but we have details, derive from them - let totalInputTokens = usage.input_tokens ?? usage.prompt_tokens ?? 0 - if (totalInputTokens === 0 && inputDetails && (cachedFromDetails > 0 || missFromDetails > 0)) { - totalInputTokens = cachedFromDetails + missFromDetails - } - - const totalOutputTokens = usage.output_tokens ?? usage.completion_tokens ?? 0 - - // Note: missFromDetails is NOT used as fallback for cache writes - // Cache miss tokens represent tokens that weren't found in cache (part of input) - // Cache write tokens represent tokens being written to cache for future use - const cacheWriteTokens = usage.cache_creation_input_tokens ?? usage.cache_write_tokens ?? 0 - - const cacheReadTokens = - usage.cache_read_input_tokens ?? usage.cache_read_tokens ?? usage.cached_tokens ?? cachedFromDetails ?? 0 - - // Resolve effective tier: prefer actual tier from response; otherwise requested tier - const effectiveTier = - this.lastServiceTier || (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined - const effectiveInfo = this.applyServiceTierPricing(model.info, effectiveTier) - - // Pass total input tokens directly to calculateApiCostOpenAI - // The function handles subtracting both cache reads and writes internally - const { totalCost } = calculateApiCostOpenAI( - effectiveInfo, - totalInputTokens, - totalOutputTokens, - cacheWriteTokens, - cacheReadTokens, - ) - - const reasoningTokens = - typeof usage.output_tokens_details?.reasoning_tokens === "number" - ? usage.output_tokens_details.reasoning_tokens - : undefined - - const out: ApiStreamUsageChunk = { - type: "usage", - // Keep inputTokens as TOTAL input to preserve correct context length - inputTokens: totalInputTokens, - outputTokens: totalOutputTokens, - cacheWriteTokens, - cacheReadTokens, - ...(typeof reasoningTokens === "number" ? { reasoningTokens } : {}), - totalCost, - } - return out - } - - override async *createMessage( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - const model = this.getModel() - - // Use Responses API for ALL models - yield* this.handleResponsesApiMessage(model, systemPrompt, messages, metadata) - } - - private async *handleResponsesApiMessage( - model: OpenAiNativeModel, - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - // Reset resolved tier for this request; will be set from response if present - this.lastServiceTier = undefined - // Reset output array to capture current response output items - this.lastResponseOutput = undefined - // Reset last response id for this request - this.lastResponseId = undefined - // Reset pending tool identity for this request - this.pendingToolCallId = undefined - this.pendingToolCallName = undefined - - // Use Responses API for ALL models - const { verbosity, reasoning } = this.getModel() - - // Resolve reasoning effort for models that support it - const reasoningEffort = this.getReasoningEffort(model) - - // Format full conversation (messages already include reasoning items from API history) - const formattedInput = this.formatFullConversation(systemPrompt, messages) - - // Build request body - const requestBody = this.buildRequestBody( - model, - formattedInput, - systemPrompt, - verbosity, - reasoningEffort, - metadata, - ) - - // Make the request (pass systemPrompt and messages for potential retry) - yield* this.executeRequest(requestBody, model, metadata, systemPrompt, messages) - } - - private buildRequestBody( - model: OpenAiNativeModel, - formattedInput: any, - systemPrompt: string, - verbosity: any, - reasoningEffort: ReasoningEffortExtended | undefined, - metadata?: ApiHandlerCreateMessageMetadata, - ): any { - // Ensure all properties are in the required array for OpenAI's strict mode - // This recursively processes nested objects and array items - const ensureAllRequired = (schema: any): any => { - if (!schema || typeof schema !== "object" || schema.type !== "object") { - return schema - } - - const result = { ...schema } - - // OpenAI Responses API requires additionalProperties: false on all object schemas - // Only add if not already set to false (to avoid unnecessary mutations) - if (result.additionalProperties !== false) { - result.additionalProperties = false - } - - if (result.properties) { - const allKeys = Object.keys(result.properties) - result.required = allKeys - - // Recursively process nested objects - const newProps = { ...result.properties } - for (const key of allKeys) { - const prop = newProps[key] - if (prop.type === "object") { - newProps[key] = ensureAllRequired(prop) - } else if (prop.type === "array" && prop.items?.type === "object") { - newProps[key] = { - ...prop, - items: ensureAllRequired(prop.items), - } - } - } - result.properties = newProps - } - - return result - } - - // Adds additionalProperties: false to all object schemas recursively - // without modifying required array. Used for MCP tools with strict: false - // to comply with OpenAI Responses API requirements. - const ensureAdditionalPropertiesFalse = (schema: any): any => { - if (!schema || typeof schema !== "object" || schema.type !== "object") { - return schema - } - - const result = { ...schema } - - // OpenAI Responses API requires additionalProperties: false on all object schemas - // Only add if not already set to false (to avoid unnecessary mutations) - if (result.additionalProperties !== false) { - result.additionalProperties = false - } - - if (result.properties) { - // Recursively process nested objects - const newProps = { ...result.properties } - for (const key of Object.keys(result.properties)) { - const prop = newProps[key] - if (prop && prop.type === "object") { - newProps[key] = ensureAdditionalPropertiesFalse(prop) - } else if (prop && prop.type === "array" && prop.items?.type === "object") { - newProps[key] = { - ...prop, - items: ensureAdditionalPropertiesFalse(prop.items), - } - } - } - result.properties = newProps - } - - return result - } - - // Build a request body for the OpenAI Responses API. - // Ensure we explicitly pass max_output_tokens based on Roo's reserved model response calculation - // so requests do not default to very large limits (e.g., 120k). - interface ResponsesRequestBody { - model: string - input: Array<{ role: "user" | "assistant"; content: any[] } | { type: string; content: string }> - stream: boolean - reasoning?: { effort?: ReasoningEffortExtended; summary?: "auto" } - text?: { verbosity: VerbosityLevel } - temperature?: number - max_output_tokens?: number - store?: boolean - instructions?: string - service_tier?: ServiceTier - include?: string[] - /** Prompt cache retention policy: "in_memory" (default) or "24h" for extended caching */ - prompt_cache_retention?: "in_memory" | "24h" - tools?: Array<{ - type: "function" - name: string - description?: string - parameters?: any - strict?: boolean - }> - tool_choice?: any - parallel_tool_calls?: boolean - } - - // Validate requested tier against model support; if not supported, omit. - const requestedTier = (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined - const allowedTierNames = new Set(model.info.tiers?.map((t) => t.name).filter(Boolean) || []) - - // Decide whether to enable extended prompt cache retention for this request - const promptCacheRetention = this.getPromptCacheRetention(model) - - const body: ResponsesRequestBody = { - model: model.id, - input: formattedInput, - stream: true, - // Always use stateless operation with encrypted reasoning - store: false, - // Always include instructions (system prompt) for Responses API. - // Unlike Chat Completions, system/developer roles in input have no special semantics here. - // The official way to set system behavior is the top-level `instructions` field. - instructions: systemPrompt, - // Only include encrypted reasoning content when reasoning effort is set - ...(reasoningEffort ? { include: ["reasoning.encrypted_content"] } : {}), - ...(reasoningEffort - ? { - reasoning: { - ...(reasoningEffort ? { effort: reasoningEffort } : {}), - ...(this.options.enableResponsesReasoningSummary ? { summary: "auto" as const } : {}), - }, - } - : {}), - // Only include temperature if the model supports it - ...(model.info.supportsTemperature !== false && { - temperature: this.options.modelTemperature ?? OPENAI_NATIVE_DEFAULT_TEMPERATURE, - }), - // Explicitly include the calculated max output tokens. - // Use the per-request reserved output computed by Roo (params.maxTokens from getModelParams). - ...(model.maxTokens ? { max_output_tokens: model.maxTokens } : {}), - // Include tier when selected and supported by the model, or when explicitly "default" - ...(requestedTier && - (requestedTier === "default" || allowedTierNames.has(requestedTier)) && { - service_tier: requestedTier, - }), - // Enable extended prompt cache retention for models that support it. - // This uses the OpenAI Responses API `prompt_cache_retention` parameter. - ...(promptCacheRetention ? { prompt_cache_retention: promptCacheRetention } : {}), - tools: (metadata?.tools ?? []) - .filter((tool) => tool.type === "function") - .map((tool) => { - // MCP tools use the 'mcp--' prefix - disable strict mode for them - // to preserve optional parameters from the MCP server schema - // But we still need to add additionalProperties: false for OpenAI Responses API - const isMcp = isMcpTool(tool.function.name) - return { - type: "function", - name: tool.function.name, - description: tool.function.description, - parameters: isMcp - ? ensureAdditionalPropertiesFalse(tool.function.parameters) - : ensureAllRequired(tool.function.parameters), - strict: !isMcp, - } - }), - tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, - } - - // Include text.verbosity only when the model explicitly supports it - if (model.info.supportsVerbosity === true) { - body.text = { verbosity: (verbosity || "medium") as VerbosityLevel } - } - - return body - } - - private async *executeRequest( - requestBody: any, - model: OpenAiNativeModel, - metadata?: ApiHandlerCreateMessageMetadata, - systemPrompt?: string, - messages?: Anthropic.Messages.MessageParam[], - ): ApiStream { - // Create AbortController for cancellation - this.abortController = new AbortController() - - // Build per-request headers using taskId when available, falling back to sessionId - const taskId = metadata?.taskId - const userAgent = `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}` - const requestHeaders: Record = { - originator: "roo-code", - session_id: taskId || this.sessionId, - "User-Agent": userAgent, - } - - try { - // Use the official SDK with per-request headers - const stream = (await (this.client as any).responses.create(requestBody, { - signal: this.abortController.signal, - headers: requestHeaders, - })) as AsyncIterable - - if (typeof (stream as any)[Symbol.asyncIterator] !== "function") { - throw new Error( - "OpenAI SDK did not return an AsyncIterable for Responses API streaming. Falling back to SSE.", - ) - } - - for await (const event of stream) { - // Check if request was aborted - if (this.abortController.signal.aborted) { - break - } - - for await (const outChunk of this.processEvent(event, model)) { - yield outChunk - } - } - } catch (sdkErr: any) { - // For errors, fallback to manual SSE via fetch - yield* this.makeResponsesApiRequest(requestBody, model, metadata, systemPrompt, messages) - } finally { - this.abortController = undefined - } - } - - private formatFullConversation(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): any { - // Format the entire conversation history for the Responses API using structured format - // The Responses API (like Realtime API) accepts a list of items, which can be messages, function calls, or function call outputs. - const formattedInput: any[] = [] - - // Do NOT embed the system prompt as a developer message in the Responses API input. - // The Responses API treats roles as free-form; use the top-level `instructions` field instead. - - // Process each message - for (const message of messages) { - // Check if this is a reasoning item (already formatted in API history) - if ((message as any).type === "reasoning") { - // Pass through reasoning items as-is - formattedInput.push(message) - continue - } - - if (message.role === "user") { - const content: any[] = [] - const toolResults: any[] = [] - - if (typeof message.content === "string") { - content.push({ type: "input_text", text: message.content }) - } else if (Array.isArray(message.content)) { - for (const block of message.content) { - if (block.type === "text") { - content.push({ type: "input_text", text: block.text }) - } else if (block.type === "image") { - const image = block as Anthropic.Messages.ImageBlockParam - const imageUrl = `data:${image.source.media_type};base64,${image.source.data}` - content.push({ type: "input_image", image_url: imageUrl }) - } else if (block.type === "tool_result") { - // Map Anthropic tool_result to Responses API function_call_output item - const result = - typeof block.content === "string" - ? block.content - : block.content?.map((c) => (c.type === "text" ? c.text : "")).join("") || "" - toolResults.push({ - type: "function_call_output", - // Sanitize and truncate call_id to fit OpenAI's 64-char limit - call_id: sanitizeOpenAiCallId(block.tool_use_id), - output: result, - }) - } - } - } - - // Add user message first - if (content.length > 0) { - formattedInput.push({ role: "user", content }) - } - - // Add tool results as separate items - if (toolResults.length > 0) { - formattedInput.push(...toolResults) - } - } else if (message.role === "assistant") { - const content: any[] = [] - const toolCalls: any[] = [] - - if (typeof message.content === "string") { - content.push({ type: "output_text", text: message.content }) - } else if (Array.isArray(message.content)) { - for (const block of message.content) { - if (block.type === "text") { - content.push({ type: "output_text", text: block.text }) - } else if (block.type === "tool_use") { - // Map Anthropic tool_use to Responses API function_call item - toolCalls.push({ - type: "function_call", - // Sanitize and truncate call_id to fit OpenAI's 64-char limit - call_id: sanitizeOpenAiCallId(block.id), - name: block.name, - arguments: JSON.stringify(block.input), - }) - } - } - } - - // Add assistant message if it has content - if (content.length > 0) { - formattedInput.push({ role: "assistant", content }) - } - - // Add tool calls as separate items - if (toolCalls.length > 0) { - formattedInput.push(...toolCalls) - } - } - } - - return formattedInput - } - - private async *makeResponsesApiRequest( - requestBody: any, - model: OpenAiNativeModel, - metadata?: ApiHandlerCreateMessageMetadata, - systemPrompt?: string, - messages?: Anthropic.Messages.MessageParam[], - ): ApiStream { - const apiKey = this.options.openAiNativeApiKey ?? "not-provided" - const baseUrl = this.options.openAiNativeBaseUrl || "https://api.openai.com" - const url = `${baseUrl}/v1/responses` - - // Create AbortController for cancellation - this.abortController = new AbortController() - - // Build per-request headers using taskId when available, falling back to sessionId - const taskId = metadata?.taskId - const userAgent = `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}` - - try { - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - originator: "roo-code", - session_id: taskId || this.sessionId, - "User-Agent": userAgent, - }, - body: JSON.stringify(requestBody), - signal: this.abortController.signal, - }) - - if (!response.ok) { - const errorText = await response.text() - - let errorMessage = `OpenAI Responses API request failed (${response.status})` - let errorDetails = "" - - // Try to parse error as JSON for better error messages - try { - const errorJson = JSON.parse(errorText) - if (errorJson.error?.message) { - errorDetails = errorJson.error.message - } else if (errorJson.message) { - errorDetails = errorJson.message - } else { - errorDetails = errorText - } - } catch { - // If not JSON, use the raw text - errorDetails = errorText - } - - // Provide user-friendly error messages based on status code - switch (response.status) { - case 400: - errorMessage = "Invalid request to Responses API. Please check your input parameters." - break - case 401: - errorMessage = "Authentication failed. Please check your OpenAI API key." - break - case 403: - errorMessage = "Access denied. Your API key may not have access to this endpoint." - break - case 404: - errorMessage = - "Responses API endpoint not found. The endpoint may not be available yet or requires a different configuration." - break - case 429: - errorMessage = "Rate limit exceeded. Please try again later." - break - case 500: - case 502: - case 503: - errorMessage = "OpenAI service error. Please try again later." - break - default: - errorMessage = `Responses API error (${response.status})` - } - - // Append details if available - if (errorDetails) { - errorMessage += ` - ${errorDetails}` - } - - throw new Error(errorMessage) - } - - if (!response.body) { - throw new Error("Responses API error: No response body") - } - - // Handle streaming response - yield* this.handleStreamResponse(response.body, model) - } catch (error) { - const model = this.getModel() - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, model.id, "createMessage") - TelemetryService.instance.captureException(apiError) - - if (error instanceof Error) { - // Re-throw with the original error message if it's already formatted - if (error.message.includes("Responses API")) { - throw error - } - // Otherwise, wrap it with context - throw new Error(`Failed to connect to Responses API: ${error.message}`) - } - // Handle non-Error objects - throw new Error(`Unexpected error connecting to Responses API`) - } finally { - this.abortController = undefined - } - } - - /** - * Handles the streaming response from the Responses API. - * - * This function iterates through the Server-Sent Events (SSE) stream, parses each event, - * and yields structured data chunks (`ApiStream`). It handles a wide variety of event types, - * including text deltas, reasoning, usage data, and various status/tool events. - */ - private async *handleStreamResponse(body: ReadableStream, model: OpenAiNativeModel): ApiStream { - const reader = body.getReader() - const decoder = new TextDecoder() - let buffer = "" - let hasContent = false - let totalInputTokens = 0 - let totalOutputTokens = 0 - - try { - while (true) { - // Check if request was aborted - if (this.abortController?.signal.aborted) { - break - } - - const { done, value } = await reader.read() - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split("\n") - buffer = lines.pop() || "" - - for (const line of lines) { - if (line.startsWith("data: ")) { - const data = line.slice(6).trim() - if (data === "[DONE]") { - continue - } - - try { - const parsed = JSON.parse(data) - - // Capture resolved service tier if present - if (parsed.response?.service_tier) { - this.lastServiceTier = parsed.response.service_tier as ServiceTier - } - // Capture complete output array (includes reasoning items with encrypted_content) - if (parsed.response?.output && Array.isArray(parsed.response.output)) { - this.lastResponseOutput = parsed.response.output - } - // Capture top-level response id - if (parsed.response?.id) { - this.lastResponseId = parsed.response.id as string - } - - // Delegate standard event types to the shared processor to avoid duplication - if (parsed?.type && this.coreHandledEventTypes.has(parsed.type)) { - for await (const outChunk of this.processEvent(parsed, model)) { - // Track whether we've emitted any content so fallback handling can decide appropriately - // Include tool calls so tool-call-only responses aren't treated as empty - if ( - outChunk.type === "text" || - outChunk.type === "reasoning" || - outChunk.type === "tool_call" || - outChunk.type === "tool_call_partial" - ) { - hasContent = true - } - yield outChunk - } - continue - } - - // Check if this is a complete response (non-streaming format) - if (parsed.response && parsed.response.output && Array.isArray(parsed.response.output)) { - // Handle complete response in the initial event - for (const outputItem of parsed.response.output) { - if (outputItem.type === "text" && outputItem.content) { - for (const content of outputItem.content) { - if (content.type === "text" && content.text) { - hasContent = true - yield { - type: "text", - text: content.text, - } - } - } - } - // Additionally handle reasoning summaries if present (non-streaming summary output) - if (outputItem.type === "reasoning" && Array.isArray(outputItem.summary)) { - for (const summary of outputItem.summary) { - if (summary?.type === "summary_text" && typeof summary.text === "string") { - hasContent = true - yield { - type: "reasoning", - text: summary.text, - } - } - } - } - } - // Check for usage in the complete response - if (parsed.response.usage) { - const usageData = this.normalizeUsage(parsed.response.usage, model) - if (usageData) { - yield usageData - } - } - } - // Handle streaming delta events for text content - else if ( - parsed.type === "response.text.delta" || - parsed.type === "response.output_text.delta" - ) { - // Primary streaming event for text deltas - if (parsed.delta) { - hasContent = true - yield { - type: "text", - text: parsed.delta, - } - } - } else if ( - parsed.type === "response.text.done" || - parsed.type === "response.output_text.done" - ) { - // Text streaming completed - final text already streamed via deltas - } - // Handle reasoning delta events - else if ( - parsed.type === "response.reasoning.delta" || - parsed.type === "response.reasoning_text.delta" - ) { - // Streaming reasoning content - if (parsed.delta) { - hasContent = true - yield { - type: "reasoning", - text: parsed.delta, - } - } - } else if ( - parsed.type === "response.reasoning.done" || - parsed.type === "response.reasoning_text.done" - ) { - // Reasoning streaming completed - } - // Handle reasoning summary events - else if ( - parsed.type === "response.reasoning_summary.delta" || - parsed.type === "response.reasoning_summary_text.delta" - ) { - // Streaming reasoning summary - if (parsed.delta) { - hasContent = true - yield { - type: "reasoning", - text: parsed.delta, - } - } - } else if ( - parsed.type === "response.reasoning_summary.done" || - parsed.type === "response.reasoning_summary_text.done" - ) { - // Reasoning summary completed - } - // Handle refusal delta events - else if (parsed.type === "response.refusal.delta") { - // Model is refusing to answer - if (parsed.delta) { - hasContent = true - yield { - type: "text", - text: `[Refusal] ${parsed.delta}`, - } - } - } else if (parsed.type === "response.refusal.done") { - // Refusal completed - } - // Handle audio delta events (for multimodal responses) - else if (parsed.type === "response.audio.delta") { - // Audio streaming - we'll skip for now as we focus on text - // Could be handled in future for voice responses - } else if (parsed.type === "response.audio.done") { - // Audio completed - } - // Handle audio transcript delta events - else if (parsed.type === "response.audio_transcript.delta") { - // Audio transcript streaming - if (parsed.delta) { - hasContent = true - yield { - type: "text", - text: parsed.delta, - } - } - } else if (parsed.type === "response.audio_transcript.done") { - // Audio transcript completed - } - // Handle content part events (for structured content) - else if (parsed.type === "response.content_part.added") { - // New content part added - could be text, image, etc. - if (parsed.part?.type === "text" && parsed.part.text) { - hasContent = true - yield { - type: "text", - text: parsed.part.text, - } - } - } else if (parsed.type === "response.content_part.done") { - // Content part completed - } - // Handle output item events (alternative format) - else if (parsed.type === "response.output_item.added") { - // This is where the actual content comes through in some test cases - if (parsed.item) { - if (parsed.item.type === "text" && parsed.item.text) { - hasContent = true - yield { type: "text", text: parsed.item.text } - } else if (parsed.item.type === "reasoning" && parsed.item.text) { - hasContent = true - yield { type: "reasoning", text: parsed.item.text } - } else if (parsed.item.type === "message" && parsed.item.content) { - // Handle message type items - for (const content of parsed.item.content) { - if (content.type === "text" && content.text) { - hasContent = true - yield { type: "text", text: content.text } - } - } - } - } - } else if (parsed.type === "response.output_item.done") { - // Output item completed - } - // Handle function/tool call events - else if ( - parsed.type === "response.function_call_arguments.delta" || - parsed.type === "response.tool_call_arguments.delta" || - parsed.type === "response.function_call_arguments.done" || - parsed.type === "response.tool_call_arguments.done" - ) { - // Delegated to processEvent (handles accumulation and completion) - for await (const outChunk of this.processEvent(parsed, model)) { - yield outChunk - } - } - // Handle MCP (Model Context Protocol) tool events - else if (parsed.type === "response.mcp_call_arguments.delta") { - // MCP tool call arguments streaming - } else if (parsed.type === "response.mcp_call_arguments.done") { - // MCP tool call completed - } else if (parsed.type === "response.mcp_call.in_progress") { - // MCP tool call in progress - } else if ( - parsed.type === "response.mcp_call.completed" || - parsed.type === "response.mcp_call.failed" - ) { - // MCP tool call status events - } else if (parsed.type === "response.mcp_list_tools.in_progress") { - // MCP list tools in progress - } else if ( - parsed.type === "response.mcp_list_tools.completed" || - parsed.type === "response.mcp_list_tools.failed" - ) { - // MCP list tools status events - } - // Handle web search events - else if (parsed.type === "response.web_search_call.searching") { - // Web search in progress - } else if (parsed.type === "response.web_search_call.in_progress") { - // Processing web search results - } else if (parsed.type === "response.web_search_call.completed") { - // Web search completed - } - // Handle code interpreter events - else if (parsed.type === "response.code_interpreter_call_code.delta") { - // Code interpreter code streaming - if (parsed.delta) { - // Could yield as a special code type if needed - } - } else if (parsed.type === "response.code_interpreter_call_code.done") { - // Code interpreter code completed - } else if (parsed.type === "response.code_interpreter_call.interpreting") { - // Code interpreter running - } else if (parsed.type === "response.code_interpreter_call.in_progress") { - // Code execution in progress - } else if (parsed.type === "response.code_interpreter_call.completed") { - // Code interpreter completed - } - // Handle file search events - else if (parsed.type === "response.file_search_call.searching") { - // File search in progress - } else if (parsed.type === "response.file_search_call.in_progress") { - // Processing file search results - } else if (parsed.type === "response.file_search_call.completed") { - // File search completed - } - // Handle image generation events - else if (parsed.type === "response.image_gen_call.generating") { - // Image generation in progress - } else if (parsed.type === "response.image_gen_call.in_progress") { - // Processing image generation - } else if (parsed.type === "response.image_gen_call.partial_image") { - // Image partially generated - } else if (parsed.type === "response.image_gen_call.completed") { - // Image generation completed - } - // Handle computer use events - else if ( - parsed.type === "response.computer_tool_call.output_item" || - parsed.type === "response.computer_tool_call.output_screenshot" - ) { - // Computer use tool events - } - // Handle annotation events - else if ( - parsed.type === "response.output_text_annotation.added" || - parsed.type === "response.text_annotation.added" - ) { - // Text annotation events - could be citations, references, etc. - } - // Handle error events - else if (parsed.type === "response.error" || parsed.type === "error") { - // Error event from the API - if (parsed.error || parsed.message) { - throw new Error( - `Responses API error: ${parsed.error?.message || parsed.message || "Unknown error"}`, - ) - } - } - // Handle incomplete event - else if (parsed.type === "response.incomplete") { - // Response was incomplete - might need to handle specially - } - // Handle queued event - else if (parsed.type === "response.queued") { - // Response is queued - } - // Handle in_progress event - else if (parsed.type === "response.in_progress") { - // Response is being processed - } - // Handle failed event - else if (parsed.type === "response.failed") { - // Response failed - if (parsed.error || parsed.message) { - throw new Error( - `Response failed: ${parsed.error?.message || parsed.message || "Unknown failure"}`, - ) - } - } else if (parsed.type === "response.completed" || parsed.type === "response.done") { - // Capture resolved service tier if present - if (parsed.response?.service_tier) { - this.lastServiceTier = parsed.response.service_tier as ServiceTier - } - // Capture top-level response id - if (parsed.response?.id) { - this.lastResponseId = parsed.response.id as string - } - // Capture complete output array (includes reasoning items with encrypted_content) - if (parsed.response?.output && Array.isArray(parsed.response.output)) { - this.lastResponseOutput = parsed.response.output - } - - // Check if the done event contains the complete output (as a fallback) - if ( - !hasContent && - parsed.response && - parsed.response.output && - Array.isArray(parsed.response.output) - ) { - for (const outputItem of parsed.response.output) { - if (outputItem.type === "message" && outputItem.content) { - for (const content of outputItem.content) { - if (content.type === "output_text" && content.text) { - hasContent = true - yield { - type: "text", - text: content.text, - } - } - } - } - // Also surface reasoning summaries if present in the final output - if (outputItem.type === "reasoning" && Array.isArray(outputItem.summary)) { - for (const summary of outputItem.summary) { - if ( - summary?.type === "summary_text" && - typeof summary.text === "string" - ) { - hasContent = true - yield { - type: "reasoning", - text: summary.text, - } - } - } - } - } - } - - // Usage for done/completed is already handled by processEvent in the SDK path. - // For SSE path, usage often arrives separately; avoid double-emitting here. - } - // These are structural or status events, we can just log them at a lower level or ignore. - else if ( - parsed.type === "response.created" || - parsed.type === "response.in_progress" || - parsed.type === "response.output_item.done" || - parsed.type === "response.content_part.added" || - parsed.type === "response.content_part.done" - ) { - // Status events - no action needed - } - // Fallback for older formats or unexpected responses - else if (parsed.choices?.[0]?.delta?.content) { - hasContent = true - yield { - type: "text", - text: parsed.choices[0].delta.content, - } - } - // Additional fallback: some events place text under 'item.text' even if type isn't matched above - else if ( - parsed.item && - typeof parsed.item.text === "string" && - parsed.item.text.length > 0 - ) { - hasContent = true - yield { - type: "text", - text: parsed.item.text, - } - } else if (parsed.usage) { - // Handle usage if it arrives in a separate, non-completed event - const usageData = this.normalizeUsage(parsed.usage, model) - if (usageData) { - yield usageData - } - } - } catch (e) { - // Only ignore JSON parsing errors, re-throw actual API errors - if (!(e instanceof SyntaxError)) { - throw e - } - } - } - // Also try to parse non-SSE formatted lines - else if (line.trim() && !line.startsWith(":")) { - try { - const parsed = JSON.parse(line) - - // Try to extract content from various possible locations - if (parsed.content || parsed.text || parsed.message) { - hasContent = true - yield { - type: "text", - text: parsed.content || parsed.text || parsed.message, - } - } - } catch { - // Not JSON, might be plain text - ignore - } - } - } - } - - // If we didn't get any content, don't throw - the API might have returned an empty response - // This can happen in certain edge cases and shouldn't break the flow - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, model.id, "createMessage") - TelemetryService.instance.captureException(apiError) - - if (error instanceof Error) { - throw new Error(`Error processing response stream: ${error.message}`) - } - throw new Error("Unexpected error processing response stream") - } finally { - reader.releaseLock() - } - } - - /** - * Shared processor for Responses API events. - */ - private async *processEvent(event: any, model: OpenAiNativeModel): ApiStream { - // Capture resolved service tier when available - if (event?.response?.service_tier) { - this.lastServiceTier = event.response.service_tier as ServiceTier - } - // Capture complete output array (includes reasoning items with encrypted_content) - if (event?.response?.output && Array.isArray(event.response.output)) { - this.lastResponseOutput = event.response.output - } - // Capture top-level response id - if (event?.response?.id) { - this.lastResponseId = event.response.id as string - } - - // Handle known streaming text deltas - if (event?.type === "response.text.delta" || event?.type === "response.output_text.delta") { - if (event?.delta) { - yield { type: "text", text: event.delta } - } - return - } - - // Handle reasoning deltas (including summary variants) - if ( - event?.type === "response.reasoning.delta" || - event?.type === "response.reasoning_text.delta" || - event?.type === "response.reasoning_summary.delta" || - event?.type === "response.reasoning_summary_text.delta" - ) { - if (event?.delta) { - yield { type: "reasoning", text: event.delta } - } - return - } - - // Handle refusal deltas - if (event?.type === "response.refusal.delta") { - if (event?.delta) { - yield { type: "text", text: `[Refusal] ${event.delta}` } - } - return - } - - // Handle tool/function call deltas - emit as partial chunks - if ( - event?.type === "response.tool_call_arguments.delta" || - event?.type === "response.function_call_arguments.delta" - ) { - // Some streams omit stable identity on delta events; fall back to the - // most recently observed tool identity from output_item events. - const callId = event.call_id || event.tool_call_id || event.id || this.pendingToolCallId || undefined - const name = event.name || event.function_name || this.pendingToolCallName || undefined - const args = event.delta || event.arguments - - // Avoid emitting incomplete tool_call_partial chunks; the downstream - // NativeToolCallParser needs a name to start a call. - if (typeof name === "string" && name.length > 0 && typeof callId === "string" && callId.length > 0) { - yield { - type: "tool_call_partial", - index: event.index ?? 0, - id: callId, - name, - arguments: args, - } - } - return - } - - // Handle tool/function call completion events - if ( - event?.type === "response.tool_call_arguments.done" || - event?.type === "response.function_call_arguments.done" - ) { - // Tool call complete - no action needed, NativeToolCallParser handles completion - return - } - - // Handle output item additions/completions (SDK or Responses API alternative format) - if (event?.type === "response.output_item.added" || event?.type === "response.output_item.done") { - const item = event?.item - if (item) { - // Capture tool identity so subsequent argument deltas can be attributed. - if (item.type === "function_call" || item.type === "tool_call") { - const callId = item.call_id || item.tool_call_id || item.id - const name = item.name || item.function?.name || item.function_name - if (typeof callId === "string" && callId.length > 0) { - this.pendingToolCallId = callId - this.pendingToolCallName = typeof name === "string" ? name : undefined - } - } - - // For "added" events, yield text/reasoning content (streaming path) - // For "done" events, do NOT yield text/reasoning - it's already been streamed via deltas - // and would cause double-emission (A, B, C, ABC). - if (event.type === "response.output_item.added") { - if (item.type === "text" && item.text) { - yield { type: "text", text: item.text } - } else if (item.type === "reasoning" && item.text) { - yield { type: "reasoning", text: item.text } - } else if (item.type === "message" && Array.isArray(item.content)) { - for (const content of item.content) { - // Some implementations send 'text'; others send 'output_text' - if ((content?.type === "text" || content?.type === "output_text") && content?.text) { - yield { type: "text", text: content.text } - } - } - } - } - - // Note: We intentionally do NOT emit tool_call from response.output_item.done - // for function_call/tool_call items. The streaming path handles tool calls via: - // 1. tool_call_partial events during argument deltas - // 2. NativeToolCallParser.finalizeRawChunks() at stream end emitting tool_call_end - // 3. NativeToolCallParser.finalizeStreamingToolCall() creating the final ToolUse - // Emitting tool_call here would cause duplicate tool rendering. - } - return - } - - // Completion events that may carry usage - if (event?.type === "response.done" || event?.type === "response.completed") { - const usage = event?.response?.usage || event?.usage || undefined - const usageData = this.normalizeUsage(usage, model) - if (usageData) { - yield usageData - } - return - } - - // Fallbacks for older formats or unexpected objects - if (event?.choices?.[0]?.delta?.content) { - yield { type: "text", text: event.choices[0].delta.content } - return - } - - if (event?.usage) { - const usageData = this.normalizeUsage(event.usage, model) - if (usageData) { - yield usageData - } - } - } - - private getReasoningEffort(model: OpenAiNativeModel): ReasoningEffortExtended | undefined { - // Single source of truth: user setting overrides, else model default (from types). - const selected = (this.options.reasoningEffort as any) ?? (model.info.reasoningEffort as any) - return selected && selected !== "disable" ? (selected as any) : undefined - } - - /** - * Returns the appropriate prompt cache retention policy for the given model, if any. - * - * The policy is driven by ModelInfo.promptCacheRetention so that model-specific details - * live in the shared types layer rather than this provider. When set to "24h" and the - * model supports prompt caching, extended prompt cache retention is requested. - */ - private getPromptCacheRetention(model: OpenAiNativeModel): "24h" | undefined { - if (!model.info.supportsPromptCache) return undefined - - if (model.info.promptCacheRetention === "24h") { - return "24h" - } - - return undefined - } - - /** - * Returns a shallow-cloned ModelInfo with pricing overridden for the given tier, if available. - * If no tier or no overrides exist, the original ModelInfo is returned. - */ - private applyServiceTierPricing(info: ModelInfo, tier?: ServiceTier): ModelInfo { - if (!tier || tier === "default") return info - - // Find the tier with matching name in the tiers array - const tierInfo = info.tiers?.find((t) => t.name === tier) - if (!tierInfo) return info - - return { - ...info, - inputPrice: tierInfo.inputPrice ?? info.inputPrice, - outputPrice: tierInfo.outputPrice ?? info.outputPrice, - cacheReadsPrice: tierInfo.cacheReadsPrice ?? info.cacheReadsPrice, - cacheWritesPrice: tierInfo.cacheWritesPrice ?? info.cacheWritesPrice, - } - } - - // Removed isResponsesApiModel method as ALL models now use the Responses API - override getModel() { const modelId = this.options.apiModelId - let id = + const id = modelId && modelId in openAiNativeModels ? (modelId as OpenAiNativeModelId) : openAiNativeDefaultModelId const info: ModelInfo = openAiNativeModels[id] @@ -1336,138 +249,303 @@ export class OpenAiNativeHandler extends BaseProvider implements SingleCompletio defaultTemperature: OPENAI_NATIVE_DEFAULT_TEMPERATURE, }) - // Reasoning effort inclusion is handled by getModelParams/getOpenAiReasoning. - // Do not re-compute or filter efforts here. - - // The o3 models are named like "o3-mini-[reasoning-effort]", which are - // not valid model ids, so we need to strip the suffix. return { id: id.startsWith("o3-mini") ? "o3-mini" : id, info, ...params, verbosity: params.verbosity } } /** - * Extracts encrypted_content and id from the first reasoning item in the output array. - * This is the minimal data needed for stateless API continuity. - * - * @returns Object with encrypted_content and id, or undefined if not available + * Get the language model for the configured model ID. + * Uses the Responses API (default for @ai-sdk/openai since AI SDK 5). */ - getEncryptedContent(): { encrypted_content: string; id?: string } | undefined { - if (!this.lastResponseOutput) return undefined + protected getLanguageModel() { + const { id } = this.getModel() + return this.provider.responses(id) + } - // Find the first reasoning item with encrypted_content - const reasoningItem = this.lastResponseOutput.find( - (item) => item.type === "reasoning" && item.encrypted_content, - ) + private getReasoningEffort(model: OpenAiNativeModel): ReasoningEffortExtended | undefined { + const selected = (this.options.reasoningEffort as any) ?? (model.info.reasoningEffort as any) + return selected && selected !== "disable" ? (selected as any) : undefined + } - if (!reasoningItem?.encrypted_content) return undefined + /** + * Returns the appropriate prompt cache retention policy for the given model, if any. + */ + private getPromptCacheRetention(model: OpenAiNativeModel): "24h" | undefined { + if (!model.info.supportsPromptCache) return undefined + if (model.info.promptCacheRetention === "24h") return "24h" + return undefined + } + + /** + * Returns a shallow-cloned ModelInfo with pricing overridden for the given tier, if available. + */ + private applyServiceTierPricing(info: ModelInfo, tier?: ServiceTier): ModelInfo { + if (!tier || tier === "default") return info + + const tierInfo = info.tiers?.find((t) => t.name === tier) + if (!tierInfo) return info return { - encrypted_content: reasoningItem.encrypted_content, - ...(reasoningItem.id ? { id: reasoningItem.id } : {}), + ...info, + inputPrice: tierInfo.inputPrice ?? info.inputPrice, + outputPrice: tierInfo.outputPrice ?? info.outputPrice, + cacheReadsPrice: tierInfo.cacheReadsPrice ?? info.cacheReadsPrice, + cacheWritesPrice: tierInfo.cacheWritesPrice ?? info.cacheWritesPrice, } } + /** + * Build OpenAI-specific provider options for the Responses API. + */ + private buildProviderOptions( + model: OpenAiNativeModel, + metadata?: ApiHandlerCreateMessageMetadata, + ): Record { + const reasoningEffort = this.getReasoningEffort(model) + const promptCacheRetention = this.getPromptCacheRetention(model) + + const requestedTier = (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined + const allowedTierNames = new Set(model.info.tiers?.map((t) => t.name).filter(Boolean) || []) + + const openaiOptions: Record = { + store: false, + parallelToolCalls: metadata?.parallelToolCalls ?? true, + } + + if (reasoningEffort) { + openaiOptions.reasoningEffort = reasoningEffort + openaiOptions.include = ["reasoning.encrypted_content"] + + if (this.options.enableResponsesReasoningSummary) { + openaiOptions.reasoningSummary = "auto" + } + } + + if (model.info.supportsVerbosity === true) { + openaiOptions.textVerbosity = (model.verbosity || "medium") as VerbosityLevel + } + + if (requestedTier && (requestedTier === "default" || allowedTierNames.has(requestedTier))) { + openaiOptions.serviceTier = requestedTier + } + + if (promptCacheRetention) { + openaiOptions.promptCacheRetention = promptCacheRetention + } + + return { openai: openaiOptions } + } + + /** + * Process usage metrics from the AI SDK response, including OpenAI-specific + * cache metrics and service-tier-adjusted pricing. + */ + protected processUsageMetrics( + usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number + } + }, + model: OpenAiNativeModel, + providerMetadata?: Record, + ): ApiStreamUsageChunk { + const inputTokens = usage.inputTokens || 0 + const outputTokens = usage.outputTokens || 0 + + const cacheReadTokens = usage.details?.cachedInputTokens ?? 0 + // The OpenAI Responses API does not report cache write tokens separately; + // only cached (read) tokens are available via usage.details.cachedInputTokens. + const cacheWriteTokens = 0 + const reasoningTokens = usage.details?.reasoningTokens + + const effectiveTier = + this.lastServiceTier || (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined + const effectiveInfo = this.applyServiceTierPricing(model.info, effectiveTier) + + const { totalCost } = calculateApiCostOpenAI( + effectiveInfo, + inputTokens, + outputTokens, + cacheWriteTokens, + cacheReadTokens, + ) + + return { + type: "usage", + inputTokens, + outputTokens, + cacheWriteTokens: cacheWriteTokens || undefined, + cacheReadTokens: cacheReadTokens || undefined, + ...(typeof reasoningTokens === "number" ? { reasoningTokens } : {}), + totalCost, + } + } + + /** + * Get the max output tokens parameter. + */ + protected getMaxOutputTokens(): number | undefined { + const model = this.getModel() + return model.maxTokens ?? undefined + } + + /** + * Create a message stream using the AI SDK. + */ + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const model = this.getModel() + const languageModel = this.getLanguageModel() + + this.lastResponseId = undefined + this.lastEncryptedContent = undefined + this.lastServiceTier = undefined + + // Step 1: Collect encrypted reasoning items and their positions before filtering. + // These are standalone items injected by buildCleanConversationHistory: + // { type: "reasoning", encrypted_content: "...", id: "...", summary: [...] } + const encryptedReasoningItems = collectEncryptedReasoningItems(messages) + + // Step 2: Filter out standalone encrypted reasoning items (they lack role + // and would break convertToAiSdkMessages which expects user/assistant/tool). + const standardMessages = messages.filter( + (msg) => + (msg as unknown as Record).type !== "reasoning" || + !(msg as unknown as Record).encrypted_content, + ) + + // Step 3: Strip plain-text reasoning blocks from assistant content arrays. + // These would be converted to AI SDK reasoning parts WITHOUT + // providerOptions.openai.itemId, which the Responses provider rejects. + const cleanedMessages = stripPlainTextReasoningBlocks(standardMessages) + + // Step 4: Convert to AI SDK messages. + const aiSdkMessages = convertToAiSdkMessages(cleanedMessages) + + // Step 5: Re-inject encrypted reasoning as properly-formed AI SDK reasoning + // parts with providerOptions.openai.itemId and reasoningEncryptedContent. + if (encryptedReasoningItems.length > 0) { + injectEncryptedReasoning(aiSdkMessages, encryptedReasoningItems, messages) + } + + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + const taskId = metadata?.taskId + const userAgent = `roo-code/${Package.version} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}` + const requestHeaders: Record = { + originator: "roo-code", + session_id: taskId || this.sessionId, + "User-Agent": userAgent, + } + + const providerOptions = this.buildProviderOptions(model, metadata) + + const requestOptions: Parameters[0] = { + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + headers: requestHeaders, + providerOptions, + ...(model.info.supportsTemperature !== false && { + temperature: this.options.modelTemperature ?? OPENAI_NATIVE_DEFAULT_TEMPERATURE, + }), + ...(model.maxTokens ? { maxOutputTokens: model.maxTokens } : {}), + } + + const result = streamText(requestOptions) + + try { + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } + + const providerMeta = await result.providerMetadata + const openaiMeta = (providerMeta as any)?.openai + + if (openaiMeta?.responseId) { + this.lastResponseId = openaiMeta.responseId + } + if (openaiMeta?.serviceTier) { + this.lastServiceTier = openaiMeta.serviceTier as ServiceTier + } + + // Capture encrypted content from reasoning parts in the response + try { + const content = await (result as any).content + if (Array.isArray(content)) { + for (const part of content) { + if (part.type === "reasoning" && part.providerMetadata) { + const partMeta = (part.providerMetadata as any)?.openai + if (partMeta?.reasoningEncryptedContent) { + this.lastEncryptedContent = { + encrypted_content: partMeta.reasoningEncryptedContent, + ...(partMeta.itemId ? { id: partMeta.itemId } : {}), + } + break + } + } + } + } + } catch { + // Content parts with encrypted reasoning may not always be available + } + + const usage = await result.usage + if (usage) { + yield this.processUsageMetrics(usage, model, providerMeta as any) + } + } catch (error) { + throw handleAiSdkError(error, this.providerName) + } + } + + /** + * Extracts encrypted_content and id from the last response's reasoning output. + */ + getEncryptedContent(): { encrypted_content: string; id?: string } | undefined { + return this.lastEncryptedContent + } + getResponseId(): string | undefined { return this.lastResponseId } + /** + * Complete a prompt using the AI SDK generateText. + */ async completePrompt(prompt: string): Promise { - // Create AbortController for cancellation - this.abortController = new AbortController() + const model = this.getModel() + const languageModel = this.getLanguageModel() + const providerOptions = this.buildProviderOptions(model) try { - const model = this.getModel() - const { verbosity, reasoning } = model - - // Resolve reasoning effort for models that support it - const reasoningEffort = this.getReasoningEffort(model) - - // Build request body for Responses API - const requestBody: any = { - model: model.id, - input: [ - { - role: "user", - content: [{ type: "input_text", text: prompt }], - }, - ], - stream: false, // Non-streaming for completePrompt - store: false, // Don't store prompt completions - // Only include encrypted reasoning content when reasoning effort is set - ...(reasoningEffort ? { include: ["reasoning.encrypted_content"] } : {}), - } - - // Include service tier if selected and supported - const requestedTier = (this.options.openAiNativeServiceTier as ServiceTier | undefined) || undefined - const allowedTierNames = new Set(model.info.tiers?.map((t) => t.name).filter(Boolean) || []) - if (requestedTier && (requestedTier === "default" || allowedTierNames.has(requestedTier))) { - requestBody.service_tier = requestedTier - } - - // Add reasoning if supported - if (reasoningEffort) { - requestBody.reasoning = { - effort: reasoningEffort, - ...(this.options.enableResponsesReasoningSummary ? { summary: "auto" as const } : {}), - } - } - - // Only include temperature if the model supports it - if (model.info.supportsTemperature !== false) { - requestBody.temperature = this.options.modelTemperature ?? OPENAI_NATIVE_DEFAULT_TEMPERATURE - } - - // Include max_output_tokens if available - if (model.maxTokens) { - requestBody.max_output_tokens = model.maxTokens - } - - // Include text.verbosity only when the model explicitly supports it - if (model.info.supportsVerbosity === true) { - requestBody.text = { verbosity: (verbosity || "medium") as VerbosityLevel } - } - - // Enable extended prompt cache retention for eligible models - const promptCacheRetention = this.getPromptCacheRetention(model) - if (promptCacheRetention) { - requestBody.prompt_cache_retention = promptCacheRetention - } - - // Make the non-streaming request - const response = await (this.client as any).responses.create(requestBody, { - signal: this.abortController.signal, + const { text } = await generateText({ + model: languageModel, + prompt, + providerOptions, + ...(model.info.supportsTemperature !== false && { + temperature: this.options.modelTemperature ?? OPENAI_NATIVE_DEFAULT_TEMPERATURE, + }), + ...(model.maxTokens ? { maxOutputTokens: model.maxTokens } : {}), }) - // Extract text from the response - if (response?.output && Array.isArray(response.output)) { - for (const outputItem of response.output) { - if (outputItem.type === "message" && outputItem.content) { - for (const content of outputItem.content) { - if (content.type === "output_text" && content.text) { - return content.text - } - } - } - } - } - - // Fallback: check for direct text in response - if (response?.text) { - return response.text - } - - return "" + return text } catch (error) { - const errorModel = this.getModel() - const errorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(errorMessage, this.providerName, errorModel.id, "completePrompt") - TelemetryService.instance.captureException(apiError) - - if (error instanceof Error) { - throw new Error(`OpenAI Native completion error: ${error.message}`) - } - throw error - } finally { - this.abortController = undefined + throw handleAiSdkError(error, this.providerName) } } + + override isAiSdkProvider(): boolean { + return true + } } diff --git a/src/package.json b/src/package.json index d596c3f1c4..14ca25d3e4 100644 --- a/src/package.json +++ b/src/package.json @@ -458,6 +458,7 @@ "@ai-sdk/google": "^3.0.22", "@ai-sdk/google-vertex": "^4.0.45", "@ai-sdk/mistral": "^3.0.19", + "@ai-sdk/openai": "^3.0.26", "@ai-sdk/xai": "^3.0.48", "@anthropic-ai/sdk": "^0.37.0", "@anthropic-ai/vertex-sdk": "^0.7.0",