From 0ed51e7d87b830f0cabc06b6bf5fe2778644d858 Mon Sep 17 00:00:00 2001 From: Alex Bujduveanu Date: Thu, 5 Nov 2026 00:15:07 +0000 Subject: [PATCH 1/2] fix(callModel): check stopWhen before running auto-approved siblings of an approval-gated call Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src/lib/model-result.ts | 19 ++- tests/unit/stop-when-before-approval.test.ts | 159 +++++++++++++++++++ 2 files changed, 174 insertions(+), 4 deletions(-) create mode 100644 tests/unit/stop-when-before-approval.test.ts diff --git a/src/lib/model-result.ts b/src/lib/model-result.ts index ebf1527c5..877642633 100644 --- a/src/lib/model-result.ts +++ b/src/lib/model-result.ts @@ -615,13 +615,16 @@ export class ModelResult< * @param toolCalls - The tool calls to check * @param currentRound - The current execution round (1-indexed) * @param currentResponse - The current response to save if pausing + * @param stopConditionMet - When true, auto-approved calls are not executed; + * they are saved as skipped results so the paused turn stays well-formed * @returns True if execution should pause for approval, false to continue * @throws Error if approval is required but no state accessor is configured */ private async handleApprovalCheck( toolCalls: ParsedToolCall[], currentRound: number, - currentResponse: OpenResponsesResult + currentResponse: OpenResponsesResult, + stopConditionMet = false ): Promise { if (!this.options.tools) return false; @@ -648,8 +651,12 @@ export class ModelResult< ); } - // Execute auto-approve tools - const unsentResults = await this.executeAutoApproveTools(autoExecute, turnContext); + // Execute auto-approve tools, unless stopWhen already says no tool may run + const unsentResults: UnsentToolResult[] = stopConditionMet + ? autoExecute.map((tc) => + createRejectedResult(tc.id, String(tc.name), 'Tool execution skipped: stop condition met') + ) + : await this.executeAutoApproveTools(autoExecute, turnContext); // Save state with pending approvals const stateUpdates: Partial, 'id' | 'createdAt' | 'updatedAt'>> = { @@ -1278,8 +1285,12 @@ export class ModelResult< // Extract and check tool calls const toolCalls = extractToolCallsFromResponse(currentResponse); + // Evaluate stopWhen before the approval check so a met stop condition + // also blocks the auto-approved siblings of an approval-gated call + const stopConditionMet = await this.shouldStopExecution(); + // Check for approval requirements - if (await this.handleApprovalCheck(toolCalls, 0, currentResponse)) { + if (await this.handleApprovalCheck(toolCalls, 0, currentResponse, stopConditionMet)) { return; // Paused for approval } diff --git a/tests/unit/stop-when-before-approval.test.ts b/tests/unit/stop-when-before-approval.test.ts new file mode 100644 index 000000000..7e14eedb3 --- /dev/null +++ b/tests/unit/stop-when-before-approval.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { z } from 'zod/v4'; +import type { OpenRouterCore } from '../../src/core.js'; +import type { OpenResponsesResult } from '../../src/models/openresponsesresult.js'; +import { tool } from '../../src/lib/tool.js'; +import type { ConversationState, StateAccessor, Tool } from '../../src/lib/tool-types.js'; + +const mockResponsesSend = vi.hoisted(() => vi.fn()); + +vi.mock('../../src/funcs/responsesSend.js', () => ({ + responsesSend: mockResponsesSend, +})); + +// Import ModelResult AFTER vi.mock so the transport mock is wired in. +const { ModelResult } = await import('../../src/lib/model-result.js'); + +function functionCall(callId: string, name: string): OpenResponsesResult['output'][number] { + return { + type: 'function_call', + id: `fc_${callId}`, + callId, + name, + arguments: '{"v":"x"}', + status: 'completed', + } as OpenResponsesResult['output'][number]; +} + +function makeResponse(output: OpenResponsesResult['output']): OpenResponsesResult { + return { + id: 'resp_1', + object: 'response', + createdAt: 0, + completedAt: 0, + model: 'test-model', + status: 'completed', + output, + error: null, + incompleteDetails: null, + temperature: null, + topP: null, + presencePenalty: null, + frequencyPenalty: null, + metadata: null, + instructions: null, + tools: [], + toolChoice: 'auto', + parallelToolCalls: false, + } as OpenResponsesResult; +} + +function createMemoryAccessor() { + let state: ConversationState | null = null; + const accessor: StateAccessor = { + load: async () => state, + save: async (next) => { + state = next; + }, + }; + return { accessor, get: () => state }; +} + +describe('stopWhen is evaluated before the approval check on the first response', () => { + beforeEach(() => { + mockResponsesSend.mockReset(); + }); + + it('does not run an auto-executable sibling of an approval-gated call when stopWhen is already met', async () => { + const autoExecute = vi.fn(async () => ({ ok: true })); + const gatedExecute = vi.fn(async () => ({ ok: true })); + const auto = tool({ + name: 'auto', + inputSchema: z.object({ v: z.string() }), + outputSchema: z.object({ ok: z.boolean() }), + execute: autoExecute, + }); + const gated = tool({ + name: 'gated', + inputSchema: z.object({ v: z.string() }), + outputSchema: z.object({ ok: z.boolean() }), + requireApproval: true, + execute: gatedExecute, + }); + const tools = [auto, gated] as const; + + mockResponsesSend.mockResolvedValue({ + ok: true, + value: makeResponse([functionCall('call_auto', 'auto'), functionCall('call_gated', 'gated')]), + }); + + const memory = createMemoryAccessor(); + const result = new ModelResult({ + request: { model: 'test-model', input: 'go' }, + client: {} as OpenRouterCore, + tools, + state: memory.accessor, + stopWhen: () => true, + }); + + await result.getResponse(); + + expect(autoExecute).not.toHaveBeenCalled(); + expect(gatedExecute).not.toHaveBeenCalled(); + expect(mockResponsesSend).toHaveBeenCalledTimes(1); + + const saved = memory.get(); + expect(saved?.status).toBe('awaiting_approval'); + expect(saved?.pendingToolCalls?.map((tc) => tc.id)).toEqual(['call_gated']); + // The skipped sibling still gets a matching output for the resume request. + expect(saved?.unsentToolResults).toEqual([ + { + callId: 'call_auto', + name: 'auto', + output: null, + error: 'Tool execution skipped: stop condition met', + }, + ]); + expect(await result.requiresApproval()).toBe(true); + }); + + it('still runs the auto-executable sibling when stopWhen is not met', async () => { + const autoExecute = vi.fn(async () => ({ ok: true })); + const auto = tool({ + name: 'auto', + inputSchema: z.object({ v: z.string() }), + outputSchema: z.object({ ok: z.boolean() }), + execute: autoExecute, + }); + const gated = tool({ + name: 'gated', + inputSchema: z.object({ v: z.string() }), + outputSchema: z.object({ ok: z.boolean() }), + requireApproval: true, + execute: async () => ({ ok: true }), + }); + const tools = [auto, gated] as const; + + mockResponsesSend.mockResolvedValue({ + ok: true, + value: makeResponse([functionCall('call_auto', 'auto'), functionCall('call_gated', 'gated')]), + }); + + const memory = createMemoryAccessor(); + const result = new ModelResult({ + request: { model: 'test-model', input: 'go' }, + client: {} as OpenRouterCore, + tools, + state: memory.accessor, + stopWhen: () => false, + }); + + await result.getResponse(); + + expect(autoExecute).toHaveBeenCalledTimes(1); + const saved = memory.get(); + expect(saved?.status).toBe('awaiting_approval'); + expect(saved?.pendingToolCalls?.map((tc) => tc.id)).toEqual(['call_gated']); + expect(saved?.unsentToolResults?.map((r) => r.callId)).toEqual(['call_auto']); + }); +}); From 32998e0419934ece387656db023e56d6f0f6c972 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 17:05:21 +0000 Subject: [PATCH 2/2] refactor: remove the unreachable callModel code that moved to @openrouter/agent --- CLAUDE.md | 81 +- .../anthropic-multimodal-tools.example.ts | 350 ---- examples/anthropic-reasoning.example.ts | 158 -- .../call-model-typed-tool-calling.example.ts | 169 -- examples/call-model.example.ts | 67 - examples/tools-example.ts | 388 ---- src/funcs/call-model.ts | 132 -- src/lib/anthropic-compat.ts | 274 --- src/lib/async-params.ts | 207 -- src/lib/chat-compat.ts | 143 -- src/lib/claude-constants.ts | 26 - src/lib/claude-type-guards.ts | 83 - src/lib/conversation-state.ts | 285 --- src/lib/model-result.ts | 1857 ----------------- src/lib/next-turn-params.ts | 177 -- src/lib/reusable-stream.ts | 220 -- src/lib/stop-conditions.ts | 129 -- src/lib/stream-transformers.ts | 1122 ---------- src/lib/stream-type-guards.ts | 173 -- src/lib/tool-context.ts | 247 --- src/lib/tool-event-broadcaster.ts | 170 -- src/lib/tool-executor.ts | 363 ---- src/lib/tool-orchestrator.ts | 260 --- src/lib/tool-types.ts | 727 ------- src/lib/tool.ts | 311 --- src/lib/turn-context.ts | 84 - src/sdk/sdk.ts | 23 - tests/unit/build-tool-call-stream.test.ts | 289 --- tests/unit/conversation-state.test.ts | 407 ---- tests/unit/create-tool.test.ts | 185 -- tests/unit/schema-sanitization.test.ts | 130 -- tests/unit/shared-context.test.ts | 205 -- tests/unit/stop-when-before-approval.test.ts | 159 -- tests/unit/tool-context.test.ts | 596 ------ tests/unit/tool-event-broadcaster.test.ts | 300 --- tests/unit/tool-executor-return.test.ts | 143 -- tests/unit/tool-type-compat.test.ts | 342 --- tests/unit/turn-end-race-condition.test.ts | 182 -- 38 files changed, 2 insertions(+), 11162 deletions(-) delete mode 100644 examples/anthropic-multimodal-tools.example.ts delete mode 100644 examples/anthropic-reasoning.example.ts delete mode 100644 examples/call-model-typed-tool-calling.example.ts delete mode 100644 examples/call-model.example.ts delete mode 100644 examples/tools-example.ts delete mode 100644 src/funcs/call-model.ts delete mode 100644 src/lib/anthropic-compat.ts delete mode 100644 src/lib/async-params.ts delete mode 100644 src/lib/chat-compat.ts delete mode 100644 src/lib/claude-constants.ts delete mode 100644 src/lib/claude-type-guards.ts delete mode 100644 src/lib/conversation-state.ts delete mode 100644 src/lib/model-result.ts delete mode 100644 src/lib/next-turn-params.ts delete mode 100644 src/lib/reusable-stream.ts delete mode 100644 src/lib/stop-conditions.ts delete mode 100644 src/lib/stream-transformers.ts delete mode 100644 src/lib/stream-type-guards.ts delete mode 100644 src/lib/tool-context.ts delete mode 100644 src/lib/tool-event-broadcaster.ts delete mode 100644 src/lib/tool-executor.ts delete mode 100644 src/lib/tool-orchestrator.ts delete mode 100644 src/lib/tool-types.ts delete mode 100644 src/lib/tool.ts delete mode 100644 src/lib/turn-context.ts delete mode 100644 tests/unit/build-tool-call-stream.test.ts delete mode 100644 tests/unit/conversation-state.test.ts delete mode 100644 tests/unit/create-tool.test.ts delete mode 100644 tests/unit/schema-sanitization.test.ts delete mode 100644 tests/unit/shared-context.test.ts delete mode 100644 tests/unit/stop-when-before-approval.test.ts delete mode 100644 tests/unit/tool-context.test.ts delete mode 100644 tests/unit/tool-event-broadcaster.test.ts delete mode 100644 tests/unit/tool-executor-return.test.ts delete mode 100644 tests/unit/tool-type-compat.test.ts delete mode 100644 tests/unit/turn-end-race-condition.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 49edfaa8e..a30c7b67a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,69 +113,7 @@ Speakeasy's [persistent edits](https://www.speakeasy.com/docs/sdks/customize/cod ## Architecture -### Core Abstractions - -**callModel** (`src/funcs/call-model.ts`) -- High-level function for making model requests with tools -- Returns a `ModelResult` wrapper with multiple consumption patterns -- Supports async parameter resolution and automatic tool execution -- Example consumption: `.getText()`, `.getTextStream()`, `.getToolStream()`, etc. - -**ModelResult** (`src/lib/model-result.ts`) -- Wraps streaming responses with multiple consumption patterns -- Handles automatic tool execution and turn orchestration -- Uses `ReusableReadableStream` to enable multiple parallel consumers - -**Tool System** (`src/lib/tool.ts`, `src/lib/tool-types.ts`, `src/lib/tool-executor.ts`) -- `tool()` helper creates type-safe tools with Zod schemas -- Three tool types: - - **Regular tools** (`execute: function`) - auto-executed, return final result - - **Generator tools** (`execute: async generator`) - stream preliminary results - - **Manual tools** (`execute: false`) - return tool calls without execution -- Tool orchestrator (`src/lib/tool-orchestrator.ts`) manages multi-turn conversations - -**Async Parameter Resolution** (`src/lib/async-params.ts`) -- Any parameter in `CallModelInput` can be a function: `(ctx: TurnContext) => value` -- Functions resolved before each turn, allowing dynamic parameter adjustment -- Supports both sync and async functions -- Example: `model: (ctx) => ctx.numberOfTurns > 3 ? 'gpt-4' : 'gpt-3.5-turbo'` - -**Next Turn Params** (`src/lib/next-turn-params.ts`) -- Tools can define `nextTurnParams` to modify request parameters after execution -- Functions receive tool input and can return parameter updates -- Applied after tool execution, before next API request -- Example: Increase temperature after seeing tool results - -**Stop Conditions** (`src/lib/stop-conditions.ts`) -- Control when tool execution loops terminate -- Built-in helpers: `stepCountIs()`, `hasToolCall()`, `maxTokensUsed()`, `maxCost()`, `finishReasonIs()` -- Custom conditions receive full step history -- Default: `stepCountIs(5)` if not specified - -## Message Format Compatibility - -The SDK supports multiple message formats: - -- **OpenRouter format** (native) -- **Claude format** via `fromClaudeMessages()` / `toClaudeMessage()` (`src/lib/anthropic-compat.ts`) -- **OpenAI Chat format** via `fromChatMessages()` / `toChatMessage()` (`src/lib/chat-compat.ts`) - -These converters handle content types, tool calls, and format-specific features. - -## Streaming Architecture - -**ReusableReadableStream** (`src/lib/reusable-stream.ts`) - -- Caches stream events to enable multiple independent consumers -- Critical for allowing parallel consumption patterns (text + tools + reasoning) -- Handles both SSE and standard ReadableStream - -**Stream Transformers** (`src/lib/stream-transformers.ts`) - -- Extract specific data from response streams -- `extractTextDeltas()`, `extractReasoningDeltas()`, `extractToolDeltas()` -- Build higher-level streams for different consumption patterns -- Handle both streaming and non-streaming responses uniformly +`callModel`, tools, stop conditions and the message-format converters moved to `@openrouter/agent` and are not part of this package. ## Development Workflow @@ -194,19 +132,7 @@ These converters handle content types, tool calls, and format-specific features. ### Running Examples -```bash -cd examples -# Set your API key in .env first -node --loader ts-node/esm call-model.example.ts -``` - -Examples demonstrate: -- `call-model.example.ts` - Basic usage -- `call-model-typed-tool-calling.example.ts` - Type-safe tools -- `anthropic-multimodal-tools.example.ts` - Multimodal inputs with tools -- `anthropic-reasoning.example.ts` - Extended thinking/reasoning -- `chat-reasoning.example.ts` - Reasoning with chat format -- `tools-example.ts` - Comprehensive tool usage +See `examples/README.md`. ## TypeScript Configuration @@ -229,10 +155,7 @@ Tests use Vitest with: E2E tests (`tests/e2e/`) make real API calls and test: - Basic chat completions -- Tool execution flows - Streaming responses -- Multi-turn conversations -- Different message formats ## Package Structure diff --git a/examples/anthropic-multimodal-tools.example.ts b/examples/anthropic-multimodal-tools.example.ts deleted file mode 100644 index ca643695d..000000000 --- a/examples/anthropic-multimodal-tools.example.ts +++ /dev/null @@ -1,350 +0,0 @@ -import dotenv from "dotenv"; - -dotenv.config(); - -/** - * Example demonstrating a multi-turn conversation with images and tool use - * using Claude-style message format with the anthropic-compat helper functions. - * - * This example shows how to: - * 1. Send messages with image inputs (both URL and base64) - * 2. Handle tool calls and tool results in conversation history - * 3. Convert between Claude format and OpenResponses format across multiple turns - * 4. Build a 3-turn conversation with complex multimodal interactions - * - * The conversation flow: - * - Turn 1: User sends an image and asks about it - * - Turn 2: Model uses tools to analyze the image, user provides tool results - * - Turn 3: User asks follow-up question based on tool results - * - * To run this example from the examples directory with Bun: - * bun run anthropic-multimodal-tools.example.ts - */ - -import type { ClaudeMessageParam } from "../src/models/claude-message.js"; -import { - OpenRouter, - fromClaudeMessages, - toClaudeMessage, - ToolType, -} from "../src/index.js"; -import { z } from "zod/v4"; - -if (!process.env["OPENROUTER_API_KEY"]) { - throw new Error("Missing OPENROUTER_API_KEY environment variable"); -} - -const openRouter = new OpenRouter({ - apiKey: process.env["OPENROUTER_API_KEY"] ?? "", -}); - -// Mock tool definition for image analysis -const tools = [ - { - type: ToolType.Function, - function: { - name: "analyze_image_details", - description: "Analyzes detailed visual features of an image including colors, objects, and composition", - inputSchema: z.object({ - image_id: z.string().describe("The ID of the image to analyze"), - analysis_type: z.enum(["color_palette", "object_detection", "scene_classification"]).describe("Type of analysis to perform"), - }), - outputSchema: z.object({ - colors: z.array(z.string()).optional(), - dominant_objects: z.array(z.string()).optional(), - composition: z.string().optional(), - lighting: z.string().optional(), - }), - }, - }, - { - type: ToolType.Function, - function: { - name: "get_image_metadata", - description: "Retrieves metadata about an image such as dimensions, format, and creation date", - inputSchema: z.object({ - image_id: z.string().describe("The ID of the image"), - }), - outputSchema: z.object({ - width: z.number(), - height: z.number(), - format: z.string(), - created: z.string(), - file_size: z.string(), - }), - }, - }, -]; - -async function multiTurnMultimodalConversation() { - // Using GPT-5 as requested - const model = "openai/gpt-5"; - - // Initialize message history with Claude-style message format - // Turn 1: User sends an image with a question - const messages: ClaudeMessageParam[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "I have this image of a sunset landscape. Can you analyze its visual features?", - }, - { - type: "image", - source: { - type: "url", - url: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4", - }, - }, - ], - }, - ]; - - console.log("=== Turn 1 ==="); - console.log("User: I have this image of a sunset landscape. Can you analyze its visual features?"); - console.log("User: [Image URL: https://images.unsplash.com/photo-1506905925346-21bda4d32df4]"); - console.log(); - - // First turn - convert Claude messages to OpenResponses format and call with tools - const result1 = await openRouter.callModel({ - model, - input: fromClaudeMessages(messages), - tools, - toolChoice: "auto", - }); - - // Get the response and convert back to Claude format - const response1 = await result1.getResponse(); - const claudeMessage1 = toClaudeMessage(response1); - - console.log("Assistant response:"); - console.log("Stop reason:", claudeMessage1.stop_reason); - - // Extract content and tool calls - const textContent1: string[] = []; - const toolCalls1: Array<{ id: string; name: string; input: Record }> = []; - - for (const block of claudeMessage1.content) { - if (block.type === "text") { - textContent1.push(block.text); - } else if (block.type === "tool_use") { - toolCalls1.push({ - id: block.id, - name: block.name, - input: block.input, - }); - } - } - - if (textContent1.length > 0) { - console.log("Text:", textContent1.join("\n")); - } - - if (toolCalls1.length > 0) { - console.log("\nTool calls made:"); - for (const call of toolCalls1) { - console.log(`- ${call.name} (${call.id})`); - console.log(` Arguments:`, JSON.stringify(call.input, null, 2)); - } - } - - console.log(); - - // Add assistant response to history (as Claude-style message) - messages.push({ - role: "assistant", - content: claudeMessage1.content.map(block => { - if (block.type === "text") { - return { type: "text" as const, text: block.text }; - } else if (block.type === "tool_use") { - return { - type: "tool_use" as const, - id: block.id, - name: block.name, - input: block.input, - }; - } - // Handle other block types if needed - return { type: "text" as const, text: "" }; - }).filter(block => block.type !== "text" || block.text !== ""), - }); - - // Turn 2: User provides tool results with an image result - console.log("=== Turn 2 ==="); - console.log("User provides tool results:"); - - const toolResults: ClaudeMessageParam = { - role: "user", - content: toolCalls1.map((call, idx) => { - if (call.name === "analyze_image_details") { - // Simulate a tool result with text - console.log(`Tool result for ${call.id}:`); - console.log(" Analysis: The image shows warm orange and pink hues typical of sunset."); - - return { - type: "tool_result" as const, - tool_use_id: call.id, - content: JSON.stringify({ - colors: ["#FF6B35", "#F7931E", "#FDC830", "#F37335"], - dominant_objects: ["sky", "clouds", "mountains", "horizon"], - composition: "rule_of_thirds", - lighting: "golden_hour", - }), - }; - } else if (call.name === "get_image_metadata") { - console.log(`Tool result for ${call.id}:`); - console.log(" Metadata: 3840x2160, JPEG format"); - - return { - type: "tool_result" as const, - tool_use_id: call.id, - content: JSON.stringify({ - width: 3840, - height: 2160, - format: "JPEG", - created: "2023-06-15T18:45:00Z", - file_size: "2.4MB", - }), - }; - } - return { - type: "tool_result" as const, - tool_use_id: call.id, - content: "Tool execution successful", - }; - }), - }; - - messages.push(toolResults); - console.log(); - - // Second API call with tool results - const result2 = await openRouter.callModel({ - model, - input: fromClaudeMessages(messages), - tools, - }); - - const response2 = await result2.getResponse(); - const claudeMessage2 = toClaudeMessage(response2); - - console.log("Assistant response:"); - const textContent2: string[] = []; - - for (const block of claudeMessage2.content) { - if (block.type === "text") { - textContent2.push(block.text); - } - } - - console.log(textContent2.join("\n")); - console.log(); - - // Add assistant response to history - messages.push({ - role: "assistant", - content: textContent2.join("\n"), - }); - - // Turn 3: User asks a follow-up question with another image using base64 - console.log("=== Turn 3 ==="); - - // Create a simple base64 encoded 1x1 pixel PNG (for demonstration) - const base64Image = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="; - - messages.push({ - role: "user", - content: [ - { - type: "text", - text: "Great! Now I have another image here. How would you compare these two images in terms of color composition?", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: base64Image, - }, - }, - ], - }); - - // Third API call - const result3 = await openRouter.callModel({ - model, - input: fromClaudeMessages(messages), - tools, - }); - - const response3 = await result3.getResponse(); - const claudeMessage3 = toClaudeMessage(response3); - - console.log("Assistant response:"); - const textContent3: string[] = []; - const toolCalls3: Array<{ id: string; name: string; input: Record }> = []; - - for (const block of claudeMessage3.content) { - if (block.type === "text") { - textContent3.push(block.text); - } else if (block.type === "tool_use") { - toolCalls3.push({ - id: block.id, - name: block.name, - input: block.input, - }); - } - } - - console.log(textContent3.join("\n")); - - if (toolCalls3.length > 0) { - console.log("\nTool calls made:"); - for (const call of toolCalls3) { - console.log(`- ${call.name} (${call.id})`); - console.log(` Arguments:`, JSON.stringify(call.input, null, 2)); - } - } - - console.log(); - - // Add final assistant response to history - messages.push({ - role: "assistant", - content: claudeMessage3.content.map(block => { - if (block.type === "text") { - return { type: "text" as const, text: block.text }; - } else if (block.type === "tool_use") { - return { - type: "tool_use" as const, - id: block.id, - name: block.name, - input: block.input, - }; - } - return { type: "text" as const, text: "" }; - }).filter(block => block.type !== "text" || block.text !== ""), - }); - - console.log("=== Conversation Complete ==="); - console.log(`Total messages in history: ${messages.length}`); - - // Show the final Claude message structure - console.log("\n=== Final Claude Message Structure ==="); - console.log("Stop reason:", claudeMessage3.stop_reason); - console.log("Model:", claudeMessage3.model); - console.log("Usage:", claudeMessage3.usage); - - return messages; -} - -async function main() { - try { - await multiTurnMultimodalConversation(); - } catch (error) { - console.error("Error:", error); - } -} - -main(); diff --git a/examples/anthropic-reasoning.example.ts b/examples/anthropic-reasoning.example.ts deleted file mode 100644 index 00c133e29..000000000 --- a/examples/anthropic-reasoning.example.ts +++ /dev/null @@ -1,158 +0,0 @@ -/* - * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - */ - -import dotenv from "dotenv"; - -dotenv.config(); - -/** - * Example demonstrating multi-turn conversations using Claude-style messages - * with the anthropic-compat helper functions. - * - * This example shows how to: - * 1. Use Claude-style message format (ClaudeMessageParam) - * 2. Convert between Claude format and OpenResponses format - * 3. Build a 3-turn conversation carrying message history forward - * - * To run this example from the examples directory with Bun: - * bun run anthropic-reasoning.example.ts - */ - -import type { ClaudeMessageParam } from "../src/models/claude-message.js"; -import { - OpenRouter, - fromClaudeMessages, - toClaudeMessage, -} from "../src/index.js"; - -if (!process.env["OPENROUTER_API_KEY"]) { - throw new Error("Missing OPENROUTER_API_KEY environment variable"); -} - -const openRouter = new OpenRouter({ - apiKey: process.env["OPENROUTER_API_KEY"] ?? "", -}); - -async function multiTurnClaudeConversation() { - const model = "anthropic/claude-sonnet-4"; - - // Initialize message history with Claude-style message format - const messages: ClaudeMessageParam[] = [ - { - role: "user", - content: "What are the key differences between functional and object-oriented programming?", - }, - ]; - - console.log("=== Turn 1 ==="); - console.log("User:", messages[0].content); - console.log(); - - // First turn - convert Claude messages to OpenResponses format - const result1 = await openRouter.callModel({ - model, - input: fromClaudeMessages(messages), - }); - - // Get the response and convert back to Claude format - const response1 = await result1.getResponse(); - const claudeMessage1 = toClaudeMessage(response1); - - // Extract text content from Claude message - const content1 = extractTextContent(claudeMessage1.content); - console.log("Assistant:", content1); - console.log(); - - // Add assistant response to history (as Claude-style message) - messages.push({ - role: "assistant", - content: content1, - }); - - // Add next user message - messages.push({ - role: "user", - content: "Can you show me an example of how the same problem would be solved differently in each paradigm?", - }); - - console.log("=== Turn 2 ==="); - console.log("User:", messages[messages.length - 1].content); - console.log(); - - // Second turn - const result2 = await openRouter.callModel({ - model, - input: fromClaudeMessages(messages), - }); - - const response2 = await result2.getResponse(); - const claudeMessage2 = toClaudeMessage(response2); - const content2 = extractTextContent(claudeMessage2.content); - console.log("Assistant:", content2); - console.log(); - - // Add to history - messages.push({ - role: "assistant", - content: content2, - }); - - messages.push({ - role: "user", - content: "Which paradigm would you recommend for building a large-scale web application and why?", - }); - - console.log("=== Turn 3 ==="); - console.log("User:", messages[messages.length - 1].content); - console.log(); - - // Third turn - const result3 = await openRouter.callModel({ - model, - input: fromClaudeMessages(messages), - }); - - const response3 = await result3.getResponse(); - const claudeMessage3 = toClaudeMessage(response3); - const content3 = extractTextContent(claudeMessage3.content); - console.log("Assistant:", content3); - console.log(); - - // Final message history - messages.push({ - role: "assistant", - content: content3, - }); - - console.log("=== Conversation Complete ==="); - console.log(`Total messages in history: ${messages.length}`); - - // Show the final Claude message structure - console.log("\n=== Final Claude Message Structure ==="); - console.log("Stop reason:", claudeMessage3.stop_reason); - console.log("Model:", claudeMessage3.model); - console.log("Usage:", claudeMessage3.usage); -} - -/** - * Helper to extract text content from Claude message content blocks - */ -function extractTextContent( - content: Array<{ type: string; text?: string }> -): string { - return content - .filter((block) => block.type === "text" && block.text) - .map((block) => block.text) - .join(""); -} - -async function main() { - try { - await multiTurnClaudeConversation(); - } catch (error) { - console.error("Error:", error); - } -} - -main(); diff --git a/examples/call-model-typed-tool-calling.example.ts b/examples/call-model-typed-tool-calling.example.ts deleted file mode 100644 index ae94b7515..000000000 --- a/examples/call-model-typed-tool-calling.example.ts +++ /dev/null @@ -1,169 +0,0 @@ -/* - * Example: Typed Tool Calling with callModel - * - * This example demonstrates how to use the tool() function for - * fully-typed tool definitions where execute params, return types, and event - * types are automatically inferred from Zod schemas. - * - * Tool types are auto-detected based on configuration: - * - Generator tool: When `eventSchema` is provided - * - Regular tool: When `execute` is a function (no `eventSchema`) - * - Manual tool: When `execute: false` is set - * - * To run this example from the examples directory: - * npm run build && npx tsx callModel-typed-tool-calling.example.ts - */ - -import dotenv from "dotenv"; -dotenv.config(); - -import { OpenRouter, tool } from "../src/index.js"; -import z from "zod"; - -const openRouter = new OpenRouter({ - apiKey: process.env["OPENROUTER_API_KEY"] ?? "", -}); - -// Create a typed regular tool using tool() -// The execute function params are automatically typed as z.infer -// The return type is enforced based on outputSchema -const weatherTool = tool({ - name: "get_weather", - description: "Get the current weather for a location", - inputSchema: z.object({ - location: z.string().describe("The city and country, e.g. San Francisco, CA"), - }), - outputSchema: z.object({ - temperature: z.number(), - description: z.string(), - }), - // params is automatically typed as { location: string } - execute: async (params) => { - console.log(`Getting weather for: ${params.location}`); - // Return type is enforced as { temperature: number; description: string } - return { - temperature: 20, - description: "Sunny", - }; - }, -}); - -// Create a generator tool with typed progress events by providing eventSchema -// The eventSchema triggers generator mode - execute becomes an async generator -const searchTool = tool({ - name: "search_database", - description: "Search database with progress updates", - inputSchema: z.object({ - query: z.string().describe("The search query"), - }), - eventSchema: z.object({ - progress: z.number(), - message: z.string(), - }), - outputSchema: z.object({ - results: z.array(z.string()), - totalFound: z.number(), - }), - // execute is a generator that yields typed progress events - execute: async function* (params) { - console.log(`Searching for: ${params.query}`); - // Each yield is typed as { progress: number; message: string } - yield { progress: 25, message: "Searching..." }; - yield { progress: 50, message: "Processing results..." }; - yield { progress: 75, message: "Almost done..." }; - // Final result is typed as { results: string[]; totalFound: number } - yield { progress: 100, message: "Complete!" }; - }, -}); - -async function main() { - console.log("=== Typed Tool Calling Example ===\n"); - - // Use 'as const' to enable full type inference for tool calls - const result = openRouter.callModel({ - instructions: "You are a helpful assistant. Your name is Mark", - model: "openai/gpt-4o-mini", - input: "Hello! What is the weather in San Francisco?", - tools: [weatherTool] as const, - }); - - // Get text response (tools are auto-executed) - const text = await result.getText(); - console.log("Response:", text); - - console.log("\n=== Getting Tool Calls ===\n"); - - // Create a fresh request for demonstrating getToolCalls - const result2 = openRouter.callModel({ - model: "openai/gpt-4o-mini", - input: "What's the weather like in Paris?", - tools: [weatherTool] as const, - stopWhen: ({ steps }) => steps.length >= 0, // Stop immediately - don't auto-execute, just get the tool calls - }); - - // Tool calls are now typed based on the tool definitions! - const toolCalls = await result2.getToolCalls(); - - for (const toolCall of toolCalls) { - console.log(`Tool: ${toolCall.name}`); - // toolCall.arguments is typed as { location: string } - console.log(`Arguments:`, toolCall.arguments); - } - - console.log("\n=== Streaming Tool Calls ===\n"); - - // Create another request for demonstrating streaming - const result3 = openRouter.callModel({ - model: "openai/gpt-4o-mini", - input: "What's the weather in Tokyo?", - tools: [weatherTool] as const, - stopWhen: ({ steps }) => steps.length >= 0, // Stop immediately - }); - - // Stream tool calls with typed arguments - for await (const toolCall of result3.getToolCallsStream()) { - console.log(`Streamed tool: ${toolCall.name}`); - // toolCall.arguments is typed based on tool definitions - console.log(`Streamed arguments:`, toolCall.arguments); - } - - console.log("\n=== Generator Tool with Typed Events ===\n"); - - // Use generator tool with typed progress events - const result4 = openRouter.callModel({ - model: "openai/gpt-4o-mini", - input: "Search for documents about TypeScript", - tools: [searchTool] as const, - }); - - // Stream events from getToolStream - events are fully typed! - for await (const event of result4.getToolStream()) { - if (event.type === "preliminary_result") { - // event.result is typed as { progress: number; message: string } - console.log(`Progress: ${event.result.progress}% - ${event.result.message}`); - } else if (event.type === "delta") { - // Tool argument deltas - process.stdout.write(event.content); - } - } - - console.log("\n=== Mixed Tools with Typed Events ===\n"); - - // Use both regular and generator tools together - const result5 = openRouter.callModel({ - model: "openai/gpt-4o-mini", - input: "First search for weather data, then get the weather in Seattle", - tools: [weatherTool, searchTool] as const, - }); - - // Events are a union of all generator tool event types - for await (const event of result5.getToolStream()) { - if (event.type === "preliminary_result") { - // event.result is typed as { progress: number; message: string } - // (only searchTool has eventSchema, so that's the event type) - console.log(`Event:`, event.result); - } - } -} - -main().catch(console.error); diff --git a/examples/call-model.example.ts b/examples/call-model.example.ts deleted file mode 100644 index 9bb71b7c0..000000000 --- a/examples/call-model.example.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. - */ - -import dotenv from "dotenv"; -dotenv.config(); -/** - * Example usage of the @openrouter/sdk SDK - * - * To run this example from the examples directory: - * npm run build && npx tsx call-model.example.ts - */ - -import { OpenRouter } from "../src/index.js"; -import type { OpenResponsesEasyInputMessage } from "../src/models"; - - -const openRouter = new OpenRouter({ - apiKey: process.env["OPENROUTER_API_KEY"] ?? "", -}); - -const text = "Hello! What is your name?"; -const responsesMessages: OpenResponsesEasyInputMessage[] = [ - { - type: "message", - role: "user", - content: [ - { - type: "input_text", - text: "What is your name?", - }, - ], - }, -]; - -const supportedInputSchemas = [text, responsesMessages]; - -for (const input of supportedInputSchemas) { - const result = await openRouter.callModel({ - instructions: "You are a helpful assistant. Your name is Mark", - model: "openai/gpt-4", - input: input, - }); - - // get resulting output each time in... - - // text format - console.log(await result.getText()); - - // response message format - console.log(await result.getResponse().then((response) => response.output)); - - // stream response message format - console.log(await result.getFullResponsesStream()); - - // stream text format - console.log(await result.getTextStream()); - - // stream just the messages (simpler than full response but more than just text) - console.log(await result.getNewMessagesStream()); - - // stream tool calls... (structured tool calls with parsed arguments) - console.log(await result.getToolCallsStream()); - - // get all tool calls... (structured tool calls with parsed arguments) - console.log(await result.getToolCalls()); -} diff --git a/examples/tools-example.ts b/examples/tools-example.ts deleted file mode 100644 index d5ec52760..000000000 --- a/examples/tools-example.ts +++ /dev/null @@ -1,388 +0,0 @@ -/** - * OpenRouter SDK - Enhanced Tool Support Examples - * - * This file demonstrates the automatic tool execution feature. - * When you provide tools with `execute` functions, they are automatically: - * 1. Validated using Zod schemas - * 2. Executed when the model calls them - * 3. Results sent back to the model - * 4. Process repeats until stopWhen condition is met (default: stepCountIs(5)) - * - * The API is simple: just call callModel() with tools, and await the result. - * Tools are executed transparently before getMessage() or getText() returns! - * - * stopWhen can be: - * - A single condition: stepCountIs(3), hasToolCall('finalize'), maxCost(0.50) - * - An array of conditions: [stepCountIs(10), maxCost(1.00)] (OR logic - stops if ANY is true) - * - A custom function: ({ steps }) => steps.length >= 5 || steps.some(s => s.finishReason === 'length') - */ - -import * as dotenv from 'dotenv'; -import { z } from 'zod/v4'; -import { OpenRouter, ToolType, stepCountIs } from '../src/index.js'; - -// Type declaration for ShadowRealm (TC39 Stage 3 proposal) -// See: https://tc39.es/proposal-shadowrealm/ -declare class ShadowRealm { - constructor(); - evaluate(sourceText: string): unknown; - importValue(specifier: string, bindingName: string): Promise; -} - -dotenv.config(); - -const client = new OpenRouter({ - apiKey: process.env.OPENROUTER_API_KEY || '', -}); - -/** - * Example 1: Basic Tool with Execute Function - * A simple weather tool that returns mock data - * Note: The context parameter is optional for backward compatibility - */ -async function basicToolExample() { - const weatherTool = { - type: ToolType.Function, - function: { - name: 'get_weather', - description: 'Get current weather for a location', - inputSchema: z.object({ - location: z.string().describe('City and country (e.g., San Francisco, CA)'), - }), - outputSchema: z.object({ - temperature: z.number(), - description: z.string(), - humidity: z.number(), - }), - execute: async ( - _params: { - location: string; - }, - _context, - ) => { - // In real usage, you would call a weather API here - return { - temperature: 72, - description: 'Sunny', - humidity: 45, - }; - }, - }, - }; - - const response = client.callModel({ - model: 'openai/gpt-4o', - input: "What's the weather like in San Francisco?", - tools: [ - weatherTool, - ], - // Example: limit to 3 steps - stopWhen: stepCountIs(3), - }); - - // Tools are automatically executed! Just get the final message - const _message = await response.getMessage(); - - // You can also check what tool calls were made initially - const _toolCalls = await response.getToolCalls(); -} - -/** - * Example 2: Generator Tool with Preliminary Results - * Shows how to use async generators for streaming intermediate results - */ -async function generatorToolExample() { - const processingTool = { - type: ToolType.Function, - function: { - name: 'process_data', - description: 'Process data with progress updates', - inputSchema: z.object({ - data: z.string().describe('Data to process'), - }), - // Events emitted during processing (validated against eventSchema) - eventSchema: z.object({ - type: z.enum([ - 'start', - 'progress', - ]), - message: z.string(), - progress: z.number().min(0).max(100).optional(), - }), - // Final output (validated against outputSchema - different structure) - outputSchema: z.object({ - result: z.string(), - processingTime: z.number(), - }), - execute: async function* ( - params: { - data: string; - }, - _context, - ) { - const startTime = Date.now(); - - // Preliminary event 1 - yield { - type: 'start' as const, - message: `Started processing: ${params.data}`, - progress: 0, - }; - - await new Promise((resolve) => setTimeout(resolve, 500)); - - // Preliminary event 2 - yield { - type: 'progress' as const, - message: 'Processing halfway done', - progress: 50, - }; - - await new Promise((resolve) => setTimeout(resolve, 500)); - - // Final output (different schema - sent to model) - yield { - result: params.data.toUpperCase(), - processingTime: Date.now() - startTime, - }; - }, - }, - }; - - const response = client.callModel({ - model: 'openai/gpt-4o', - input: 'Process this data: hello world', - tools: [ - processingTool, - ], - }); - for await (const event of response.getToolStream()) { - if (event.type === 'preliminary_result') { - } else if (event.type === 'delta') { - process.stdout.write(event.content); - } - } - - // Tools are automatically executed with preliminary results available - const _message = await response.getMessage(); -} - -/** - * Safely evaluate a math expression using ShadowRealm - * - * ShadowRealm provides a secure, isolated JavaScript execution environment - * that prevents access to the host realm's globals (no access to DOM, fetch, - * filesystem, etc.). Combined with input validation, this makes it safe for - * evaluating untrusted math expressions. - * - * Note: ShadowRealm isolates from host APIs but does not prevent DoS vectors - * like infinite loops. For production use, consider additional safeguards. - * - * Browser support: Chrome 124+, Edge 124+, Firefox 128+, Safari 18+ - * Node.js support: Available behind --experimental-shadow-realm flag (v19+) - * See: https://tc39.es/proposal-shadowrealm/ - */ -function safeEvaluateMath(expression: string): number { - // Only allow digits, whitespace, basic operators, decimal point, and parentheses - if (!/^[\d\s+\-*/.()]+$/.test(expression)) { - throw new Error('Expression contains invalid characters'); - } - - // ShadowRealm creates a separate realm with its own global object, preventing - // access to the host environment's APIs (no DOM, no Node.js APIs, no fetch, etc.) - const realm = new ShadowRealm(); - - // The expression is evaluated in complete isolation - const result = realm.evaluate(expression); - - if (typeof result !== 'number' || !Number.isFinite(result)) { - throw new Error('Expression did not evaluate to a finite number'); - } - - return result; -} - -/** - * Example 3: Manual Tool Execution - * Define a tool without execute function for manual handling - */ -async function manualToolExample() { - const calculatorTool = { - type: ToolType.Function, - function: { - name: 'calculate', - description: 'Perform mathematical calculations', - inputSchema: z.object({ - expression: z.string().describe('Math expression to evaluate'), - }), - outputSchema: z.object({ - result: z.number(), - }), - // No execute function - tool calls are returned but not executed - }, - }; - - const response = client.callModel({ - model: 'openai/gpt-4o', - input: 'What is 25 * 4 + 10?', - tools: [ - calculatorTool, - ], - }); - - // Since there's no execute function, tool calls are returned but not executed - const toolCalls = await response.getToolCalls(); - - // You can manually handle tool execution here - for (const toolCall of toolCalls) { - if (toolCall.name === 'calculate') { - const expression = ( - toolCall.arguments as { - expression: string; - } - ).expression; - - // Safely evaluate the math expression using ShadowRealm - try { - const _result = safeEvaluateMath(expression); - } catch (_error) {} - } - } - - // Then you would need to make a new request with the tool results - // (This example just shows the manual detection, not the full loop) -} - -/** - * Example 4: Streaming Tool Calls - * Show how to stream structured tool call objects as they arrive - * Note: This tool doesn't use context - demonstrating backward compatibility - */ -async function streamingToolCallsExample() { - const searchTool = { - type: ToolType.Function, - function: { - name: 'search', - description: 'Search for information', - inputSchema: z.object({ - query: z.string().describe('Search query'), - }), - outputSchema: z.object({ - results: z.array(z.string()), - }), - execute: async (params: { query: string }) => { - // Context parameter is optional - this tool doesn't need it - return { - results: [ - `Result 1 for "${params.query}"`, - `Result 2 for "${params.query}"`, - ], - }; - }, - }, - }; - - const response = client.callModel({ - model: 'openai/gpt-4o', - input: 'Search for information about TypeScript', - tools: [ - searchTool, - ], - }); - - // Stream structured tool call objects - for await (const _toolCall of response.getToolCallsStream()) { - } -} - -/** - * Example 5: Multiple Tools - * Use multiple tools in a single request - * Note: Shows mixing tools with and without context parameter - */ -async function multipleToolsExample() { - const tools = [ - { - type: ToolType.Function, - function: { - name: 'get_time', - description: 'Get current time', - inputSchema: z.object({ - timezone: z.string().optional(), - }), - outputSchema: z.object({ - time: z.string(), - timezone: z.string(), - }), - execute: async ( - params: { - timezone?: string; - }, - _context, - ) => { - return { - time: new Date().toISOString(), - timezone: params.timezone || 'UTC', - }; - }, - }, - }, - { - type: ToolType.Function, - function: { - name: 'get_weather', - description: 'Get weather information', - inputSchema: z.object({ - location: z.string(), - }), - outputSchema: z.object({ - temperature: z.number(), - description: z.string(), - }), - execute: async (_params: { location: string }) => { - // This tool doesn't need context - return { - temperature: 68, - description: 'Partly cloudy', - }; - }, - }, - }, - ]; - - const response = client.callModel({ - model: 'openai/gpt-4o', - input: "What time is it and what's the weather in New York?", - tools, - }); - - // Tools are automatically executed! - const _message = await response.getMessage(); - - // You can check which tools were called - const _toolCalls = await response.getToolCalls(); -} - -// Run examples -async function main() { - try { - await basicToolExample(); - await generatorToolExample(); - await manualToolExample(); - await streamingToolCallsExample(); - await multipleToolsExample(); - } catch (_error) {} -} - -// Only run if this file is executed directly -if (import.meta.url === `file://${process.argv[1]}`) { - main(); -} - -export { - basicToolExample, - generatorToolExample, - manualToolExample, - streamingToolCallsExample, - multipleToolsExample, -}; diff --git a/src/funcs/call-model.ts b/src/funcs/call-model.ts deleted file mode 100644 index 58132fc47..000000000 --- a/src/funcs/call-model.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { $ZodObject, $ZodShape, infer as zodInfer } from 'zod/v4/core'; -import type { OpenRouterCore } from '../core.js'; -import type { CallModelInput } from '../lib/async-params.js'; -import type { RequestOptions } from '../lib/sdks.js'; -import type { Tool } from '../lib/tool-types.js'; - -import { ModelResult, type GetResponseOptions } from '../lib/model-result.js'; -import { convertToolsToAPIFormat } from '../lib/tool-executor.js'; - -// Re-export CallModelInput for convenience -export type { CallModelInput } from '../lib/async-params.js'; - -/** - * Get a response with multiple consumption patterns - * - * @remarks - * Creates a response using the OpenResponses API and returns - * a wrapper that allows consuming the response in multiple ways: - * - * - `await response.getText()` - Get just the text content (tools auto-executed) - * - `await response.getResponse()` - Get full response with usage data (inputTokens, cachedTokens, etc.) - * - `for await (const delta of response.getTextStream())` - Stream text deltas - * - `for await (const delta of response.getReasoningStream())` - Stream reasoning deltas - * - `for await (const event of response.getToolStream())` - Stream tool events (incl. preliminary results) - * - `for await (const toolCall of response.getToolCallsStream())` - Stream structured tool calls - * - `await response.getToolCalls()` - Get all tool calls from completed response - * - `for await (const msg of response.getNewMessagesStream())` - Stream cumulative message snapshots - * - `for await (const event of response.getFullResponsesStream())` - Stream all events (incl. tool preliminary) - * - * All consumption patterns can be used concurrently on the same response. - * - * **Context:** - * - * Tools can declare a `contextSchema` to receive typed context data. - * The `context` parameter on callModel is typed as the intersection of all tool schemas: - * - * @example - * ```typescript - * const result = callModel(client, { - * model: 'gpt-4', - * input: 'Hello', - * tools: [weatherTool, dbTool] as const, - * context: { - * get_weather: { apiKey: 'sk-...' }, - * db_query: { dbUrl: 'postgres://...' }, - * }, - * }); - * ``` - * - * Context can also be a function or async function: - * ```typescript - * context: (turn) => ({ - * get_weather: { apiKey: turn.numberOfTurns > 1 ? refreshedKey : initialKey }, - * }) - * ``` - * - * **Stop Conditions:** - * - * Control when tool execution stops using the `stopWhen` parameter: - * - * @example - * ```typescript - * stopWhen: stepCountIs(3) - * stopWhen: [stepCountIs(10), maxCost(0.50), hasToolCall('finalize')] - * ``` - * - * Default: `stepCountIs(5)` if not specified - */ -export function callModel< - TTools extends readonly Tool[], - TSharedSchema extends $ZodObject<$ZodShape> | undefined = undefined, - TShared extends Record = TSharedSchema extends $ZodObject<$ZodShape> ? zodInfer : Record, ->( - client: OpenRouterCore, - request: CallModelInput & { sharedContextSchema?: TSharedSchema }, - options?: RequestOptions, -): ModelResult { - // Destructure state management options along with tools and stopWhen - const { - tools, - stopWhen, - state, - requireApproval, - approveToolCalls, - rejectToolCalls, - context, - sharedContextSchema, - onTurnStart, - onTurnEnd, - ...apiRequest - } = request; - - // Convert tools to API format - no cast needed now that convertToolsToAPIFormat accepts readonly - const apiTools = tools ? convertToolsToAPIFormat(tools) : undefined; - - // Build the request with converted tools - // Note: async functions are resolved later in ModelResult.executeToolsIfNeeded() - // The request can have async fields (functions) or sync fields, and the tools are converted to API format - const finalRequest: Record = { - ...apiRequest, - }; - - if (apiTools !== undefined) { - finalRequest['tools'] = apiTools; - } - - // Inject x-openrouter-callmodel header into every callModel request - const callModelOptions: RequestOptions = { - ...options, - headers: { - ...Object.fromEntries(new Headers(options?.headers ?? options?.fetchOptions?.headers ?? undefined)), - 'x-openrouter-callmodel': 'true', - }, - }; - - return new ModelResult({ - client, - request: finalRequest, - options: callModelOptions, - tools, - ...(stopWhen !== undefined && { stopWhen }), - // Pass state management options - ...(state !== undefined && { state }), - ...(requireApproval !== undefined && { requireApproval }), - ...(approveToolCalls !== undefined && { approveToolCalls }), - ...(rejectToolCalls !== undefined && { rejectToolCalls }), - ...(context !== undefined && { context }), - ...(sharedContextSchema !== undefined && { sharedContextSchema }), - ...(onTurnStart !== undefined && { onTurnStart }), - ...(onTurnEnd !== undefined && { onTurnEnd }), - } as GetResponseOptions); -} diff --git a/src/lib/anthropic-compat.ts b/src/lib/anthropic-compat.ts deleted file mode 100644 index 4dd1081c4..000000000 --- a/src/lib/anthropic-compat.ts +++ /dev/null @@ -1,274 +0,0 @@ -import type { EasyInputMessage, EasyInputMessageRoleUnion } from '../models/easyinputmessage.js'; -import type { FunctionCallItem } from '../models/functioncallitem.js'; -import type { FunctionCallOutputItem } from '../models/functioncalloutputitem.js'; -import type { InputImage } from '../models/inputimage.js'; -import type { InputMessageItem } from '../models/inputmessageitem.js'; -import type { InputsUnion } from '../models/inputsunion.js'; -import type { InputText } from '../models/inputtext.js'; -import type { OutputImageGenerationCallItem } from '../models/outputimagegenerationcallitem.js'; -import type { - ClaudeImageBlockParam, - ClaudeMessageParam, - ClaudeTextBlockParam, - ClaudeToolResultBlockParam, - ClaudeToolUseBlockParam, -} from '../models/claude-message.js'; - -import { - EasyInputMessageRoleAssistant, - EasyInputMessageRoleUser, -} from '../models/easyinputmessage.js'; -import { InputMessageItemRoleUser, InputMessageItemRoleDeveloper } from '../models/inputmessageitem.js'; -import { convertToClaudeMessage } from './stream-transformers.js'; - -/** - * Maps Claude role strings to OpenResponses role types - */ -function mapClaudeRole(role: 'user' | 'assistant'): EasyInputMessageRoleUnion { - if (role === 'user') { - return EasyInputMessageRoleUser.User; - } - return EasyInputMessageRoleAssistant.Assistant; -} - -/** - * Creates a properly typed EasyInputMessage - */ -function createEasyInputMessage( - role: 'user' | 'assistant', - content: string, -): EasyInputMessage { - return { - role: mapClaudeRole(role), - content, - }; -} - -/** - * Creates a properly typed FunctionCallOutputItem - */ -function createFunctionCallOutput( - callId: string, - output: string, -): FunctionCallOutputItem { - return { - type: "function_call_output" as const, - callId, - output, - }; -} - -/** - * Convert Anthropic Claude-style messages to OpenResponses input format. - * - * This function transforms ClaudeMessageParam[] (Anthropic SDK format) to - * OpenResponsesInput format that can be passed directly to callModel(). - * - * Note: Some Claude features are lost in conversion as OpenRouter doesn't support them: - * - cache_control on content blocks - * - is_error flag on tool_result blocks - * - * @example - * ```typescript - * import { fromClaudeMessages } from '@openrouter/sdk'; - * - * const claudeMessages = [ - * { role: "user", content: "Hello!" }, - * { role: "assistant", content: "Hi there!" }, - * ]; - * - * const response = openrouter.callModel({ - * model: "anthropic/claude-3-sonnet", - * input: fromClaudeMessages(claudeMessages), - * }); - * ``` - */ -export function fromClaudeMessages( - messages: ClaudeMessageParam[], -): InputsUnion { - const result: ( - | EasyInputMessage - | InputMessageItem - | FunctionCallOutputItem - | FunctionCallItem - | OutputImageGenerationCallItem - )[] = []; - - for (const msg of messages) { - const { role, content } = msg; - - if (typeof content === 'string') { - result.push(createEasyInputMessage(role, content)); - continue; - } - - // Separate content blocks into categories for clearer processing - const textBlocks: ClaudeTextBlockParam[] = []; - const imageBlocks: ClaudeImageBlockParam[] = []; - const toolUseBlocks: ClaudeToolUseBlockParam[] = []; - const toolResultBlocks: ClaudeToolResultBlockParam[] = []; - - for (const block of content) { - switch (block.type) { - case 'text': - textBlocks.push(block); - break; - case 'image': - imageBlocks.push(block); - break; - case 'tool_use': - toolUseBlocks.push(block); - break; - case 'tool_result': - toolResultBlocks.push(block); - break; - default: { - // Exhaustiveness check - TypeScript will error if we don't handle all block types - const exhaustiveCheck: never = block; - throw new Error(`Unhandled content block type: ${JSON.stringify(exhaustiveCheck)}`); - } - } - } - - // Process tool use blocks first (they go directly to result) - for (const toolUseBlock of toolUseBlocks) { - result.push({ - type: 'function_call', - callId: toolUseBlock.id, - name: toolUseBlock.name, - arguments: JSON.stringify(toolUseBlock.input), - id: toolUseBlock.id, - status: 'completed', - }); - } - - // Process tool result blocks - for (const toolResultBlock of toolResultBlocks) { - let toolOutput = ''; - - if (typeof toolResultBlock.content === 'string') { - toolOutput = toolResultBlock.content; - } else { - // Extract text and handle images separately - const textParts: string[] = []; - const imageParts: ClaudeImageBlockParam[] = []; - - for (const part of toolResultBlock.content) { - if (part.type === 'text') { - textParts.push(part.text); - } else if (part.type === 'image') { - imageParts.push(part); - } - } - - toolOutput = textParts.join(''); - - // Map images to image_generation_call items - imageParts.forEach((imagePart, i) => { - let imageUrl: string; - - if (imagePart.source.type === 'url') { - imageUrl = imagePart.source.url; - } else if (imagePart.source.type === 'base64') { - imageUrl = `data:${imagePart.source.media_type};base64,${imagePart.source.data}`; - } else { - const exhaustiveCheck: never = imagePart.source; - throw new Error(`Unhandled image source type: ${exhaustiveCheck}`); - } - - result.push({ - type: 'image_generation_call', - id: `${toolResultBlock.tool_use_id}-image-${i}`, - result: imageUrl, - status: 'completed', - }); - }); - } - - // Add the function call output for the text portion (if any) - if (toolOutput.length > 0) { - result.push(createFunctionCallOutput(toolResultBlock.tool_use_id, toolOutput)); - } - } - - // Process text and image blocks (these become message content) - if (textBlocks.length > 0 || imageBlocks.length > 0) { - const contentItems: (InputText | InputImage)[] = []; - - // Add text blocks - for (const textBlock of textBlocks) { - contentItems.push({ - type: 'input_text', - text: textBlock.text, - }); - } - - // Add image blocks - for (const imageBlock of imageBlocks) { - let imageUrl: string; - - if (imageBlock.source.type === 'url') { - imageUrl = imageBlock.source.url; - } else if (imageBlock.source.type === 'base64') { - imageUrl = `data:${imageBlock.source.media_type};base64,${imageBlock.source.data}`; - } else { - const exhaustiveCheck: never = imageBlock.source; - throw new Error(`Unhandled image source type: ${exhaustiveCheck}`); - } - - contentItems.push({ - type: 'input_image', - detail: 'auto', - imageUrl, - }); - } - - // Determine output format based on content - if (imageBlocks.length > 0) { - // Use structured format for messages with images - result.push({ - type: 'message', - role: - role === 'user' - ? InputMessageItemRoleUser.User - : InputMessageItemRoleDeveloper.Developer, - content: contentItems, - }); - } else { - // Use simple string format for text-only messages - const textContent = contentItems - .filter((item): item is InputText => item.type === 'input_text') - .map((item) => item.text) - .join(''); - - if (textContent.length > 0) { - result.push(createEasyInputMessage(role, textContent)); - } - } - } - } - - return result; -} - -/** - * Convert an OpenResponses response to Anthropic Claude message format. - * - * This function transforms OpenResponsesResult to ClaudeMessage - * (Anthropic SDK format) for compatibility with code expecting Claude responses. - * - * @example - * ```typescript - * import { toClaudeMessage } from '@openrouter/sdk'; - * - * const response = await openrouter.callModel({ - * model: "anthropic/claude-3-sonnet", - * input: "Hello!", - * }); - * - * const openResponsesResult = await response.getResponse(); - * const claudeMessage = toClaudeMessage(openResponsesResult); - * // claudeMessage is now compatible with Anthropic SDK types - * ``` - */ -export const toClaudeMessage = convertToClaudeMessage; diff --git a/src/lib/async-params.ts b/src/lib/async-params.ts deleted file mode 100644 index 50f42c354..000000000 --- a/src/lib/async-params.ts +++ /dev/null @@ -1,207 +0,0 @@ -import type { OpenResponsesResult } from '../models/openresponsesresult.js'; -import type { ResponsesRequest } from '../models/responsesrequest.js'; -import type { ToolContextMapWithShared, ParsedToolCall, StateAccessor, StopWhen, Tool, TurnContext } from './tool-types.js'; -import type { ContextInput } from './tool-context.js'; - -// Re-export Tool type for convenience -export type { Tool } from './tool-types.js'; - -/** - * Type guard to check if a value is a parameter function - * Parameter functions take TurnContext and return a value or promise - */ -function isParameterFunction( - value: unknown -): value is (context: TurnContext) => unknown | Promise { - return typeof value === 'function'; -} - -/** - * Build a resolved request object from entries - * This validates the structure matches the expected ResolvedCallModelInput shape - */ -function buildResolvedRequest( - entries: ReadonlyArray -): ResolvedCallModelInput { - const obj = Object.fromEntries(entries); - - return obj satisfies ResolvedCallModelInput; -} - -/** - * A field can be either a value of type T or a function that computes T - */ -export type FieldOrAsyncFunction = T | ((context: TurnContext) => T | Promise); - -/** - * Base input type for callModel without approval-related fields - */ -type BaseCallModelInput< - TTools extends readonly Tool[] = readonly Tool[], - TShared extends Record = Record, -> = { - [K in keyof Omit]?: FieldOrAsyncFunction< - ResponsesRequest[K] - >; -} & { - tools?: TTools; - stopWhen?: StopWhen; - /** Typed context data passed to tools via contextSchema. Includes optional `shared` key. */ - context?: ContextInput>; - /** - * Call-level approval check - overrides tool-level requireApproval setting - * Receives the tool call and turn context, can be sync or async - */ - requireApproval?: ( - toolCall: ParsedToolCall, - context: TurnContext - ) => boolean | Promise; - /** - * Callback invoked at the start of each tool execution turn - * Receives the turn context with the current turn number - */ - onTurnStart?: (context: TurnContext) => void | Promise; - /** - * Callback invoked at the end of each tool execution turn - * Receives the turn context and the completed response for that turn - */ - onTurnEnd?: (context: TurnContext, response: OpenResponsesResult) => void | Promise; -}; - -/** - * Approval params when state is provided (allows approve/reject) - */ -type ApprovalParamsWithState = { - /** State accessor for multi-turn persistence and approval gates */ - state: StateAccessor; - /** Tool call IDs to approve (for resuming from awaiting_approval status) */ - approveToolCalls?: string[]; - /** Tool call IDs to reject (for resuming from awaiting_approval status) */ - rejectToolCalls?: string[]; -}; - -/** - * Approval params when state is NOT provided (forbids approve/reject) - */ -type ApprovalParamsWithoutState = { - /** State accessor for multi-turn persistence and approval gates */ - state?: undefined; - /** Not allowed without state - will cause type error */ - approveToolCalls?: never; - /** Not allowed without state - will cause type error */ - rejectToolCalls?: never; -}; - -/** - * Input type for callModel function - * Each field can independently be a static value or a function that computes the value - * Generic over TTools to enable proper type inference for stopWhen conditions - * - * Type enforcement: - * - `approveToolCalls` and `rejectToolCalls` are only valid when `state` is provided - * - Using these without `state` will cause a TypeScript error - */ -export type CallModelInput< - TTools extends readonly Tool[] = readonly Tool[], - TShared extends Record = Record, -> = - BaseCallModelInput & (ApprovalParamsWithState | ApprovalParamsWithoutState); - -/** - * CallModelInput variant that requires state - use when approval workflows are needed - */ -export type CallModelInputWithState< - TTools extends readonly Tool[] = readonly Tool[], - TShared extends Record = Record, -> = - BaseCallModelInput & ApprovalParamsWithState; - -/** - * Resolved CallModelInput (all functions evaluated to values) - * This is the type after all async functions have been resolved to their values - */ -export type ResolvedCallModelInput = Omit & { - tools?: never; -}; - -/** - * Resolve all async functions in CallModelInput to their values - * - * @param input - Input with possible functions - * @param context - Turn context for function execution - * @returns Resolved input with all values (no functions) - * - * @example - * ```typescript - * const resolved = await resolveAsyncFunctions( - * { - * model: 'gpt-4', - * temperature: (ctx) => ctx.numberOfTurns * 0.1, - * input: 'Hello', - * }, - * { numberOfTurns: 2 } - * ); - * // resolved.temperature === 0.2 - * ``` - */ -export async function resolveAsyncFunctions( - input: CallModelInput, - context: TurnContext, -): Promise { - // Build array of resolved entries - const resolvedEntries: Array = []; - - // Fields that should not be sent to the API (client-side only) - const clientOnlyFields = new Set([ - 'stopWhen', // Handled separately in ModelResult - 'state', // Client-side state management - 'requireApproval', // Client-side approval check function - 'approveToolCalls', // Client-side approval decisions - 'rejectToolCalls', // Client-side rejection decisions - 'context', // Passed through via GetResponseOptions, not sent to API - 'sharedContextSchema', // Client-side schema for shared context validation - 'onTurnStart', // Client-side turn start callback - 'onTurnEnd', // Client-side turn end callback - ]); - - // Iterate over all keys in the input - for (const [key, value] of Object.entries(input)) { - // Skip client-only fields - they're handled separately and shouldn't be sent to the API - // Note: tools are already in API format at this point (converted in callModel()), so we include them - if (clientOnlyFields.has(key)) { - continue; - } - - if (isParameterFunction(value)) { - try { - // Execute the function with context and store the result - const result = await Promise.resolve(value(context)); - resolvedEntries.push([key, result] as const); - } catch (error) { - // Wrap errors with context about which field failed - throw new Error( - `Failed to resolve async function for field "${key}": ${error instanceof Error ? error.message : String(error) - }`, - ); - } - } else { - // Not a function, use as-is - resolvedEntries.push([key, value] as const); - } - } - - return buildResolvedRequest(resolvedEntries); -} - -/** - * Check if input has any async functions that need resolution - * - * @param input - Input to check - * @returns True if any field is a function - */ -export function hasAsyncFunctions(input: unknown): boolean { - if (!input || typeof input !== 'object') { - return false; - } - return Object.values(input).some((value) => typeof value === 'function'); -} diff --git a/src/lib/chat-compat.ts b/src/lib/chat-compat.ts deleted file mode 100644 index 7bb9a3869..000000000 --- a/src/lib/chat-compat.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { ChatAssistantMessage } from "../models/chatassistantmessage.js"; -import type { ChatMessages } from "../models/chatmessages.js"; -import type { ChatToolMessage } from "../models/chattoolmessage.js"; -import type { EasyInputMessage, EasyInputMessageRoleUnion } from "../models/easyinputmessage.js"; -import type { FunctionCallOutputItem } from "../models/functioncalloutputitem.js"; -import type { InputsUnion } from "../models/inputsunion.js"; -import { - EasyInputMessageRoleUser, - EasyInputMessageRoleSystem, - EasyInputMessageRoleAssistant, - EasyInputMessageRoleDeveloper, -} from "../models/easyinputmessage.js"; -import { extractMessageFromResponse } from "./stream-transformers.js"; - -/** - * Type guard for ChatToolMessage - */ -function isToolResponseMessage( - msg: ChatMessages -): msg is ChatToolMessage { - return msg.role === "tool"; -} - -/** - * Type guard for ChatAssistantMessage - */ -function isAssistantMessage( - msg: ChatMessages -): msg is ChatAssistantMessage { - return msg.role === "assistant"; -} - -/** - * Maps chat role strings to OpenResponses role types - */ -function mapChatRole( - role: "user" | "system" | "assistant" | "developer" -): EasyInputMessageRoleUnion { - switch (role) { - case "user": - return EasyInputMessageRoleUser.User; - case "system": - return EasyInputMessageRoleSystem.System; - case "assistant": - return EasyInputMessageRoleAssistant.Assistant; - case "developer": - return EasyInputMessageRoleDeveloper.Developer; - default: { - const exhaustiveCheck: never = role; - throw new Error(`Unhandled role type: ${exhaustiveCheck}`); - } - } -} - -/** - * Convert message content to a string representation. - * Handles string, null, undefined, and object content types. - */ -function contentToString(content: unknown): string { - if (typeof content === "string") { - return content; - } - if (content === null || content === undefined) { - return ""; - } - return JSON.stringify(content); -} - -/** - * Convert OpenAI chat-style messages to OpenResponses input format. - * - * This function transforms Message[] (OpenAI chat format) to OpenResponsesInput - * format that can be passed directly to callModel(). - * - * @example - * ```typescript - * import { fromChatMessages } from '@openrouter/sdk'; - * - * const chatMessages = [ - * { role: "system", content: "You are a helpful assistant." }, - * { role: "user", content: "Hello!" }, - * ]; - * - * const response = openrouter.callModel({ - * model: "openai/gpt-4", - * input: fromChatMessages(chatMessages), - * }); - * ``` - */ -export function fromChatMessages( - messages: ChatMessages[] -): InputsUnion { - return messages.map( - ( - msg - ): - | EasyInputMessage - | FunctionCallOutputItem => { - if (isToolResponseMessage(msg)) { - return { - type: "function_call_output" as const, - callId: msg.toolCallId, - output: contentToString(msg.content), - }; - } - - if (isAssistantMessage(msg)) { - return { - role: mapChatRole("assistant"), - content: contentToString(msg.content), - }; - } - - // System, user, developer messages - return { - role: mapChatRole(msg.role), - content: contentToString(msg.content), - }; - } - ); -} - -/** - * Convert an OpenResponses response to OpenAI chat message format. - * - * This function transforms OpenResponsesResult to ChatAssistantMessage - * (OpenAI chat format) for compatibility with code expecting chat responses. - * - * @example - * ```typescript - * import { toChatMessage } from '@openrouter/sdk'; - * - * const response = await openrouter.callModel({ - * model: "openai/gpt-4", - * input: "Hello!", - * }); - * - * const openResponsesResult = await response.getResponse(); - * const chatMessage = toChatMessage(openResponsesResult); - * // chatMessage is now { role: "assistant", content: "..." } - * ``` - */ -export const toChatMessage = extractMessageFromResponse; diff --git a/src/lib/claude-constants.ts b/src/lib/claude-constants.ts deleted file mode 100644 index c9d466cfc..000000000 --- a/src/lib/claude-constants.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Claude-specific content block types - * Used for detecting Claude message format - */ -export const ClaudeContentBlockType = { - Text: "text", - Image: "image", - ToolUse: "tool_use", - ToolResult: "tool_result", -} as const; - -export type ClaudeContentBlockType = - (typeof ClaudeContentBlockType)[keyof typeof ClaudeContentBlockType]; - -/** - * Message roles that are NOT supported in Claude format - * Used for distinguishing Claude vs OpenAI format - */ -export const NonClaudeMessageRole = { - System: "system", - Developer: "developer", - Tool: "tool", -} as const; - -export type NonClaudeMessageRole = - (typeof NonClaudeMessageRole)[keyof typeof NonClaudeMessageRole]; diff --git a/src/lib/claude-type-guards.ts b/src/lib/claude-type-guards.ts deleted file mode 100644 index 325203c2b..000000000 --- a/src/lib/claude-type-guards.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { ClaudeMessageParam } from "../models/claude-message.js"; -import { - ClaudeContentBlockType, - NonClaudeMessageRole, -} from "./claude-constants.js"; - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function isNonClaudeRole(role: unknown): boolean { - return ( - role === NonClaudeMessageRole.System || - role === NonClaudeMessageRole.Developer || - role === NonClaudeMessageRole.Tool - ); -} - -function isClaudeToolResultBlock(block: unknown): boolean { - if (!isRecord(block)) return false; - return block["type"] === ClaudeContentBlockType.ToolResult; -} - -function isClaudeImageBlockWithSource(block: unknown): boolean { - if (!isRecord(block)) return false; - return ( - block["type"] === ClaudeContentBlockType.Image && - "source" in block && - isRecord(block["source"]) - ); -} - -function isClaudeToolUseBlockWithId(block: unknown): boolean { - if (!isRecord(block)) return false; - return ( - block["type"] === ClaudeContentBlockType.ToolUse && - "id" in block && - typeof block["id"] === "string" - ); -} - -function hasClaudeSpecificBlocks(content: unknown[]): boolean { - for (const block of content) { - if (isClaudeToolResultBlock(block)) return true; - if (isClaudeImageBlockWithSource(block)) return true; - if (isClaudeToolUseBlockWithId(block)) return true; - } - return false; -} - -/** - * Check if input is in Claude message format - * Uses structural analysis to detect Claude-specific patterns - * - * @param input - Input to check - * @returns True if input appears to be Claude format - */ -export function isClaudeStyleMessages( - input: unknown -): input is ClaudeMessageParam[] { - if (!Array.isArray(input) || input.length === 0) { - return false; - } - - for (const msg of input) { - if (!isRecord(msg)) continue; - if (!("role" in msg)) continue; - if ("type" in msg) continue; // Claude messages don't have top-level "type" - - // If we find a non-Claude role, it's not Claude format - if (isNonClaudeRole(msg["role"])) { - return false; - } - - // If we find Claude-specific content blocks, it's Claude format - const content = msg["content"]; - if (Array.isArray(content) && hasClaudeSpecificBlocks(content)) { - return true; - } - } - - return false; -} diff --git a/src/lib/conversation-state.ts b/src/lib/conversation-state.ts deleted file mode 100644 index 344856ead..000000000 --- a/src/lib/conversation-state.ts +++ /dev/null @@ -1,285 +0,0 @@ -import type { FunctionCallOutputItem } from '../models/functioncalloutputitem.js'; -import type { InputsUnion, InputsUnion1 } from '../models/inputsunion.js'; -import type { OpenResponsesResult } from '../models/openresponsesresult.js'; -import type { - ConversationState, - ParsedToolCall, - Tool, - TurnContext, - UnsentToolResult, -} from './tool-types.js'; -import { normalizeInputToArray } from './turn-context.js'; - -/** - * Type guard to verify an object is a valid UnsentToolResult - */ -function isValidUnsentToolResult( - obj: unknown -): obj is UnsentToolResult { - if (typeof obj !== 'object' || obj === null) return false; - return ( - 'callId' in obj && - typeof obj.callId === 'string' && - 'name' in obj && - typeof obj.name === 'string' && - 'output' in obj - ); -} - -/** - * Type guard to verify an object is a valid ParsedToolCall - */ -function isValidParsedToolCall( - obj: unknown -): obj is ParsedToolCall { - if (typeof obj !== 'object' || obj === null) return false; - return ( - 'id' in obj && - typeof obj.id === 'string' && - 'name' in obj && - typeof obj.name === 'string' && - 'arguments' in obj - ); -} - -/** - * Generate a unique ID for a conversation - * Uses crypto.randomUUID if available, falls back to timestamp + random - */ -export function generateConversationId(): string { - if (typeof crypto !== 'undefined' && crypto.randomUUID) { - return `conv_${crypto.randomUUID()}`; - } - // Fallback for environments without crypto.randomUUID - return `conv_${Date.now()}_${Math.random().toString(36).substring(2, 15)}`; -} - -/** - * Create an initial conversation state - * @param id - Optional custom ID, generates one if not provided - */ -export function createInitialState( - id?: string -): ConversationState { - const now = Date.now(); - return { - id: id ?? generateConversationId(), - messages: [], - status: 'in_progress', - createdAt: now, - updatedAt: now, - }; -} - -/** - * Update a conversation state with new values - * Automatically updates the updatedAt timestamp - */ -export function updateState( - state: ConversationState, - updates: Partial, 'id' | 'createdAt' | 'updatedAt'>> -): ConversationState { - return { - ...state, - ...updates, - updatedAt: Date.now(), - }; -} - -/** - * Append new items to the message history - */ -export function appendToMessages( - current: InputsUnion, - newItems: InputsUnion1[] -): InputsUnion { - const currentArray = normalizeInputToArray(current); - return [...currentArray, ...newItems]; -} - -/** - * Check if a tool call requires approval - * @param toolCall - The tool call to check - * @param tools - Available tools - * @param context - Turn context for the approval check - * @param callLevelCheck - Optional call-level approval function (overrides tool-level), can be async - */ -export async function toolRequiresApproval( - toolCall: ParsedToolCall, - tools: TTools, - context: TurnContext, - callLevelCheck?: (toolCall: ParsedToolCall, context: TurnContext) => boolean | Promise -): Promise { - // Call-level check takes precedence - if (callLevelCheck) { - return callLevelCheck(toolCall, context); - } - - // Fall back to tool-level setting - const tool = tools.find(t => t.function.name === toolCall.name); - if (!tool) return false; - - const requireApproval = tool.function.requireApproval; - - // If it's a function, call it with the tool's arguments and context - if (typeof requireApproval === 'function') { - return requireApproval(toolCall.arguments, context); - } - - // Otherwise treat as boolean - return requireApproval ?? false; -} - -/** - * Partition tool calls into those requiring approval and those that can auto-execute - * @param toolCalls - Tool calls to partition - * @param tools - Available tools - * @param context - Turn context for the approval check - * @param callLevelCheck - Optional call-level approval function (overrides tool-level), can be async - */ -export async function partitionToolCalls( - toolCalls: ParsedToolCall[], - tools: TTools, - context: TurnContext, - callLevelCheck?: (toolCall: ParsedToolCall, context: TurnContext) => boolean | Promise -): Promise<{ - requiresApproval: ParsedToolCall[]; - autoExecute: ParsedToolCall[]; -}> { - const requiresApproval: ParsedToolCall[] = []; - const autoExecute: ParsedToolCall[] = []; - - for (const tc of toolCalls) { - if (await toolRequiresApproval(tc, tools, context, callLevelCheck)) { - requiresApproval.push(tc); - } else { - autoExecute.push(tc); - } - } - - return { requiresApproval, autoExecute }; -} - -/** - * Create an unsent tool result from a successful execution - */ -export function createUnsentResult( - callId: string, - name: string, - output: unknown -): UnsentToolResult { - const result = { callId, name, output }; - if (!isValidUnsentToolResult(result)) { - throw new Error('Invalid UnsentToolResult structure'); - } - return result; -} - -/** - * Create an unsent tool result from a rejection - */ -export function createRejectedResult( - callId: string, - name: string, - reason?: string -): UnsentToolResult { - const result = { - callId, - name, - output: null, - error: reason ?? 'Tool call rejected by user', - }; - if (!isValidUnsentToolResult(result)) { - throw new Error('Invalid UnsentToolResult structure'); - } - return result; -} - -/** - * Convert unsent tool results to API format for sending to the model - */ -export function unsentResultsToAPIFormat( - results: UnsentToolResult[] -): FunctionCallOutputItem[] { - return results.map(r => ({ - type: 'function_call_output' as const, - id: `output_${r.callId}`, - callId: r.callId, - output: r.error - ? JSON.stringify({ error: r.error }) - : JSON.stringify(r.output), - })); -} - -/** - * Extract text content from a response - */ -export function extractTextFromResponse( - response: OpenResponsesResult -): string { - if (!response.output) { - return ''; - } - - const outputs = Array.isArray(response.output) ? response.output : [response.output]; - const textParts: string[] = []; - - for (const item of outputs) { - if (item.type === 'message' && 'content' in item && item.content) { - for (const content of item.content) { - if (content.type === 'output_text' && 'text' in content && content.text) { - textParts.push(content.text); - } - } - } - } - - return textParts.join(''); -} - -/** - * Extract tool calls from a response - */ -export function extractToolCallsFromResponse( - response: OpenResponsesResult -): ParsedToolCall[] { - if (!response.output) { - return []; - } - - const outputs = Array.isArray(response.output) ? response.output : [response.output]; - const toolCalls: ParsedToolCall[] = []; - - for (const item of outputs) { - if (item.type !== 'function_call' || !('arguments' in item)) { - continue; - } - - let parsedArguments: unknown; - if (typeof item.arguments === 'string') { - try { - parsedArguments = JSON.parse(item.arguments); - } catch (error) { - // Log warning and skip malformed tool call, similar to stream-transformers.ts - console.warn( - `Failed to parse arguments for tool call "${'name' in item ? item.name : 'unknown'}": ${error instanceof Error ? error.message : String(error)}` - ); - continue; - } - } else { - parsedArguments = item.arguments; - } - - const toolCall = { - id: ('callId' in item ? item.callId : undefined) ?? item.id ?? '', - name: ('name' in item ? item.name : undefined) ?? '', - arguments: parsedArguments, - }; - if (!isValidParsedToolCall(toolCall)) { - throw new Error(`Invalid tool call structure for tool: ${toolCall.name}`); - } - toolCalls.push(toolCall); - } - - return toolCalls; -} diff --git a/src/lib/model-result.ts b/src/lib/model-result.ts deleted file mode 100644 index 877642633..000000000 --- a/src/lib/model-result.ts +++ /dev/null @@ -1,1857 +0,0 @@ -import type { OpenRouterCore } from '../core.js'; -import type { FunctionCallOutputItem } from '../models/functioncalloutputitem.js'; -import type { InputsUnion, InputsUnion1 } from '../models/inputsunion.js'; -import type { OpenResponsesResult } from '../models/openresponsesresult.js'; -import type { OutputFunctionCallItem } from '../models/outputfunctioncallitem.js'; -import type { OutputMessage } from '../models/outputmessage.js'; -import type { ReasoningDeltaEvent } from '../models/reasoningdeltaevent.js'; -import type { ResponsesRequest } from '../models/responsesrequest.js'; -import type { StreamEvents } from '../models/streamevents.js'; -import type { TextDeltaEvent } from '../models/textdeltaevent.js'; -import type { CallModelInput } from './async-params.js'; -import type { EventStream } from './event-streams.js'; -import type { RequestOptions } from './sdks.js'; -import type { $ZodObject, $ZodShape } from 'zod/v4/core'; -import type { - ConversationState, - ToolCallOutputEvent, - ToolContextMapWithShared, - ResponseStreamEvent, - InferToolEventsUnion, - InferToolOutputsUnion, - ParsedToolCall, - StateAccessor, - StopWhen, - Tool, - ToolStreamEvent, - TurnContext, - TurnStartEvent, - TurnEndEvent, - UnsentToolResult, -} from './tool-types.js'; -import { ToolEventBroadcaster } from './tool-event-broadcaster.js'; -import { type ContextInput, ToolContextStore, resolveContext } from './tool-context.js'; - -import { responsesSend } from '../funcs/responsesSend.js'; -import { - hasAsyncFunctions, - resolveAsyncFunctions, - type ResolvedCallModelInput, -} from './async-params.js'; -import { - appendToMessages, - createInitialState, - createRejectedResult, - createUnsentResult, - extractTextFromResponse as extractTextFromResponseState, - partitionToolCalls, - unsentResultsToAPIFormat, - updateState, -} from './conversation-state.js'; -import { ReusableReadableStream } from './reusable-stream.js'; -import { - buildItemsStream, - buildResponsesMessageStream, - buildToolCallStream, - consumeStreamForCompletion, - extractReasoningDeltas, - extractResponsesMessageFromResponse, - extractTextDeltas, - extractTextFromResponse, - extractToolCallsFromResponse, - extractToolDeltas, - itemsStreamHandlers, - streamTerminationEvents, - type ItemInProgress, - type StreamableOutputItem, -} from './stream-transformers.js'; -import { executeTool } from './tool-executor.js'; -import { executeNextTurnParamsFunctions, applyNextTurnParamsToRequest } from './next-turn-params.js'; -import { hasExecuteFunction, isToolCallOutputEvent } from './tool-types.js'; -import { isStopConditionMet, stepCountIs } from './stop-conditions.js'; -import { - isFunctionCallItem, - isResponseCompletedEvent, - isResponseFailedEvent, - isResponseIncompleteEvent, - isOutputTextDeltaEvent, - isReasoningDeltaEvent, - hasTypeProperty, -} from './stream-type-guards.js'; - -/** - * Default maximum number of tool execution steps if no stopWhen is specified. - * This prevents infinite loops in tool execution. - */ -const DEFAULT_MAX_STEPS = 5; - -/** - * Type guard for stream event with toReadableStream method - * Checks constructor name, prototype, and method availability - */ -function isEventStream(value: unknown): value is EventStream { - if (value === null || typeof value !== 'object') { - return false; - } - - // Check constructor name for EventStream - const constructorName = Object.getPrototypeOf(value)?.constructor?.name; - if (constructorName === 'EventStream') { - return true; - } - - // Fallback: check for toReadableStream method (may be on prototype) - const maybeStream = value as { toReadableStream?: unknown }; - return typeof maybeStream.toReadableStream === 'function'; -} - -export interface GetResponseOptions< - TTools extends readonly Tool[], - TShared extends Record = Record, -> { - // Request can have async functions that will be resolved before sending to API - request: CallModelInput; - client: OpenRouterCore; - options?: RequestOptions; - tools?: TTools; - stopWhen?: StopWhen; - // State management for multi-turn conversations - state?: StateAccessor; - /** Typed context data passed to tools via contextSchema. `shared` key for shared context. */ - context?: ContextInput>; - /** Zod schema for shared context validation */ - sharedContextSchema?: $ZodObject<$ZodShape>; - - /** - * Call-level approval check - overrides tool-level requireApproval setting - * Receives the tool call and turn context, can be sync or async - */ - requireApproval?: ( - toolCall: ParsedToolCall, - context: TurnContext - ) => boolean | Promise; - approveToolCalls?: string[]; - rejectToolCalls?: string[]; - - /** Callback invoked at the start of each tool execution turn */ - onTurnStart?: (context: TurnContext) => void | Promise; - /** Callback invoked at the end of each tool execution turn */ - onTurnEnd?: (context: TurnContext, response: OpenResponsesResult) => void | Promise; -} - -/** - * A wrapper around a streaming response that provides multiple consumption patterns. - * - * Allows consuming the response in multiple ways: - * - `await result.getText()` - Get just the text - * - `await result.getResponse()` - Get the full response object - * - `for await (const delta of result.getTextStream())` - Stream text deltas - * - `for await (const msg of result.getNewMessagesStream())` - Stream cumulative message snapshots - * - `for await (const event of result.getFullResponsesStream())` - Stream all response events - * - * For message format conversion, use the helper functions: - * - `toChatMessage(response)` for OpenAI chat format - * - `toClaudeMessage(response)` for Anthropic Claude format - * - * All consumption patterns can be used concurrently thanks to the underlying - * ReusableReadableStream implementation. - * - * @template TTools - The tools array type to enable typed tool calls and results - * @template TShared - The shape of the shared context (inferred from sharedContextSchema) - */ -export class ModelResult< - TTools extends readonly Tool[], - TShared extends Record = Record, -> { - private reusableStream: ReusableReadableStream | null = null; - private textPromise: Promise | null = null; - private options: GetResponseOptions; - private initPromise: Promise | null = null; - private toolExecutionPromise: Promise | null = null; - private finalResponse: OpenResponsesResult | null = null; - private toolEventBroadcaster: ToolEventBroadcaster< - | { - type: 'preliminary_result'; - toolCallId: string; - result: InferToolEventsUnion; - } - | { - type: 'tool_result'; - toolCallId: string; - result: InferToolOutputsUnion; - preliminaryResults?: InferToolEventsUnion[]; - } - > | null = null; - private allToolExecutionRounds: Array<{ - round: number; - toolCalls: ParsedToolCall[]; - response: OpenResponsesResult; - toolResults: Array; - }> = []; - // Track resolved request after async function resolution - private resolvedRequest: ResponsesRequest | null = null; - - // State management for multi-turn conversations - private stateAccessor: StateAccessor | null = null; - private currentState: ConversationState | null = null; - private requireApprovalFn: ((toolCall: ParsedToolCall, context: TurnContext) => boolean | Promise) | null = null; - private approvedToolCalls: string[] = []; - private rejectedToolCalls: string[] = []; - private isResumingFromApproval = false; - - // Unified turn broadcaster for multi-turn streaming - private turnBroadcaster: ToolEventBroadcaster< - ResponseStreamEvent, InferToolOutputsUnion> - > | null = null; - private initialStreamPipeStarted = false; - private initialPipePromise: Promise | null = null; - - // Context store for typed tool context (persists across turns) - private contextStore: ToolContextStore | null = null; - - constructor(options: GetResponseOptions) { - this.options = options; - - // Runtime validation: approval decisions require state - const hasApprovalDecisions = - (options.approveToolCalls && options.approveToolCalls.length > 0) || - (options.rejectToolCalls && options.rejectToolCalls.length > 0); - - if (hasApprovalDecisions && !options.state) { - throw new Error( - 'approveToolCalls and rejectToolCalls require a state accessor. ' + - 'Provide a StateAccessor via the "state" parameter to persist approval decisions.' - ); - } - - // Initialize state management - this.stateAccessor = options.state ?? null; - this.requireApprovalFn = options.requireApproval ?? null; - this.approvedToolCalls = options.approveToolCalls ?? []; - this.rejectedToolCalls = options.rejectToolCalls ?? []; - } - - /** - * Get or create the unified turn broadcaster (lazy initialization). - * Broadcasts all API stream events, tool events, and turn delimiters across turns. - */ - private ensureTurnBroadcaster(): ToolEventBroadcaster< - ResponseStreamEvent, InferToolOutputsUnion> - > { - if (!this.turnBroadcaster) { - this.turnBroadcaster = new ToolEventBroadcaster(); - } - return this.turnBroadcaster; - } - - /** - * Start piping the initial stream into the turn broadcaster. - * Idempotent — only starts once even if called multiple times. - * Wraps the initial stream events with turn.start(0) / turn.end(0) delimiters. - */ - private startInitialStreamPipe(): void { - if (this.initialStreamPipeStarted) return; - this.initialStreamPipeStarted = true; - - const broadcaster = this.ensureTurnBroadcaster(); - - if (!this.reusableStream) { - return; - } - - const stream = this.reusableStream; - - this.initialPipePromise = (async () => { - broadcaster.push({ - type: 'turn.start', - turnNumber: 0, - timestamp: Date.now(), - } satisfies TurnStartEvent); - - const consumer = stream.createConsumer(); - for await (const event of consumer) { - broadcaster.push(event); - } - - broadcaster.push({ - type: 'turn.end', - turnNumber: 0, - timestamp: Date.now(), - } satisfies TurnEndEvent); - })().catch((error) => { - broadcaster.complete(error instanceof Error ? error : new Error(String(error))); - }); - } - - /** - * Pipe a follow-up stream into the turn broadcaster and capture the completed response. - * Emits turn.start / turn.end delimiters around the stream events. - */ - private async pipeAndConsumeStream( - stream: ReusableReadableStream, - turnNumber: number - ): Promise { - const broadcaster = this.turnBroadcaster!; - - broadcaster.push({ - type: 'turn.start', - turnNumber, - timestamp: Date.now(), - } satisfies TurnStartEvent); - - const consumer = stream.createConsumer(); - let completedResponse: OpenResponsesResult | null = null; - - for await (const event of consumer) { - broadcaster.push(event); - if (isResponseCompletedEvent(event)) { - completedResponse = event.response; - } - if (isResponseFailedEvent(event)) { - const errorMsg = 'message' in event ? String(event.message) : 'Response failed'; - throw new Error(errorMsg); - } - if (isResponseIncompleteEvent(event)) { - completedResponse = event.response; - } - } - - broadcaster.push({ - type: 'turn.end', - turnNumber, - timestamp: Date.now(), - } satisfies TurnEndEvent); - - if (!completedResponse) { - throw new Error('Follow-up stream ended without a completed response'); - } - - return completedResponse; - } - - /** - * Push a tool result event to both the legacy tool event broadcaster - * and the unified turn broadcaster. - */ - private broadcastToolResult( - toolCallId: string, - result: InferToolOutputsUnion, - preliminaryResults?: InferToolEventsUnion[] - ): void { - this.toolEventBroadcaster?.push({ - type: 'tool_result' as const, - toolCallId, - result, - ...(preliminaryResults?.length && { preliminaryResults }), - }); - this.turnBroadcaster?.push({ - type: 'tool.result' as const, - toolCallId, - result, - timestamp: Date.now(), - ...(preliminaryResults?.length && { preliminaryResults }), - }); - } - - /** - * Push a preliminary result event to both the legacy tool event broadcaster - * and the unified turn broadcaster. - */ - private broadcastPreliminaryResult( - toolCallId: string, - result: InferToolEventsUnion - ): void { - this.toolEventBroadcaster?.push({ - type: 'preliminary_result' as const, - toolCallId, - result, - }); - this.turnBroadcaster?.push({ - type: 'tool.preliminary_result' as const, - toolCallId, - result, - timestamp: Date.now(), - }); - } - - /** - * Set up the turn broadcaster with tool execution and return the consumer. - * Used by stream methods that need to iterate over all turns. - */ - private startTurnBroadcasterExecution(): { - consumer: AsyncIterableIterator, InferToolOutputsUnion>>; - executionPromise: Promise; - } { - const broadcaster = this.ensureTurnBroadcaster(); - this.startInitialStreamPipe(); - const consumer = broadcaster.createConsumer(); - const executionPromise = this.executeToolsIfNeeded().finally(async () => { - // Wait for the initial stream pipe to finish pushing all events - // (including turn.end) before marking the broadcaster as complete. - // Without this, turn.end can be silently dropped if the pipe hasn't - // finished when executeToolsIfNeeded completes. - if (this.initialPipePromise) { - await this.initialPipePromise; - } - broadcaster.complete(); - }); - return { consumer, executionPromise }; - } - - /** - * Type guard to check if a value is a non-streaming response - * Only requires 'output' field and absence of 'toReadableStream' method - */ - private isNonStreamingResponse( - value: unknown, - ): value is OpenResponsesResult { - return ( - value !== null && - typeof value === 'object' && - 'output' in value && - !('toReadableStream' in value) - ); - } - - // ========================================================================= - // Extracted Helper Methods for executeToolsIfNeeded - // ========================================================================= - - /** - * Get initial response from stream or cached final response. - * Consumes the stream to completion if needed to extract the response. - * - * @returns The complete non-streaming response - * @throws Error if neither stream nor response has been initialized - */ - private async getInitialResponse(): Promise { - if (this.finalResponse) { - return this.finalResponse; - } - if (this.reusableStream) { - return consumeStreamForCompletion(this.reusableStream); - } - throw new Error('Neither stream nor response initialized'); - } - - /** - * Save response output to state. - * Appends the response output to the message history and records the response ID. - * - * @param response - The API response to save - */ - private async saveResponseToState( - response: OpenResponsesResult - ): Promise { - if (!this.stateAccessor || !this.currentState) return; - - const outputItems = Array.isArray(response.output) - ? response.output - : [response.output]; - - await this.saveStateSafely({ - messages: appendToMessages( - this.currentState.messages, - outputItems as InputsUnion1[] - ), - previousResponseId: response.id, - }); - } - - /** - * Mark state as complete. - * Sets the conversation status to 'complete' indicating no further tool execution is needed. - */ - private async markStateComplete(): Promise { - await this.saveStateSafely({ status: 'complete' }); - } - - /** - * Save tool results to state. - * Appends tool execution results to the message history for multi-turn context. - * - * @param toolResults - The tool execution results to save - */ - private async saveToolResultsToState( - toolResults: FunctionCallOutputItem[] - ): Promise { - if (!this.currentState) return; - await this.saveStateSafely({ - messages: appendToMessages(this.currentState.messages, toolResults), - }); - } - - /** - * Check if execution should be interrupted by external signal. - * Polls the state accessor for interruption flags set by external processes. - * - * @param currentResponse - The current response to save as partial state - * @returns True if interrupted and caller should exit, false to continue - */ - private async checkForInterruption( - currentResponse: OpenResponsesResult - ): Promise { - if (!this.stateAccessor) return false; - - const freshState = await this.stateAccessor.load(); - if (!freshState?.interruptedBy) return false; - - // Save partial state - if (this.currentState) { - const currentToolCalls = extractToolCallsFromResponse(currentResponse); - await this.saveStateSafely({ - status: 'interrupted', - partialResponse: { - text: extractTextFromResponseState(currentResponse), - toolCalls: currentToolCalls as ParsedToolCall[], - }, - }); - } - - this.finalResponse = currentResponse; - return true; - } - - /** - * Check if stop conditions are met. - * Returns true if execution should stop. - * - * @remarks - * Default: stepCountIs(DEFAULT_MAX_STEPS) if no stopWhen is specified. - * This evaluates stop conditions against the complete step history. - */ - private async shouldStopExecution(): Promise { - const stopWhen = this.options.stopWhen ?? stepCountIs(DEFAULT_MAX_STEPS); - - const stopConditions = Array.isArray(stopWhen) - ? stopWhen - : [stopWhen]; - - return isStopConditionMet({ - stopConditions, - steps: this.allToolExecutionRounds.map((round) => ({ - stepType: 'continue' as const, - text: extractTextFromResponse(round.response), - toolCalls: round.toolCalls, - toolResults: round.toolResults.map((tr) => ({ - toolCallId: tr.callId, - toolName: round.toolCalls.find((tc) => tc.id === tr.callId)?.name ?? '', - result: typeof tr.output === 'string' ? JSON.parse(tr.output) : tr.output, - })), - response: round.response, - usage: round.response.usage, - finishReason: undefined, - })), - }); - } - - /** - * Check if any tool calls have execute functions. - * Used to determine if automatic tool execution should be attempted. - * - * @param toolCalls - The tool calls to check - * @returns True if at least one tool call has an executable function - */ - private hasExecutableToolCalls(toolCalls: ParsedToolCall[]): boolean { - return toolCalls.some((toolCall) => { - const tool = this.options.tools?.find((t) => t.function.name === toolCall.name); - return tool && hasExecuteFunction(tool); - }); - } - - /** - * Execute tools that can auto-execute (don't require approval) in parallel. - * - * @param toolCalls - The tool calls to execute - * @param turnContext - The current turn context - * @returns Array of unsent tool results for later submission - */ - private async executeAutoApproveTools( - toolCalls: ParsedToolCall[], - turnContext: TurnContext - ): Promise[]> { - const toolCallPromises = toolCalls.map(async (tc) => { - const tool = this.options.tools?.find(t => t.function.name === tc.name); - if (!tool || !hasExecuteFunction(tool)) { - return null; - } - - const result = await executeTool(tool, tc as ParsedToolCall, turnContext, undefined, this.contextStore ?? undefined, this.options.sharedContextSchema); - - if (result.error) { - return createRejectedResult(tc.id, String(tc.name), result.error.message); - } - return createUnsentResult(tc.id, String(tc.name), result.result); - }); - - const settledResults = await Promise.allSettled(toolCallPromises); - - const results: UnsentToolResult[] = []; - for (let i = 0; i < settledResults.length; i++) { - const settled = settledResults[i]; - const tc = toolCalls[i]; - if (!settled || !tc) continue; - - if (settled.status === 'rejected') { - const errorMessage = settled.reason instanceof Error - ? settled.reason.message - : String(settled.reason); - results.push(createRejectedResult(tc.id, String(tc.name), errorMessage) as UnsentToolResult); - continue; - } - - if (settled.value) { - results.push(settled.value as UnsentToolResult); - } - } - - return results; - } - - /** - * Check for tools requiring approval and handle accordingly. - * Partitions tool calls into those needing approval and those that can auto-execute. - * - * @param toolCalls - The tool calls to check - * @param currentRound - The current execution round (1-indexed) - * @param currentResponse - The current response to save if pausing - * @param stopConditionMet - When true, auto-approved calls are not executed; - * they are saved as skipped results so the paused turn stays well-formed - * @returns True if execution should pause for approval, false to continue - * @throws Error if approval is required but no state accessor is configured - */ - private async handleApprovalCheck( - toolCalls: ParsedToolCall[], - currentRound: number, - currentResponse: OpenResponsesResult, - stopConditionMet = false - ): Promise { - if (!this.options.tools) return false; - - const turnContext: TurnContext = { - numberOfTurns: currentRound, - // context is handled via contextStore, not on TurnContext - }; - - const { requiresApproval: needsApproval, autoExecute } = await partitionToolCalls( - toolCalls as ParsedToolCall[], - this.options.tools, - turnContext, - this.requireApprovalFn ?? undefined - ); - - if (needsApproval.length === 0) return false; - - // Validate: approval requires state accessor - if (!this.stateAccessor) { - const toolNames = needsApproval.map(tc => tc.name).join(', '); - throw new Error( - `Tool(s) require approval but no state accessor is configured: ${toolNames}. ` + - 'Provide a StateAccessor via the "state" parameter to enable approval workflows.' - ); - } - - // Execute auto-approve tools, unless stopWhen already says no tool may run - const unsentResults: UnsentToolResult[] = stopConditionMet - ? autoExecute.map((tc) => - createRejectedResult(tc.id, String(tc.name), 'Tool execution skipped: stop condition met') - ) - : await this.executeAutoApproveTools(autoExecute, turnContext); - - // Save state with pending approvals - const stateUpdates: Partial, 'id' | 'createdAt' | 'updatedAt'>> = { - pendingToolCalls: needsApproval, - status: 'awaiting_approval', - }; - if (unsentResults.length > 0) { - stateUpdates.unsentToolResults = unsentResults; - } - await this.saveStateSafely(stateUpdates); - - this.finalResponse = currentResponse; - return true; // Pause for approval - } - - /** - * Execute all tools in a single round in parallel. - * Emits tool.result events after tool execution completes. - * - * @param toolCalls - The tool calls to execute - * @param turnContext - The current turn context - * @returns Array of function call outputs formatted for the API - */ - private async executeToolRound( - toolCalls: ParsedToolCall[], - turnContext: TurnContext - ): Promise { - const toolCallPromises = toolCalls.map(async (toolCall) => { - const tool = this.options.tools?.find((t) => t.function.name === toolCall.name); - if (!tool || !hasExecuteFunction(tool)) { - return null; - } - - // Check if arguments failed to parse (remained as string instead of object) - const args: unknown = toolCall.arguments; - if (typeof args === 'string') { - const rawArgs = args; - const errorMessage = `Failed to parse tool call arguments for "${toolCall.name}": The model provided invalid JSON. ` + - `Raw arguments received: "${rawArgs}". ` + - `Please provide valid JSON arguments for this tool call.`; - - this.broadcastToolResult(toolCall.id, { error: errorMessage } as InferToolOutputsUnion); - - return { - type: 'parse_error' as const, - toolCall, - output: { - type: 'function_call_output' as const, - id: `output_${toolCall.id}`, - callId: toolCall.id, - output: JSON.stringify({ error: errorMessage }), - }, - }; - } - - const preliminaryResultsForCall: InferToolEventsUnion[] = []; - - const hasBroadcaster = this.toolEventBroadcaster || this.turnBroadcaster; - const onPreliminaryResult = hasBroadcaster - ? (callId: string, resultValue: unknown) => { - const typedResult = resultValue as InferToolEventsUnion; - preliminaryResultsForCall.push(typedResult); - this.broadcastPreliminaryResult(callId, typedResult); - } - : undefined; - - const result = await executeTool(tool, toolCall, turnContext, onPreliminaryResult, this.contextStore ?? undefined, this.options.sharedContextSchema); - - return { - type: 'execution' as const, - toolCall, - tool, - result, - preliminaryResultsForCall, - }; - }); - - const settledResults = await Promise.allSettled(toolCallPromises); - const toolResults: FunctionCallOutputItem[] = []; - - for (let i = 0; i < settledResults.length; i++) { - const settled = settledResults[i]; - const originalToolCall = toolCalls[i]; - if (!settled || !originalToolCall) continue; - - if (settled.status === 'rejected') { - const errorMessage = settled.reason instanceof Error - ? settled.reason.message - : String(settled.reason); - - this.broadcastToolResult(originalToolCall.id, { error: errorMessage } as InferToolOutputsUnion); - - const rejectedOutput: FunctionCallOutputItem = { - type: 'function_call_output' as const, - id: `output_${originalToolCall.id}`, - callId: originalToolCall.id, - output: JSON.stringify({ error: errorMessage }), - }; - toolResults.push(rejectedOutput); - this.turnBroadcaster?.push({ - type: 'tool.call_output' as const, - output: rejectedOutput, - timestamp: Date.now(), - } satisfies ToolCallOutputEvent); - continue; - } - - const value = settled.value; - if (!value) continue; - - if (value.type === 'parse_error') { - toolResults.push(value.output); - this.turnBroadcaster?.push({ - type: 'tool.call_output' as const, - output: value.output, - timestamp: Date.now(), - } satisfies ToolCallOutputEvent); - continue; - } - - const toolResult = (value.result.error - ? { error: value.result.error.message } - : value.result.result) as InferToolOutputsUnion; - this.broadcastToolResult( - value.toolCall.id, - toolResult, - value.preliminaryResultsForCall.length > 0 ? value.preliminaryResultsForCall : undefined - ); - - const executedOutput: FunctionCallOutputItem = { - type: 'function_call_output' as const, - id: `output_${value.toolCall.id}`, - callId: value.toolCall.id, - output: value.result.error - ? JSON.stringify({ error: value.result.error.message }) - : JSON.stringify(value.result.result), - }; - toolResults.push(executedOutput); - this.turnBroadcaster?.push({ - type: 'tool.call_output' as const, - output: executedOutput, - timestamp: Date.now(), - } satisfies ToolCallOutputEvent); - } - - return toolResults; - } - - /** - * Resolve async functions for the current turn. - * Updates the resolved request with turn-specific parameter values. - * - * @param turnContext - The turn context for parameter resolution - */ - private async resolveAsyncFunctionsForTurn(turnContext: TurnContext): Promise { - if (hasAsyncFunctions(this.options.request)) { - const resolved = await resolveAsyncFunctions(this.options.request, turnContext); - // Preserve accumulated input from previous turns - const preservedInput = this.resolvedRequest?.input; - const preservedStream = this.resolvedRequest?.stream; - this.resolvedRequest = { - ...resolved, - stream: preservedStream ?? true, - ...(preservedInput !== undefined && { input: preservedInput }), - }; - } - } - - /** - * Apply nextTurnParams from executed tools. - * Allows tools to modify request parameters for subsequent turns. - * - * @param toolCalls - The tool calls that were just executed - */ - private async applyNextTurnParams(toolCalls: ParsedToolCall[]): Promise { - if (!this.options.tools || toolCalls.length === 0 || !this.resolvedRequest) { - return; - } - - const computedParams = await executeNextTurnParamsFunctions( - toolCalls, - this.options.tools, - this.resolvedRequest - ); - - if (Object.keys(computedParams).length > 0) { - this.resolvedRequest = applyNextTurnParamsToRequest( - this.resolvedRequest, - computedParams - ); - } - } - - /** - * Make a follow-up API request with tool results. - * Uses streaming and pipes events through the turn broadcaster when available. - */ - private async makeFollowupRequest( - currentResponse: OpenResponsesResult, - toolResults: FunctionCallOutputItem[], - turnNumber: number - ): Promise { - const originalInput = this.resolvedRequest?.input; - const normalizedOriginalInput: InputsUnion1[] = - Array.isArray(originalInput) - ? originalInput - : originalInput - ? [{ role: 'user', content: originalInput }] - : []; - - const newInput: InputsUnion = [ - ...normalizedOriginalInput, - ...((Array.isArray(currentResponse.output) - ? currentResponse.output - : [currentResponse.output]) as InputsUnion1[]), - ...toolResults, - ]; - - if (!this.resolvedRequest) { - throw new Error('Request not initialized'); - } - - // Update resolvedRequest.input with accumulated conversation for next turn - this.resolvedRequest = { - ...this.resolvedRequest, - input: newInput, - }; - - const newRequest: ResponsesRequest = { - ...this.resolvedRequest, - stream: true, - }; - - const newResult = await responsesSend( - this.options.client, - { responsesRequest: newRequest }, - this.options.options, - ); - - if (!newResult.ok) { - throw newResult.error; - } - - // Handle streaming or non-streaming response - const value = newResult.value; - if (isEventStream(value)) { - const followUpStream = new ReusableReadableStream(value); - - if (this.turnBroadcaster) { - return this.pipeAndConsumeStream(followUpStream, turnNumber); - } - - return consumeStreamForCompletion(followUpStream); - } else if (this.isNonStreamingResponse(value)) { - return value; - } else { - throw new Error('Unexpected response type from API'); - } - } - - /** - * Validate the final response has required fields. - * - * @param response - The response to validate - * @throws Error if response is missing required fields or has invalid output - */ - private validateFinalResponse( - response: OpenResponsesResult - ): void { - if (!response?.id || !response?.output) { - throw new Error('Invalid final response: missing required fields'); - } - if (!Array.isArray(response.output) || response.output.length === 0) { - throw new Error('Invalid final response: empty or invalid output'); - } - } - - /** - * Resolve async functions in the request for a given turn context. - * Extracts non-function fields and resolves any async parameter functions. - * - * @param context - The turn context for parameter resolution - * @returns The resolved request without async functions - */ - private async resolveRequestForContext(context: TurnContext): Promise { - if (hasAsyncFunctions(this.options.request)) { - return resolveAsyncFunctions(this.options.request, context); - } - // Already resolved, extract non-function fields - // Filter out stopWhen and state-related fields that aren't part of the API request - const { stopWhen: _, state: _s, requireApproval: _r, approveToolCalls: _a, rejectToolCalls: _rj, context: _c, ...rest } = this.options.request; - return rest as ResolvedCallModelInput; - } - - /** - * Safely persist state with error handling. - * Wraps state save operations to ensure failures are properly reported. - * - * @param updates - Optional partial state updates to apply before saving - * @throws Error if state persistence fails - */ - private async saveStateSafely( - updates?: Partial, 'id' | 'createdAt' | 'updatedAt'>> - ): Promise { - if (!this.stateAccessor || !this.currentState) return; - - if (updates) { - this.currentState = updateState(this.currentState, updates); - } - - try { - await this.stateAccessor.save(this.currentState); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - throw new Error(`Failed to persist conversation state: ${message}`); - } - } - - /** - * Remove optional properties from state when they should be cleared. - * Uses delete to properly remove optional properties rather than setting undefined. - * - * @param props - Array of property names to remove from current state - */ - private clearOptionalStateProperties( - props: Array<'pendingToolCalls' | 'unsentToolResults' | 'interruptedBy' | 'partialResponse'> - ): void { - if (!this.currentState) return; - for (const prop of props) { - delete this.currentState[prop]; - } - } - - // ========================================================================= - // Core Methods - // ========================================================================= - - /** - * Initialize the stream if not already started - * This is idempotent - multiple calls will return the same promise - */ - private initStream(): Promise { - if (this.initPromise) { - return this.initPromise; - } - - this.initPromise = (async () => { - // Load or create state if accessor provided - if (this.stateAccessor) { - const loadedState = await this.stateAccessor.load(); - if (loadedState) { - this.currentState = loadedState; - - // Check if we're resuming from awaiting_approval with decisions - if (loadedState.status === 'awaiting_approval' && - (this.approvedToolCalls.length > 0 || this.rejectedToolCalls.length > 0)) { - // Initialize context store before resuming so tools have access - if (this.options.context !== undefined) { - const approvalContext: TurnContext = { numberOfTurns: 0 }; - const resolvedCtx = await resolveContext(this.options.context, approvalContext); - this.contextStore = new ToolContextStore(resolvedCtx); - } - - this.isResumingFromApproval = true; - await this.processApprovalDecisions(); - return; // Skip normal initialization, we're resuming - } - - // Check for interruption flag and handle - if (loadedState.interruptedBy) { - // Clear interruption flag and continue from saved state - this.currentState = updateState(loadedState, { status: 'in_progress' }); - this.clearOptionalStateProperties(['interruptedBy']); - await this.saveStateSafely(); - } - } else { - this.currentState = createInitialState(); - } - - // Update status to in_progress - await this.saveStateSafely({ status: 'in_progress' }); - } - - // Resolve async functions before initial request - // Build initial turn context (turn 0 for initial request) - const initialContext: TurnContext = { numberOfTurns: 0 }; - - // Initialize context store from the context option - if (this.options.context !== undefined) { - const resolvedCtx = await resolveContext(this.options.context, initialContext); - this.contextStore = new ToolContextStore(resolvedCtx); - } - - // Resolve any async functions first - let baseRequest = await this.resolveRequestForContext(initialContext); - - // If we have state with existing messages, use those as input - if (this.currentState && this.currentState.messages && - Array.isArray(this.currentState.messages) && this.currentState.messages.length > 0) { - // Append new input to existing messages - const newInput = baseRequest.input; - if (newInput) { - const inputArray = Array.isArray(newInput) ? newInput : [newInput]; - baseRequest = { - ...baseRequest, - input: appendToMessages(this.currentState.messages, inputArray as InputsUnion1[]), - }; - } else { - baseRequest = { - ...baseRequest, - input: this.currentState.messages, - }; - } - } - - // Store resolved request with stream mode - this.resolvedRequest = { - ...baseRequest, - stream: true as const, - }; - - // Force stream mode for initial request - const request = this.resolvedRequest; - - // Make the API request - const apiResult = await responsesSend( - this.options.client, - { responsesRequest: request }, - this.options.options, - ); - - if (!apiResult.ok) { - throw apiResult.error; - } - - // Handle both streaming and non-streaming responses - // The API may return a non-streaming response even when stream: true is requested - if (isEventStream(apiResult.value)) { - this.reusableStream = new ReusableReadableStream(apiResult.value); - } else if (this.isNonStreamingResponse(apiResult.value)) { - // API returned a complete response directly - use it as the final response - this.finalResponse = apiResult.value; - } else { - throw new Error('Unexpected response type from API'); - } - })(); - - return this.initPromise; - } - - /** - * Process approval/rejection decisions and resume execution - */ - private async processApprovalDecisions(): Promise { - if (!this.currentState || !this.stateAccessor) { - throw new Error('Cannot process approval decisions without state'); - } - - const pendingCalls = this.currentState.pendingToolCalls ?? []; - const unsentResults = [...(this.currentState.unsentToolResults ?? [])]; - - // Build turn context - numberOfTurns represents the current turn (1-indexed after initial) - const turnContext: TurnContext = { - numberOfTurns: this.allToolExecutionRounds.length + 1, - // context is handled via contextStore, not on TurnContext - }; - - // Process approvals - execute the approved tools - for (const callId of this.approvedToolCalls) { - const toolCall = pendingCalls.find(tc => tc.id === callId); - if (!toolCall) continue; - - const tool = this.options.tools?.find(t => t.function.name === toolCall.name); - if (!tool || !hasExecuteFunction(tool)) { - // Can't execute, create error result - unsentResults.push(createRejectedResult(callId, String(toolCall.name), 'Tool not found or not executable')); - continue; - } - - const result = await executeTool(tool, toolCall as ParsedToolCall, turnContext, undefined, this.contextStore ?? undefined, this.options.sharedContextSchema); - - if (result.error) { - unsentResults.push(createRejectedResult(callId, String(toolCall.name), result.error.message)); - } else { - unsentResults.push(createUnsentResult(callId, String(toolCall.name), result.result)); - } - } - - // Process rejections - for (const callId of this.rejectedToolCalls) { - const toolCall = pendingCalls.find(tc => tc.id === callId); - if (!toolCall) continue; - - unsentResults.push(createRejectedResult(callId, String(toolCall.name), 'Rejected by user')); - } - - // Remove processed calls from pending - const processedIds = new Set([...this.approvedToolCalls, ...this.rejectedToolCalls]); - const remainingPending = pendingCalls.filter(tc => !processedIds.has(tc.id)); - - // Update state - conditionally include optional properties only if they have values - const stateUpdates: Partial, 'id' | 'createdAt' | 'updatedAt'>> = { - status: remainingPending.length > 0 ? 'awaiting_approval' : 'in_progress', - }; - if (remainingPending.length > 0) { - stateUpdates.pendingToolCalls = remainingPending; - } - if (unsentResults.length > 0) { - stateUpdates.unsentToolResults = unsentResults as UnsentToolResult[]; - } - await this.saveStateSafely(stateUpdates); - - // Clear optional properties if they should be empty - const propsToClear: Array<'pendingToolCalls' | 'unsentToolResults'> = []; - if (remainingPending.length === 0) propsToClear.push('pendingToolCalls'); - if (unsentResults.length === 0) propsToClear.push('unsentToolResults'); - if (propsToClear.length > 0) { - this.clearOptionalStateProperties(propsToClear); - await this.saveStateSafely(); - } - - // If we still have pending approvals, stop here - if (remainingPending.length > 0) { - return; - } - - // Otherwise, continue with tool execution using unsent results - await this.continueWithUnsentResults(); - } - - /** - * Continue execution with unsent tool results - */ - private async continueWithUnsentResults(): Promise { - if (!this.currentState || !this.stateAccessor) return; - - const unsentResults = this.currentState.unsentToolResults ?? []; - if (unsentResults.length === 0) return; - - // Convert to API format - const toolOutputs = unsentResultsToAPIFormat(unsentResults); - - // Build new input with tool results - const currentMessages = this.currentState.messages; - const newInput = appendToMessages(currentMessages, toolOutputs); - - // Clear unsent results from state - this.currentState = updateState(this.currentState, { - messages: newInput, - }); - this.clearOptionalStateProperties(['unsentToolResults']); - await this.saveStateSafely(); - - // Build request with the updated input - // numberOfTurns represents the current turn number (1-indexed after initial) - const turnContext: TurnContext = { - numberOfTurns: this.allToolExecutionRounds.length + 1, - }; - - const baseRequest = await this.resolveRequestForContext(turnContext); - - // Create request with the accumulated messages - const request: ResponsesRequest = { - ...baseRequest, - input: newInput, - stream: true, - }; - - this.resolvedRequest = request; - - // Make the API request - const apiResult = await responsesSend( - this.options.client, - { responsesRequest: request }, - this.options.options, - ); - - if (!apiResult.ok) { - throw apiResult.error; - } - - // Handle both streaming and non-streaming responses - if (isEventStream(apiResult.value)) { - this.reusableStream = new ReusableReadableStream(apiResult.value); - } else if (this.isNonStreamingResponse(apiResult.value)) { - this.finalResponse = apiResult.value; - } else { - throw new Error('Unexpected response type from API'); - } - } - - /** - * Execute tools automatically if they are provided and have execute functions - * This is idempotent - multiple calls will return the same promise - */ - private async executeToolsIfNeeded(): Promise { - if (this.toolExecutionPromise) { - return this.toolExecutionPromise; - } - - this.toolExecutionPromise = (async () => { - await this.initStream(); - - // If resuming from approval and still pending, don't continue - if (this.isResumingFromApproval && this.currentState?.status === 'awaiting_approval') { - return; - } - - // Get initial response - let currentResponse = await this.getInitialResponse(); - - // Save initial response to state - await this.saveResponseToState(currentResponse); - - // Check if tools should be executed - const hasToolCalls = currentResponse.output.some( - (item) => hasTypeProperty(item) && item.type === 'function_call' - ); - - if (!this.options.tools?.length || !hasToolCalls) { - this.finalResponse = currentResponse; - await this.markStateComplete(); - return; - } - - // Extract and check tool calls - const toolCalls = extractToolCallsFromResponse(currentResponse); - - // Evaluate stopWhen before the approval check so a met stop condition - // also blocks the auto-approved siblings of an approval-gated call - const stopConditionMet = await this.shouldStopExecution(); - - // Check for approval requirements - if (await this.handleApprovalCheck(toolCalls, 0, currentResponse, stopConditionMet)) { - return; // Paused for approval - } - - if (!this.hasExecutableToolCalls(toolCalls)) { - this.finalResponse = currentResponse; - await this.markStateComplete(); - return; - } - - // Main execution loop - let currentRound = 0; - - while (true) { - // Check for external interruption - if (await this.checkForInterruption(currentResponse)) { - return; - } - - // Check stop conditions - if (await this.shouldStopExecution()) { - break; - } - - const currentToolCalls = extractToolCallsFromResponse(currentResponse); - if (currentToolCalls.length === 0) { - break; - } - - // Check for approval requirements - if (await this.handleApprovalCheck(currentToolCalls, currentRound + 1, currentResponse)) { - return; - } - - if (!this.hasExecutableToolCalls(currentToolCalls)) { - break; - } - - // Build turn context - const turnNumber = currentRound + 1; - const turnContext: TurnContext = { numberOfTurns: turnNumber }; - - await this.options.onTurnStart?.(turnContext); - - // Resolve async functions for this turn - await this.resolveAsyncFunctionsForTurn(turnContext); - - // Execute tools - const toolResults = await this.executeToolRound(currentToolCalls, turnContext); - - // Track execution round - this.allToolExecutionRounds.push({ - round: currentRound, - toolCalls: currentToolCalls, - response: currentResponse, - toolResults, - }); - - // Save tool results to state - await this.saveToolResultsToState(toolResults); - - // Apply nextTurnParams - await this.applyNextTurnParams(currentToolCalls); - - currentResponse = await this.makeFollowupRequest(currentResponse, toolResults, turnNumber); - - await this.options.onTurnEnd?.(turnContext, currentResponse); - - // Save new response to state - await this.saveResponseToState(currentResponse); - - currentRound++; - } - - // Validate and finalize - this.validateFinalResponse(currentResponse); - this.finalResponse = currentResponse; - await this.markStateComplete(); - })(); - - return this.toolExecutionPromise; - } - - /** - * Internal helper to get the text after tool execution - */ - private async getTextInternal(): Promise { - await this.executeToolsIfNeeded(); - - if (!this.finalResponse) { - throw new Error('Response not available'); - } - - return extractTextFromResponse(this.finalResponse); - } - - /** - * Get just the text content from the response. - * This will consume the stream until completion, execute any tools, and extract the text. - */ - getText(): Promise { - if (this.textPromise) { - return this.textPromise; - } - - this.textPromise = this.getTextInternal(); - return this.textPromise; - } - - /** - * Get the complete response object including usage information. - * This will consume the stream until completion and execute any tools. - * Returns the full OpenResponsesResult with usage data (inputTokens, outputTokens, cachedTokens, etc.) - */ - async getResponse(): Promise { - await this.executeToolsIfNeeded(); - - if (!this.finalResponse) { - throw new Error('Response not available'); - } - - return this.finalResponse; - } - - /** - * Stream all response events as they arrive across all turns. - * Multiple consumers can iterate over this stream concurrently. - * Includes API events, tool events, and turn.start/turn.end delimiters. - */ - getFullResponsesStream(): AsyncIterableIterator, InferToolOutputsUnion>> { - return async function* (this: ModelResult) { - await this.initStream(); - - if (!this.reusableStream && !this.finalResponse) { - throw new Error('Stream not initialized'); - } - - if (!this.options.tools?.length) { - if (this.reusableStream) { - const consumer = this.reusableStream.createConsumer(); - for await (const event of consumer) { - yield event; - } - } - return; - } - - const { consumer, executionPromise } = this.startTurnBroadcasterExecution(); - - for await (const event of consumer) { - yield event; - } - - await executionPromise; - }.call(this); - } - - /** - * Stream only text deltas as they arrive from all turns. - * This filters the full event stream to only yield text content, - * including text from follow-up responses in multi-turn tool loops. - */ - getTextStream(): AsyncIterableIterator { - return async function* (this: ModelResult) { - await this.initStream(); - - if (!this.reusableStream && !this.finalResponse) { - throw new Error('Stream not initialized'); - } - - if (!this.options.tools?.length) { - if (this.reusableStream) { - yield* extractTextDeltas(this.reusableStream); - } - return; - } - - const { consumer, executionPromise } = this.startTurnBroadcasterExecution(); - - for await (const event of consumer) { - if (isOutputTextDeltaEvent(event as StreamEvents)) { - yield (event as TextDeltaEvent).delta; - } - } - - await executionPromise; - }.call(this); - } - - /** - * Stream all output items cumulatively as they arrive. - * Items are emitted with the same ID but progressively updated content as streaming progresses. - * Also yields tool results (function_call_output) after tool execution completes. - * - * Item types include: - * - message: Assistant text responses (emitted cumulatively as text streams) - * - function_call: Tool calls (emitted cumulatively as arguments stream) - * - reasoning: Model reasoning (emitted cumulatively as thinking streams) - * - web_search_call: Web search operations - * - file_search_call: File search operations - * - image_generation_call: Image generation operations - * - function_call_output: Results from executed tools - */ - getItemsStream(): AsyncIterableIterator { - return async function* (this: ModelResult) { - await this.initStream(); - - if (!this.reusableStream && !this.finalResponse) { - throw new Error('Stream not initialized'); - } - - // No tools — stream single turn directly (no broadcaster needed) - if (!this.options.tools?.length) { - if (this.reusableStream) { - yield* buildItemsStream(this.reusableStream); - } - return; - } - - // Use turnBroadcaster (same pattern as getTextStream/getFullResponsesStream). - // executeToolsIfNeeded() drives tool execution in the background while we - // passively consume events from the broadcaster in real-time. - const { consumer, executionPromise } = this.startTurnBroadcasterExecution(); - const itemsInProgress = new Map(); - - for await (const event of consumer) { - // Tool call outputs → yield directly as function_call_output items - if (isToolCallOutputEvent(event)) { - yield event.output; - continue; - } - - // Stream termination → reset items map for next turn - if ('type' in event && streamTerminationEvents.has(event.type)) { - itemsInProgress.clear(); - } - - // API stream events → dispatch through item handlers - // Cast is necessary: TypeScript cannot narrow a union via Record key lookup, - // but `event.type in itemsStreamHandlers` guarantees the event is an - // StreamEvents whose type matches a handler key. - if ('type' in event && event.type in itemsStreamHandlers) { - const handler = itemsStreamHandlers[event.type]; - if (handler) { - const result = handler( - event as StreamEvents, - itemsInProgress, - ); - if (result) { - yield result; - } - } - } - } - - await executionPromise; - }.call(this); - } - - /** - * @deprecated Use `getItemsStream()` instead. This method only streams messages, - * while `getItemsStream()` streams all output item types (messages, function_calls, - * reasoning, etc.) with cumulative updates. - * - * Stream cumulative message snapshots as content is added in responses format. - * Each iteration yields an updated version of the message with new content. - * Also yields function_call items and FunctionCallOutputItem after tool execution completes. - * Returns OutputMessage, OutputFunctionCallItem, or FunctionCallOutputItem - * compatible with OpenAI Responses API format. - */ - getNewMessagesStream(): AsyncIterableIterator< - OutputMessage | FunctionCallOutputItem | OutputFunctionCallItem - > { - return async function* (this: ModelResult) { - await this.initStream(); - - if (!this.reusableStream && !this.finalResponse) { - throw new Error('Stream not initialized'); - } - - // First yield messages from the stream in responses format - if (this.reusableStream) { - yield* buildResponsesMessageStream(this.reusableStream); - } - - // Execute tools if needed - await this.executeToolsIfNeeded(); - - // Yield function calls and their outputs for each executed tool - for (const round of this.allToolExecutionRounds) { - // First yield the function_call items from the response that triggered tool execution - for (const item of round.response.output) { - if (isFunctionCallItem(item)) { - yield item; - } - } - // Then yield the function_call_output results - for (const toolResult of round.toolResults) { - yield toolResult; - } - } - - // If tools were executed, yield the final message (if there is one) - if (this.finalResponse && this.allToolExecutionRounds.length > 0) { - // Check if the final response contains a message - const hasMessage = this.finalResponse.output.some( - (item: unknown) => hasTypeProperty(item) && item.type === 'message', - ); - if (hasMessage) { - yield extractResponsesMessageFromResponse(this.finalResponse); - } - } - }.call(this); - } - - /** - * Stream only reasoning deltas as they arrive from all turns. - * This filters the full event stream to only yield reasoning content, - * including reasoning from follow-up responses in multi-turn tool loops. - */ - getReasoningStream(): AsyncIterableIterator { - return async function* (this: ModelResult) { - await this.initStream(); - - if (!this.reusableStream && !this.finalResponse) { - throw new Error('Stream not initialized'); - } - - if (!this.options.tools?.length) { - if (this.reusableStream) { - yield* extractReasoningDeltas(this.reusableStream); - } - return; - } - - const { consumer, executionPromise } = this.startTurnBroadcasterExecution(); - - for await (const event of consumer) { - if (isReasoningDeltaEvent(event as StreamEvents)) { - yield (event as ReasoningDeltaEvent).delta; - } - } - - await executionPromise; - }.call(this); - } - - /** - * Stream tool call argument deltas and preliminary results from all turns. - * Preliminary results are streamed in REAL-TIME as generator tools yield. - * - Tool call argument deltas as { type: "delta", content: string } - * - Preliminary results as { type: "preliminary_result", toolCallId, result } - */ - getToolStream(): AsyncIterableIterator>> { - return async function* (this: ModelResult) { - await this.initStream(); - - if (!this.reusableStream && !this.finalResponse) { - throw new Error('Stream not initialized'); - } - - if (!this.options.tools?.length) { - if (this.reusableStream) { - for await (const delta of extractToolDeltas(this.reusableStream)) { - yield { type: 'delta' as const, content: delta }; - } - } - return; - } - - const { consumer, executionPromise } = this.startTurnBroadcasterExecution(); - - for await (const event of consumer) { - if (event.type === 'response.function_call_arguments.delta') { - yield { type: 'delta' as const, content: (event as { delta: string }).delta }; - continue; - } - if (event.type === 'tool.preliminary_result') { - yield { - type: 'preliminary_result' as const, - toolCallId: (event as { toolCallId: string }).toolCallId, - result: (event as { result: InferToolEventsUnion }).result, - }; - } - } - - await executionPromise; - }.call(this); - } - - /** - * Get all tool calls from the completed response (before auto-execution). - * Note: If tools have execute functions, they will be automatically executed - * and this will return the tool calls from the initial response. - * Returns structured tool calls with parsed arguments. - */ - async getToolCalls(): Promise[]> { - await this.initStream(); - - // Handle non-streaming response case - use finalResponse directly - if (this.finalResponse) { - return extractToolCallsFromResponse(this.finalResponse) as ParsedToolCall[]; - } - - if (!this.reusableStream) { - throw new Error('Stream not initialized'); - } - - const completedResponse = await consumeStreamForCompletion(this.reusableStream); - return extractToolCallsFromResponse(completedResponse) as ParsedToolCall[]; - } - - /** - * Stream structured tool call objects as they're completed. - * Each iteration yields a complete tool call with parsed arguments. - */ - getToolCallsStream(): AsyncIterableIterator> { - return async function* (this: ModelResult) { - await this.initStream(); - - if (!this.reusableStream && !this.finalResponse) { - throw new Error('Stream not initialized'); - } - - if (this.reusableStream) { - yield* buildToolCallStream(this.reusableStream) as AsyncIterableIterator>; - } - }.call(this); - } - - /** - * Returns an async iterable that emits a full context snapshot every time - * any tool calls ctx.update(). Can be consumed concurrently with getText(), - * getToolStream(), etc. - * - * @example - * ```typescript - * for await (const snapshot of result.getContextUpdates()) { - * console.log('Context changed:', snapshot); - * } - * ``` - */ - async *getContextUpdates(): AsyncGenerator> { - // Ensure stream is initialized (which creates the context store) - await this.initStream(); - - if (!this.contextStore) { - return; - } - - type Snapshot = ToolContextMapWithShared; - const store = this.contextStore; - const queue: Snapshot[] = []; - let resolve: (() => void) | null = null; - let done = false; - - const unsubscribe = store.subscribe((snapshot) => { - queue.push(snapshot as Snapshot); - if (resolve) { - resolve(); - resolve = null; - } - }); - - // Signal completion when tool execution finishes - this.executeToolsIfNeeded().then( - () => { - done = true; - if (resolve) { - resolve(); - resolve = null; - } - }, - () => { - done = true; - if (resolve) { - resolve(); - resolve = null; - } - }, - ); - - try { - while (!done) { - if (queue.length > 0) { - yield queue.shift()!; - } else { - // Wait for next update or completion - await new Promise((r) => { resolve = r; }); - } - } - // Drain any remaining queued snapshots - while (queue.length > 0) { - yield queue.shift()!; - } - } finally { - unsubscribe(); - } - } - - /** - * Cancel the underlying stream and all consumers - */ - async cancel(): Promise { - if (this.reusableStream) { - await this.reusableStream.cancel(); - } - } - - // ========================================================================= - // Multi-Turn Conversation State Methods - // ========================================================================= - - /** - * Check if the conversation requires human approval to continue. - * Returns true if there are pending tool calls awaiting approval. - */ - async requiresApproval(): Promise { - await this.initStream(); - - // If we have pending tool calls in state, approval is required - if (this.currentState?.status === 'awaiting_approval') { - return true; - } - - // Also check if pendingToolCalls is populated - return (this.currentState?.pendingToolCalls?.length ?? 0) > 0; - } - - /** - * Get the pending tool calls that require approval. - * Returns empty array if no approvals needed. - */ - async getPendingToolCalls(): Promise[]> { - await this.initStream(); - - // Try to trigger tool execution to populate pending calls - if (!this.isResumingFromApproval) { - await this.executeToolsIfNeeded(); - } - - return (this.currentState?.pendingToolCalls ?? []) as ParsedToolCall[]; - } - - /** - * Get the current conversation state. - * Useful for inspection, debugging, or custom persistence. - * Note: This returns the raw ConversationState for inspection only. - * To resume a conversation, use the StateAccessor pattern. - */ - async getState(): Promise> { - await this.initStream(); - - // Ensure tool execution has been attempted (to populate final state) - if (!this.isResumingFromApproval) { - await this.executeToolsIfNeeded(); - } - - if (!this.currentState) { - throw new Error('State not initialized. Make sure a StateAccessor was provided to callModel.'); - } - - return this.currentState; - } -} diff --git a/src/lib/next-turn-params.ts b/src/lib/next-turn-params.ts deleted file mode 100644 index f9ab64e67..000000000 --- a/src/lib/next-turn-params.ts +++ /dev/null @@ -1,177 +0,0 @@ -import type { ResponsesRequest } from '../models/responsesrequest.js'; -import type { NextTurnParamsContext, ParsedToolCall, Tool } from './tool-types.js'; - -/** - * Type guard to check if a value is a Record - */ -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -/** - * Build a NextTurnParamsContext from the current request - * Extracts relevant fields that can be modified by nextTurnParams functions - * - * @param request - The current ResponsesRequest - * @returns Context object with current parameter values - */ -export function buildNextTurnParamsContext( - request: ResponsesRequest -): NextTurnParamsContext { - return { - input: request.input ?? [], - model: request.model ?? '', - models: request.models ?? [], - temperature: request.temperature ?? null, - maxOutputTokens: request.maxOutputTokens ?? null, - topP: request.topP ?? null, - topK: request.topK, - instructions: request.instructions ?? null, - }; -} - -/** - * Execute nextTurnParams functions for all called tools - * Composes functions when multiple tools modify the same parameter - * - * @param toolCalls - Tool calls that were executed in this turn - * @param tools - All available tools - * @param currentRequest - The current request - * @returns Object with computed parameter values - */ -export async function executeNextTurnParamsFunctions( - toolCalls: ParsedToolCall[], - tools: readonly Tool[], - currentRequest: ResponsesRequest -): Promise> { - // Build initial context from current request - const context = buildNextTurnParamsContext(currentRequest); - - // Collect all nextTurnParams functions from tools (in tools array order) - const result: Partial = {}; - const workingContext = { ...context }; - - for (const tool of tools) { - if (!tool.function.nextTurnParams) { - continue; - } - - // Find tool calls for this tool - const callsForTool = toolCalls.filter(tc => tc.name === tool.function.name); - - for (const call of callsForTool) { - // For each parameter function in this tool's nextTurnParams - // We need to process each key individually to maintain type safety - const nextParams = tool.function.nextTurnParams; - - // Validate that call.arguments is a record using type guard - if (!isRecord(call.arguments)) { - const typeStr = Array.isArray(call.arguments) - ? 'array' - : typeof call.arguments; - throw new Error( - `Tool call arguments for ${tool.function.name} must be an object, got ${typeStr}` - ); - } - - // Process each parameter key with proper typing - await processNextTurnParamsForCall(nextParams, call.arguments, workingContext, result, tool.function.name); - } - } - - return result; -} - -/** - * Process nextTurnParams for a single tool call with full type safety - */ -async function processNextTurnParamsForCall( - nextParams: Record, - params: Record, - workingContext: NextTurnParamsContext, - result: Partial, - toolName: string -): Promise { - // Type-safe processing for each known parameter key - // We iterate through keys and use runtime checks instead of casts - for (const paramKey of Object.keys(nextParams)) { - const fn = nextParams[paramKey]; - - if (typeof fn !== 'function') { - continue; - } - - // Validate that paramKey is actually a key of NextTurnParamsContext - if (!isValidNextTurnParamKey(paramKey)) { - if (process.env['NODE_ENV'] !== 'production') { - console.warn( - `Invalid nextTurnParams key "${paramKey}" in tool "${toolName}". ` + - `Valid keys: input, model, models, temperature, maxOutputTokens, topP, topK, instructions` - ); - } - continue; - } - - // Execute the function and await the result - const newValue = await Promise.resolve(fn(params, workingContext)); - - // Update both result and workingContext to enable composition - // Later tools will see modifications made by earlier tools - setNextTurnParam(result, paramKey, newValue); - setNextTurnParam(workingContext, paramKey, newValue); - } -} - -/** - * Type guard to check if a string is a valid NextTurnParamsContext key - */ -function isValidNextTurnParamKey(key: string): key is keyof NextTurnParamsContext { - const validKeys: ReadonlySet = new Set([ - 'input', - 'model', - 'models', - 'temperature', - 'maxOutputTokens', - 'topP', - 'topK', - 'instructions', - ]); - return validKeys.has(key); -} - -/** - * Type-safe setter for NextTurnParamsContext - * This wrapper is needed because TypeScript doesn't properly narrow the type - * after the type guard, even though we've validated the key - */ -function setNextTurnParam( - target: Partial, - key: K, - value: NextTurnParamsContext[K] -): void { - target[key] = value; -} - -/** - * Apply computed nextTurnParams to the current request - * Returns a new request object with updated parameters - * - * @param request - The current request - * @param computedParams - Computed parameter values from nextTurnParams functions - * @returns New request with updated parameters - */ -export function applyNextTurnParamsToRequest( - request: ResponsesRequest, - computedParams: Partial -): ResponsesRequest { - // Strip null values to undefined so they're compatible with ResponsesRequest - // fields that may be typed as `number | undefined` (without null) - const sanitized: Record = {}; - for (const [key, value] of Object.entries(computedParams)) { - sanitized[key] = value === null ? undefined : value; - } - return { - ...request, - ...sanitized, - }; -} diff --git a/src/lib/reusable-stream.ts b/src/lib/reusable-stream.ts deleted file mode 100644 index 58871606f..000000000 --- a/src/lib/reusable-stream.ts +++ /dev/null @@ -1,220 +0,0 @@ -/** - * A reusable readable stream that allows multiple consumers to read from the same source stream - * concurrently while it's actively streaming, without forcing consumers to wait for full buffering. - * - * Key features: - * - Multiple concurrent consumers with independent read positions - * - New consumers can attach while streaming is active - * - Efficient memory management with automatic cleanup - * - Each consumer can read at their own pace - */ -export class ReusableReadableStream { - private buffer: T[] = []; - private consumers = new Map(); - private nextConsumerId = 0; - private sourceReader: ReadableStreamDefaultReader | null = null; - private sourceComplete = false; - private sourceError: Error | null = null; - private pumpStarted = false; - - constructor(private sourceStream: ReadableStream) {} - - /** - * Create a new consumer that can independently iterate over the stream. - * Multiple consumers can be created and will all receive the same data. - */ - createConsumer(): AsyncIterableIterator { - const consumerId = this.nextConsumerId++; - const state: ConsumerState = { - position: 0, - waitingPromise: null, - cancelled: false, - }; - this.consumers.set(consumerId, state); - - // Start pumping the source stream if not already started - if (!this.pumpStarted) { - this.startPump(); - } - - // eslint-disable-next-line @typescript-eslint/no-this-alias - const self = this; - - return { - async next(): Promise> { - const consumer = self.consumers.get(consumerId); - if (!consumer) { - return { - done: true, - value: undefined, - }; - } - - if (consumer.cancelled) { - return { - done: true, - value: undefined, - }; - } - - // If we have buffered data at this position, return it - if (consumer.position < self.buffer.length) { - const value = self.buffer[consumer.position]!; - consumer.position++; - // Note: We don't clean up buffer to allow sequential/reusable access - return { - done: false, - value, - }; - } - - // If source is complete and we've read everything, we're done - if (self.sourceComplete) { - self.consumers.delete(consumerId); - return { - done: true, - value: undefined, - }; - } - - // If source had an error, propagate it - if (self.sourceError) { - self.consumers.delete(consumerId); - throw self.sourceError; - } - - // Set up the waiting promise FIRST to avoid race condition - // where source completes after the check but before promise is set - const waitPromise = new Promise((resolve, reject) => { - consumer.waitingPromise = { - resolve, - reject, - }; - - // Immediately check if we should resolve after setting up the promise - // This handles the case where data arrived or source completed - // between our initial checks and promise creation - if (self.sourceComplete || self.sourceError || consumer.position < self.buffer.length) { - resolve(); - } - }); - - await waitPromise; - - // Clear the promise reference after it resolves - consumer.waitingPromise = null; - - // Recursively try again after waking up - return this.next(); - }, - - async return(): Promise> { - const consumer = self.consumers.get(consumerId); - if (consumer) { - consumer.cancelled = true; - self.consumers.delete(consumerId); - } - return { - done: true, - value: undefined, - }; - }, - - async throw(e?: unknown): Promise> { - const consumer = self.consumers.get(consumerId); - if (consumer) { - consumer.cancelled = true; - self.consumers.delete(consumerId); - } - throw e; - }, - - [Symbol.asyncIterator]() { - return this; - }, - }; - } - - /** - * Start pumping data from the source stream into the buffer - */ - private startPump(): void { - if (this.pumpStarted) { - return; - } - this.pumpStarted = true; - this.sourceReader = this.sourceStream.getReader(); - - void (async () => { - try { - while (true) { - const result = await this.sourceReader!.read(); - - if (result.done) { - this.sourceComplete = true; - this.notifyAllConsumers(); - break; - } - - // Add to buffer - this.buffer.push(result.value); - - // Notify waiting consumers - this.notifyAllConsumers(); - } - } catch (error) { - this.sourceError = error instanceof Error ? error : new Error(String(error)); - this.notifyAllConsumers(); - } finally { - if (this.sourceReader) { - this.sourceReader.releaseLock(); - } - } - })(); - } - - /** - * Notify all waiting consumers that new data is available - */ - private notifyAllConsumers(): void { - for (const consumer of this.consumers.values()) { - if (consumer.waitingPromise) { - if (this.sourceError) { - consumer.waitingPromise.reject(this.sourceError); - } else { - consumer.waitingPromise.resolve(); - } - consumer.waitingPromise = null; - } - } - } - - /** - * Cancel the source stream and all consumers - */ - async cancel(): Promise { - // Cancel all consumers - for (const consumer of this.consumers.values()) { - consumer.cancelled = true; - if (consumer.waitingPromise) { - consumer.waitingPromise.resolve(); - } - } - this.consumers.clear(); - - // Cancel the source stream - if (this.sourceReader) { - await this.sourceReader.cancel(); - this.sourceReader.releaseLock(); - } - } -} - -interface ConsumerState { - position: number; - waitingPromise: { - resolve: () => void; - reject: (error: Error) => void; - } | null; - cancelled: boolean; -} diff --git a/src/lib/stop-conditions.ts b/src/lib/stop-conditions.ts deleted file mode 100644 index 968862392..000000000 --- a/src/lib/stop-conditions.ts +++ /dev/null @@ -1,129 +0,0 @@ -import type { StepResult, StopCondition, Tool } from './tool-types.js'; - -/** - * Stop condition that checks if step count equals or exceeds a specific number - * @param stepCount - The number of steps to allow before stopping - * @returns StopCondition that returns true when steps.length >= stepCount - * - * @example - * ```typescript - * stopWhen: stepCountIs(5) // Stop after 5 steps - * ``` - */ -export function stepCountIs(stepCount: number): StopCondition { - return ({ steps }: { readonly steps: ReadonlyArray }) => steps.length >= stepCount; -} - -/** - * Stop condition that checks if any step contains a tool call with the given name - * @param toolName - The name of the tool to check for - * @returns StopCondition that returns true if the tool was called in any step - * - * @example - * ```typescript - * stopWhen: hasToolCall('search') // Stop when search tool is called - * ``` - */ -export function hasToolCall(toolName: string): StopCondition { - return ({ steps }: { readonly steps: ReadonlyArray }) => { - return steps.some((step: StepResult) => - step.toolCalls.some((call: { name: string }) => call.name === toolName), - ); - }; -} - -/** - * Evaluates an array of stop conditions - * Returns true if ANY condition returns true (OR logic) - * @param options - Object containing stopConditions and steps - * @returns Promise indicating if execution should stop - * - * @example - * ```typescript - * const shouldStop = await isStopConditionMet({ - * stopConditions: [stepCountIs(5), hasToolCall('search')], - * steps: allSteps - * }); - * ``` - */ -export async function isStopConditionMet(options: { - readonly stopConditions: ReadonlyArray>; - readonly steps: ReadonlyArray>; -}): Promise { - const { stopConditions, steps } = options; - - // Evaluate all conditions in parallel - const results = await Promise.all( - stopConditions.map((condition: StopCondition) => - Promise.resolve( - condition({ - steps, - }), - ), - ), - ); - - // Return true if ANY condition is true (OR logic) - return results.some((result: boolean | undefined) => result === true); -} - -/** - * Stop when total token usage exceeds a threshold - * OpenRouter-specific helper using usage data - * - * @param maxTokens - Maximum total tokens to allow - * @returns StopCondition that returns true when token usage exceeds threshold - * - * @example - * ```typescript - * stopWhen: maxTokensUsed(10000) // Stop when total tokens exceed 10,000 - * ``` - */ -export function maxTokensUsed(maxTokens: number): StopCondition { - return ({ steps }: { readonly steps: ReadonlyArray }) => { - const totalTokens = steps.reduce( - (sum: number, step: StepResult) => sum + (step.usage?.totalTokens ?? 0), - 0, - ); - return totalTokens >= maxTokens; - }; -} - -/** - * Stop when total cost exceeds a threshold - * OpenRouter-specific helper using cost data - * - * @param maxCostInDollars - Maximum cost in dollars to allow - * @returns StopCondition that returns true when cost exceeds threshold - * - * @example - * ```typescript - * stopWhen: maxCost(0.50) // Stop when total cost exceeds $0.50 - * ``` - */ -export function maxCost(maxCostInDollars: number): StopCondition { - return ({ steps }: { readonly steps: ReadonlyArray }) => { - const totalCost = steps.reduce( - (sum: number, step: StepResult) => sum + (step.usage?.cost ?? 0), - 0, - ); - return totalCost >= maxCostInDollars; - }; -} - -/** - * Stop when a specific finish reason is encountered - * - * @param reason - The finish reason to check for - * @returns StopCondition that returns true when finish reason matches - * - * @example - * ```typescript - * stopWhen: finishReasonIs('length') // Stop when context length limit is hit - * ``` - */ -export function finishReasonIs(reason: string): StopCondition { - return ({ steps }: { readonly steps: ReadonlyArray }) => { - return steps.some((step: StepResult) => step.finishReason === reason); - }; -} diff --git a/src/lib/stream-transformers.ts b/src/lib/stream-transformers.ts deleted file mode 100644 index fe7a06495..000000000 --- a/src/lib/stream-transformers.ts +++ /dev/null @@ -1,1122 +0,0 @@ -import type { ChatAssistantMessage } from '../models/chatassistantmessage.js'; -import type { FunctionCallOutputItem } from '../models/functioncalloutputitem.js'; -import type { OpenAIResponsesAnnotation } from '../models/openairesponsesannotation.js'; -import type { OpenResponsesResult } from '../models/openresponsesresult.js'; -import type { OutputFileSearchCallItem } from '../models/outputfilesearchcallitem.js'; -import type { OutputFunctionCallItem } from '../models/outputfunctioncallitem.js'; -import type { OutputImageGenerationCallItem } from '../models/outputimagegenerationcallitem.js'; -import type { OutputMessage } from '../models/outputmessage.js'; -import type { OutputReasoningItem } from '../models/outputreasoningitem.js'; -import type { OutputWebSearchCallItem } from '../models/outputwebsearchcallitem.js'; -import type { ResponseOutputText } from '../models/responseoutputtext.js'; -import type { StreamEvents } from '../models/streamevents.js'; -import type { - ClaudeContentBlock, - ClaudeMessage, - ClaudeStopReason, - ClaudeTextCitation, - UnsupportedContent, -} from '../models/claude-message.js'; -import type { ReusableReadableStream } from './reusable-stream.js'; -import type { ParsedToolCall, Tool } from './tool-types.js'; -import { - isOutputTextDeltaEvent, - isReasoningDeltaEvent, - isFunctionCallArgumentsDeltaEvent, - isOutputItemAddedEvent, - isOutputItemDoneEvent, - isResponseCompletedEvent, - isResponseFailedEvent, - isResponseIncompleteEvent, - isFunctionCallArgumentsDoneEvent, - isOutputMessage, - isFunctionCallItem, - isReasoningOutputItem, - isWebSearchCallOutputItem, - isFileSearchCallOutputItem, - isImageGenerationCallOutputItem, - isOutputTextPart, - isRefusalPart, - isFileCitationAnnotation, - isURLCitationAnnotation, - isFilePathAnnotation, -} from './stream-type-guards.js'; - -/** - * Extract text deltas from responses stream events - */ -export async function* extractTextDeltas( - stream: ReusableReadableStream, -): AsyncIterableIterator { - const consumer = stream.createConsumer(); - - for await (const event of consumer) { - if (isOutputTextDeltaEvent(event)) { - if (event.delta) { - yield event.delta; - } - } - } -} - -/** - * Extract reasoning deltas from responses stream events - */ -export async function* extractReasoningDeltas( - stream: ReusableReadableStream, -): AsyncIterableIterator { - const consumer = stream.createConsumer(); - - for await (const event of consumer) { - if (isReasoningDeltaEvent(event)) { - if (event.delta) { - yield event.delta; - } - } - } -} - -/** - * Extract tool call argument deltas from responses stream events - */ -export async function* extractToolDeltas( - stream: ReusableReadableStream, -): AsyncIterableIterator { - const consumer = stream.createConsumer(); - - for await (const event of consumer) { - if (isFunctionCallArgumentsDeltaEvent(event)) { - if (event.delta) { - yield event.delta; - } - } - } -} - -/** - * Core message stream builder - shared logic for both formats - * Accumulates text deltas and yields updates - */ -async function* buildMessageStreamCore( - stream: ReusableReadableStream, -): AsyncIterableIterator<{ - type: 'delta' | 'complete'; - text?: string; - messageId?: string; - completeMessage?: OutputMessage; -}> { - const consumer = stream.createConsumer(); - - // Track the accumulated text and message info - let currentText = ''; - let currentId = ''; - let hasStarted = false; - - for await (const event of consumer) { - if (!('type' in event)) { - continue; - } - - switch (event.type) { - case 'response.output_item.added': { - if (isOutputItemAddedEvent(event)) { - if (event.item && isOutputMessage(event.item)) { - hasStarted = true; - currentText = ''; - currentId = event.item.id; - } - } - break; - } - - case 'response.output_text.delta': { - if (isOutputTextDeltaEvent(event)) { - if (hasStarted && event.delta) { - currentText += event.delta; - yield { - type: 'delta' as const, - text: currentText, - messageId: currentId, - }; - } - } - break; - } - - case 'response.output_item.done': { - if (isOutputItemDoneEvent(event)) { - if (event.item && isOutputMessage(event.item)) { - yield { - type: 'complete' as const, - completeMessage: event.item, - }; - } - } - break; - } - - case 'response.completed': - case 'response.failed': - case 'response.incomplete': - // Stream is complete, stop consuming - return; - - default: - // Ignore other event types - this is intentionally not exhaustive - // as we only care about specific events for message building - break; - } - } -} - -/** - * Build incremental message updates from responses stream events - * Returns OutputMessage (assistant/responses format) - */ -export async function* buildResponsesMessageStream( - stream: ReusableReadableStream, -): AsyncIterableIterator { - for await (const update of buildMessageStreamCore(stream)) { - if (update.type === 'delta' && update.text !== undefined && update.messageId !== undefined) { - // Yield incremental update in OutputMessage format - yield { - id: update.messageId, - type: 'message' as const, - role: 'assistant' as const, - status: 'in_progress' as const, - content: [ - { - type: 'output_text' as const, - text: update.text, - annotations: [], - }, - ], - }; - } else if (update.type === 'complete' && update.completeMessage) { - // Yield final complete message - yield update.completeMessage; - } - } -} - -/** - * Output item types that can be streamed from a response. - * This is the union of all item types that appear in response output, - * plus function_call_output for tool results. - */ -export type StreamableOutputItem = - | OutputMessage // type: "message" - | OutputFunctionCallItem // type: "function_call" - | OutputReasoningItem // type: "reasoning" - | OutputWebSearchCallItem // type: "web_search_call" - | OutputFileSearchCallItem // type: "file_search_call" - | OutputImageGenerationCallItem // type: "image_generation_call" - | FunctionCallOutputItem; // type: "function_call_output" (tool results) - -//#region ItemsStream Types and Handlers - -/** - * Discriminated union for tracking items in progress. - * Each variant has only the fields relevant to that item type. - */ -export type ItemInProgress = - | { type: 'message'; id: string; textContent: string } - | { type: 'function_call'; id: string; name: string; callId: string; argumentsAccumulated: string } - | { type: 'reasoning'; id: string; reasoningContent: string }; - -/** - * Handle output_item.added event - Initialize tracking for new items - */ -function handleOutputItemAdded( - event: StreamEvents, - itemsInProgress: Map, -): StreamableOutputItem | undefined { - if (!isOutputItemAddedEvent(event) || !event.item) { - return undefined; - } - - const item = event.item; - - if (isOutputMessage(item)) { - itemsInProgress.set(item.id, { - type: 'message', - id: item.id, - textContent: '', - }); - return { - id: item.id, - type: 'message' as const, - role: 'assistant' as const, - status: 'in_progress' as const, - content: [], - }; - } - - if (isFunctionCallItem(item)) { - // Use item.id if available (matches itemId in delta events), fall back to callId - const itemKey = item.id ?? item.callId; - itemsInProgress.set(itemKey, { - type: 'function_call', - id: itemKey, - name: item.name, - callId: item.callId, - argumentsAccumulated: '', - }); - return { - type: 'function_call' as const, - id: item.id, - callId: item.callId, - name: item.name, - arguments: '', - status: 'in_progress' as const, - }; - } - - if (isReasoningOutputItem(item)) { - itemsInProgress.set(item.id, { - type: 'reasoning', - id: item.id, - reasoningContent: '', - }); - return { - type: 'reasoning' as const, - id: item.id, - status: 'in_progress' as const, - summary: [], - }; - } - - if (isWebSearchCallOutputItem(item)) { - return item; - } - - if (isFileSearchCallOutputItem(item)) { - return item; - } - - if (isImageGenerationCallOutputItem(item)) { - return item; - } - - return undefined; -} - -/** - * Handle text delta event for messages - */ -function handleTextDelta( - event: StreamEvents, - itemsInProgress: Map, -): StreamableOutputItem | undefined { - if (!isOutputTextDeltaEvent(event) || !event.delta) { - return undefined; - } - - const item = itemsInProgress.get(event.itemId); - if (item?.type === 'message') { - item.textContent += event.delta; - return { - id: item.id, - type: 'message' as const, - role: 'assistant' as const, - status: 'in_progress' as const, - content: [ - { - type: 'output_text' as const, - text: item.textContent, - annotations: [], - }, - ], - }; - } - - return undefined; -} - -/** - * Handle function call argument delta event - */ -function handleFunctionCallDelta( - event: StreamEvents, - itemsInProgress: Map, -): StreamableOutputItem | undefined { - if (!isFunctionCallArgumentsDeltaEvent(event) || !event.delta) { - return undefined; - } - - const item = itemsInProgress.get(event.itemId); - if (item?.type === 'function_call') { - item.argumentsAccumulated += event.delta; - return { - type: 'function_call' as const, - // Include id if it differs from callId (means API provided an id) - id: item.id !== item.callId ? item.id : undefined, - callId: item.callId, - name: item.name, - arguments: item.argumentsAccumulated, - status: 'in_progress' as const, - }; - } - - return undefined; -} - -/** - * Handle reasoning text delta event - */ -function handleReasoningDelta( - event: StreamEvents, - itemsInProgress: Map, -): StreamableOutputItem | undefined { - if (!isReasoningDeltaEvent(event) || !event.delta) { - return undefined; - } - - const item = itemsInProgress.get(event.itemId); - if (item?.type === 'reasoning') { - item.reasoningContent += event.delta; - return { - type: 'reasoning' as const, - id: item.id, - status: 'in_progress' as const, - summary: [ - { - type: 'summary_text' as const, - text: item.reasoningContent, - }, - ], - }; - } - - return undefined; -} - -/** - * Handle output_item.done event - Yield final complete item - */ -function handleOutputItemDone( - event: StreamEvents, - itemsInProgress: Map, -): StreamableOutputItem | undefined { - if (!isOutputItemDoneEvent(event) || !event.item) { - return undefined; - } - - const item = event.item; - - if (isOutputMessage(item)) { - itemsInProgress.delete(item.id); - return item; - } - - if (isFunctionCallItem(item)) { - // Use item.id if available (matches itemId in delta events), fall back to callId - itemsInProgress.delete(item.id ?? item.callId); - return item; - } - - if (isReasoningOutputItem(item)) { - itemsInProgress.delete(item.id); - return item; - } - - if (isWebSearchCallOutputItem(item)) { - return item; - } - - if (isFileSearchCallOutputItem(item)) { - return item; - } - - if (isImageGenerationCallOutputItem(item)) { - return item; - } - - return undefined; -} - -type ItemsStreamHandler = ( - event: StreamEvents, - itemsInProgress: Map, -) => StreamableOutputItem | undefined; - -export const itemsStreamHandlers: Record = { - 'response.output_item.added': handleOutputItemAdded, - 'response.output_text.delta': handleTextDelta, - 'response.function_call_arguments.delta': handleFunctionCallDelta, - 'response.reasoning_text.delta': handleReasoningDelta, - 'response.output_item.done': handleOutputItemDone, -}; - -export const streamTerminationEvents = new Set([ - 'response.completed', - 'response.failed', - 'response.incomplete', -]); - -//#endregion - -/** - * Build incremental output item updates from responses stream events. - * Yields all item types cumulatively - same item may be emitted multiple times - * with the same ID but progressively updated content as streaming progresses. - */ -export async function* buildItemsStream( - stream: ReusableReadableStream, -): AsyncIterableIterator { - const consumer = stream.createConsumer(); - const itemsInProgress = new Map(); - - for await (const event of consumer) { - if (!('type' in event)) { - continue; - } - - if (streamTerminationEvents.has(event.type)) { - return; - } - - const handler = itemsStreamHandlers[event.type]; - if (handler) { - const result = handler(event, itemsInProgress); - if (result) { - yield result; - } - } - } -} - -/** - * Build incremental message updates from responses stream events - * Returns ChatAssistantMessage (chat format) instead of OutputMessage - */ -export async function* buildMessageStream( - stream: ReusableReadableStream, -): AsyncIterableIterator { - for await (const update of buildMessageStreamCore(stream)) { - if (update.type === 'delta' && update.text !== undefined) { - // Yield incremental update in chat format - yield { - role: 'assistant' as const, - content: update.text, - }; - } else if (update.type === 'complete' && update.completeMessage) { - // Yield final complete message converted to chat format - yield convertToAssistantMessage(update.completeMessage); - } - } -} - -/** - * Consume stream until completion and return the complete response - */ -export async function consumeStreamForCompletion( - stream: ReusableReadableStream, -): Promise { - const consumer = stream.createConsumer(); - - for await (const event of consumer) { - if (!('type' in event)) { - continue; - } - - if (isResponseCompletedEvent(event)) { - return event.response; - } - - if (isResponseFailedEvent(event)) { - // The failed event contains the full response with error information - throw new Error(`Response failed: ${JSON.stringify(event.response.error)}`); - } - - if (isResponseIncompleteEvent(event)) { - // Return the incomplete response - return event.response; - } - } - - throw new Error('Stream ended without completion event'); -} - -/** - * Convert OutputMessage to ChatAssistantMessage (chat format) - */ -function convertToAssistantMessage( - outputMessage: OutputMessage, -): ChatAssistantMessage { - // Extract text content - const textContent = outputMessage.content - .filter( - (part): part is ResponseOutputText => 'type' in part && part.type === 'output_text', - ) - .map((part) => part.text) - .join(''); - - return { - role: 'assistant' as const, - content: textContent || null, - }; -} - -/** - * Extract the first message from a completed response (chat format) - */ -export function extractMessageFromResponse( - response: OpenResponsesResult, -): ChatAssistantMessage { - const messageItem = response.output.find( - (item): item is OutputMessage => 'type' in item && item.type === 'message', - ); - - if (!messageItem) { - throw new Error('No message found in response output'); - } - - return convertToAssistantMessage(messageItem); -} - -/** - * Extract the first message from a completed response (responses format) - */ -export function extractResponsesMessageFromResponse( - response: OpenResponsesResult, -): OutputMessage { - const messageItem = response.output.find( - (item): item is OutputMessage => 'type' in item && item.type === 'message', - ); - - if (!messageItem) { - throw new Error('No message found in response output'); - } - - return messageItem; -} - -/** - * Extract text from a response, either from outputText or by concatenating message content - */ -export function extractTextFromResponse( - response: OpenResponsesResult, -): string { - // Use pre-concatenated outputText if available - if (response.outputText) { - return response.outputText; - } - - // Check if there's a message in the output - const hasMessage = response.output.some( - (item): item is OutputMessage => 'type' in item && item.type === 'message', - ); - - if (!hasMessage) { - // No message in response (e.g., only function calls) - return ''; - } - - // Otherwise, extract from the first message (convert to ChatAssistantMessage which has string content) - const message = extractMessageFromResponse(response); - - // ChatAssistantMessage.content is string | Array | null | undefined - if (typeof message.content === 'string') { - return message.content; - } - - return ''; -} - -/** - * Extract all tool calls from a completed response - * Returns parsed tool calls with arguments as objects (not JSON strings) - */ -export function extractToolCallsFromResponse( - response: OpenResponsesResult, -): ParsedToolCall[] { - const toolCalls: ParsedToolCall[] = []; - - for (const item of response.output) { - if (isFunctionCallItem(item)) { - try { - const trimmedArgs = item.arguments.trim(); - const parsedArguments = trimmedArgs ? JSON.parse(trimmedArgs) : {}; - - toolCalls.push({ - id: item.callId, - name: item.name, - arguments: parsedArguments, - }); - } catch (error) { - console.warn( - `Failed to parse tool call arguments for ${item.name}:`, - error instanceof Error ? error.message : String(error), - `\nArguments: ${item.arguments.substring(0, 100)}${item.arguments.length > 100 ? '...' : ''}` - ); - // Include the tool call with unparsed arguments - toolCalls.push({ - id: item.callId, - name: item.name, - arguments: item.arguments as unknown, // Keep as string if parsing fails - } as ParsedToolCall); - } - } - } - - return toolCalls; -} - -/** - * Build incremental tool call updates from responses stream events - * Yields structured tool call objects as they're built from deltas - */ -export async function* buildToolCallStream( - stream: ReusableReadableStream, -): AsyncIterableIterator> { - const consumer = stream.createConsumer(); - - // Track tool calls being built - const toolCallsInProgress = new Map< - string, - { - id: string; - name: string; - argumentsAccumulated: string; - } - >(); - - for await (const event of consumer) { - if (!('type' in event)) { - continue; - } - - switch (event.type) { - case 'response.output_item.added': { - if (isOutputItemAddedEvent(event) && event.item && isFunctionCallItem(event.item)) { - // Use item.id if available (matches itemId in delta events), fall back to callId - const itemKey = event.item.id ?? event.item.callId; - toolCallsInProgress.set(itemKey, { - id: event.item.callId, - name: event.item.name, - argumentsAccumulated: '', - }); - } - break; - } - - case 'response.function_call_arguments.delta': { - if (isFunctionCallArgumentsDeltaEvent(event)) { - const toolCall = toolCallsInProgress.get(event.itemId); - if (toolCall && event.delta) { - toolCall.argumentsAccumulated += event.delta; - } - } - break; - } - - case 'response.function_call_arguments.done': { - if (isFunctionCallArgumentsDoneEvent(event)) { - const toolCall = toolCallsInProgress.get(event.itemId); - - if (toolCall) { - // Parse complete arguments (empty string → empty object for no-param tools) - try { - const trimmedArgs = event.arguments.trim(); - const parsedArguments = trimmedArgs ? JSON.parse(trimmedArgs) : {}; - yield { - id: toolCall.id, - name: event.name, - arguments: parsedArguments, - }; - } catch (error) { - console.warn( - `Failed to parse tool call arguments for ${event.name}:`, - error instanceof Error ? error.message : String(error), - `\nArguments: ${event.arguments.substring(0, 100)}${event.arguments.length > 100 ? '...' : ''}` - ); - // Yield with unparsed arguments if parsing fails - yield { - id: toolCall.id, - name: event.name, - arguments: event.arguments as unknown, - } as ParsedToolCall; - } - - // Clean up - toolCallsInProgress.delete(event.itemId); - } - } - break; - } - - case 'response.output_item.done': { - if (isOutputItemDoneEvent(event) && event.item && isFunctionCallItem(event.item)) { - // Use item.id if available (matches itemId in delta events), fall back to callId - const itemKey = event.item.id ?? event.item.callId; - // Yield final tool call if we haven't already - if (toolCallsInProgress.has(itemKey)) { - try { - const trimmedArgs = event.item.arguments.trim(); - const parsedArguments = trimmedArgs ? JSON.parse(trimmedArgs) : {}; - yield { - id: event.item.callId, - name: event.item.name, - arguments: parsedArguments, - }; - } catch (_error) { - yield { - id: event.item.callId, - name: event.item.name, - arguments: event.item.arguments as unknown, - } as ParsedToolCall; - } - - toolCallsInProgress.delete(itemKey); - } - } - break; - } - } - } -} - -/** - * Check if a response contains any tool calls - */ -export function responseHasToolCalls(response: OpenResponsesResult): boolean { - return response.output.some((item) => 'type' in item && item.type === 'function_call'); -} - -/** - * Convert OpenRouter annotations to Claude citations - */ -function mapAnnotationsToCitations( - annotations?: Array, -): ClaudeTextCitation[] | undefined { - if (!annotations || annotations.length === 0) { - return undefined; - } - - const citations: ClaudeTextCitation[] = []; - - for (const annotation of annotations) { - if (!('type' in annotation)) { - continue; - } - - switch (annotation.type) { - case 'file_citation': { - if (isFileCitationAnnotation(annotation)) { - citations.push({ - type: 'char_location', - cited_text: '', - document_index: annotation.index, - document_title: annotation.filename, - file_id: annotation.fileId, - start_char_index: 0, - end_char_index: 0, - }); - } - break; - } - - case 'url_citation': { - if (isURLCitationAnnotation(annotation)) { - citations.push({ - type: 'web_search_result_location', - cited_text: '', - title: annotation.title, - url: annotation.url, - encrypted_index: '', - }); - } - break; - } - - case 'file_path': { - if (isFilePathAnnotation(annotation)) { - citations.push({ - type: 'char_location', - cited_text: '', - document_index: annotation.index, - document_title: '', - file_id: annotation.fileId, - start_char_index: 0, - end_char_index: 0, - }); - } - break; - } - - default: - // Unknown annotation types are skipped for forward compatibility. - break; - } - } - - return citations.length > 0 ? citations : undefined; -} - -/** - * Map OpenResponses status to Claude stop reason - */ -function mapStopReason( - response: OpenResponsesResult, -): ClaudeStopReason | null { - // Check if any tool calls exist in the response - const hasToolCalls = response.output.some( - (item) => 'type' in item && item.type === 'function_call', - ); - - if (hasToolCalls) { - return 'tool_use'; - } - - // Check the response status - if (response.status === 'completed') { - return 'end_turn'; - } - - if (response.status === 'incomplete') { - // Check incomplete reason if available - const incompleteReason = response.incompleteDetails?.reason; - if (incompleteReason === 'max_output_tokens') { - return 'max_tokens'; - } - return 'end_turn'; - } - - return 'end_turn'; -} - -/** - * Convert OpenResponsesResult to ClaudeMessage format - * Compatible with the Anthropic SDK BetaMessage type - */ -export function convertToClaudeMessage( - response: OpenResponsesResult, -): ClaudeMessage { - const content: ClaudeContentBlock[] = []; - const unsupportedContent: UnsupportedContent[] = []; - - for (const item of response.output) { - if (!('type' in item)) { - // Handle items without type field - // Convert unknown item to a record format for storage - const itemData = typeof item === 'object' && item !== null - ? item - : { value: item }; - unsupportedContent.push({ - original_type: 'unknown', - data: itemData, - reason: 'Output item missing type field', - }); - continue; - } - - switch (item.type) { - case 'message': { - if (isOutputMessage(item)) { - for (const part of item.content) { - if (!('type' in part)) { - // Convert unknown part to a record format for storage - const partData = typeof part === 'object' && part !== null - ? part - : { value: part }; - unsupportedContent.push({ - original_type: 'unknown_message_part', - data: partData, - reason: 'Message content part missing type field', - }); - continue; - } - - if (isOutputTextPart(part)) { - const citations = mapAnnotationsToCitations(part.annotations); - - content.push({ - type: 'text', - text: part.text, - ...(citations && { - citations, - }), - }); - } else if (isRefusalPart(part)) { - unsupportedContent.push({ - original_type: 'refusal', - data: { - refusal: part.refusal, - }, - reason: 'Claude does not have a native refusal content type', - }); - } else { - // Unknown content types are skipped for forward compatibility. - } - } - } - break; - } - - case 'function_call': { - if (isFunctionCallItem(item)) { - let parsedInput: Record; - - try { - const trimmedArgs = item.arguments.trim(); - parsedInput = trimmedArgs ? JSON.parse(trimmedArgs) : {}; - } catch (error) { - console.warn( - `Failed to parse tool call arguments for ${item.name}:`, - error instanceof Error ? error.message : String(error), - `\nArguments: ${item.arguments.substring(0, 100)}${item.arguments.length > 100 ? '...' : ''}` - ); - // Preserve raw arguments if JSON parsing fails - parsedInput = { - _raw_arguments: item.arguments, - }; - } - - content.push({ - type: 'tool_use', - id: item.callId, - name: item.name, - input: parsedInput, - }); - } - break; - } - - case 'reasoning': { - if (isReasoningOutputItem(item)) { - if (item.summary && item.summary.length > 0) { - for (const summaryItem of item.summary) { - if (summaryItem.type === 'summary_text' && summaryItem.text) { - content.push({ - type: 'thinking', - thinking: summaryItem.text, - signature: '', - }); - } - } - } - - if (item.encryptedContent) { - unsupportedContent.push({ - original_type: 'reasoning_encrypted', - data: { - id: item.id, - encrypted_content: item.encryptedContent, - }, - reason: 'Encrypted reasoning content preserved for round-trip', - }); - } - } - break; - } - - case 'web_search_call': { - if (isWebSearchCallOutputItem(item)) { - content.push({ - type: 'server_tool_use', - id: item.id, - name: 'web_search', - input: { - status: item.status, - }, - }); - } - break; - } - - case 'file_search_call': { - if (isFileSearchCallOutputItem(item)) { - content.push({ - type: 'tool_use', - id: item.id, - name: 'file_search', - input: { - queries: item.queries, - status: item.status, - }, - }); - } - break; - } - - case 'image_generation_call': { - if (isImageGenerationCallOutputItem(item)) { - unsupportedContent.push({ - original_type: 'image_generation_call', - data: { - id: item.id, - result: item.result, - status: item.status, - }, - reason: 'Claude does not support image outputs in assistant messages', - }); - } - break; - } - - default: - // Unknown output types (e.g. new server tools) are skipped during Claude format - // conversion — they round-trip natively via the Responses API input union. - break; - } - } - - return { - id: response.id, - type: 'message', - role: 'assistant', - model: response.model ?? 'unknown', - content, - stop_reason: mapStopReason(response), - stop_sequence: null, - usage: { - input_tokens: response.usage?.inputTokens ?? 0, - output_tokens: response.usage?.outputTokens ?? 0, - cache_creation_input_tokens: response.usage?.inputTokensDetails?.cachedTokens ?? 0, - cache_read_input_tokens: 0, - }, - ...(unsupportedContent.length > 0 && { - unsupported_content: unsupportedContent, - }), - }; -} - -/** - * Extract unsupported content by original type - */ -export function extractUnsupportedContent( - message: ClaudeMessage, - originalType: string, -): UnsupportedContent[] { - if (!message.unsupported_content) { - return []; - } - - return message.unsupported_content.filter((item) => item.original_type === originalType); -} - -/** - * Check if message has any unsupported content - */ -export function hasUnsupportedContent(message: ClaudeMessage): boolean { - return !!(message.unsupported_content && message.unsupported_content.length > 0); -} - -/** - * Get summary of unsupported content types - */ -export function getUnsupportedContentSummary( - message: ClaudeMessage, -): Record { - if (!message.unsupported_content) { - return {}; - } - - const summary: Record = {}; - for (const item of message.unsupported_content) { - summary[item.original_type] = (summary[item.original_type] || 0) + 1; - } - - return summary; -} diff --git a/src/lib/stream-type-guards.ts b/src/lib/stream-type-guards.ts deleted file mode 100644 index 3cdc51b6e..000000000 --- a/src/lib/stream-type-guards.ts +++ /dev/null @@ -1,173 +0,0 @@ -import type { FileCitation } from '../models/filecitation.js'; -import type { FilePath } from '../models/filepath.js'; -import type { FunctionCallArgsDeltaEvent } from '../models/functioncallargsdeltaevent.js'; -import type { FunctionCallArgsDoneEvent } from '../models/functioncallargsdoneevent.js'; -import type { OpenAIResponsesRefusalContent } from '../models/openairesponsesrefusalcontent.js'; -import type { OutputFileSearchCallItem } from '../models/outputfilesearchcallitem.js'; -import type { OutputFunctionCallItem } from '../models/outputfunctioncallitem.js'; -import type { OutputImageGenerationCallItem } from '../models/outputimagegenerationcallitem.js'; -import type { OutputMessage } from '../models/outputmessage.js'; -import type { OutputReasoningItem } from '../models/outputreasoningitem.js'; -import type { OutputWebSearchCallItem } from '../models/outputwebsearchcallitem.js'; -import type { ReasoningDeltaEvent } from '../models/reasoningdeltaevent.js'; -import type { ResponseOutputText } from '../models/responseoutputtext.js'; -import type { StreamEvents } from '../models/streamevents.js'; -import type { StreamEventsResponseCompleted } from '../models/streameventsresponsecompleted.js'; -import type { StreamEventsResponseFailed } from '../models/streameventsresponsefailed.js'; -import type { StreamEventsResponseIncomplete } from '../models/streameventsresponseincomplete.js'; -import type { StreamEventsResponseOutputItemAdded } from '../models/streameventsresponseoutputitemadded.js'; -import type { StreamEventsResponseOutputItemDone } from '../models/streameventsresponseoutputitemdone.js'; -import type { TextDeltaEvent } from '../models/textdeltaevent.js'; -import type { URLCitation } from '../models/urlcitation.js'; - -/** - * Type guards for OpenResponses stream events - * These enable proper TypeScript narrowing without type casts - */ - -// Stream event type guards - -export function isOutputTextDeltaEvent( - event: StreamEvents, -): event is TextDeltaEvent { - return 'type' in event && event.type === 'response.output_text.delta'; -} - -export function isReasoningDeltaEvent( - event: StreamEvents, -): event is ReasoningDeltaEvent { - return 'type' in event && event.type === 'response.reasoning_text.delta'; -} - -export function isFunctionCallArgumentsDeltaEvent( - event: StreamEvents, -): event is FunctionCallArgsDeltaEvent { - return 'type' in event && event.type === 'response.function_call_arguments.delta'; -} - -export function isOutputItemAddedEvent( - event: StreamEvents, -): event is StreamEventsResponseOutputItemAdded { - return 'type' in event && event.type === 'response.output_item.added'; -} - -export function isOutputItemDoneEvent( - event: StreamEvents, -): event is StreamEventsResponseOutputItemDone { - return 'type' in event && event.type === 'response.output_item.done'; -} - -export function isResponseCompletedEvent( - event: StreamEvents, -): event is StreamEventsResponseCompleted { - return 'type' in event && event.type === 'response.completed'; -} - -export function isResponseFailedEvent( - event: StreamEvents, -): event is StreamEventsResponseFailed { - return 'type' in event && event.type === 'response.failed'; -} - -export function isResponseIncompleteEvent( - event: StreamEvents, -): event is StreamEventsResponseIncomplete { - return 'type' in event && event.type === 'response.incomplete'; -} - -export function isFunctionCallArgumentsDoneEvent( - event: StreamEvents, -): event is FunctionCallArgsDoneEvent { - return 'type' in event && event.type === 'response.function_call_arguments.done'; -} - -// Output item type guards - -export function isOutputMessage(item: unknown): item is OutputMessage { - return typeof item === 'object' && item !== null && 'type' in item && item.type === 'message'; -} - -export function isFunctionCallItem(item: unknown): item is OutputFunctionCallItem { - return ( - typeof item === 'object' && item !== null && 'type' in item && item.type === 'function_call' - ); -} - -export function isReasoningOutputItem(item: unknown): item is OutputReasoningItem { - return typeof item === 'object' && item !== null && 'type' in item && item.type === 'reasoning'; -} - -export function isWebSearchCallOutputItem( - item: unknown, -): item is OutputWebSearchCallItem { - return ( - typeof item === 'object' && item !== null && 'type' in item && item.type === 'web_search_call' - ); -} - -export function isFileSearchCallOutputItem( - item: unknown, -): item is OutputFileSearchCallItem { - return ( - typeof item === 'object' && item !== null && 'type' in item && item.type === 'file_search_call' - ); -} - -export function isImageGenerationCallOutputItem( - item: unknown, -): item is OutputImageGenerationCallItem { - return ( - typeof item === 'object' && - item !== null && - 'type' in item && - item.type === 'image_generation_call' - ); -} - -// Content part type guards - -export function isOutputTextPart(part: unknown): part is ResponseOutputText { - return typeof part === 'object' && part !== null && 'type' in part && part.type === 'output_text'; -} - -export function isRefusalPart(part: unknown): part is OpenAIResponsesRefusalContent { - return typeof part === 'object' && part !== null && 'type' in part && part.type === 'refusal'; -} - -// Annotation type guards for Claude conversion - -export function isFileCitationAnnotation(annotation: unknown): annotation is FileCitation { - return ( - typeof annotation === 'object' && - annotation !== null && - 'type' in annotation && - annotation.type === 'file_citation' - ); -} - -export function isURLCitationAnnotation(annotation: unknown): annotation is URLCitation { - return ( - typeof annotation === 'object' && - annotation !== null && - 'type' in annotation && - annotation.type === 'url_citation' - ); -} - -export function isFilePathAnnotation(annotation: unknown): annotation is FilePath { - return ( - typeof annotation === 'object' && - annotation !== null && - 'type' in annotation && - annotation.type === 'file_path' - ); -} - -// Helper to check if output has a type property -export function hasTypeProperty(item: unknown): item is { - type: string; -} { - return ( - typeof item === 'object' && item !== null && 'type' in item && typeof item.type === 'string' - ); -} diff --git a/src/lib/tool-context.ts b/src/lib/tool-context.ts deleted file mode 100644 index 1cac8b7f3..000000000 --- a/src/lib/tool-context.ts +++ /dev/null @@ -1,247 +0,0 @@ -import type { $ZodObject, $ZodShape } from 'zod/v4/core'; -import * as z4 from 'zod/v4'; -import type { ToolExecuteContext, TurnContext } from './tool-types.js'; -import { SHARED_CONTEXT_KEY } from './tool-types.js'; - -//#region Types - -/** - * Listener function called when context values change - * Receives a shallow copy of the full context store - */ -type ContextChangeListener = (snapshot: Record>) => void; - -/** - * Context input can be a static value, a sync function, or an async function - */ -export type ContextInput>> = - | T - | ((turn: TurnContext) => T) - | ((turn: TurnContext) => Promise); - -//#endregion - -//#region ToolContextStore - -/** - * Mutable context store shared across all tool executions within a callModel invocation. - * Stores context keyed by tool name: `{ get_weather: { apiKey: '...' }, db_query: { dbUrl: '...' } }`. - * Notifies listeners on changes. - */ -export class ToolContextStore { - private store: Record>; - private listeners: Set = new Set(); - - constructor(initialValues: Record> = {}) { - this.store = {}; - for (const [key, value] of Object.entries(initialValues)) { - this.store[key] = { ...value }; - } - } - - /** Subscribe to context changes. Returns an unsubscribe function. */ - subscribe(listener: ContextChangeListener): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } - - /** Get a deep-shallow copy of the full context (all tools) */ - getSnapshot(): Record> { - const snapshot: Record> = {}; - for (const [key, value] of Object.entries(this.store)) { - snapshot[key] = { ...value }; - } - return snapshot; - } - - /** Get a shallow copy of context for a specific tool */ - getToolContext(toolName: string): Record { - const data = this.store[toolName]; - if (!data) { - return {}; - } - return { ...data }; - } - - /** Set context for a specific tool and notify listeners */ - setToolContext(toolName: string, values: Record): void { - this.store[toolName] = { ...values }; - this.notifyListeners(); - } - - /** Merge partial values into a specific tool's context and notify listeners */ - mergeToolContext(toolName: string, partial: Record): void { - const existing = this.store[toolName] ?? {}; - this.store[toolName] = { ...existing, ...partial }; - this.notifyListeners(); - } - - private notifyListeners(): void { - const snapshot: Record> = {}; - for (const [key, value] of Object.entries(this.store)) { - snapshot[key] = { ...value }; - } - for (const listener of this.listeners) { - listener(snapshot); - } - } -} - -//#endregion - -//#region buildToolExecuteContext - -/** - * Validate a partial update against a schema's shape, filtering to known keys - * and validating each key individually. Returns the filtered partial. - */ -function validatePartialAgainstSchema( - partial: Record, - schema: $ZodObject<$ZodShape>, -): Record { - const schemaKeys = Object.keys(schema._zod.def.shape); - const filteredPartial: Record = {}; - for (const [key, value] of Object.entries(partial)) { - if (schemaKeys.includes(key)) { - filteredPartial[key] = value; - } - } - - const shape = schema._zod.def.shape; - for (const [key, value] of Object.entries(filteredPartial)) { - const keySchema = shape[key]; - if (keySchema) { - z4.parse(keySchema, value); - } - } - - return filteredPartial; -} - -/** - * Build a flat ToolExecuteContext for a specific tool. - * Returns a merged object with TurnContext fields, a `local` getter - * (reads from the store on each access), `shared` getter, and mutation methods. - * - * The `local` and `shared` getters are live — calling `setContext()` or - * `setSharedContext()` and then reading the property reflects updated values immediately. - * - * @param turnContext - The current turn context - * @param store - The shared context store (keyed by tool name) - * @param toolName - The tool's name - * @param schema - The tool's contextSchema (for validation) - * @param sharedSchema - The shared contextSchema (for validation) - * @returns A flat ToolExecuteContext - */ -export function buildToolExecuteContext< - TName extends string, - TContext extends Record, - TShared extends Record = Record, ->( - turnContext: TurnContext, - store: ToolContextStore | undefined, - toolName: TName, - schema: $ZodObject<$ZodShape> | undefined, - sharedSchema?: $ZodObject<$ZodShape> | undefined, -): ToolExecuteContext { - // Validate initial context eagerly (throws on bad data) - if (store && schema) { - extractToolContext(store, toolName, schema); - } - if (store && sharedSchema) { - extractToolContext(store, SHARED_CONTEXT_KEY, sharedSchema); - } - - const ctx: ToolExecuteContext = { - ...turnContext, - - get local(): Readonly { - const data = store ? store.getToolContext(toolName) : {}; - return Object.freeze(data) as Readonly; - }, - - setContext(partial: Partial): void { - if (!store || !schema) { - return; - } - const filteredPartial = validatePartialAgainstSchema(partial as Record, schema); - store.mergeToolContext(toolName, filteredPartial); - }, - - get shared(): Readonly { - const data = store ? store.getToolContext(SHARED_CONTEXT_KEY) : {}; - return Object.freeze(data) as Readonly; - }, - - setSharedContext(partial: Partial): void { - if (!store || !sharedSchema) { - return; - } - const filteredPartial = validatePartialAgainstSchema(partial as Record, sharedSchema); - store.mergeToolContext(SHARED_CONTEXT_KEY, filteredPartial); - }, - }; - - return ctx; -} - -//#endregion - -//#region resolveContext - -/** - * Resolve a context input (static value, sync function, or async function) to a plain object. - * - * @param contextInput - The context value or function from callModel - * @param turnContext - The current turn context for function resolution - * @returns The resolved context object (keyed by tool name) - */ -export async function resolveContext>>( - contextInput: ContextInput | undefined, - turnContext: TurnContext, -): Promise { - if (contextInput === undefined) { - return {} as T; - } - - if (typeof contextInput === 'function') { - return Promise.resolve((contextInput as (turn: TurnContext) => T | Promise)(turnContext)); - } - - return contextInput; -} - -//#endregion - -//#region extractToolContext - -/** - * Extract and validate context values for a specific tool from the context store. - * Returns a shallow copy so the caller cannot mutate the store directly. - * - * @param store - The shared context store (keyed by tool name) - * @param toolName - The tool's name - * @param schema - The tool's contextSchema - * @returns A shallow copy of the validated context values for this tool - */ -export function extractToolContext( - store: ToolContextStore, - toolName: string, - schema: $ZodObject<$ZodShape> | undefined, -): Record { - if (!schema) { - return {}; - } - - const toolData = store.getToolContext(toolName); - - // Validate the extracted values against the schema - z4.parse(schema, toolData); - - // getToolContext already returns a shallow copy - return toolData; -} - -//#endregion diff --git a/src/lib/tool-event-broadcaster.ts b/src/lib/tool-event-broadcaster.ts deleted file mode 100644 index 7a5ecb537..000000000 --- a/src/lib/tool-event-broadcaster.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * A push-based event broadcaster that supports multiple concurrent consumers. - * Similar to ReusableReadableStream but for push-based events from tool execution. - * - * Each consumer gets their own position in the buffer and receives all events - * from their join point onward. This enables real-time streaming of generator - * tool preliminary results to multiple consumers simultaneously. - * - * @template T - The event type being broadcast - */ -export class ToolEventBroadcaster { - private buffer: T[] = []; - private consumers = new Map(); - private nextConsumerId = 0; - private isComplete = false; - private completionError: Error | null = null; - - /** - * Push a new event to all consumers. - * Events are buffered so late-joining consumers can catch up. - */ - push(event: T): void { - if (this.isComplete) { - return; - } - this.buffer.push(event); - this.notifyWaitingConsumers(); - } - - /** - * Mark the broadcaster as complete - no more events will be pushed. - * Optionally pass an error to signal failure to all consumers. - * Cleans up buffer and consumers after completion. - */ - complete(error?: Error): void { - this.isComplete = true; - this.completionError = error ?? null; - this.notifyWaitingConsumers(); - // Schedule cleanup after consumers have processed completion - queueMicrotask(() => this.cleanup()); - } - - /** - * Clean up resources after all consumers have finished. - * Called automatically after complete(), but can be called manually. - */ - private cleanup(): void { - // Only cleanup if complete and all consumers are done - if (this.isComplete && this.consumers.size === 0) { - this.buffer = []; - } - } - - /** - * Create a new consumer that can independently iterate over events. - * Consumers can join at any time and will receive events from position 0. - * Multiple consumers can be created and will all receive the same events. - */ - createConsumer(): AsyncIterableIterator { - const consumerId = this.nextConsumerId++; - const state: ConsumerState = { - position: 0, - waitingPromise: null, - cancelled: false, - }; - this.consumers.set(consumerId, state); - - // eslint-disable-next-line @typescript-eslint/no-this-alias - const self = this; - - return { - async next(): Promise> { - const consumer = self.consumers.get(consumerId); - if (!consumer) { - return { done: true, value: undefined }; - } - - if (consumer.cancelled) { - return { done: true, value: undefined }; - } - - // Return buffered event if available - if (consumer.position < self.buffer.length) { - const value = self.buffer[consumer.position]!; - consumer.position++; - return { done: false, value }; - } - - // If complete and caught up, we're done - if (self.isComplete) { - self.consumers.delete(consumerId); - self.cleanup(); - if (self.completionError) { - throw self.completionError; - } - return { done: true, value: undefined }; - } - - // Set up waiting promise FIRST to avoid race condition - const waitPromise = new Promise((resolve, reject) => { - consumer.waitingPromise = { resolve, reject }; - - // Immediately check if we should resolve after setting up promise - if ( - self.isComplete || - self.completionError || - consumer.position < self.buffer.length - ) { - resolve(); - } - }); - - await waitPromise; - consumer.waitingPromise = null; - - // Recursively try again after waking up - return this.next(); - }, - - async return(): Promise> { - const consumer = self.consumers.get(consumerId); - if (consumer) { - consumer.cancelled = true; - self.consumers.delete(consumerId); - self.cleanup(); - } - return { done: true, value: undefined }; - }, - - async throw(e?: unknown): Promise> { - const consumer = self.consumers.get(consumerId); - if (consumer) { - consumer.cancelled = true; - self.consumers.delete(consumerId); - self.cleanup(); - } - throw e; - }, - - [Symbol.asyncIterator]() { - return this; - }, - }; - } - - /** - * Notify all waiting consumers that new data is available or stream completed - */ - private notifyWaitingConsumers(): void { - for (const consumer of this.consumers.values()) { - if (consumer.waitingPromise) { - if (this.completionError) { - consumer.waitingPromise.reject(this.completionError); - } else { - consumer.waitingPromise.resolve(); - } - consumer.waitingPromise = null; - } - } - } -} - -interface ConsumerState { - position: number; - waitingPromise: { - resolve: () => void; - reject: (error: Error) => void; - } | null; - cancelled: boolean; -} diff --git a/src/lib/tool-executor.ts b/src/lib/tool-executor.ts deleted file mode 100644 index 02bc49a09..000000000 --- a/src/lib/tool-executor.ts +++ /dev/null @@ -1,363 +0,0 @@ -import type { $ZodType } from 'zod/v4/core'; -import type { $ZodObject, $ZodShape } from 'zod/v4/core'; -import type { - APITool, - ParsedToolCall, - Tool, - ToolExecutionResult, - TurnContext, - ToolExecuteContext, -} from './tool-types.js'; - -import * as z4 from 'zod/v4'; -import { hasExecuteFunction, isGeneratorTool, isRegularExecuteTool } from './tool-types.js'; -import { type ToolContextStore, buildToolExecuteContext } from './tool-context.js'; - -// Re-export ZodError for convenience -export const ZodError = z4.ZodError; - -/** - * Typeguard to check if a value is a non-null object (not an array). - */ -function isNonNullObject(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -/** - * Recursively remove keys prefixed with ~ from an object. - * These are metadata properties (like ~standard from Standard Schema) - * that should not be sent to downstream providers. - * @see https://github.com/OpenRouterTeam/typescript-sdk/issues/131 - * - * When given a Record, returns Record. - * When given unknown, returns unknown (preserves primitives, null, etc). - */ -export function sanitizeJsonSchema(obj: Record): Record; -export function sanitizeJsonSchema(obj: unknown): unknown; -export function sanitizeJsonSchema(obj: unknown): unknown { - if (obj === null || typeof obj !== 'object') { - return obj; - } - - if (Array.isArray(obj)) { - return obj.map(sanitizeJsonSchema); - } - - // At this point, obj is a non-null, non-array object - // Use typeguard to narrow the type for type-safe property access - if (!isNonNullObject(obj)) { - return obj; - } - - const result: Record = {}; - for (const key of Object.keys(obj)) { - if (!key.startsWith('~')) { - result[key] = sanitizeJsonSchema(obj[key]); - } - } - return result; -} - -/** - * Typeguard to check if a value is a valid Zod schema compatible with zod/v4. - * Zod schemas have a _zod property that contains schema metadata. - */ -function isZodSchema(value: unknown): value is z4.ZodType { - if (typeof value !== 'object' || value === null) { - return false; - } - if (!('_zod' in value)) { - return false; - } - // After the 'in' check, TypeScript knows value has _zod property - return typeof value._zod === 'object'; -} - -/** - * Convert a Zod schema to JSON Schema using Zod v4's toJSONSchema function. - * Accepts ZodType from the main zod package for user compatibility. - * The resulting schema is sanitized to remove metadata properties (like ~standard) - * that would cause 400 errors with downstream providers. - */ -export function convertZodToJsonSchema(zodSchema: $ZodType): Record { - if (!isZodSchema(zodSchema)) { - throw new Error('Invalid Zod schema provided'); - } - // Use draft-7 as it's closest to OpenAPI 3.0's JSON Schema variant - const jsonSchema = z4.toJSONSchema(zodSchema, { - target: 'draft-7', - }); - // jsonSchema is always a Record from toJSONSchema - // The overloaded sanitizeJsonSchema preserves this type - return sanitizeJsonSchema(jsonSchema); -} - -/** - * Convert tools to OpenRouter API format - * Accepts readonly arrays for better type compatibility - */ -export function convertToolsToAPIFormat(tools: readonly Tool[]): APITool[] { - return tools.map((tool) => ({ - type: 'function' as const, - name: tool.function.name, - description: tool.function.description || null, - strict: null, - parameters: convertZodToJsonSchema(tool.function.inputSchema), - })); -} - -/** - * Validate tool input against Zod schema - * @throws ZodError if validation fails - */ -export function validateToolInput(schema: $ZodType, args: unknown): T { - return z4.parse(schema, args); -} - -/** - * Validate tool output against Zod schema - * @throws ZodError if validation fails - */ -export function validateToolOutput(schema: $ZodType, result: unknown): T { - return z4.parse(schema, result); -} - -/** - * Try to validate a value against a Zod schema without throwing - * @returns true if validation succeeds, false otherwise - */ -function tryValidate(schema: $ZodType, value: unknown): boolean { - const result = z4.safeParse(schema, value); - return result.success; -} - -/** - * Parse tool call arguments from JSON string. - * Treats empty/whitespace-only strings as an empty object — some providers - * return `arguments: ""` for tools that take no parameters. - */ -export function parseToolCallArguments(argumentsString: string): unknown { - const trimmed = argumentsString.trim(); - if (!trimmed) { - return {}; - } - try { - return JSON.parse(trimmed); - } catch (error) { - throw new Error( - `Failed to parse tool call arguments: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - } -} - -/** - * Build a ToolExecuteContext for a tool from a TurnContext and optional context store - */ -function buildExecuteCtx( - tool: Tool, - turnContext: TurnContext, - contextStore?: ToolContextStore, - sharedSchema?: $ZodObject<$ZodShape>, -): ToolExecuteContext { - return buildToolExecuteContext( - turnContext, - contextStore, - tool.function.name, - tool.function.contextSchema, - sharedSchema, - ); -} - -/** - * Execute a regular (non-generator) tool - */ -export async function executeRegularTool( - tool: Tool, - toolCall: ParsedToolCall, - context: TurnContext, - contextStore?: ToolContextStore, - sharedSchema?: $ZodObject<$ZodShape>, -): Promise> { - if (!isRegularExecuteTool(tool)) { - throw new Error( - `Tool "${toolCall.name}" is not a regular execute tool or has no execute function`, - ); - } - - try { - const validatedInput = validateToolInput(tool.function.inputSchema, toolCall.arguments); - const executeContext = buildExecuteCtx(tool, context, contextStore, sharedSchema); - - // Execute tool with context - const result = await Promise.resolve(tool.function.execute(validatedInput, executeContext)); - - // Validate output if schema is provided - if (tool.function.outputSchema) { - const validatedOutput = validateToolOutput(tool.function.outputSchema, result); - - return { - toolCallId: toolCall.id, - toolName: toolCall.name, - result: validatedOutput, - }; - } - - return { - toolCallId: toolCall.id, - toolName: toolCall.name, - result, - }; - } catch (error) { - return { - toolCallId: toolCall.id, - toolName: toolCall.name, - result: null, - error: error instanceof Error ? error : new Error(String(error)), - }; - } -} - -/** - * Execute a generator tool and collect preliminary and final results - * - Intermediate yields are validated against eventSchema (preliminary events) - * - Last yield is validated against outputSchema (final result sent to model) - * - Generator must emit at least one value - */ -export async function executeGeneratorTool( - tool: Tool, - toolCall: ParsedToolCall, - context: TurnContext, - onPreliminaryResult?: (toolCallId: string, result: unknown) => void, - contextStore?: ToolContextStore, - sharedSchema?: $ZodObject<$ZodShape>, -): Promise> { - if (!isGeneratorTool(tool)) { - throw new Error(`Tool "${toolCall.name}" is not a generator tool`); - } - - try { - const validatedInput = validateToolInput(tool.function.inputSchema, toolCall.arguments); - const executeContext = buildExecuteCtx(tool, context, contextStore, sharedSchema); - - const preliminaryResults: unknown[] = []; - let finalResult: unknown; - let hasFinalResult = false; - let lastEmittedValue: unknown; - let hasEmittedValue = false; - - const iterator = tool.function.execute(validatedInput, executeContext); - let iterResult = await iterator.next(); - - while (!iterResult.done) { - const event = iterResult.value; - lastEmittedValue = event; - hasEmittedValue = true; - - const matchesOutputSchema = tryValidate(tool.function.outputSchema, event); - const matchesEventSchema = tryValidate(tool.function.eventSchema, event); - - if (matchesOutputSchema && !matchesEventSchema && !hasFinalResult) { - finalResult = validateToolOutput(tool.function.outputSchema, event); - hasFinalResult = true; - } else { - const validatedPreliminary = validateToolOutput(tool.function.eventSchema, event); - preliminaryResults.push(validatedPreliminary); - if (onPreliminaryResult) { - onPreliminaryResult(toolCall.id, validatedPreliminary); - } - } - - iterResult = await iterator.next(); - } - - if (iterResult.value !== undefined) { - finalResult = validateToolOutput(tool.function.outputSchema, iterResult.value); - hasFinalResult = true; - } - - if (!hasFinalResult) { - if (!hasEmittedValue) { - throw new Error( - `Generator tool "${toolCall.name}" completed without emitting any values or returning a result`, - ); - } - finalResult = validateToolOutput(tool.function.outputSchema, lastEmittedValue); - } - - return { - toolCallId: toolCall.id, - toolName: toolCall.name, - result: finalResult, - preliminaryResults, - }; - } catch (error) { - return { - toolCallId: toolCall.id, - toolName: toolCall.name, - result: null, - error: error instanceof Error ? error : new Error(String(error)), - }; - } -} - -/** - * Execute a tool call - * Automatically detects if it's a regular or generator tool - */ -export async function executeTool( - tool: Tool, - toolCall: ParsedToolCall, - context: TurnContext, - onPreliminaryResult?: (toolCallId: string, result: unknown) => void, - contextStore?: ToolContextStore, - sharedSchema?: $ZodObject<$ZodShape>, -): Promise> { - if (!hasExecuteFunction(tool)) { - throw new Error(`Tool "${toolCall.name}" has no execute function. Use manual tool execution.`); - } - - if (isGeneratorTool(tool)) { - return executeGeneratorTool(tool, toolCall, context, onPreliminaryResult, contextStore, sharedSchema); - } - - return executeRegularTool(tool, toolCall, context, contextStore, sharedSchema); -} - -/** - * Find a tool by name in the tools array - */ -export function findToolByName(tools: Tool[], name: string): Tool | undefined { - return tools.find((tool) => tool.function.name === name); -} - -/** - * Format tool execution result as a string for sending to the model - */ -export function formatToolResultForModel(result: ToolExecutionResult): string { - if (result.error) { - return JSON.stringify({ - error: result.error.message, - toolName: result.toolName, - }); - } - - return JSON.stringify(result.result); -} - -/** - * Create a user-friendly error message for tool execution errors - */ -export function formatToolExecutionError(error: Error, toolCall: ParsedToolCall): string { - if (error instanceof ZodError) { - const issues = error.issues.map((issue) => ({ - path: issue.path.join('.'), - message: issue.message, - })); - - return `Tool "${toolCall.name}" validation error:\n${JSON.stringify(issues, null, 2)}`; - } - - return `Tool "${toolCall.name}" execution error: ${error.message}`; -} diff --git a/src/lib/tool-orchestrator.ts b/src/lib/tool-orchestrator.ts deleted file mode 100644 index fd835dd56..000000000 --- a/src/lib/tool-orchestrator.ts +++ /dev/null @@ -1,260 +0,0 @@ -import type { FunctionCallItem } from '../models/functioncallitem.js'; -import type { InputsUnion } from '../models/inputsunion.js'; -import type { OpenResponsesResult } from '../models/openresponsesresult.js'; -import type { OutputFunctionCallItem } from '../models/outputfunctioncallitem.js'; -import type { ResponsesRequest } from '../models/responsesrequest.js'; -import type { APITool, Tool, ToolExecutionResult } from './tool-types.js'; - -import { extractToolCallsFromResponse, responseHasToolCalls } from './stream-transformers.js'; -import { isFunctionCallItem } from './stream-type-guards.js'; -import { executeTool, findToolByName } from './tool-executor.js'; -import { hasExecuteFunction } from './tool-types.js'; -import { buildTurnContext } from './turn-context.js'; -import { executeNextTurnParamsFunctions, applyNextTurnParamsToRequest } from './next-turn-params.js'; - -/** - * Options for tool execution - */ -export interface ToolExecutionOptions { - onPreliminaryResult?: (toolCallId: string, result: unknown) => void; -} - -/** - * Result of the tool execution loop - */ -export interface ToolOrchestrationResult { - finalResponse: OpenResponsesResult; - allResponses: OpenResponsesResult[]; - toolExecutionResults: ToolExecutionResult[]; - conversationInput: InputsUnion; -} - -/** - * Execute tool calls and manage multi-turn conversations - * This orchestrates the loop of: request -> tool calls -> execute -> send results -> repeat - * - * @param sendRequest - Function to send a request and get a response - * @param initialInput - Starting input for the conversation - * @param initialRequest - Full initial request with all parameters - * @param tools - Enhanced tools with Zod schemas and execute functions - * @param apiTools - Converted tools in API format (JSON Schema) - * @param options - Execution options - * @returns Result containing final response and all execution data - */ -export async function executeToolLoop( - sendRequest: ( - input: InputsUnion, - tools: APITool[], - ) => Promise, - initialInput: InputsUnion, - initialRequest: ResponsesRequest, - tools: Tool[], - apiTools: APITool[], - options: ToolExecutionOptions = {}, -): Promise { - const onPreliminaryResult = options.onPreliminaryResult; - - const allResponses: OpenResponsesResult[] = []; - const toolExecutionResults: ToolExecutionResult[] = []; - let conversationInput: InputsUnion = initialInput; - let currentRequest: ResponsesRequest = { ...initialRequest }; - - let currentRound = 0; - let currentResponse: OpenResponsesResult; - - // Initial request - currentResponse = await sendRequest(conversationInput, apiTools); - allResponses.push(currentResponse); - - // Loop until no more tool calls (model decides when to stop) - while (responseHasToolCalls(currentResponse)) { - currentRound++; - - // Extract tool calls from response - const toolCalls = extractToolCallsFromResponse(currentResponse); - - if (toolCalls.length === 0) { - break; - } - - // Check if any tools have execute functions - const hasExecutableTools = toolCalls.some((toolCall) => { - const tool = findToolByName(tools, toolCall.name); - return tool && hasExecuteFunction(tool); - }); - - // If no executable tools, return (manual execution mode) - if (!hasExecutableTools) { - break; - } - - // Execute all tool calls in parallel (parallel tool calling) - const toolCallPromises = toolCalls.map(async (toolCall) => { - const tool = findToolByName(tools, toolCall.name); - - if (!tool) { - // Tool not found in definitions - return { - toolCallId: toolCall.id, - toolName: toolCall.name, - result: null, - error: new Error(`Tool "${toolCall.name}" not found in tool definitions`), - } as ToolExecutionResult; - } - - if (!hasExecuteFunction(tool)) { - // Tool has no execute function - return null to filter out - return null; - } - - // Find the raw tool call from the response output - const rawToolCall = currentResponse.output.find( - (item): item is OutputFunctionCallItem => - isFunctionCallItem(item) && item.callId === toolCall.id, - ); - - if (!rawToolCall) { - throw new Error(`Could not find raw tool call for ${toolCall.id}`); - } - - // Convert to FunctionCallItem format - const openResponsesToolCall: FunctionCallItem = { - type: 'function_call' as const, - callId: rawToolCall.callId, - name: rawToolCall.name, - arguments: rawToolCall.arguments, - id: rawToolCall.callId, - status: rawToolCall.status, - }; - - // Build turn context with full information - const turnContext = buildTurnContext({ - numberOfTurns: currentRound, - toolCall: openResponsesToolCall, - turnRequest: currentRequest, - }); - - // Execute the tool - return executeTool(tool, toolCall, turnContext, onPreliminaryResult); - }); - - // Wait for all tool executions to complete in parallel - const settledResults = await Promise.allSettled(toolCallPromises); - - // Process settled results, handling both fulfilled and rejected promises - const roundResults: ToolExecutionResult[] = []; - settledResults.forEach((settled, i) => { - const toolCall = toolCalls[i]; - if (!toolCall) return; - - if (settled.status === "fulfilled") { - if (settled.value !== null) { - roundResults.push(settled.value); - } - } else { - // Promise rejected - create error result - roundResults.push({ - toolCallId: toolCall.id, - toolName: toolCall.name, - result: null, - error: settled.reason instanceof Error - ? settled.reason - : new Error(String(settled.reason)), - }); - } - }); - - toolExecutionResults.push(...roundResults); - - // Execute nextTurnParams functions for tools that were called - const computedParams = await executeNextTurnParamsFunctions( - toolCalls, - tools, - currentRequest - ); - - // Apply computed parameters to request - if (Object.keys(computedParams).length > 0) { - currentRequest = applyNextTurnParamsToRequest(currentRequest, computedParams); - conversationInput = currentRequest.input ?? conversationInput; - } - - // Build array input with all output from previous response plus tool results - // The API expects continuation via previousResponseId, not by including outputs - // For now, we'll keep the conversation going via previousResponseId - // conversationInput is updated above if nextTurnParams modified it - - // Note: The OpenRouter Responses API uses previousResponseId for continuation - // Tool results are automatically associated with the previous response's tool calls - - // Send updated conversation to API - this should use previousResponseId - currentResponse = await sendRequest(conversationInput, apiTools); - allResponses.push(currentResponse); - } - - return { - finalResponse: currentResponse, - allResponses, - toolExecutionResults, - conversationInput, - }; -} - -/** - * Convert tool execution results to a map for easy lookup - */ -export function toolResultsToMap(results: ToolExecutionResult[]): Map< - string, - { - result: unknown; - preliminaryResults?: unknown[]; - } -> { - const map = new Map(); - - for (const result of results) { - map.set(result.toolCallId, { - result: result.result, - preliminaryResults: result.preliminaryResults, - }); - } - - return map; -} - -/** - * Build a summary of tool executions for debugging/logging - */ -export function summarizeToolExecutions(results: ToolExecutionResult[]): string { - const lines: string[] = []; - - for (const result of results) { - if (result.error) { - lines.push(`❌ ${result.toolName} (${result.toolCallId}): ERROR - ${result.error.message}`); - } else { - const prelimCount = result.preliminaryResults?.length ?? 0; - const prelimInfo = prelimCount > 0 ? ` (${prelimCount} preliminary results)` : ''; - lines.push(`✅ ${result.toolName} (${result.toolCallId}): SUCCESS${prelimInfo}`); - } - } - - return lines.join('\n'); -} - -/** - * Check if any tool executions had errors - */ -export function hasToolExecutionErrors(results: ToolExecutionResult[]): boolean { - return results.some((result) => result.error !== undefined); -} - -/** - * Get all tool execution errors - */ -export function getToolExecutionErrors(results: ToolExecutionResult[]): Error[] { - return results - .filter((result): result is ToolExecutionResult & { error: Error } => - result.error !== undefined - ) - .map((result) => result.error); -} diff --git a/src/lib/tool-types.ts b/src/lib/tool-types.ts deleted file mode 100644 index 1c2bf45da..000000000 --- a/src/lib/tool-types.ts +++ /dev/null @@ -1,727 +0,0 @@ -import type { $ZodObject, $ZodShape, $ZodType, infer as zodInfer } from 'zod/v4/core'; -import type { FunctionCallItem } from '../models/functioncallitem.js'; -import type { FunctionCallOutputItem } from '../models/functioncalloutputitem.js'; -import type { InputsUnion } from '../models/inputsunion.js'; -import type { OpenResponsesResult, Usage } from '../models/openresponsesresult.js'; -import type { ResponsesRequest } from '../models/responsesrequest.js'; -import type { StreamEvents } from '../models/streamevents.js'; -import type { ModelResult } from './model-result.js'; - -/** - * Tool type enum for enhanced tools - */ -export enum ToolType { - Function = 'function', -} - -/** - * Turn context passed to tool execute functions and async parameter resolution - * Contains information about the current conversation state - */ -export interface TurnContext { - /** The specific tool call being executed (only available during tool execution) */ - toolCall?: FunctionCallItem; - /** Number of tool execution turns so far (1-indexed: first turn = 1, 0 = initial request) */ - numberOfTurns: number; - /** The full request being sent to the API (only available during tool execution) */ - turnRequest?: ResponsesRequest; -} - -//#region Context Types - -/** - * Extract context schema type from a tool definition - * Returns the inferred type of the tool's contextSchema, or empty object if none - */ -export type InferToolContext = T extends { function: { contextSchema: infer S } } - ? S extends $ZodType ? zodInfer : Record - : Record; - -/** - * Extract tool name from a tool definition - */ -type InferToolName = T extends { function: { name: infer N extends string } } ? N : string; - -/** - * Flat execute context passed as the second argument to tool execute functions. - * Merges TurnContext fields with a `local` getter (own tool context) and `setContext()`. - * - * @template TName - The tool's literal name string - * @template TContext - The shape of the tool's contextSchema - * @template TShared - The shape of the sharedContextSchema - */ -export type ToolExecuteContext< - TName extends string = string, - TContext extends Record = Record, - TShared extends Record = Record, -> = TurnContext & { - /** The tool's name (type-level only, for generic inference) */ - readonly _toolName?: TName; - /** This tool's own context (reads from the store, frozen snapshot) */ - local: Readonly; - /** Mutate this tool's context in the shared store (persists across turns) */ - setContext(partial: Partial): void; - /** Shared context visible to all tools */ - shared: Readonly; - /** Mutate the shared context in the store (persists across turns) */ - setSharedContext(partial: Partial): void; -}; - -/** - * Context map keyed by tool name for callModel's `context` option. - * Each key is a tool's name, each value is that tool's inferred context type. - */ -export type ToolContextMap = { - [K in T[number] as InferToolName]: InferToolContext; -}; - -/** - * Context map with an optional `shared` key for shared context. - * When TShared is provided (non-empty), a `shared` key is added to the map. - */ -export type ToolContextMapWithShared< - T extends readonly Tool[], - TShared extends Record = Record, -> = ToolContextMap & - (TShared extends Record ? {} : { shared: TShared }); - -/** - * Reserved key in the context store for shared context data. - * The tool name 'shared' is forbidden — it's reserved for this purpose. - */ -export const SHARED_CONTEXT_KEY = 'shared' as const; - -//#endregion - -/** - * Context passed to nextTurnParams functions - * Contains current request state for parameter computation - * Allows modification of key request parameters between turns - */ -export type NextTurnParamsContext = { - /** Current input (messages) */ - input: InputsUnion; - /** Current model selection */ - model: string; - /** Current models array */ - models: string[]; - /** Current temperature */ - temperature: number | null; - /** Current maxOutputTokens */ - maxOutputTokens: number | null; - /** Current topP */ - topP: number | null; - /** Current topK */ - topK?: number | undefined; - /** Current instructions */ - instructions: string | null; -}; - -/** - * Functions to compute next turn parameters - * Each function receives the tool's input params and current request context - */ -export type NextTurnParamsFunctions = { - [K in keyof NextTurnParamsContext]?: ( - params: TInput, - context: NextTurnParamsContext - ) => NextTurnParamsContext[K] | Promise; -}; - -/** - * Tool-level approval check function type - * Receives the tool's input params and turn context - * Returns true if approval is required, false otherwise - */ -export type ToolApprovalCheck = ( - params: TInput, - context: TurnContext -) => boolean | Promise; - -/** - * Base tool function interface with inputSchema - * @template TInput - Zod schema for tool input - */ -export interface BaseToolFunction> { - name: string; - description?: string; - inputSchema: TInput; - /** Zod schema declaring the context data this tool needs */ - contextSchema?: $ZodObject<$ZodShape>; - nextTurnParams?: NextTurnParamsFunctions>; - /** - * Whether this tool requires human approval before execution - * Can be a boolean or an async function that receives the tool's input params and context - */ - requireApproval?: boolean | ToolApprovalCheck>; -} - -/** - * Regular tool with synchronous or asynchronous execute function and optional outputSchema - * @template TContext - Shape of the tool's context (inferred from contextSchema) - * @template TName - The tool's literal name string - */ -export interface ToolFunctionWithExecute< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType = $ZodType, - TContext extends Record = Record, - TName extends string = string, -> extends BaseToolFunction { - outputSchema?: TOutput; - execute: ( - params: zodInfer, - context?: ToolExecuteContext, - ) => Promise> | zodInfer; -} - -/** - * Generator-based tool with async generator execute function - * Emits preliminary events (validated by eventSchema) during execution - * and a final output (validated by outputSchema) as the last emission - * - * The generator can yield both events and the final output. - * All yields are validated against eventSchema (which should be a union of event and output types), - * and the last yield is additionally validated against outputSchema. - * - * @example - * ```typescript - * { - * eventSchema: z.object({ status: z.string() }), // For progress events - * outputSchema: z.object({ result: z.number() }), // For final output - * execute: async function* (params) { - * yield { status: "processing..." }; // Event - * yield { status: "almost done..." }; // Event - * yield { result: 42 }; // Final output (must be last) - * } - * } - * ``` - */ -export interface ToolFunctionWithGenerator< - TInput extends $ZodObject<$ZodShape>, - TEvent extends $ZodType = $ZodType, - TOutput extends $ZodType = $ZodType, - TContext extends Record = Record, - TName extends string = string, -> extends BaseToolFunction { - eventSchema: TEvent; - outputSchema: TOutput; - execute: ( - params: zodInfer, - context?: ToolExecuteContext, - ) => AsyncGenerator | zodInfer, zodInfer | void>; -} - -/** - * Manual tool without execute function - requires manual handling by developer - */ -export interface ManualToolFunction< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType = $ZodType, -> extends BaseToolFunction { - outputSchema?: TOutput; -} - -/** - * Tool with execute function (regular or generator) - */ -export type ToolWithExecute< - TInput extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, - TOutput extends $ZodType = $ZodType, - TContext extends Record = Record, -> = { - type: ToolType.Function; - function: ToolFunctionWithExecute; -}; - -/** - * Tool with generator execute function - */ -export type ToolWithGenerator< - TInput extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, - TEvent extends $ZodType = $ZodType, - TOutput extends $ZodType = $ZodType, - TContext extends Record = Record, -> = { - type: ToolType.Function; - function: ToolFunctionWithGenerator; -}; - -/** - * Tool without execute function (manual handling) - */ -export type ManualTool< - TInput extends $ZodObject<$ZodShape> = $ZodObject<$ZodShape>, - TOutput extends $ZodType = $ZodType, -> = { - type: ToolType.Function; - function: ManualToolFunction; -}; - -/** - * Union type of all enhanced tool types - */ -export type Tool = - | ToolWithExecute<$ZodObject<$ZodShape>, $ZodType> - | ToolWithGenerator<$ZodObject<$ZodShape>, $ZodType, $ZodType> - | ManualTool<$ZodObject<$ZodShape>, $ZodType>; - -/** - * Extracts the input type from a tool definition - */ -export type InferToolInput = T extends { function: { inputSchema: infer S } } - ? S extends $ZodType - ? zodInfer - : unknown - : unknown; - -/** - * Extracts the output type from a tool definition - */ -export type InferToolOutput = T extends { function: { outputSchema: infer S } } - ? S extends $ZodType - ? zodInfer - : unknown - : unknown; - -/** - * A tool call with typed arguments based on the tool's inputSchema - */ -export type TypedToolCall = { - id: string; - name: T extends { function: { name: infer N } } ? N : string; - arguments: InferToolInput; -}; - -/** - * Union of typed tool calls for a tuple of tools - */ -export type TypedToolCallUnion = { - [K in keyof T]: T[K] extends Tool ? TypedToolCall : never; -}[number]; - -/** - * Union of typed tool execution results for a tuple of tools - */ -export type ToolExecutionResultUnion = { - [K in keyof T]: T[K] extends Tool ? ToolExecutionResult : never; -}[number]; - -/** - * Union of output types for all tools in a tuple - * Used for typing tool result events - */ -export type InferToolOutputsUnion = { - [K in keyof T]: T[K] extends Tool ? InferToolOutput : never; -}[number]; - -/** - * Extracts the event type from a generator tool definition - * Returns `never` for non-generator tools - */ -export type InferToolEvent = T extends { function: { eventSchema: infer S } } - ? S extends $ZodType - ? zodInfer - : never - : never; - -/** - * Union of event types for all generator tools in a tuple - * Filters out non-generator tools (which return `never`) - */ -export type InferToolEventsUnion = { - [K in keyof T]: T[K] extends Tool ? InferToolEvent : never; -}[number]; - -/** - * Type guard to check if a tool has an execute function - */ -export function hasExecuteFunction( - tool: Tool, -): tool is ToolWithExecute | ToolWithGenerator { - return 'execute' in tool.function && typeof tool.function.execute === 'function'; -} - -/** - * Type guard to check if a tool uses a generator (has eventSchema) - */ -export function isGeneratorTool(tool: Tool): tool is ToolWithGenerator { - return 'eventSchema' in tool.function; -} - -/** - * Type guard to check if a tool is a regular execution tool (not generator) - */ -export function isRegularExecuteTool(tool: Tool): tool is ToolWithExecute { - return hasExecuteFunction(tool) && !isGeneratorTool(tool); -} - -/** - * Type guard to check if a tool is a manual tool (no execute function) - */ -export function isManualTool(tool: Tool): tool is ManualTool { - return !('execute' in tool.function); -} - -/** - * Parsed tool call from API response - * @template T - The tool type to infer argument types from - */ -export interface ParsedToolCall { - id: string; - name: T extends { function: { name: infer N } } ? N : string; - arguments: InferToolInput; // Typed based on tool's inputSchema -} - -/** - * Result of tool execution - * @template T - The tool type to infer result types from - */ -export interface ToolExecutionResult { - toolCallId: string; - toolName: string; - result: T extends ToolWithExecute<$ZodObject<$ZodShape>, infer O> | ToolWithGenerator<$ZodObject<$ZodShape>, $ZodType, infer O> - ? zodInfer - : unknown; // Final result (sent to model) - preliminaryResults?: T extends ToolWithGenerator<$ZodObject<$ZodShape>, infer E> - ? zodInfer[] - : undefined; // All yielded values from generator - error?: Error; -} - -/** - * Warning from step execution - */ -export interface Warning { - type: string; - message: string; -} - -/** - * Result of a single step in the tool execution loop - * Compatible with Vercel AI SDK pattern - */ -export interface StepResult { - readonly stepType: 'initial' | 'continue'; - readonly text: string; - readonly toolCalls: TypedToolCallUnion[]; - readonly toolResults: ToolExecutionResultUnion[]; - readonly response: OpenResponsesResult; - readonly usage?: Usage | null | undefined; - readonly finishReason?: string | undefined; - readonly warnings?: Warning[] | undefined; - readonly experimental_providerMetadata?: Record | undefined; -} - -/** - * A condition function that determines whether to stop tool execution - * Returns true to STOP execution, false to CONTINUE - * (Matches Vercel AI SDK semantics) - */ -export type StopCondition = (options: { - readonly steps: ReadonlyArray>; -}) => boolean | Promise; - -/** - * Stop condition configuration - * Can be a single condition or array of conditions - */ -export type StopWhen = - | StopCondition - | ReadonlyArray>; - -/** - * Result of executeTools operation - */ -export interface ExecuteToolsResult { - finalResponse: ModelResult; - allResponses: ModelResult[]; - toolResults: Map< - string, - { - result: unknown; - preliminaryResults?: unknown[]; - } - >; -} - -/** - * Standard tool format for OpenRouter API (JSON Schema based) - * Matches ResponsesRequestToolFunction structure - */ -export interface APITool { - type: 'function'; - name: string; - description?: string | null; - strict?: boolean | null; - parameters: { - [k: string]: unknown; - } | null; -} - -/** - * Tool preliminary result event emitted during generator tool execution - * @template TEvent - The event type from the tool's eventSchema - */ -export type ToolPreliminaryResultEvent = { - type: 'tool.preliminary_result'; - toolCallId: string; - result: TEvent; - timestamp: number; -}; - -/** - * Tool result event emitted when a tool execution completes - * Contains the final result and any preliminary results that were emitted - * @template TResult - The result type from the tool's outputSchema - * @template TPreliminaryResults - The event type from generator tools' eventSchema - */ -export type ToolResultEvent = { - type: 'tool.result'; - toolCallId: string; - result: TResult; - timestamp: number; - preliminaryResults?: TPreliminaryResults[]; -}; - -/** - * Tool call output event carrying the fully-formed FunctionCallOutputItem. - * Broadcast by executeToolRound so passive consumers (getItemsStream) can yield - * tool results in real-time without owning tool execution. - */ -export type ToolCallOutputEvent = { - type: 'tool.call_output'; - output: FunctionCallOutputItem; - timestamp: number; -}; - -/** - * Turn start event emitted at the beginning of each API turn - * Turn 0 is the initial request, subsequent turns follow tool execution - */ -export type TurnStartEvent = { - type: 'turn.start'; - turnNumber: number; - timestamp: number; -}; - -/** - * Turn end event emitted at the end of each API turn - */ -export type TurnEndEvent = { - type: 'turn.end'; - turnNumber: number; - timestamp: number; -}; - -/** - * Enhanced stream event types for getFullResponsesStream - * Extends StreamEvents with tool preliminary results, tool results, - * and turn delimiter events for multi-turn streaming - * @template TEvent - The event type from generator tools - * @template TResult - The result type from tool execution - */ -export type ResponseStreamEvent = - | StreamEvents - | ToolPreliminaryResultEvent - | ToolResultEvent - | ToolCallOutputEvent - | TurnStartEvent - | TurnEndEvent; - -/** - * Type guard to check if an event is a tool preliminary result event - */ -export function isToolPreliminaryResultEvent( - event: ResponseStreamEvent, -): event is ToolPreliminaryResultEvent { - return event.type === 'tool.preliminary_result'; -} - -/** - * Type guard to check if an event is a tool result event - */ -export function isToolResultEvent( - event: ResponseStreamEvent, -): event is ToolResultEvent { - return event.type === 'tool.result'; -} - -/** - * Type guard to check if an event is a tool call output event - */ -export function isToolCallOutputEvent( - event: ResponseStreamEvent, -): event is ToolCallOutputEvent { - return event.type === 'tool.call_output'; -} - -/** - * Type guard to check if an event is a turn start event - */ -export function isTurnStartEvent( - event: ResponseStreamEvent, -): event is TurnStartEvent { - return event.type === 'turn.start'; -} - -/** - * Type guard to check if an event is a turn end event - */ -export function isTurnEndEvent( - event: ResponseStreamEvent, -): event is TurnEndEvent { - return event.type === 'turn.end'; -} - -/** - * Tool stream event types for getToolStream - * Includes both argument deltas and preliminary results - * @template TEvent - The event type from generator tools - */ -export type ToolStreamEvent = - | { - type: 'delta'; - content: string; - } - | { - type: 'preliminary_result'; - toolCallId: string; - result: TEvent; - }; - -/** - * Chat stream event types for getFullChatStream - * Includes content deltas, completion events, and tool preliminary results - * @template TEvent - The event type from generator tools - */ -export type ChatStreamEvent = - | { - type: 'content.delta'; - delta: string; - } - | { - type: 'message.complete'; - response: OpenResponsesResult; - } - | { - type: 'tool.preliminary_result'; - toolCallId: string; - result: TEvent; - } - | { - type: string; - event: StreamEvents; - }; // Pass-through for other events -/** - * Result of a tool execution that hasn't been sent to the model yet - * Used for interrupted or awaiting approval states - * @template TTools - The tools array type for proper type inference - */ -export interface UnsentToolResult { - /** The ID of the tool call this result is for */ - callId: string; - /** The name of the tool that was executed */ - name: TTools[number]['function']['name']; - /** The output of the tool execution */ - output: unknown; - /** Error message if the tool call was rejected or failed */ - error?: string; -} - -/** - * Partial response captured during interruption - * @template TTools - The tools array type for proper type inference - */ -export interface PartialResponse { - /** Partial text response accumulated before interruption */ - text?: string; - /** Tool calls that were in progress when interrupted */ - toolCalls?: Array>; -} - -/** - * Status of a conversation state - */ -export type ConversationStatus = - | 'complete' - | 'interrupted' - | 'awaiting_approval' - | 'in_progress'; - -/** - * State for multi-turn conversations with persistence and approval gates - * @template TTools - The tools array type for proper type inference - */ -export interface ConversationState { - /** Unique identifier for this conversation */ - id: string; - /** Full message history */ - messages: InputsUnion; - /** Previous response ID for chaining (OpenRouter server-side optimization) */ - previousResponseId?: string; - /** Tool calls awaiting human approval */ - pendingToolCalls?: Array>; - /** Tool results executed but not yet sent to the model */ - unsentToolResults?: Array>; - /** Partial response data captured during interruption */ - partialResponse?: PartialResponse; - /** Signal from a new request to interrupt this conversation */ - interruptedBy?: string; - /** Current status of the conversation */ - status: ConversationStatus; - /** Creation timestamp (Unix ms) */ - createdAt: number; - /** Last update timestamp (Unix ms) */ - updatedAt: number; -} - -/** - * State accessor for loading and saving conversation state - * Enables any storage backend (memory, Redis, database, etc.) - * @template TTools - The tools array type for proper type inference - */ -export interface StateAccessor { - /** Load the current conversation state, or null if none exists */ - load: () => Promise | null>; - /** Save the conversation state */ - save: (state: ConversationState) => Promise; -} -/** - * Check if a single tool has approval configured (non-false, non-undefined) - * Returns true if the tool definitely requires approval, - * false if it definitely doesn't, or boolean if it's uncertain - */ -export type ToolHasApproval = - T extends { function: { requireApproval: true | ToolApprovalCheck } } - ? true - : T extends { function: { requireApproval: false } } - ? false - : T extends { function: { requireApproval: undefined } } - ? false - : boolean; // Could be either (optional property) - -/** - * Check if ANY tool in an array has approval configured - * Returns true if at least one tool might require approval - */ -export type HasApprovalTools = - TTools extends readonly [infer First extends Tool, ...infer Rest extends Tool[]] - ? ToolHasApproval extends true - ? true - : HasApprovalTools - : false; - -/** - * Type guard to check if a tool has approval configured at runtime - */ -export function toolHasApprovalConfigured(tool: Tool): boolean { - const requireApproval = tool.function.requireApproval; - return requireApproval === true || typeof requireApproval === 'function'; -} - -/** - * Type guard to check if any tools in array have approval configured at runtime - */ -export function hasApprovalRequiredTools(tools: readonly Tool[]): boolean { - return tools.some(toolHasApprovalConfigured); -} diff --git a/src/lib/tool.ts b/src/lib/tool.ts deleted file mode 100644 index afea78b4b..000000000 --- a/src/lib/tool.ts +++ /dev/null @@ -1,311 +0,0 @@ -import type { $ZodObject, $ZodShape, $ZodType, infer as zodInfer } from 'zod/v4/core'; -import { - ToolType, - SHARED_CONTEXT_KEY, - type Tool, - type ToolExecuteContext, - type ToolWithExecute, - type ToolWithGenerator, - type ManualTool, - type NextTurnParamsFunctions, - type ToolApprovalCheck, -} from "./tool-types.js"; - -//#region Config Types - -/** - * Configuration for a regular tool with outputSchema - */ -type RegularToolConfigWithOutput< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType, - TContext extends Record = Record, - TName extends string = string, -> = { - name: TName; - description?: string; - inputSchema: TInput; - outputSchema: TOutput; - eventSchema?: undefined; - /** Zod schema declaring the context data this tool needs */ - contextSchema?: $ZodObject<$ZodShape>; - nextTurnParams?: NextTurnParamsFunctions>; - requireApproval?: boolean | ToolApprovalCheck>; - execute: ( - params: zodInfer, - context?: ToolExecuteContext - ) => Promise> | zodInfer; -}; - -/** - * Configuration for a regular tool without outputSchema (infers return type from execute) - */ -type RegularToolConfigWithoutOutput< - TInput extends $ZodObject<$ZodShape>, - TReturn, - TContext extends Record = Record, - TName extends string = string, -> = { - name: TName; - description?: string; - inputSchema: TInput; - outputSchema?: undefined; - eventSchema?: undefined; - /** Zod schema declaring the context data this tool needs */ - contextSchema?: $ZodObject<$ZodShape>; - nextTurnParams?: NextTurnParamsFunctions>; - requireApproval?: boolean | ToolApprovalCheck>; - execute: ( - params: zodInfer, - context?: ToolExecuteContext - ) => Promise | TReturn; -}; - -/** - * Configuration for a generator tool (with eventSchema) - */ -type GeneratorToolConfig< - TInput extends $ZodObject<$ZodShape>, - TEvent extends $ZodType, - TOutput extends $ZodType, - TContext extends Record = Record, - TName extends string = string, -> = { - name: TName; - description?: string; - inputSchema: TInput; - eventSchema: TEvent; - outputSchema: TOutput; - /** Zod schema declaring the context data this tool needs */ - contextSchema?: $ZodObject<$ZodShape>; - nextTurnParams?: NextTurnParamsFunctions>; - requireApproval?: boolean | ToolApprovalCheck>; - execute: ( - params: zodInfer, - context?: ToolExecuteContext - ) => AsyncGenerator | zodInfer>; -}; - -/** - * Configuration for a manual tool (execute: false, no eventSchema or outputSchema) - */ -type ManualToolConfig< - TInput extends $ZodObject<$ZodShape>, -> = { - name: string; // Manual tools don't use TName since they have no execute - description?: string; - inputSchema: TInput; - /** Zod schema declaring the context data this tool needs */ - contextSchema?: $ZodObject<$ZodShape>; - nextTurnParams?: NextTurnParamsFunctions>; - requireApproval?: boolean | ToolApprovalCheck>; - execute: false; -}; - -/** - * Loose config type for the `tool()` overload. - * Accepts any valid tool config while typing `ctx.shared` from TShared. - */ -type ToolConfigWithSharedContext> = { - name: string; - description?: string; - inputSchema: $ZodObject<$ZodShape>; - outputSchema?: $ZodType; - eventSchema?: $ZodType; - contextSchema?: $ZodObject<$ZodShape>; - nextTurnParams?: NextTurnParamsFunctions>; - requireApproval?: boolean | ToolApprovalCheck>; - execute: - | (( - params: Record, - context?: ToolExecuteContext, TShared>, - ) => unknown) - | (( - params: Record, - context?: ToolExecuteContext, TShared>, - ) => AsyncGenerator) - | false; -}; - -//#endregion - -//#region Union Config Type - -/** - * Union type for all regular tool configs - */ -type RegularToolConfig< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType, - TReturn, - TContext extends Record = Record, - TName extends string = string, -> = - | RegularToolConfigWithOutput - | RegularToolConfigWithoutOutput; - -//#endregion - -//#region tool() Factory - -/** - * Creates a tool with full type inference from Zod schemas. - * - * The tool type is automatically determined based on the configuration: - * - **Generator tool**: When `eventSchema` is provided - * - **Regular tool**: When `execute` is a function (no `eventSchema`) - * - **Manual tool**: When `execute: false` is set - * - * Shared context typing: Pass a type parameter to type `ctx.shared` - * in the execute callback. Runtime validation happens at callModel - * via `sharedContextSchema`. - * - * @example Regular tool with typed shared context: - * ```typescript - * type SharedCtx = z.infer; - * - * const execTool = tool({ - * name: "sandbox_exec", - * inputSchema: z.object({ command: z.string() }), - * execute: async (params, ctx) => { - * ctx?.shared._sessionId; // string | undefined - * return { output: '...' }; - * }, - * }); - * ``` - */ -// Overload for generator tools (when eventSchema is provided) -export function tool< - TInput extends $ZodObject<$ZodShape>, - TEvent extends $ZodType, - TOutput extends $ZodType, - TContext extends Record = Record, - TName extends string = string, ->( - config: GeneratorToolConfig -): ToolWithGenerator; - -// Overload for manual tools (execute: false) -export function tool>( - config: ManualToolConfig -): ManualTool; - -// Overload for regular tools with outputSchema -export function tool< - TInput extends $ZodObject<$ZodShape>, - TOutput extends $ZodType, - TContext extends Record = Record, - TName extends string = string, ->(config: RegularToolConfigWithOutput): ToolWithExecute; - -// Overload for regular tools without outputSchema (infers return type) -export function tool< - TInput extends $ZodObject<$ZodShape>, - TReturn, - TContext extends Record = Record, - TName extends string = string, ->(config: RegularToolConfigWithoutOutput): ToolWithExecute, TContext>; - -// Overload for explicit TShared: tool({...}) -// When a non-ZodObject type is provided as the first generic, -// the specific overloads above won't match (constraint mismatch), -// so TypeScript falls through to this catch-all. -export function tool>( - config: ToolConfigWithSharedContext -): Tool; - -// Implementation -export function tool( - config: - | GeneratorToolConfig<$ZodObject<$ZodShape>, $ZodType, $ZodType> - | RegularToolConfig<$ZodObject<$ZodShape>, $ZodType, unknown> - | ManualToolConfig<$ZodObject<$ZodShape>> - | ToolConfigWithSharedContext> -): Tool { - // 'shared' is reserved for shared context — forbid it as a tool name - if (config.name === SHARED_CONTEXT_KEY) { - throw new Error( - `Tool name "${SHARED_CONTEXT_KEY}" is reserved for shared context. Choose a different name.`, - ); - } - - // Check for manual tool first (execute === false) - if (config.execute === false) { - const fn: ManualTool<$ZodObject<$ZodShape>>["function"] = { - name: config.name, - inputSchema: config.inputSchema, - }; - - if (config.description !== undefined) { - fn.description = config.description; - } - - if (config.contextSchema !== undefined) { - fn.contextSchema = config.contextSchema; - } - - if (config.nextTurnParams !== undefined) { - fn.nextTurnParams = config.nextTurnParams; - } - - if (config.requireApproval !== undefined) { - fn.requireApproval = config.requireApproval; - } - - return { - type: ToolType.Function, - function: fn, - }; - } - - // Check for generator tool (has eventSchema) - if ('eventSchema' in config && config.eventSchema !== undefined) { - const fn = { - name: config.name, - inputSchema: config.inputSchema, - eventSchema: config.eventSchema, - outputSchema: config.outputSchema, - execute: config.execute, - } as ToolWithGenerator<$ZodObject<$ZodShape>, $ZodType, $ZodType>["function"]; - - if (config.description !== undefined) { - fn.description = config.description; - } - - if (config.contextSchema !== undefined) { - fn.contextSchema = config.contextSchema; - } - - if (config.nextTurnParams !== undefined) { - fn.nextTurnParams = config.nextTurnParams; - } - - if (config.requireApproval !== undefined) { - fn.requireApproval = config.requireApproval; - } - - return { - type: ToolType.Function, - function: fn, - }; - } - - // Regular tool (has execute function, no eventSchema) - const functionObj = { - name: config.name, - inputSchema: config.inputSchema, - execute: config.execute, - ...(config.description !== undefined && { description: config.description }), - ...(config.outputSchema !== undefined && { outputSchema: config.outputSchema }), - ...(config.contextSchema !== undefined && { contextSchema: config.contextSchema }), - ...(config.nextTurnParams !== undefined && { nextTurnParams: config.nextTurnParams }), - ...(config.requireApproval !== undefined && { requireApproval: config.requireApproval }), - }; - - return { - type: ToolType.Function, - function: functionObj, - }; -} - -//#endregion diff --git a/src/lib/turn-context.ts b/src/lib/turn-context.ts deleted file mode 100644 index c4a45370e..000000000 --- a/src/lib/turn-context.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { EasyInputMessage } from '../models/easyinputmessage.js'; -import { EasyInputMessageRoleUser } from '../models/easyinputmessage.js'; -import type { FunctionCallItem } from '../models/functioncallitem.js'; -import type { InputsUnion, InputsUnion1 } from '../models/inputsunion.js'; -import type { ResponsesRequest } from '../models/responsesrequest.js'; -import type { TurnContext } from './tool-types.js'; - -/** - * Options for building a turn context - */ -export interface BuildTurnContextOptions { - /** Number of turns so far (1-indexed for tool execution, 0 for initial request) */ - numberOfTurns: number; - /** The specific tool call being executed (optional for initial/async resolution contexts) */ - toolCall?: FunctionCallItem; - /** The full request being sent to the API (optional for initial/async resolution contexts) */ - turnRequest?: ResponsesRequest; -} - -/** - * Build a turn context for tool execution or async parameter resolution - * - * @param options - Options for building the context - * @returns A TurnContext object - * - * @example - * ```typescript - * // For tool execution with full context - * const context = buildTurnContext({ - * numberOfTurns: 1, - * toolCall: rawToolCall, - * turnRequest: currentRequest, - * }); - * - * // For async parameter resolution (partial context) - * const context = buildTurnContext({ - * numberOfTurns: 0, - * }); - * ``` - */ -export function buildTurnContext( - options: BuildTurnContextOptions -): TurnContext { - const context: TurnContext = { - numberOfTurns: options.numberOfTurns, - }; - - if (options.toolCall !== undefined) { - context.toolCall = options.toolCall; - } - - if (options.turnRequest !== undefined) { - context.turnRequest = options.turnRequest; - } - - return context; -} - -/** - * Normalize OpenResponsesInput to an array format - * Converts string input to array with single user message - * - * @param input - The input to normalize - * @returns Array format of the input - * - * @example - * ```typescript - * const arrayInput = normalizeInputToArray("Hello!"); - * // Returns: [{ role: "user", content: "Hello!" }] - * ``` - */ -export function normalizeInputToArray( - input: InputsUnion -): Array { - if (typeof input === 'string') { - // Construct object with all required fields - type is optional - const message: EasyInputMessage = { - role: EasyInputMessageRoleUser.User, - content: input, - }; - return [message]; - } - return input; -} diff --git a/src/sdk/sdk.ts b/src/sdk/sdk.ts index 8faacf278..93ade4300 100644 --- a/src/sdk/sdk.ts +++ b/src/sdk/sdk.ts @@ -43,16 +43,6 @@ import { Vault } from "./vault.js"; import { VideoGeneration } from "./videogeneration.js"; import { Workspaces } from "./workspaces.js"; // #region imports -import type { $ZodObject, $ZodShape, infer as zodInfer } from "zod/v4/core"; -import { - callModel as callModelFunc, - type CallModelInput, -} from "../funcs/call-model.js"; -import type { ModelResult } from "../lib/model-result.js"; -import type { RequestOptions } from "../lib/sdks.js"; -import { type Tool, ToolType } from "../lib/tool-types.js"; - -export { ToolType }; // #endregion imports export class OpenRouter extends ClientSDK { @@ -247,18 +237,5 @@ export class OpenRouter extends ClientSDK { } // #region sdk-class-body - callModel< - TTools extends readonly Tool[], - TSharedSchema extends $ZodObject<$ZodShape> | undefined = undefined, - TShared extends Record = TSharedSchema extends - $ZodObject<$ZodShape> ? zodInfer : Record, - >( - request: CallModelInput & { - sharedContextSchema?: TSharedSchema; - }, - options?: RequestOptions, - ): ModelResult { - return callModelFunc(this, request, options); - } // #endregion sdk-class-body } diff --git a/tests/unit/build-tool-call-stream.test.ts b/tests/unit/build-tool-call-stream.test.ts deleted file mode 100644 index b540ff6cb..000000000 --- a/tests/unit/build-tool-call-stream.test.ts +++ /dev/null @@ -1,289 +0,0 @@ -import type { - StreamEvents, - StreamEventsResponseCompleted, - StreamEventsResponseOutputItemAdded, -} from '../../src/models/streamevents.js'; -import type { FunctionCallArgsDeltaEvent } from '../../src/models/functioncallargsdeltaevent.js'; -import type { FunctionCallArgsDoneEvent } from '../../src/models/functioncallargsdoneevent.js'; - -import { describe, expect, it } from 'vitest'; -import { ReusableReadableStream } from '../../src/lib/reusable-stream.js'; -import { buildToolCallStream } from '../../src/lib/stream-transformers.js'; - -// ============================================================================ -// Synthetic event factories -// ============================================================================ - -function createImmediateStream( - events: StreamEvents[], -): ReusableReadableStream { - const readable = new ReadableStream({ - start(controller) { - for (const event of events) { - controller.enqueue(event); - } - controller.close(); - }, - }); - return new ReusableReadableStream(readable); -} - -function outputItemAddedFunctionCallEvent( - callId: string, - name: string, - itemId?: string, -): StreamEventsResponseOutputItemAdded { - return { - type: 'response.output_item.added', - outputIndex: 0, - item: { - type: 'function_call', - id: itemId ?? callId, - callId, - name, - arguments: '', - status: 'in_progress', - }, - sequenceNumber: 0, - }; -} - -/** - * Creates a function_call output_item.added event with the id property omitted. - * In real API responses, JSON.parse() never produces `id: undefined` — the - * property is simply absent when the API doesn't include it. This exercises - * the `item.id ?? item.callId` fallback in production code realistically. - */ -function outputItemAddedFunctionCallEventNoId( - callId: string, - name: string, -): StreamEventsResponseOutputItemAdded { - return { - type: 'response.output_item.added', - outputIndex: 0, - item: { - type: 'function_call', - callId, - name, - arguments: '', - status: 'in_progress', - }, - sequenceNumber: 0, - }; -} - -function functionCallArgsDeltaEvent( - delta: string, - itemId: string, -): FunctionCallArgsDeltaEvent { - return { - type: 'response.function_call_arguments.delta', - itemId, - outputIndex: 0, - delta, - sequenceNumber: 0, - }; -} - -function functionCallArgsDoneEvent( - args: string, - name: string, - itemId: string, -): FunctionCallArgsDoneEvent { - return { - type: 'response.function_call_arguments.done', - itemId, - outputIndex: 0, - name, - arguments: args, - sequenceNumber: 0, - }; -} - -function responseCompletedEvent(): StreamEventsResponseCompleted { - return { - type: 'response.completed', - response: { - id: 'resp_1', - object: 'response', - createdAt: 0, - model: 'test-model', - status: 'completed', - completedAt: 0, - output: [], - error: null, - incompleteDetails: null, - temperature: null, - topP: null, - presencePenalty: null, - frequencyPenalty: null, - metadata: null, - tools: [], - toolChoice: 'auto', - parallelToolCalls: false, - }, - sequenceNumber: 0, - }; -} - -// ============================================================================ -// Tests -// ============================================================================ - -describe('buildToolCallStream — callId/itemId key mismatch fix', () => { - it('should use item.id (not callId) as map key so deltas are found when item.id differs from callId', async () => { - // This is the core regression test for the fix. - // item.id='item_1' differs from callId='call_abc'. - // Delta events use itemId='item_1' for lookup. - // Before the fix, the map key was callId, so deltas were silently dropped. - const stream = createImmediateStream([ - outputItemAddedFunctionCallEvent('call_abc', 'get_weather', 'item_1'), - functionCallArgsDeltaEvent('{"location":', 'item_1'), - functionCallArgsDeltaEvent('"NYC"}', 'item_1'), - functionCallArgsDoneEvent('{"location":"NYC"}', 'get_weather', 'item_1'), - responseCompletedEvent(), - ]); - - const toolCalls: Array<{ - id: string; - name: string; - arguments: unknown; - }> = []; - for await (const tc of buildToolCallStream(stream)) { - toolCalls.push({ - id: tc.id, - name: tc.name, - arguments: tc.arguments, - }); - } - - // Tool call should be yielded (not silently dropped) - expect(toolCalls).toHaveLength(1); - expect(toolCalls[0]!.name).toBe('get_weather'); - expect(toolCalls[0]!.arguments).toEqual({ location: 'NYC' }); - }); - - it('should yield callId (not item.id) as the ParsedToolCall.id for API compatibility', async () => { - // The yielded id must be callId for downstream consumers - // (tool-orchestrator.ts, model-result.ts) that submit tool results back to the API. - // Before the second fix, the stored id was itemKey (item.id), not callId. - const stream = createImmediateStream([ - outputItemAddedFunctionCallEvent('call_abc', 'get_weather', 'item_1'), - functionCallArgsDeltaEvent('{"location":"NYC"}', 'item_1'), - functionCallArgsDoneEvent('{"location":"NYC"}', 'get_weather', 'item_1'), - responseCompletedEvent(), - ]); - - const toolCalls: Array<{ - id: string; - name: string; - }> = []; - for await (const tc of buildToolCallStream(stream)) { - toolCalls.push({ - id: tc.id, - name: tc.name, - }); - } - - expect(toolCalls).toHaveLength(1); - // id must be the callId, NOT item.id - expect(toolCalls[0]!.id).toBe('call_abc'); - expect(toolCalls[0]!.id).not.toBe('item_1'); - }); - - it('should handle multiple tool calls with different callId/itemId mappings', async () => { - const stream = createImmediateStream([ - outputItemAddedFunctionCallEvent('call_weather', 'get_weather', 'item_100'), - outputItemAddedFunctionCallEvent('call_time', 'get_time', 'item_200'), - functionCallArgsDeltaEvent('{"location":"NYC"}', 'item_100'), - functionCallArgsDoneEvent('{"location":"NYC"}', 'get_weather', 'item_100'), - functionCallArgsDeltaEvent('{"tz":"EST"}', 'item_200'), - functionCallArgsDoneEvent('{"tz":"EST"}', 'get_time', 'item_200'), - responseCompletedEvent(), - ]); - - const toolCalls: Array<{ - id: string; - name: string; - arguments: unknown; - }> = []; - for await (const tc of buildToolCallStream(stream)) { - toolCalls.push({ - id: tc.id, - name: tc.name, - arguments: tc.arguments, - }); - } - - expect(toolCalls).toHaveLength(2); - - // First tool call: id should be callId, not itemId - expect(toolCalls[0]!.id).toBe('call_weather'); - expect(toolCalls[0]!.name).toBe('get_weather'); - expect(toolCalls[0]!.arguments).toEqual({ location: 'NYC' }); - - // Second tool call: id should be callId, not itemId - expect(toolCalls[1]!.id).toBe('call_time'); - expect(toolCalls[1]!.name).toBe('get_time'); - expect(toolCalls[1]!.arguments).toEqual({ tz: 'EST' }); - }); - - it('should still work when item.id equals callId (backwards compatibility)', async () => { - // When item.id === callId, both old and new code work. - // This verifies no regression for the common case. - const stream = createImmediateStream([ - outputItemAddedFunctionCallEvent('call_same', 'search', 'call_same'), - functionCallArgsDeltaEvent('{"q":"test"}', 'call_same'), - functionCallArgsDoneEvent('{"q":"test"}', 'search', 'call_same'), - responseCompletedEvent(), - ]); - - const toolCalls: Array<{ - id: string; - name: string; - arguments: unknown; - }> = []; - for await (const tc of buildToolCallStream(stream)) { - toolCalls.push({ - id: tc.id, - name: tc.name, - arguments: tc.arguments, - }); - } - - expect(toolCalls).toHaveLength(1); - expect(toolCalls[0]!.id).toBe('call_same'); - expect(toolCalls[0]!.name).toBe('search'); - expect(toolCalls[0]!.arguments).toEqual({ q: 'test' }); - }); - - it('should fall back to callId as map key when item.id is undefined', async () => { - // When item.id is literally undefined, the fallback `item.id ?? item.callId` uses callId. - // Uses outputItemAddedFunctionCallEventNoId which sets id: undefined explicitly. - // Delta events use callId as itemId since that's what the fallback resolves to. - const stream = createImmediateStream([ - outputItemAddedFunctionCallEventNoId('call_fallback', 'lookup'), - functionCallArgsDeltaEvent('{"key":"val"}', 'call_fallback'), - functionCallArgsDoneEvent('{"key":"val"}', 'lookup', 'call_fallback'), - responseCompletedEvent(), - ]); - - const toolCalls: Array<{ - id: string; - name: string; - arguments: unknown; - }> = []; - for await (const tc of buildToolCallStream(stream)) { - toolCalls.push({ - id: tc.id, - name: tc.name, - arguments: tc.arguments, - }); - } - - expect(toolCalls).toHaveLength(1); - expect(toolCalls[0]!.id).toBe('call_fallback'); - expect(toolCalls[0]!.name).toBe('lookup'); - expect(toolCalls[0]!.arguments).toEqual({ key: 'val' }); - }); -}); diff --git a/tests/unit/conversation-state.test.ts b/tests/unit/conversation-state.test.ts deleted file mode 100644 index 9544ce01f..000000000 --- a/tests/unit/conversation-state.test.ts +++ /dev/null @@ -1,407 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { z } from 'zod/v4'; -import { - createInitialState, - updateState, - generateConversationId, - createUnsentResult, - createRejectedResult, - partitionToolCalls, - toolRequiresApproval, - appendToMessages, -} from '../../src/lib/conversation-state.js'; -import { tool } from '../../src/lib/tool.js'; -import { - toolHasApprovalConfigured, - hasApprovalRequiredTools, -} from '../../src/lib/tool-types.js'; - -describe('Conversation State Utilities', () => { - describe('generateConversationId', () => { - it('should generate unique IDs with conv_ prefix', () => { - const id1 = generateConversationId(); - const id2 = generateConversationId(); - - expect(id1).toMatch(/^conv_/); - expect(id2).toMatch(/^conv_/); - expect(id1).not.toBe(id2); - }); - }); - - describe('createInitialState', () => { - it('should create state with default values', () => { - const state = createInitialState(); - - expect(state.id).toMatch(/^conv_/); - expect(state.messages).toEqual([]); - expect(state.status).toBe('in_progress'); - expect(state.createdAt).toBeDefined(); - expect(state.updatedAt).toBeDefined(); - }); - - it('should use custom ID when provided', () => { - const state = createInitialState('custom-id'); - expect(state.id).toBe('custom-id'); - }); - }); - - describe('updateState', () => { - it('should update status and timestamp', () => { - const initial = createInitialState(); - const originalUpdatedAt = initial.updatedAt; - - const updated = updateState(initial, { status: 'complete' }); - - expect(updated.status).toBe('complete'); - expect(updated.updatedAt).toBeGreaterThanOrEqual(originalUpdatedAt); - expect(updated.id).toBe(initial.id); // ID unchanged - }); - - it('should preserve fields not being updated', () => { - const initial = createInitialState('test-id'); - const updated = updateState(initial, { status: 'awaiting_approval' }); - - expect(updated.id).toBe('test-id'); - expect(updated.messages).toEqual([]); - expect(updated.createdAt).toBe(initial.createdAt); - }); - }); - - describe('createUnsentResult', () => { - it('should create valid unsent result', () => { - const result = createUnsentResult('call-1', 'test_tool', { data: 'test' }); - - expect(result.callId).toBe('call-1'); - expect(result.name).toBe('test_tool'); - expect(result.output).toEqual({ data: 'test' }); - expect(result.error).toBeUndefined(); - }); - - it('should handle null output', () => { - const result = createUnsentResult('call-1', 'test_tool', null); - - expect(result.callId).toBe('call-1'); - expect(result.output).toBeNull(); - }); - }); - - describe('createRejectedResult', () => { - it('should create rejected result with default message', () => { - const result = createRejectedResult('call-1', 'test_tool'); - - expect(result.callId).toBe('call-1'); - expect(result.output).toBeNull(); - expect(result.error).toBe('Tool call rejected by user'); - }); - - it('should use custom rejection reason', () => { - const result = createRejectedResult('call-1', 'test_tool', 'Not allowed'); - expect(result.error).toBe('Not allowed'); - }); - }); - - describe('toolRequiresApproval', () => { - const toolWithApproval = tool({ - name: 'dangerous_action', - inputSchema: z.object({}), - requireApproval: true, - execute: async () => ({}), - }); - - const toolWithoutApproval = tool({ - name: 'safe_action', - inputSchema: z.object({}), - execute: async () => ({}), - }); - - const context = { numberOfTurns: 1 }; - - it('should return true for tools with requireApproval', async () => { - const toolCall = { id: '1', name: 'dangerous_action', arguments: {} }; - expect(await toolRequiresApproval(toolCall, [toolWithApproval, toolWithoutApproval], context)).toBe(true); - }); - - it('should return false for tools without requireApproval', async () => { - const toolCall = { id: '1', name: 'safe_action', arguments: {} }; - expect(await toolRequiresApproval(toolCall, [toolWithApproval, toolWithoutApproval], context)).toBe(false); - }); - - it('should return false for unknown tools', async () => { - const toolCall = { id: '1', name: 'unknown_tool', arguments: {} }; - expect(await toolRequiresApproval(toolCall, [toolWithApproval, toolWithoutApproval], context)).toBe(false); - }); - - it('should use call-level check when provided', async () => { - const toolCall = { id: '1', name: 'safe_action', arguments: {} }; - const alwaysRequire = () => true; - - expect(await toolRequiresApproval(toolCall, [toolWithoutApproval], context, alwaysRequire)).toBe(true); - }); - - it('should call-level check can override tool-level approval', async () => { - const toolCall = { id: '1', name: 'dangerous_action', arguments: {} }; - const neverRequire = () => false; - - // Call-level check takes precedence - expect(await toolRequiresApproval(toolCall, [toolWithApproval], context, neverRequire)).toBe(false); - }); - - it('should support async call-level check', async () => { - const toolCall = { id: '1', name: 'safe_action', arguments: {} }; - const asyncCheck = async (_tc: unknown, ctx: { numberOfTurns: number }) => { - // Simulate async operation - await Promise.resolve(); - return ctx.numberOfTurns > 0; - }; - - expect(await toolRequiresApproval(toolCall, [toolWithoutApproval], context, asyncCheck)).toBe(true); - expect(await toolRequiresApproval(toolCall, [toolWithoutApproval], { numberOfTurns: 0 }, asyncCheck)).toBe(false); - }); - - it('should support function-based tool-level requireApproval', async () => { - // Tool with function-based approval that checks params - const toolWithFunctionApproval = tool({ - name: 'conditional_action', - inputSchema: z.object({ dangerous: z.boolean() }), - requireApproval: (params) => params.dangerous === true, - execute: async () => ({}), - }); - - // Safe action - should not require approval - const safeCall = { id: '1', name: 'conditional_action', arguments: { dangerous: false } }; - expect(await toolRequiresApproval(safeCall, [toolWithFunctionApproval], context)).toBe(false); - - // Dangerous action - should require approval - const dangerousCall = { id: '2', name: 'conditional_action', arguments: { dangerous: true } }; - expect(await toolRequiresApproval(dangerousCall, [toolWithFunctionApproval], context)).toBe(true); - }); - - it('should support async function-based tool-level requireApproval', async () => { - // Tool with async function-based approval - const toolWithAsyncApproval = tool({ - name: 'async_conditional', - inputSchema: z.object({ value: z.number() }), - requireApproval: async (params, ctx) => { - // Simulate async operation - await Promise.resolve(); - // Require approval if value > 100 OR after first turn - return params.value > 100 || ctx.numberOfTurns > 1; - }, - execute: async () => ({}), - }); - - // Low value, first turn - no approval needed - const lowValueCall = { id: '1', name: 'async_conditional', arguments: { value: 50 } }; - expect(await toolRequiresApproval(lowValueCall, [toolWithAsyncApproval], { numberOfTurns: 1 })).toBe(false); - - // High value - approval needed - const highValueCall = { id: '2', name: 'async_conditional', arguments: { value: 150 } }; - expect(await toolRequiresApproval(highValueCall, [toolWithAsyncApproval], { numberOfTurns: 1 })).toBe(true); - - // Low value but second turn - approval needed - expect(await toolRequiresApproval(lowValueCall, [toolWithAsyncApproval], { numberOfTurns: 2 })).toBe(true); - }); - - it('should pass context to function-based tool-level approval', async () => { - const receivedContexts: Array<{ numberOfTurns: number }> = []; - - const toolWithContextCheck = tool({ - name: 'context_checker', - inputSchema: z.object({}), - requireApproval: (_params, ctx) => { - receivedContexts.push(ctx); - return ctx.numberOfTurns > 2; - }, - execute: async () => ({}), - }); - - const toolCall = { id: '1', name: 'context_checker', arguments: {} }; - - await toolRequiresApproval(toolCall, [toolWithContextCheck], { numberOfTurns: 1 }); - await toolRequiresApproval(toolCall, [toolWithContextCheck], { numberOfTurns: 3 }); - - expect(receivedContexts).toEqual([ - { numberOfTurns: 1 }, - { numberOfTurns: 3 }, - ]); - }); - }); - - describe('partitionToolCalls', () => { - const approvalTool = tool({ - name: 'needs_approval', - inputSchema: z.object({}), - requireApproval: true, - execute: async () => ({}), - }); - - const autoTool = tool({ - name: 'auto_execute', - inputSchema: z.object({}), - execute: async () => ({}), - }); - - const context = { numberOfTurns: 1 }; - - it('should partition tool calls correctly', async () => { - const toolCalls = [ - { id: '1', name: 'needs_approval', arguments: {} }, - { id: '2', name: 'auto_execute', arguments: {} }, - ]; - - const { requiresApproval, autoExecute } = await partitionToolCalls( - toolCalls, - [approvalTool, autoTool], - context - ); - - expect(requiresApproval).toHaveLength(1); - expect(requiresApproval[0]?.name).toBe('needs_approval'); - expect(autoExecute).toHaveLength(1); - expect(autoExecute[0]?.name).toBe('auto_execute'); - }); - - it('should handle all tools requiring approval', async () => { - const toolCalls = [ - { id: '1', name: 'needs_approval', arguments: {} }, - ]; - - const { requiresApproval, autoExecute } = await partitionToolCalls( - toolCalls, - [approvalTool, autoTool], - context - ); - - expect(requiresApproval).toHaveLength(1); - expect(autoExecute).toHaveLength(0); - }); - - it('should handle all tools auto-executing', async () => { - const toolCalls = [ - { id: '1', name: 'auto_execute', arguments: {} }, - ]; - - const { requiresApproval, autoExecute } = await partitionToolCalls( - toolCalls, - [approvalTool, autoTool], - context - ); - - expect(requiresApproval).toHaveLength(0); - expect(autoExecute).toHaveLength(1); - }); - - it('should handle empty tool calls', async () => { - const { requiresApproval, autoExecute } = await partitionToolCalls( - [], - [approvalTool, autoTool], - context - ); - - expect(requiresApproval).toHaveLength(0); - expect(autoExecute).toHaveLength(0); - }); - }); - - describe('appendToMessages', () => { - it('should append to empty messages', () => { - const result = appendToMessages([], [ - { role: 'user' as const, content: 'Hello' }, - ]); - - expect(result).toHaveLength(1); - }); - - it('should append to existing messages', () => { - const existing = [ - { role: 'user' as const, content: 'Hello' }, - ]; - const result = appendToMessages(existing, [ - { role: 'assistant' as const, content: 'Hi there!' }, - ]); - - expect(result).toHaveLength(2); - }); - - it('should handle string input', () => { - const result = appendToMessages('What is 2+2?', [ - { role: 'assistant' as const, content: '4' }, - ]); - - expect(result).toHaveLength(2); - }); - }); - - describe('Approval Detection Type Guards', () => { - const toolWithBooleanApproval = tool({ - name: 'needs_approval', - inputSchema: z.object({}), - requireApproval: true, - execute: async () => ({}), - }); - - const toolWithFunctionApproval = tool({ - name: 'conditional_approval', - inputSchema: z.object({ dangerous: z.boolean() }), - requireApproval: (params) => params.dangerous, - execute: async () => ({}), - }); - - const toolWithoutApproval = tool({ - name: 'safe_tool', - inputSchema: z.object({}), - execute: async () => ({}), - }); - - const toolWithFalseApproval = tool({ - name: 'explicitly_safe', - inputSchema: z.object({}), - requireApproval: false, - execute: async () => ({}), - }); - - describe('toolHasApprovalConfigured', () => { - it('should return true for tools with requireApproval: true', () => { - expect(toolHasApprovalConfigured(toolWithBooleanApproval)).toBe(true); - }); - - it('should return true for tools with requireApproval function', () => { - expect(toolHasApprovalConfigured(toolWithFunctionApproval)).toBe(true); - }); - - it('should return false for tools without requireApproval', () => { - expect(toolHasApprovalConfigured(toolWithoutApproval)).toBe(false); - }); - - it('should return false for tools with requireApproval: false', () => { - expect(toolHasApprovalConfigured(toolWithFalseApproval)).toBe(false); - }); - }); - - describe('hasApprovalRequiredTools', () => { - it('should return true if any tool has approval configured', () => { - expect(hasApprovalRequiredTools([ - toolWithoutApproval, - toolWithBooleanApproval, - ])).toBe(true); - }); - - it('should return true for function-based approval', () => { - expect(hasApprovalRequiredTools([ - toolWithFunctionApproval, - ])).toBe(true); - }); - - it('should return false if no tools have approval configured', () => { - expect(hasApprovalRequiredTools([ - toolWithoutApproval, - toolWithFalseApproval, - ])).toBe(false); - }); - - it('should return false for empty array', () => { - expect(hasApprovalRequiredTools([])).toBe(false); - }); - }); - }); -}); diff --git a/tests/unit/create-tool.test.ts b/tests/unit/create-tool.test.ts deleted file mode 100644 index bb26c0cde..000000000 --- a/tests/unit/create-tool.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { z } from 'zod/v4'; -import { tool } from '../../src/lib/tool.js'; -import { ToolType } from '../../src/lib/tool-types.js'; - -describe('tool', () => { - describe('tool - regular tools', () => { - it('should create a tool with the correct structure', () => { - const testTool = tool({ - name: 'test_tool', - description: 'A test tool', - inputSchema: z.object({ - input: z.string(), - }), - execute: async (params) => { - return { result: params.input }; - }, - }); - - expect(testTool.type).toBe(ToolType.Function); - expect(testTool.function.name).toBe('test_tool'); - expect(testTool.function.description).toBe('A test tool'); - expect(testTool.function.inputSchema).toBeDefined(); - }); - - it('should infer execute params from inputSchema', async () => { - const weatherTool = tool({ - name: 'weather', - inputSchema: z.object({ - location: z.string(), - units: z.enum(['celsius', 'fahrenheit']).optional(), - }), - execute: async (params) => { - // params should be typed as { location: string; units?: 'celsius' | 'fahrenheit' } - const location: string = params.location; - const units: 'celsius' | 'fahrenheit' | undefined = params.units; - return { location, units }; - }, - }); - - const result = await weatherTool.function.execute({ location: 'NYC', units: 'fahrenheit' }); - expect(result.location).toBe('NYC'); - expect(result.units).toBe('fahrenheit'); - }); - - it('should enforce output schema return type', async () => { - const tempTool = tool({ - name: 'get_temperature', - inputSchema: z.object({ - location: z.string(), - }), - outputSchema: z.object({ - temperature: z.number(), - description: z.string(), - }), - execute: async (_params) => { - // Return type should be enforced as { temperature: number; description: string } - return { - temperature: 72, - description: 'Sunny', - }; - }, - }); - - const result = await tempTool.function.execute({ location: 'NYC' }); - expect(result.temperature).toBe(72); - expect(result.description).toBe('Sunny'); - }); - - it('should support synchronous execute functions', () => { - const syncTool = tool({ - name: 'sync_tool', - inputSchema: z.object({ - a: z.number(), - b: z.number(), - }), - execute: (params) => { - return { sum: params.a + params.b }; - }, - }); - - const result = syncTool.function.execute({ a: 5, b: 3 }); - expect(result).toEqual({ sum: 8 }); - }); - - it('should pass context to execute function', async () => { - let receivedContext: unknown; - - const contextTool = tool({ - name: 'context_tool', - inputSchema: z.object({}), - execute: async (_params, context) => { - receivedContext = context; - return {}; - }, - }); - - const mockContext = { - numberOfTurns: 3, - messageHistory: [], - model: 'test-model', - }; - - await contextTool.function.execute({}, mockContext); - expect(receivedContext).toEqual(mockContext); - }); - }); - - describe('tool - generator tools (with eventSchema)', () => { - it('should create a generator tool with the correct structure', () => { - const streamingTool = tool({ - name: 'streaming_tool', - description: 'A streaming tool', - inputSchema: z.object({ - query: z.string(), - }), - eventSchema: z.object({ - progress: z.number(), - }), - outputSchema: z.object({ - result: z.string(), - }), - execute: async function* (_params) { - yield { progress: 50 }; - yield { result: 'done' }; - }, - }); - - expect(streamingTool.type).toBe(ToolType.Function); - expect(streamingTool.function.name).toBe('streaming_tool'); - expect(streamingTool.function.eventSchema).toBeDefined(); - expect(streamingTool.function.outputSchema).toBeDefined(); - }); - - it('should yield properly typed events and output', async () => { - const progressTool = tool({ - name: 'progress_tool', - inputSchema: z.object({ - data: z.string(), - }), - eventSchema: z.object({ - status: z.string(), - progress: z.number(), - }), - outputSchema: z.object({ - completed: z.boolean(), - result: z.string(), - }), - execute: async function* (params) { - yield { status: 'started', progress: 0 }; - yield { status: 'processing', progress: 50 }; - yield { completed: true, result: `Processed: ${params.data}` }; - }, - }); - - const results: unknown[] = []; - const mockContext = { numberOfTurns: 1, messageHistory: [] }; - for await (const event of progressTool.function.execute({ data: 'test' }, mockContext)) { - results.push(event); - } - - expect(results).toHaveLength(3); - expect(results[0]).toEqual({ status: 'started', progress: 0 }); - expect(results[1]).toEqual({ status: 'processing', progress: 50 }); - expect(results[2]).toEqual({ completed: true, result: 'Processed: test' }); - }); - }); - - describe('tool - manual tools (execute: false)', () => { - it('should create a manual tool without execute function', () => { - const manualTool = tool({ - name: 'manual_tool', - description: 'A manual tool', - inputSchema: z.object({ - query: z.string(), - }), - execute: false, - }); - - expect(manualTool.type).toBe(ToolType.Function); - expect(manualTool.function.name).toBe('manual_tool'); - expect(manualTool.function).not.toHaveProperty('execute'); - }); - }); -}); diff --git a/tests/unit/schema-sanitization.test.ts b/tests/unit/schema-sanitization.test.ts deleted file mode 100644 index 57e680687..000000000 --- a/tests/unit/schema-sanitization.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { sanitizeJsonSchema, convertZodToJsonSchema } from '../../src/lib/tool-executor.js'; -import { z } from 'zod/v4'; -import { assertNoTildeKeys } from '../utils/schema-test-helpers.js'; - -describe('sanitizeJsonSchema', () => { - it('should remove ~standard property from root level', () => { - const input = { - type: 'object', - properties: { name: { type: 'string' } }, - '~standard': { version: 1, vendor: 'zod' }, - }; - - const result = sanitizeJsonSchema(input); - - expect(result).not.toHaveProperty('~standard'); - expect(result).toHaveProperty('type', 'object'); - expect(result).toHaveProperty('properties'); - }); - - it('should remove all ~ prefixed properties from nested objects', () => { - const input = { - type: 'object', - '~metadata': { foo: 'bar' }, - properties: { - user: { - type: 'object', - '~internal': true, - properties: { - name: { type: 'string', '~validator': 'custom' }, - }, - }, - }, - }; - - const result = sanitizeJsonSchema(input); - const typedResult = result as Record; - const properties = typedResult['properties'] as Record; - const user = properties['user'] as Record; - const userProperties = user['properties'] as Record; - const name = userProperties['name'] as Record; - - expect(typedResult).not.toHaveProperty('~metadata'); - expect(user).not.toHaveProperty('~internal'); - expect(name).not.toHaveProperty('~validator'); - expect(name).toHaveProperty('type', 'string'); - }); - - it('should handle arrays correctly', () => { - const input = { - type: 'array', - '~arrayMeta': 'remove-me', - items: [ - { type: 'string', '~itemMeta': 'also-remove' }, - { type: 'number' }, - ], - }; - - const result = sanitizeJsonSchema(input); - const typedResult = result as Record; - const items = typedResult['items'] as Array>; - - expect(typedResult).not.toHaveProperty('~arrayMeta'); - expect(items[0]).not.toHaveProperty('~itemMeta'); - expect(items[0]).toHaveProperty('type', 'string'); - expect(items[1]).toHaveProperty('type', 'number'); - }); - - it('should pass through primitive values unchanged', () => { - expect(sanitizeJsonSchema(null)).toBe(null); - expect(sanitizeJsonSchema(undefined)).toBe(undefined); - expect(sanitizeJsonSchema(42)).toBe(42); - expect(sanitizeJsonSchema('string')).toBe('string'); - expect(sanitizeJsonSchema(true)).toBe(true); - }); - - it('should handle empty objects', () => { - expect(sanitizeJsonSchema({})).toEqual({}); - }); - - it('should handle empty arrays', () => { - expect(sanitizeJsonSchema([])).toEqual([]); - }); - - it('should preserve non-~ prefixed properties', () => { - const input = { - type: 'object', - description: 'A user object', - required: ['name'], - properties: { - name: { type: 'string' }, - }, - }; - - const result = sanitizeJsonSchema(input); - - expect(result).toEqual(input); - }); -}); - -describe('convertZodToJsonSchema', () => { - it('should return sanitized JSON schema without ~ prefixed properties', () => { - const schema = z.object({ - name: z.string().describe("The user's name"), - age: z.number().min(0).describe("The user's age"), - }); - - const jsonSchema = convertZodToJsonSchema(schema); - - // Check that standard schema properties are present - expect(jsonSchema).toHaveProperty('type', 'object'); - expect(jsonSchema).toHaveProperty('properties'); - - // Check that no ~ prefixed keys exist anywhere in the schema - assertNoTildeKeys(jsonSchema); - }); - - it('should preserve all valid JSON Schema properties', () => { - const schema = z.object({ - location: z.string().describe('City and country'), - }); - - const jsonSchema = convertZodToJsonSchema(schema); - const properties = jsonSchema['properties'] as Record; - const location = properties['location'] as Record; - - expect(location).toHaveProperty('description', 'City and country'); - expect(location).toHaveProperty('type', 'string'); - }); -}); diff --git a/tests/unit/shared-context.test.ts b/tests/unit/shared-context.test.ts deleted file mode 100644 index 674c8d14d..000000000 --- a/tests/unit/shared-context.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as z4 from 'zod/v4'; -import { - ToolContextStore, - buildToolExecuteContext, -} from '../../src/lib/tool-context.js'; -import { SHARED_CONTEXT_KEY } from '../../src/lib/tool-types.js'; -import type { TurnContext } from '../../src/lib/tool-types.js'; - -//#region Shared context via buildToolExecuteContext - -describe('shared context', () => { - const turnContext: TurnContext = { numberOfTurns: 1 }; - - it('should return empty frozen object when store has no shared context', () => { - const store = new ToolContextStore({ my_tool: { key: 'value' } }); - - const ctx = buildToolExecuteContext<'my_tool', Record>( - turnContext, store, 'my_tool', undefined, - ); - - expect(ctx.shared).toEqual({}); - expect(Object.isFrozen(ctx.shared)).toBe(true); - }); - - it('should return empty frozen object when store is undefined', () => { - const ctx = buildToolExecuteContext<'my_tool', Record>( - turnContext, undefined, 'my_tool', undefined, - ); - - expect(ctx.shared).toEqual({}); - expect(Object.isFrozen(ctx.shared)).toBe(true); - }); - - it('should read shared context seeded in the store', () => { - const store = new ToolContextStore({ - [SHARED_CONTEXT_KEY]: { sessionId: 'abc-123', userId: 'user-1' }, - }); - - const ctx = buildToolExecuteContext<'my_tool', Record>( - turnContext, store, 'my_tool', undefined, - ); - - expect(ctx.shared).toEqual({ sessionId: 'abc-123', userId: 'user-1' }); - }); - - it('should write shared context via setSharedContext with schema', () => { - const store = new ToolContextStore({}); - const sharedSchema = z4.object({ key: z4.string().optional() }); - - const ctx = buildToolExecuteContext<'my_tool', Record, { key?: string }>( - turnContext, store, 'my_tool', undefined, sharedSchema, - ); - - ctx.setSharedContext({ key: 'value' }); - - expect(ctx.shared).toEqual({ key: 'value' }); - expect(store.getToolContext(SHARED_CONTEXT_KEY)).toEqual({ key: 'value' }); - }); - - it('should merge partial values into shared context', () => { - const store = new ToolContextStore({ - [SHARED_CONTEXT_KEY]: { existing: 'data' }, - }); - const sharedSchema = z4.object({ existing: z4.string().optional(), newKey: z4.string().optional() }); - - const ctx = buildToolExecuteContext<'my_tool', Record, { existing?: string; newKey?: string }>( - turnContext, store, 'my_tool', undefined, sharedSchema, - ); - - ctx.setSharedContext({ newKey: 'newValue' }); - - expect(ctx.shared).toEqual({ existing: 'data', newKey: 'newValue' }); - }); - - it('should be readable across different tools via the same store', () => { - const store = new ToolContextStore({ - tool_a: { a: 1 }, - tool_b: { b: 2 }, - }); - const schemaA = z4.object({ a: z4.number() }); - const schemaB = z4.object({ b: z4.number() }); - const sharedSchema = z4.object({ fromA: z4.string().optional(), fromB: z4.string().optional() }); - - const ctxA = buildToolExecuteContext<'tool_a', { a: number }, { fromA?: string; fromB?: string }>( - turnContext, store, 'tool_a', schemaA, sharedSchema, - ); - const ctxB = buildToolExecuteContext<'tool_b', { b: number }, { fromA?: string; fromB?: string }>( - turnContext, store, 'tool_b', schemaB, sharedSchema, - ); - - // Tool A writes shared context - ctxA.setSharedContext({ fromA: 'hello' }); - - // Tool B can read it - expect(ctxB.shared).toEqual({ fromA: 'hello' }); - - // Tool B adds more - ctxB.setSharedContext({ fromB: 'world' }); - - // Both tools see merged result - expect(ctxA.shared).toEqual({ fromA: 'hello', fromB: 'world' }); - expect(ctxB.shared).toEqual({ fromA: 'hello', fromB: 'world' }); - }); - - it('should be a no-op for setSharedContext when store is undefined', () => { - const ctx = buildToolExecuteContext<'my_tool', Record>( - turnContext, undefined, 'my_tool', undefined, - ); - - // Should not throw - ctx.setSharedContext({ key: 'value' } as Record); - - // Still empty - expect(ctx.shared).toEqual({}); - }); - - it('should return frozen snapshots from shared getter', () => { - const store = new ToolContextStore({ - [SHARED_CONTEXT_KEY]: { key: 'value' }, - }); - - const ctx = buildToolExecuteContext<'my_tool', Record>( - turnContext, store, 'my_tool', undefined, - ); - - const snapshot = ctx.shared; - expect(Object.isFrozen(snapshot)).toBe(true); - }); - - it('should reflect setSharedContext changes immediately via live getter', () => { - const store = new ToolContextStore({}); - const sharedSchema = z4.object({ counter: z4.number().optional() }); - - const ctx = buildToolExecuteContext<'my_tool', Record, { counter?: number }>( - turnContext, store, 'my_tool', undefined, sharedSchema, - ); - - expect(ctx.shared).toEqual({}); - - ctx.setSharedContext({ counter: 1 }); - expect(ctx.shared).toEqual({ counter: 1 }); - - ctx.setSharedContext({ counter: 2 }); - expect(ctx.shared).toEqual({ counter: 2 }); - }); -}); - -//#endregion - -//#region sharedContext seeds the store - -describe('shared context seeds the store via SHARED_CONTEXT_KEY', () => { - it('should seed shared context into ToolContextStore', () => { - const store = new ToolContextStore({ - [SHARED_CONTEXT_KEY]: { sessionId: 'sess-1', mode: 'sandbox' }, - }); - - expect(store.getToolContext(SHARED_CONTEXT_KEY)).toEqual({ - sessionId: 'sess-1', - mode: 'sandbox', - }); - }); - - it('should be accessible from tool execute context after seeding', () => { - const turnContext: TurnContext = { numberOfTurns: 0 }; - const store = new ToolContextStore({ - [SHARED_CONTEXT_KEY]: { apiUrl: 'https://example.com' }, - }); - - const ctx = buildToolExecuteContext<'my_tool', Record>( - turnContext, store, 'my_tool', undefined, - ); - - expect(ctx.shared).toEqual({ apiUrl: 'https://example.com' }); - }); - - it('should not interfere with tool-specific context', () => { - const turnContext: TurnContext = { numberOfTurns: 0 }; - const store = new ToolContextStore({ - [SHARED_CONTEXT_KEY]: { global: 'data' }, - my_tool: { toolSpecific: 'value' }, - }); - const schema = z4.object({ toolSpecific: z4.string() }); - - const ctx = buildToolExecuteContext<'my_tool', { toolSpecific: string }>( - turnContext, store, 'my_tool', schema, - ); - - expect(ctx.shared).toEqual({ global: 'data' }); - expect(ctx.local).toEqual({ toolSpecific: 'value' }); - }); -}); - -//#endregion - -//#region SHARED_CONTEXT_KEY constant - -describe('SHARED_CONTEXT_KEY', () => { - it('should be "shared"', () => { - expect(SHARED_CONTEXT_KEY).toBe('shared'); - }); -}); - -//#endregion diff --git a/tests/unit/stop-when-before-approval.test.ts b/tests/unit/stop-when-before-approval.test.ts deleted file mode 100644 index 7e14eedb3..000000000 --- a/tests/unit/stop-when-before-approval.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { z } from 'zod/v4'; -import type { OpenRouterCore } from '../../src/core.js'; -import type { OpenResponsesResult } from '../../src/models/openresponsesresult.js'; -import { tool } from '../../src/lib/tool.js'; -import type { ConversationState, StateAccessor, Tool } from '../../src/lib/tool-types.js'; - -const mockResponsesSend = vi.hoisted(() => vi.fn()); - -vi.mock('../../src/funcs/responsesSend.js', () => ({ - responsesSend: mockResponsesSend, -})); - -// Import ModelResult AFTER vi.mock so the transport mock is wired in. -const { ModelResult } = await import('../../src/lib/model-result.js'); - -function functionCall(callId: string, name: string): OpenResponsesResult['output'][number] { - return { - type: 'function_call', - id: `fc_${callId}`, - callId, - name, - arguments: '{"v":"x"}', - status: 'completed', - } as OpenResponsesResult['output'][number]; -} - -function makeResponse(output: OpenResponsesResult['output']): OpenResponsesResult { - return { - id: 'resp_1', - object: 'response', - createdAt: 0, - completedAt: 0, - model: 'test-model', - status: 'completed', - output, - error: null, - incompleteDetails: null, - temperature: null, - topP: null, - presencePenalty: null, - frequencyPenalty: null, - metadata: null, - instructions: null, - tools: [], - toolChoice: 'auto', - parallelToolCalls: false, - } as OpenResponsesResult; -} - -function createMemoryAccessor() { - let state: ConversationState | null = null; - const accessor: StateAccessor = { - load: async () => state, - save: async (next) => { - state = next; - }, - }; - return { accessor, get: () => state }; -} - -describe('stopWhen is evaluated before the approval check on the first response', () => { - beforeEach(() => { - mockResponsesSend.mockReset(); - }); - - it('does not run an auto-executable sibling of an approval-gated call when stopWhen is already met', async () => { - const autoExecute = vi.fn(async () => ({ ok: true })); - const gatedExecute = vi.fn(async () => ({ ok: true })); - const auto = tool({ - name: 'auto', - inputSchema: z.object({ v: z.string() }), - outputSchema: z.object({ ok: z.boolean() }), - execute: autoExecute, - }); - const gated = tool({ - name: 'gated', - inputSchema: z.object({ v: z.string() }), - outputSchema: z.object({ ok: z.boolean() }), - requireApproval: true, - execute: gatedExecute, - }); - const tools = [auto, gated] as const; - - mockResponsesSend.mockResolvedValue({ - ok: true, - value: makeResponse([functionCall('call_auto', 'auto'), functionCall('call_gated', 'gated')]), - }); - - const memory = createMemoryAccessor(); - const result = new ModelResult({ - request: { model: 'test-model', input: 'go' }, - client: {} as OpenRouterCore, - tools, - state: memory.accessor, - stopWhen: () => true, - }); - - await result.getResponse(); - - expect(autoExecute).not.toHaveBeenCalled(); - expect(gatedExecute).not.toHaveBeenCalled(); - expect(mockResponsesSend).toHaveBeenCalledTimes(1); - - const saved = memory.get(); - expect(saved?.status).toBe('awaiting_approval'); - expect(saved?.pendingToolCalls?.map((tc) => tc.id)).toEqual(['call_gated']); - // The skipped sibling still gets a matching output for the resume request. - expect(saved?.unsentToolResults).toEqual([ - { - callId: 'call_auto', - name: 'auto', - output: null, - error: 'Tool execution skipped: stop condition met', - }, - ]); - expect(await result.requiresApproval()).toBe(true); - }); - - it('still runs the auto-executable sibling when stopWhen is not met', async () => { - const autoExecute = vi.fn(async () => ({ ok: true })); - const auto = tool({ - name: 'auto', - inputSchema: z.object({ v: z.string() }), - outputSchema: z.object({ ok: z.boolean() }), - execute: autoExecute, - }); - const gated = tool({ - name: 'gated', - inputSchema: z.object({ v: z.string() }), - outputSchema: z.object({ ok: z.boolean() }), - requireApproval: true, - execute: async () => ({ ok: true }), - }); - const tools = [auto, gated] as const; - - mockResponsesSend.mockResolvedValue({ - ok: true, - value: makeResponse([functionCall('call_auto', 'auto'), functionCall('call_gated', 'gated')]), - }); - - const memory = createMemoryAccessor(); - const result = new ModelResult({ - request: { model: 'test-model', input: 'go' }, - client: {} as OpenRouterCore, - tools, - state: memory.accessor, - stopWhen: () => false, - }); - - await result.getResponse(); - - expect(autoExecute).toHaveBeenCalledTimes(1); - const saved = memory.get(); - expect(saved?.status).toBe('awaiting_approval'); - expect(saved?.pendingToolCalls?.map((tc) => tc.id)).toEqual(['call_gated']); - expect(saved?.unsentToolResults?.map((r) => r.callId)).toEqual(['call_auto']); - }); -}); diff --git a/tests/unit/tool-context.test.ts b/tests/unit/tool-context.test.ts deleted file mode 100644 index f77ac43b4..000000000 --- a/tests/unit/tool-context.test.ts +++ /dev/null @@ -1,596 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import * as z4 from 'zod/v4'; -import { ToolContextStore, buildToolExecuteContext, resolveContext, extractToolContext } from '../../src/lib/tool-context.js'; -import type { TurnContext } from '../../src/lib/tool-types.js'; -import { tool } from '../../src/lib/tool.js'; - -//#region ToolContextStore - -describe('ToolContextStore', () => { - it('should initialize with provided values', () => { - const store = new ToolContextStore({ get_weather: { apiKey: 'sk-123' }, db_query: { dbUrl: 'postgres://...' } }); - const snapshot = store.getSnapshot(); - - expect(snapshot).toEqual({ get_weather: { apiKey: 'sk-123' }, db_query: { dbUrl: 'postgres://...' } }); - }); - - it('should return a shallow copy from getSnapshot', () => { - const store = new ToolContextStore({ my_tool: { key: 'value' } }); - const snapshot1 = store.getSnapshot(); - const snapshot2 = store.getSnapshot(); - - expect(snapshot1).toEqual(snapshot2); - expect(snapshot1).not.toBe(snapshot2); - }); - - it('should get tool context by name', () => { - const store = new ToolContextStore({ get_weather: { apiKey: 'sk-123' } }); - - expect(store.getToolContext('get_weather')).toEqual({ apiKey: 'sk-123' }); - expect(store.getToolContext('nonexistent')).toEqual({}); - }); - - it('should set tool context and notify listeners', () => { - const store = new ToolContextStore({ get_weather: { apiKey: 'old' } }); - const snapshots: Record>[] = []; - - store.subscribe((snapshot) => snapshots.push(snapshot)); - store.setToolContext('get_weather', { apiKey: 'new' }); - - expect(store.getToolContext('get_weather')).toEqual({ apiKey: 'new' }); - expect(snapshots).toHaveLength(1); - expect(snapshots[0]).toEqual({ get_weather: { apiKey: 'new' } }); - }); - - it('should merge partial values into tool context and notify listeners', () => { - const store = new ToolContextStore({ my_tool: { a: 1, b: 2 } }); - const snapshots: Record>[] = []; - - store.subscribe((snapshot) => snapshots.push(snapshot)); - store.mergeToolContext('my_tool', { b: 3, c: 4 }); - - expect(store.getToolContext('my_tool')).toEqual({ a: 1, b: 3, c: 4 }); - expect(snapshots).toHaveLength(1); - }); - - it('should support unsubscribing', () => { - const store = new ToolContextStore({}); - const snapshots: Record>[] = []; - - const unsubscribe = store.subscribe((snapshot) => snapshots.push(snapshot)); - store.setToolContext('my_tool', { key: 'value1' }); - expect(snapshots).toHaveLength(1); - - unsubscribe(); - store.setToolContext('my_tool', { key: 'value2' }); - expect(snapshots).toHaveLength(1); - }); -}); - -//#endregion - -//#region buildToolExecuteContext - -describe('buildToolExecuteContext', () => { - const turnContext: TurnContext = { numberOfTurns: 1 }; - - it('should return flat context with local, shared, and setContext', () => { - const store = new ToolContextStore({ get_weather: { apiKey: 'sk-123' } }); - const schema = z4.object({ apiKey: z4.string() }); - - const ctx = buildToolExecuteContext<'get_weather', { apiKey: string }>( - turnContext, store, 'get_weather', schema, - ); - - // TurnContext fields are merged in - expect(ctx.numberOfTurns).toBe(1); - - // local getter contains this tool's context - expect(ctx.local.apiKey).toBe('sk-123'); - - // shared getter is available - expect(ctx.shared).toEqual({}); - - // setContext and setSharedContext are available - expect(typeof ctx.setContext).toBe('function'); - expect(typeof ctx.setSharedContext).toBe('function'); - }); - - it('should allow setContext to mutate store', () => { - const store = new ToolContextStore({ get_weather: { apiKey: 'old' } }); - const schema = z4.object({ apiKey: z4.string() }); - - const ctx = buildToolExecuteContext<'get_weather', { apiKey: string }>( - turnContext, store, 'get_weather', schema, - ); - - ctx.setContext({ apiKey: 'new' }); - - // Store is updated - expect(store.getToolContext('get_weather')).toEqual({ apiKey: 'new' }); - }); - - it('should validate setContext values against schema', () => { - const store = new ToolContextStore({ my_tool: { count: 0 } }); - const schema = z4.object({ count: z4.number() }); - - const ctx = buildToolExecuteContext<'my_tool', { count: number }>( - turnContext, store, 'my_tool', schema, - ); - - expect(() => ctx.setContext({ count: 'not a number' as unknown as number })).toThrow(); - }); - - it('should filter out keys not in schema on setContext', () => { - const store = new ToolContextStore({ my_tool: { apiKey: 'old', extra: 'extra' } }); - const schema = z4.object({ apiKey: z4.string() }); - - const ctx = buildToolExecuteContext<'my_tool', { apiKey: string }>( - turnContext, store, 'my_tool', schema, - ); - - ctx.setContext({ apiKey: 'new', notInSchema: 'ignored' } as Record as { apiKey: string }); - - // Only apiKey is in schema, so only it gets merged; extra stays from initial store - expect(store.getToolContext('my_tool').apiKey).toBe('new'); - expect(store.getToolContext('my_tool').extra).toBe('extra'); - }); - - it('should be a no-op for setContext when schema is undefined', () => { - const store = new ToolContextStore({ my_tool: { key: 'value' } }); - - const ctx = buildToolExecuteContext<'my_tool', Record>( - turnContext, store, 'my_tool', undefined, - ); - - ctx.setContext({ key: 'new' }); - // setContext is a no-op when no schema - expect(store.getToolContext('my_tool')).toEqual({ key: 'value' }); - }); - - it('should return empty context when store is undefined', () => { - const ctx = buildToolExecuteContext<'my_tool', Record>( - turnContext, undefined, 'my_tool', undefined, - ); - - expect(ctx.numberOfTurns).toBe(1); - expect(ctx.local).toEqual({}); - }); - - it('should notify store listeners when setContext is called', () => { - const store = new ToolContextStore({ get_weather: { apiKey: 'old' } }); - const schema = z4.object({ apiKey: z4.string() }); - const ctx = buildToolExecuteContext<'get_weather', { apiKey: string }>( - turnContext, store, 'get_weather', schema, - ); - - const snapshots: Record>[] = []; - store.subscribe((s) => snapshots.push(s)); - - ctx.setContext({ apiKey: 'new' }); - - expect(snapshots).toHaveLength(1); - expect(snapshots[0]?.get_weather).toEqual({ apiKey: 'new' }); - }); -}); - -//#endregion - -//#region resolveContext - -describe('resolveContext', () => { - const turnContext: TurnContext = { numberOfTurns: 0 }; - - it('should return empty object when input is undefined', async () => { - const result = await resolveContext(undefined, turnContext); - expect(result).toEqual({}); - }); - - it('should return static value as-is', async () => { - const input = { get_weather: { apiKey: 'sk-123' } }; - const result = await resolveContext(input, turnContext); - expect(result).toEqual(input); - }); - - it('should resolve sync function', async () => { - const input = (turn: TurnContext) => ({ my_tool: { turnNum: turn.numberOfTurns } }); - const result = await resolveContext(input, turnContext); - expect(result).toEqual({ my_tool: { turnNum: 0 } }); - }); - - it('should resolve async function', async () => { - const input = async (turn: TurnContext) => ({ my_tool: { turnNum: turn.numberOfTurns + 1 } }); - const result = await resolveContext(input, turnContext); - expect(result).toEqual({ my_tool: { turnNum: 1 } }); - }); -}); - -//#endregion - -//#region extractToolContext - -describe('extractToolContext', () => { - it('should extract and validate context for a tool', () => { - const store = new ToolContextStore({ - get_weather: { apiKey: 'sk-123', dbUrl: 'pg://' }, - other_tool: { extra: 'x' }, - }); - const schema = z4.object({ apiKey: z4.string(), dbUrl: z4.string() }); - - const result = extractToolContext(store, 'get_weather', schema); - expect(result).toEqual({ apiKey: 'sk-123', dbUrl: 'pg://' }); - }); - - it('should return empty object when schema is undefined', () => { - const store = new ToolContextStore({ my_tool: { key: 'value' } }); - const result = extractToolContext(store, 'my_tool', undefined); - expect(result).toEqual({}); - }); - - it('should throw when validation fails', () => { - const store = new ToolContextStore({ my_tool: { apiKey: 123 } }); - const schema = z4.object({ apiKey: z4.string() }); - - expect(() => extractToolContext(store, 'my_tool', schema)).toThrow(); - }); -}); - -//#endregion - -//#region tool() with contextSchema - -describe('tool() with contextSchema', () => { - it('should store contextSchema on the tool function', () => { - const schema = z4.object({ apiKey: z4.string() }); - const t = tool({ - name: 'test_tool', - inputSchema: z4.object({ query: z4.string() }), - contextSchema: schema, - execute: async (_params, context) => { - const key = context!.local.apiKey; - return `result: ${key}`; - }, - }); - - expect(t.function.contextSchema).toBe(schema); - }); - - it('should work without contextSchema', () => { - const t = tool({ - name: 'test_tool', - inputSchema: z4.object({ query: z4.string() }), - execute: async (_params, context) => { - return `turn ${context!.numberOfTurns}`; - }, - }); - - expect(t.function.contextSchema).toBeUndefined(); - }); - - it('should set contextSchema on generator tools', () => { - const ctxSchema = z4.object({ sessionId: z4.string() }); - const t = tool({ - name: 'gen_tool', - inputSchema: z4.object({ data: z4.string() }), - eventSchema: z4.object({ progress: z4.number() }), - outputSchema: z4.object({ result: z4.string() }), - contextSchema: ctxSchema, - execute: async function* (_params, _context) { - yield { progress: 50 }; - yield { result: 'done' }; - }, - }); - - expect(t.function.contextSchema).toBe(ctxSchema); - }); - - it('should set contextSchema on manual tools', () => { - const ctxSchema = z4.object({ userId: z4.string() }); - const t = tool({ - name: 'manual_tool', - inputSchema: z4.object({ action: z4.string() }), - contextSchema: ctxSchema, - execute: false, - }); - - expect(t.function.contextSchema).toBe(ctxSchema); - }); - - it('should type ctx.shared via tool() generic', () => { - type SharedCtx = { _sessionId?: string }; - const t = tool({ - name: 'typed_shared', - inputSchema: z4.object({ cmd: z4.string() }), - execute: async (_params, ctx) => { - // Compile-time check: ctx.shared._sessionId is string | undefined - const sid: string | undefined = ctx!.shared._sessionId; - return `sid=${sid}`; - }, - }); - - expect(t.function.name).toBe('typed_shared'); - }); - - it('should type ctx.shared as Record without TShared generic', () => { - const t = tool({ - name: 'untyped_shared', - inputSchema: z4.object({ cmd: z4.string() }), - execute: async (_params, ctx) => { - // Without TShared, shared is Record - const val: unknown = ctx!.shared['anything']; - return `val=${val}`; - }, - }); - - expect(t.function.name).toBe('untyped_shared'); - }); -}); - -//#endregion - -//#region Context mutation persistence - -describe('context mutation persistence across tool calls', () => { - const turnContext: TurnContext = { numberOfTurns: 1 }; - - it('should persist mutations across calls via shared store', () => { - const store = new ToolContextStore({ my_tool: { counter: 0 } }); - const schema = z4.object({ counter: z4.number() }); - - // First tool call - const ctx1 = buildToolExecuteContext<'my_tool', { counter: number }>( - turnContext, store, 'my_tool', schema, - ); - ctx1.setContext({ counter: 1 }); - - // Second tool call — same store, different context instance - const ctx2 = buildToolExecuteContext<'my_tool', { counter: number }>( - turnContext, store, 'my_tool', schema, - ); - expect(ctx2.local.counter).toBe(1); - - ctx2.setContext({ counter: 2 }); - - // Third context reads latest value - const ctx3 = buildToolExecuteContext<'my_tool', { counter: number }>( - turnContext, store, 'my_tool', schema, - ); - expect(ctx3.local.counter).toBe(2); - }); -}); - -//#endregion - -//#region Live reads after setContext - -describe('live reads after setContext within same execution', () => { - const turnContext: TurnContext = { numberOfTurns: 1 }; - - it('should reflect setContext changes immediately via local getter', () => { - const store = new ToolContextStore({ my_tool: { counter: 0 } }); - const schema = z4.object({ counter: z4.number() }); - - const ctx = buildToolExecuteContext<'my_tool', { counter: number }>( - turnContext, store, 'my_tool', schema, - ); - - // Initial read - expect(ctx.local.counter).toBe(0); - - // Mutate via setContext - ctx.setContext({ counter: 42 }); - - // Same context instance sees the update immediately - expect(ctx.local.counter).toBe(42); - }); - - it('should return frozen snapshots from local getter', () => { - const store = new ToolContextStore({ my_tool: { value: 'original' } }); - const schema = z4.object({ value: z4.string() }); - - const ctx = buildToolExecuteContext<'my_tool', { value: string }>( - turnContext, store, 'my_tool', schema, - ); - - const snapshot = ctx.local; - expect(Object.isFrozen(snapshot)).toBe(true); - }); -}); - -//#endregion - -//#region Tool isolation - -describe('tool isolation — each tool sees only its own context via local', () => { - const turnContext: TurnContext = { numberOfTurns: 1 }; - - it('should expose own tool context via local', () => { - const store = new ToolContextStore({ - get_weather: { apiKey: 'weather-key' }, - db_query: { connectionString: 'pg://...' }, - }); - const weatherSchema = z4.object({ apiKey: z4.string() }); - const dbSchema = z4.object({ connectionString: z4.string() }); - - const weatherCtx = buildToolExecuteContext<'get_weather', { apiKey: string }>( - turnContext, store, 'get_weather', weatherSchema, - ); - const dbCtx = buildToolExecuteContext<'db_query', { connectionString: string }>( - turnContext, store, 'db_query', dbSchema, - ); - - // Weather tool sees its own context - expect(weatherCtx.local.apiKey).toBe('weather-key'); - - // DB tool sees its own context - expect(dbCtx.local.connectionString).toBe('pg://...'); - }); - - it('should not leak mutations between tools', () => { - const store = new ToolContextStore({ - tool_a: { count: 0 }, - tool_b: { count: 100 }, - }); - const schema = z4.object({ count: z4.number() }); - - const ctxA = buildToolExecuteContext<'tool_a', { count: number }>( - turnContext, store, 'tool_a', schema, - ); - const ctxB = buildToolExecuteContext<'tool_b', { count: number }>( - turnContext, store, 'tool_b', schema, - ); - - ctxA.setContext({ count: 1 }); - - // Tool B's context is unchanged - expect(ctxB.local.count).toBe(100); - // Tool A's context is updated - expect(ctxA.local.count).toBe(1); - }); -}); - -//#endregion - -//#region getToolContext returns defensive copies - -describe('getToolContext returns defensive copies', () => { - it('should not allow external mutation of store via returned reference', () => { - const store = new ToolContextStore({ my_tool: { key: 'original' } }); - - const retrieved = store.getToolContext('my_tool'); - retrieved['key'] = 'mutated'; - - // Store should be unchanged - expect(store.getToolContext('my_tool').key).toBe('original'); - }); -}); - -//#endregion - -//#region Shared context - -describe('shared context via sharedSchema', () => { - const turnContext: TurnContext = { numberOfTurns: 1 }; - - it('should read shared context from store', () => { - const store = new ToolContextStore({ - my_tool: { apiKey: 'sk-123' }, - shared: { _sessionId: 'sess-abc' }, - }); - const schema = z4.object({ apiKey: z4.string() }); - const sharedSchema = z4.object({ _sessionId: z4.string().optional() }); - - const ctx = buildToolExecuteContext<'my_tool', { apiKey: string }, { _sessionId?: string }>( - turnContext, store, 'my_tool', schema, sharedSchema, - ); - - expect(ctx.shared._sessionId).toBe('sess-abc'); - expect(ctx.local.apiKey).toBe('sk-123'); - }); - - it('should allow setSharedContext to mutate shared store', () => { - const store = new ToolContextStore({ - shared: { _sessionId: 'old' }, - }); - const sharedSchema = z4.object({ _sessionId: z4.string().optional() }); - - const ctx = buildToolExecuteContext<'my_tool', Record, { _sessionId?: string }>( - turnContext, store, 'my_tool', undefined, sharedSchema, - ); - - ctx.setSharedContext({ _sessionId: 'new' }); - expect(ctx.shared._sessionId).toBe('new'); - expect(store.getToolContext('shared')).toEqual({ _sessionId: 'new' }); - }); - - it('should validate setSharedContext values against sharedSchema', () => { - const store = new ToolContextStore({ shared: { count: 0 } }); - const sharedSchema = z4.object({ count: z4.number() }); - - const ctx = buildToolExecuteContext<'my_tool', Record, { count: number }>( - turnContext, store, 'my_tool', undefined, sharedSchema, - ); - - expect(() => ctx.setSharedContext({ count: 'not a number' as unknown as number })).toThrow(); - }); - - it('should be a no-op for setSharedContext when sharedSchema is undefined', () => { - const store = new ToolContextStore({ shared: { key: 'value' } }); - - const ctx = buildToolExecuteContext<'my_tool', Record>( - turnContext, store, 'my_tool', undefined, - ); - - ctx.setSharedContext({ key: 'new' } as Record); - // setSharedContext is a no-op when no sharedSchema - expect(store.getToolContext('shared')).toEqual({ key: 'value' }); - }); - - it('should share context across different tools', () => { - const store = new ToolContextStore({ - tool_a: { key_a: 'a' }, - tool_b: { key_b: 'b' }, - shared: { sessionId: 'sess-1' }, - }); - const schemaA = z4.object({ key_a: z4.string() }); - const schemaB = z4.object({ key_b: z4.string() }); - const sharedSchema = z4.object({ sessionId: z4.string() }); - - const ctxA = buildToolExecuteContext<'tool_a', { key_a: string }, { sessionId: string }>( - turnContext, store, 'tool_a', schemaA, sharedSchema, - ); - const ctxB = buildToolExecuteContext<'tool_b', { key_b: string }, { sessionId: string }>( - turnContext, store, 'tool_b', schemaB, sharedSchema, - ); - - // Both tools see the same shared context - expect(ctxA.shared.sessionId).toBe('sess-1'); - expect(ctxB.shared.sessionId).toBe('sess-1'); - - // Tool A updates shared context - ctxA.setSharedContext({ sessionId: 'sess-2' }); - - // Tool B sees the updated value - expect(ctxB.shared.sessionId).toBe('sess-2'); - }); - - it('should return frozen snapshots from shared getter', () => { - const store = new ToolContextStore({ shared: { key: 'value' } }); - const sharedSchema = z4.object({ key: z4.string() }); - - const ctx = buildToolExecuteContext<'my_tool', Record, { key: string }>( - turnContext, store, 'my_tool', undefined, sharedSchema, - ); - - const snapshot = ctx.shared; - expect(Object.isFrozen(snapshot)).toBe(true); - }); -}); - -//#endregion - -//#region Reserved name 'shared' - -describe('tool() rejects reserved name "shared"', () => { - it('should throw when name is "shared"', () => { - expect(() => - tool({ - name: 'shared', - inputSchema: z4.object({ query: z4.string() }), - execute: async () => 'result', - }), - ).toThrow('Tool name "shared" is reserved'); - }); - - it('should not throw for other names', () => { - expect(() => - tool({ - name: 'my_tool', - inputSchema: z4.object({ query: z4.string() }), - execute: async () => 'result', - }), - ).not.toThrow(); - }); -}); - -//#endregion diff --git a/tests/unit/tool-event-broadcaster.test.ts b/tests/unit/tool-event-broadcaster.test.ts deleted file mode 100644 index a9fce6d9d..000000000 --- a/tests/unit/tool-event-broadcaster.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { ToolEventBroadcaster } from '../../src/lib/tool-event-broadcaster.js'; - -describe('ToolEventBroadcaster', () => { - describe('single consumer', () => { - it('should deliver events to a single consumer', async () => { - const broadcaster = new ToolEventBroadcaster(); - const consumer = broadcaster.createConsumer(); - - broadcaster.push(1); - broadcaster.push(2); - broadcaster.push(3); - broadcaster.complete(); - - const results: number[] = []; - for await (const event of consumer) { - results.push(event); - } - - expect(results).toEqual([1, 2, 3]); - }); - - it('should handle empty stream', async () => { - const broadcaster = new ToolEventBroadcaster(); - const consumer = broadcaster.createConsumer(); - - broadcaster.complete(); - - const results: string[] = []; - for await (const event of consumer) { - results.push(event); - } - - expect(results).toEqual([]); - }); - - it('should handle consumer cancellation via return()', async () => { - const broadcaster = new ToolEventBroadcaster(); - const consumer = broadcaster.createConsumer(); - - broadcaster.push(1); - broadcaster.push(2); - - // Get first value - const first = await consumer.next(); - expect(first.done).toBe(false); - expect(first.value).toBe(1); - - // Cancel consumer - await consumer.return!(); - - // Should be done now - const after = await consumer.next(); - expect(after.done).toBe(true); - }); - }); - - describe('multiple consumers', () => { - it('should deliver same events to multiple consumers', async () => { - const broadcaster = new ToolEventBroadcaster(); - const consumer1 = broadcaster.createConsumer(); - const consumer2 = broadcaster.createConsumer(); - - broadcaster.push('a'); - broadcaster.push('b'); - broadcaster.complete(); - - const results1: string[] = []; - const results2: string[] = []; - - await Promise.all([ - (async () => { - for await (const e of consumer1) results1.push(e); - })(), - (async () => { - for await (const e of consumer2) results2.push(e); - })(), - ]); - - expect(results1).toEqual(['a', 'b']); - expect(results2).toEqual(['a', 'b']); - }); - - it('should allow consumers at different read positions', async () => { - const broadcaster = new ToolEventBroadcaster(); - const consumer1 = broadcaster.createConsumer(); - - broadcaster.push(1); - broadcaster.push(2); - - // Consumer 1 reads first event - const first = await consumer1.next(); - expect(first.value).toBe(1); - - // Consumer 2 joins after events pushed - const consumer2 = broadcaster.createConsumer(); - - broadcaster.push(3); - broadcaster.complete(); - - // Consumer 1 continues from position 1 - const remaining1: number[] = []; - for await (const e of consumer1) remaining1.push(e); - expect(remaining1).toEqual([2, 3]); - - // Consumer 2 gets all events from position 0 - const all2: number[] = []; - for await (const e of consumer2) all2.push(e); - expect(all2).toEqual([1, 2, 3]); - }); - }); - - describe('async waiting', () => { - it('should wait for events when consumer is ahead of buffer', async () => { - const broadcaster = new ToolEventBroadcaster(); - const consumer = broadcaster.createConsumer(); - - // Start consuming before events arrive - const consumePromise = (async () => { - const results: number[] = []; - for await (const event of consumer) { - results.push(event); - } - return results; - })(); - - // Push events after consumer starts waiting - await new Promise((r) => setTimeout(r, 10)); - broadcaster.push(1); - await new Promise((r) => setTimeout(r, 10)); - broadcaster.push(2); - broadcaster.complete(); - - const results = await consumePromise; - expect(results).toEqual([1, 2]); - }); - - it('should handle rapid push/consume interleaving', async () => { - const broadcaster = new ToolEventBroadcaster(); - const consumer = broadcaster.createConsumer(); - - const received: number[] = []; - const consumePromise = (async () => { - for await (const event of consumer) { - received.push(event); - } - })(); - - // Push events with minimal delay - for (let i = 0; i < 10; i++) { - broadcaster.push(i); - await new Promise((r) => setTimeout(r, 1)); - } - broadcaster.complete(); - - await consumePromise; - expect(received).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]); - }); - }); - - describe('error handling', () => { - it('should propagate errors to consumers', async () => { - const broadcaster = new ToolEventBroadcaster(); - const consumer = broadcaster.createConsumer(); - - broadcaster.push(1); - broadcaster.complete(new Error('Test error')); - - const results: number[] = []; - let caughtError: Error | null = null; - - try { - for await (const event of consumer) { - results.push(event); - } - } catch (e) { - caughtError = e as Error; - } - - expect(results).toEqual([1]); - expect(caughtError).not.toBeNull(); - expect(caughtError!.message).toBe('Test error'); - }); - - it('should propagate errors to waiting consumers', async () => { - const broadcaster = new ToolEventBroadcaster(); - const consumer = broadcaster.createConsumer(); - - // Start consuming (will wait) - const consumePromise = (async () => { - const results: number[] = []; - for await (const event of consumer) { - results.push(event); - } - return results; - })(); - - // Complete with error while consumer is waiting - await new Promise((r) => setTimeout(r, 10)); - broadcaster.complete(new Error('Async error')); - - await expect(consumePromise).rejects.toThrow('Async error'); - }); - }); - - describe('ignore after complete', () => { - it('should ignore pushes after complete', async () => { - const broadcaster = new ToolEventBroadcaster(); - const consumer = broadcaster.createConsumer(); - - broadcaster.push(1); - broadcaster.complete(); - broadcaster.push(2); // Should be ignored - - const results: number[] = []; - for await (const event of consumer) { - results.push(event); - } - - expect(results).toEqual([1]); - }); - }); - - describe('completion between iterations', () => { - it('should handle completion between consumer iterations', async () => { - const broadcaster = new ToolEventBroadcaster(); - const consumer = broadcaster.createConsumer(); - - broadcaster.push(1); - const first = await consumer.next(); - expect(first.done).toBe(false); - expect(first.value).toBe(1); - - // Complete while consumer is between iterations - broadcaster.complete(); - - const second = await consumer.next(); - expect(second.done).toBe(true); - }); - - it('should handle completion with remaining buffered events', async () => { - const broadcaster = new ToolEventBroadcaster(); - const consumer = broadcaster.createConsumer(); - - broadcaster.push(1); - broadcaster.push(2); - broadcaster.push(3); - - // Read first event - const first = await consumer.next(); - expect(first.value).toBe(1); - - // Complete with events still in buffer - broadcaster.complete(); - - // Should still get remaining buffered events - const second = await consumer.next(); - expect(second.value).toBe(2); - - const third = await consumer.next(); - expect(third.value).toBe(3); - - // Now should be done - const fourth = await consumer.next(); - expect(fourth.done).toBe(true); - }); - }); - - describe('typed events', () => { - it('should work with typed tool events', async () => { - type ToolEvent = - | { type: 'delta'; content: string } - | { type: 'preliminary_result'; toolCallId: string; result: unknown }; - - const broadcaster = new ToolEventBroadcaster(); - const consumer = broadcaster.createConsumer(); - - broadcaster.push({ type: 'delta', content: 'test' }); - broadcaster.push({ - type: 'preliminary_result', - toolCallId: 'call_123', - result: { progress: 50 }, - }); - broadcaster.complete(); - - const events: ToolEvent[] = []; - for await (const event of consumer) { - events.push(event); - } - - expect(events).toHaveLength(2); - expect(events[0]).toEqual({ type: 'delta', content: 'test' }); - expect(events[1]).toEqual({ - type: 'preliminary_result', - toolCallId: 'call_123', - result: { progress: 50 }, - }); - }); - }); -}); diff --git a/tests/unit/tool-executor-return.test.ts b/tests/unit/tool-executor-return.test.ts deleted file mode 100644 index 6f98fb7f3..000000000 --- a/tests/unit/tool-executor-return.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { z } from 'zod/v4'; -import { executeGeneratorTool } from '../../src/lib/tool-executor.js'; -import { tool } from '../../src/lib/tool.js'; -import type { ParsedToolCall, Tool, TurnContext } from '../../src/lib/tool-types.js'; - -describe('executeGeneratorTool - return value capture', () => { - const mockContext: TurnContext = { - numberOfTurns: 1, - }; - - it('should capture generator return value as final result', async () => { - const generatorTool = tool({ - name: 'return_value_tool', - inputSchema: z.object({ - input: z.string(), - }), - eventSchema: z.object({ - status: z.string(), - }), - outputSchema: z.object({ - result: z.string(), - }), - execute: async function* (params) { - yield { status: 'working' }; - yield { status: 'almost done' }; - return { result: `Done: ${params.input}` }; // RETURN, not yield - }, - }); - - const toolCall: ParsedToolCall = { - id: 'test-call-1', - name: 'return_value_tool', - arguments: { input: 'test' }, - }; - - const result = await executeGeneratorTool(generatorTool, toolCall, mockContext); - - expect(result.error).toBeUndefined(); - expect(result.preliminaryResults).toHaveLength(2); - expect(result.preliminaryResults).toEqual([ - { status: 'working' }, - { status: 'almost done' }, - ]); - // The return value should be captured as the final result - expect(result.result).toEqual({ result: 'Done: test' }); - }); - - it('should capture return value even with no yields', async () => { - const generatorTool = tool({ - name: 'return_only_tool', - inputSchema: z.object({ - input: z.string(), - }), - eventSchema: z.object({ - status: z.string(), - }), - outputSchema: z.object({ - result: z.string(), - }), - execute: async function* (params) { - // No yields, only return - return { result: `Direct: ${params.input}` }; - }, - }); - - const toolCall: ParsedToolCall = { - id: 'test-call-2', - name: 'return_only_tool', - arguments: { input: 'test' }, - }; - - const result = await executeGeneratorTool(generatorTool, toolCall, mockContext); - - expect(result.error).toBeUndefined(); - expect(result.preliminaryResults).toHaveLength(0); - expect(result.result).toEqual({ result: 'Direct: test' }); - }); - - it('should prefer return value over last yield when both present', async () => { - const generatorTool = tool({ - name: 'both_yield_return_tool', - inputSchema: z.object({ - input: z.string(), - }), - eventSchema: z.object({ - status: z.string(), - }), - outputSchema: z.object({ - finalValue: z.string(), - }), - execute: async function* (params) { - yield { status: 'progress 1' }; - yield { status: 'progress 2' }; - // Return is the intended final result, not last yield - return { finalValue: `Final: ${params.input}` }; - }, - }); - - const toolCall: ParsedToolCall = { - id: 'test-call-3', - name: 'both_yield_return_tool', - arguments: { input: 'test' }, - }; - - const result = await executeGeneratorTool(generatorTool, toolCall, mockContext); - - expect(result.error).toBeUndefined(); - expect(result.preliminaryResults).toHaveLength(2); - // Return value should take precedence - expect(result.result).toEqual({ finalValue: 'Final: test' }); - }); - - it('should still work with yield-only generators (backwards compatibility)', async () => { - const generatorTool = tool({ - name: 'yield_only_tool', - inputSchema: z.object({}), - eventSchema: z.object({ - status: z.string(), - }), - outputSchema: z.object({ - completed: z.boolean(), - }), - execute: async function* () { - yield { status: 'working' }; - // Final value yielded (not returned) - existing behavior - yield { completed: true }; - }, - }); - - const toolCall: ParsedToolCall = { - id: 'test-call-4', - name: 'yield_only_tool', - arguments: {}, - }; - - const result = await executeGeneratorTool(generatorTool, toolCall, mockContext); - - expect(result.error).toBeUndefined(); - // Last yield that matches outputSchema but not eventSchema becomes result - expect(result.result).toEqual({ completed: true }); - }); -}); diff --git a/tests/unit/tool-type-compat.test.ts b/tests/unit/tool-type-compat.test.ts deleted file mode 100644 index ddc4a1272..000000000 --- a/tests/unit/tool-type-compat.test.ts +++ /dev/null @@ -1,342 +0,0 @@ -/** - * Type compatibility tests for tool() function - * Verifies all permutations of tool definitions work with plain 'zod' imports - */ -import { describe, it, expect } from 'vitest'; -import { z } from 'zod'; // Plain zod import (what users typically use) -import { tool } from '../../src/lib/tool.js'; - -describe('Tool Type Compatibility with plain zod imports', () => { - describe('Regular tools with execute function', () => { - it('should accept simple inputSchema with execute function', () => { - const simpleTool = tool({ - name: 'simple_tool', - description: 'A simple tool', - inputSchema: z.object({ - message: z.string(), - }), - execute: async (params) => { - return `Received: ${params.message}`; - }, - }); - - expect(simpleTool.type).toBe('function'); - expect(simpleTool.function.name).toBe('simple_tool'); - }); - - it('should accept complex nested inputSchema', () => { - const complexTool = tool({ - name: 'complex_tool', - inputSchema: z.object({ - user: z.object({ - name: z.string(), - age: z.number(), - email: z.string().email(), - }), - preferences: z.object({ - theme: z.enum(['light', 'dark']), - notifications: z.boolean(), - }), - tags: z.array(z.string()), - }), - execute: async (params) => { - return { processed: true, userName: params.user.name }; - }, - }); - - expect(complexTool.function.name).toBe('complex_tool'); - }); - - it('should accept tool with outputSchema', () => { - const typedOutputTool = tool({ - name: 'typed_output_tool', - inputSchema: z.object({ - query: z.string(), - }), - outputSchema: z.object({ - result: z.string(), - count: z.number(), - }), - execute: async (params) => { - return { result: params.query.toUpperCase(), count: params.query.length }; - }, - }); - - expect(typedOutputTool.function.name).toBe('typed_output_tool'); - }); - - it('should accept tool with sync execute function', () => { - const syncTool = tool({ - name: 'sync_tool', - inputSchema: z.object({ - value: z.number(), - }), - execute: (params) => { - return params.value * 2; - }, - }); - - expect(syncTool.function.name).toBe('sync_tool'); - }); - - it('should accept tool with optional fields in schema', () => { - const optionalFieldsTool = tool({ - name: 'optional_fields_tool', - inputSchema: z.object({ - required: z.string(), - optional: z.string().optional(), - nullable: z.string().nullable(), - defaulted: z.string().default('default'), - }), - execute: async (params) => params.required, - }); - - expect(optionalFieldsTool.function.name).toBe('optional_fields_tool'); - }); - - it('should accept tool with union types in schema', () => { - const unionTool = tool({ - name: 'union_tool', - inputSchema: z.object({ - value: z.union([z.string(), z.number()]), - status: z.enum(['active', 'inactive', 'pending']), - }), - execute: async (params) => String(params.value), - }); - - expect(unionTool.function.name).toBe('union_tool'); - }); - - it('should accept tool with array types', () => { - const arrayTool = tool({ - name: 'array_tool', - inputSchema: z.object({ - items: z.array(z.object({ - id: z.number(), - name: z.string(), - })), - counts: z.array(z.number()), - }), - execute: async (params) => params.items.length, - }); - - expect(arrayTool.function.name).toBe('array_tool'); - }); - - it('should accept tool with descriptions on schema fields', () => { - const describedTool = tool({ - name: 'described_tool', - description: 'A tool with field descriptions', - inputSchema: z.object({ - location: z.string().describe('City and country e.g. Tokyo, Japan'), - units: z.enum(['celsius', 'fahrenheit']).describe('Temperature units'), - }), - execute: async (params) => ({ temp: 22, location: params.location }), - }); - - expect(describedTool.function.name).toBe('described_tool'); - }); - }); - - describe('Manual tools (execute: false)', () => { - it('should accept manual tool with simple schema', () => { - const manualTool = tool({ - name: 'manual_tool', - description: 'A manual tool that requires external handling', - inputSchema: z.object({ - action: z.string(), - target: z.string(), - }), - execute: false, - }); - - expect(manualTool.type).toBe('function'); - expect(manualTool.function.name).toBe('manual_tool'); - expect('execute' in manualTool.function).toBe(false); - }); - - it('should accept manual tool with complex schema', () => { - const complexManualTool = tool({ - name: 'complex_manual_tool', - inputSchema: z.object({ - admin_access: z.boolean().describe('Whether to request admin access'), - permissions: z.array(z.enum(['read', 'write', 'delete'])), - metadata: z.object({ - reason: z.string(), - timestamp: z.string(), - }).optional(), - }), - execute: false, - }); - - expect(complexManualTool.function.name).toBe('complex_manual_tool'); - }); - }); - - describe('Generator tools (with eventSchema)', () => { - it('should accept generator tool with event and output schemas', () => { - const generatorTool = tool({ - name: 'generator_tool', - description: 'A tool that streams progress updates', - inputSchema: z.object({ - task: z.string(), - }), - eventSchema: z.object({ - progress: z.number(), - status: z.string(), - }), - outputSchema: z.object({ - result: z.string(), - totalSteps: z.number(), - }), - execute: async function* (params) { - yield { progress: 0, status: 'Starting...' }; - yield { progress: 50, status: 'Processing...' }; - yield { progress: 100, status: 'Done' }; - yield { result: `Completed: ${params.task}`, totalSteps: 3 }; - }, - }); - - expect(generatorTool.type).toBe('function'); - expect(generatorTool.function.name).toBe('generator_tool'); - expect('eventSchema' in generatorTool.function).toBe(true); - }); - - it('should accept generator tool with complex event types', () => { - const complexGeneratorTool = tool({ - name: 'complex_generator_tool', - inputSchema: z.object({ - files: z.array(z.string()), - }), - eventSchema: z.object({ - type: z.enum(['progress', 'warning', 'info']), - message: z.string(), - data: z.record(z.unknown()).optional(), - }), - outputSchema: z.object({ - processedFiles: z.array(z.string()), - errors: z.array(z.string()), - }), - execute: async function* (params) { - for (const file of params.files) { - yield { type: 'progress' as const, message: `Processing ${file}` }; - } - yield { processedFiles: params.files, errors: [] }; - }, - }); - - expect(complexGeneratorTool.function.name).toBe('complex_generator_tool'); - }); - }); - - describe('Tools with nextTurnParams', () => { - it('should accept tool with nextTurnParams functions', () => { - const adaptiveTool = tool({ - name: 'adaptive_tool', - inputSchema: z.object({ - complexity: z.enum(['low', 'medium', 'high']), - }), - nextTurnParams: { - temperature: (params) => { - switch (params.complexity) { - case 'low': return 0.3; - case 'medium': return 0.7; - case 'high': return 1.0; - } - }, - maxOutputTokens: (params) => params.complexity === 'high' ? 4000 : 2000, - }, - execute: async (params) => `Processed with ${params.complexity} complexity`, - }); - - expect(adaptiveTool.function.name).toBe('adaptive_tool'); - expect(adaptiveTool.function.nextTurnParams).toBeDefined(); - }); - - it('should accept manual tool with nextTurnParams', () => { - const manualWithParams = tool({ - name: 'manual_with_params', - inputSchema: z.object({ - model_preference: z.string(), - }), - nextTurnParams: { - model: (params) => params.model_preference, - }, - execute: false, - }); - - expect(manualWithParams.function.name).toBe('manual_with_params'); - expect(manualWithParams.function.nextTurnParams).toBeDefined(); - }); - }); - - describe('Edge cases', () => { - it('should handle empty object schema', () => { - const emptyInputTool = tool({ - name: 'empty_input_tool', - inputSchema: z.object({}), - execute: async () => 'No input needed', - }); - - expect(emptyInputTool.function.name).toBe('empty_input_tool'); - }); - - it('should handle deeply nested schemas', () => { - const deeplyNestedTool = tool({ - name: 'deeply_nested_tool', - inputSchema: z.object({ - level1: z.object({ - level2: z.object({ - level3: z.object({ - level4: z.object({ - value: z.string(), - }), - }), - }), - }), - }), - execute: async (params) => params.level1.level2.level3.level4.value, - }); - - expect(deeplyNestedTool.function.name).toBe('deeply_nested_tool'); - }); - - it('should handle schema with all Zod primitive types', () => { - const allPrimitivesTool = tool({ - name: 'all_primitives_tool', - inputSchema: z.object({ - str: z.string(), - num: z.number(), - bool: z.boolean(), - bigint: z.bigint(), - date: z.date(), - undef: z.undefined(), - nullVal: z.null(), - any: z.any(), - unknown: z.unknown(), - }), - execute: async (params) => ({ received: true }), - }); - - expect(allPrimitivesTool.function.name).toBe('all_primitives_tool'); - }); - - it('should handle schema with refinements', () => { - const refinedTool = tool({ - name: 'refined_tool', - inputSchema: z.object({ - email: z.string().email(), - url: z.string().url(), - uuid: z.string().uuid(), - positiveNumber: z.number().positive(), - integerOnly: z.number().int(), - minMax: z.number().min(0).max(100), - lengthConstrained: z.string().min(1).max(255), - }), - execute: async (params) => params.email, - }); - - expect(refinedTool.function.name).toBe('refined_tool'); - }); - }); -}); diff --git a/tests/unit/turn-end-race-condition.test.ts b/tests/unit/turn-end-race-condition.test.ts deleted file mode 100644 index 5ed70f5f0..000000000 --- a/tests/unit/turn-end-race-condition.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -/** - * Tests for the turn.end race condition bug introduced in PR #14925. - * - * Bug: In startTurnBroadcasterExecution(), startInitialStreamPipe() launches a - * fire-and-forget async IIFE that pushes turn.start, pipes stream events, then - * pushes turn.end. Concurrently, executeToolsIfNeeded() runs and its .finally() - * calls broadcaster.complete(). If the IIFE hasn't pushed turn.end yet when - * complete() fires, the event is silently dropped. - * - * The fix: startInitialStreamPipe() stores the IIFE's promise, which - * startTurnBroadcasterExecution() awaits before calling broadcaster.complete(). - */ -import { describe, expect, it } from 'vitest'; -import { ToolEventBroadcaster } from '../../src/lib/tool-event-broadcaster.js'; -import { ReusableReadableStream } from '../../src/lib/reusable-stream.js'; - -/** - * Helper: creates a ReadableStream that enqueues events with a delay between - * each, simulating real network streaming latency. - */ -function createDelayedStream( - events: Array<{ type: string }>, - delayMs: number, -): ReadableStream<{ type: string }> { - return new ReadableStream<{ type: string }>({ - async start(controller) { - for (const event of events) { - await new Promise((r) => setTimeout(r, delayMs)); - controller.enqueue(event); - } - controller.close(); - }, - }); -} - -describe('turn.end race condition', () => { - /** - * Characterization test: proves that ToolEventBroadcaster.push() silently - * drops events after complete() is called. This is the root cause of the - * turn.end drop bug - NOT a bug in the broadcaster itself, but a contract - * that callers must respect. - */ - it('ToolEventBroadcaster silently drops events pushed after complete()', async () => { - const broadcaster = new ToolEventBroadcaster<{ type: string }>(); - const consumer = broadcaster.createConsumer(); - - broadcaster.push({ type: 'turn.start' }); - broadcaster.push({ type: 'event1' }); - broadcaster.complete(); - broadcaster.push({ type: 'turn.end' }); // Silently dropped! - - const events: { type: string }[] = []; - for await (const event of consumer) { - events.push(event); - } - - // Proves the mechanism: events after complete() are dropped - expect(events.map((e) => e.type)).toEqual(['turn.start', 'event1']); - expect(events.filter((e) => e.type === 'turn.end')).toHaveLength(0); - }); - - /** - * Simulates the BUGGY pattern from the original startTurnBroadcasterExecution(). - * - * The pipe (async IIFE) pushes turn.start, iterates the stream, then pushes - * turn.end. Meanwhile, executeToolsIfNeeded completes quickly and calls - * broadcaster.complete() WITHOUT awaiting the pipe. The pipe is still - * iterating the delayed stream, so turn.end is pushed after complete() - * and silently dropped. - * - * This test documents the bug by asserting that turn.end IS dropped. - */ - it('buggy pattern: turn.end is dropped when complete() fires before pipe finishes', async () => { - const broadcaster = new ToolEventBroadcaster<{ type: string; turnNumber?: number }>(); - - const stream = createDelayedStream( - [{ type: 'response.output_text.delta' }, { type: 'response.completed' }], - 20, - ); - const reusableStream = new ReusableReadableStream(stream); - - // Fire-and-forget pipe (the buggy pattern) - const pipePromise = (async () => { - broadcaster.push({ type: 'turn.start', turnNumber: 0 }); - - const consumer = reusableStream.createConsumer(); - for await (const event of consumer) { - broadcaster.push(event); - } - - broadcaster.push({ type: 'turn.end', turnNumber: 0 }); - })(); - - const broadcastConsumer = broadcaster.createConsumer(); - - // Simulate executeToolsIfNeeded completing before the pipe finishes - const executionPromise = (async () => { - await new Promise((r) => setTimeout(r, 5)); - })().finally(() => { - // BUG: complete() is called without awaiting pipePromise - broadcaster.complete(); - }); - - const collectedEvents: { type: string; turnNumber?: number }[] = []; - for await (const event of broadcastConsumer) { - collectedEvents.push(event); - } - - await executionPromise; - await pipePromise; - - // turn.start is present (pushed synchronously before first await in the IIFE) - expect(collectedEvents.filter((e) => e.type === 'turn.start')).toHaveLength(1); - - // BUG: turn.end is dropped because complete() was called before the pipe finished - expect(collectedEvents.filter((e) => e.type === 'turn.end')).toHaveLength(0); - }); - - /** - * Simulates the FIXED pattern: await the pipe promise before calling - * complete(). This ensures turn.end is always pushed before the - * broadcaster is marked complete. - * - * This matches the fix applied in model-result.ts where - * startTurnBroadcasterExecution() now awaits initialPipePromise - * in the .finally() block before calling broadcaster.complete(). - */ - it('fixed pattern: turn.end is preserved when pipe is awaited before complete()', async () => { - const broadcaster = new ToolEventBroadcaster<{ type: string; turnNumber?: number }>(); - - const stream = createDelayedStream( - [{ type: 'response.output_text.delta' }, { type: 'response.completed' }], - 20, - ); - const reusableStream = new ReusableReadableStream(stream); - - const pipePromise = (async () => { - broadcaster.push({ type: 'turn.start', turnNumber: 0 }); - - const consumer = reusableStream.createConsumer(); - for await (const event of consumer) { - broadcaster.push(event); - } - - broadcaster.push({ type: 'turn.end', turnNumber: 0 }); - })(); - - const broadcastConsumer = broadcaster.createConsumer(); - - // FIX: await pipePromise before calling complete() - const executionPromise = (async () => { - await new Promise((r) => setTimeout(r, 5)); - })().finally(async () => { - await pipePromise; // THE FIX: ensures turn.end is pushed first - broadcaster.complete(); - }); - - const collectedEvents: { type: string; turnNumber?: number }[] = []; - for await (const event of broadcastConsumer) { - collectedEvents.push(event); - } - - await executionPromise; - - // turn.start is present - expect(collectedEvents.filter((e) => e.type === 'turn.start')).toHaveLength(1); - - // FIX: turn.end is now preserved - expect(collectedEvents.filter((e) => e.type === 'turn.end')).toHaveLength(1); - - // Verify ordering: turn.start comes before turn.end - const startIdx = collectedEvents.findIndex((e) => e.type === 'turn.start'); - const endIdx = collectedEvents.findIndex((e) => e.type === 'turn.end'); - expect(startIdx).toBeLessThan(endIdx); - - // Verify turn numbers - const turnStart = collectedEvents.find((e) => e.type === 'turn.start'); - const turnEnd = collectedEvents.find((e) => e.type === 'turn.end'); - expect(turnStart).toHaveProperty('turnNumber', 0); - expect(turnEnd).toHaveProperty('turnNumber', 0); - }); -});