diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 00000000..dba34ff6 --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,10 @@ +{ + "tab_size": 2, + "project_name": "@openrouter/sdk", + "formatter": { + "language_server": { + "name": "eslint" + } + }, + "language_servers": ["!biome", "..."] +} diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 00000000..681311eb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..21de943c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,218 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Overview + +This is the OpenRouter TypeScript SDK - a type-safe toolkit for building AI applications with access to 300+ language models. The SDK is **generated using Speakeasy** from an OpenAPI specification, with custom hand-written features for tool orchestration, async parameter resolution, and streaming. + +**IMPORTANT**: Most code in this repository is auto-generated by Speakeasy. Do not manually edit generated files - changes will be overwritten. See the "Code Generation" section below for how to make changes. + +## Common Commands + +### Building +```bash +pnpm run build +``` +Compiles TypeScript to `esm/` directory using `tsc`. + +### Linting +```bash +pnpm run lint +``` +**Note**: This project uses **ESLint** (not Biome). Configuration is in `eslint.config.mjs`. + +### Testing +```bash +# Run all tests +npx vitest + +# Run specific test file +npx vitest tests/e2e/call-model.test.ts + +# Run tests in watch mode +npx vitest --watch +``` + +Tests require an OpenRouter API key: +1. Copy `.env.example` to `.env` +2. Add your API key: `OPENROUTER_API_KEY=your_key_here` + +Test organization: +- `tests/e2e/` - End-to-end integration tests +- `tests/unit/` - Unit tests +- `tests/funcs/` - Function-specific tests + +### Publishing +```bash +pnpm run prepublishOnly +``` +This runs the build automatically before publishing. + +## Code Generation with Speakeasy + +The SDK is generated from `.speakeasy/in.openapi.yaml` using [Speakeasy](https://www.speakeasy.com/docs). + +### Generated vs Hand-Written Code + +**Generated Files** (DO NOT EDIT - will be overwritten): +- `src/models/` - Type definitions from OpenAPI schemas +- `src/funcs/*Send.ts`, `src/funcs/*Get.ts`, etc. - Most API operation functions +- `src/sdk/` - SDK service classes +- `src/hooks/registration.ts` - Hook registration + +**Hand-Written Files** (safe to edit): +- `src/lib/` - All library utilities and helpers +- `src/funcs/call-model.ts` - High-level model calling abstraction +- `src/index.ts` - Main exports +- `src/hooks/hooks.ts` and `src/hooks/types.ts` - Custom hooks + +### Regenerating the SDK + +To regenerate after updating the OpenAPI spec: +```bash +speakeasy run +``` + +This reads configuration from `.speakeasy/gen.yaml` and workflow from `.speakeasy/workflow.yaml`. + +### Making Changes to Generated Code + +1. **For type/schema changes**: Update `.speakeasy/in.openapi.yaml` and regenerate +2. **For overlays**: Edit files in `.speakeasy/overlays/` to apply transformations +3. **For generation config**: Edit `.speakeasy/gen.yaml` +4. **Always commit both** the OpenAPI spec changes AND the regenerated code + +## 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 + +## Development Workflow + +### When Adding New Features + +1. **If it's an API change**: Update `.speakeasy/in.openapi.yaml` in the monorepo (see `/Users/mattapperson/Development/CLAUDE.md` for monorepo workflow) +2. **If it's SDK functionality**: Add to `src/lib/` or extend existing hand-written files +3. **Add tests** to appropriate directory (`tests/e2e/`, `tests/unit/`) +4. **Update examples** if user-facing (in `examples/`) + +### When Fixing Bugs + +1. **In generated code**: Fix the OpenAPI spec or Speakeasy generation config, then regenerate +2. **In hand-written code**: Fix directly in `src/lib/` or other hand-written files +3. **Add regression test** to prevent reoccurrence + +### 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 + +## TypeScript Configuration + +- **Target**: ES2020, module: Node16 +- **Strict mode**: Enabled with strictest settings from tsconfig/bases +- **Output**: `esm/` directory with declaration files +- **Module format**: ESM only (no CommonJS) + +Key compiler options: +- `exactOptionalPropertyTypes: true` - Strict optional handling +- `noUncheckedIndexedAccess: true` - Array access safety +- `isolatedModules: true` - Required for module transforms + +## Testing Strategy + +Tests use Vitest with: +- 30s timeout for API calls +- Environment variables from `.env` +- Type checking enabled for test files + +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 + +This is an ES Module (ESM) package with multiple exports: +- `@openrouter/sdk` - Main SDK +- `@openrouter/sdk/types` - Type definitions +- `@openrouter/sdk/models` - Model types +- `@openrouter/sdk/models/operations` - Operation types +- `@openrouter/sdk/models/errors` - Error types + +The package uses conditional exports in `package.json` to map source files to build outputs. diff --git a/examples/callModel-typed-tool-calling.example.ts b/examples/call-model-typed-tool-calling.example.ts similarity index 96% rename from examples/callModel-typed-tool-calling.example.ts rename to examples/call-model-typed-tool-calling.example.ts index 2f383ac2..ae94b751 100644 --- a/examples/callModel-typed-tool-calling.example.ts +++ b/examples/call-model-typed-tool-calling.example.ts @@ -98,7 +98,7 @@ async function main() { model: "openai/gpt-4o-mini", input: "What's the weather like in Paris?", tools: [weatherTool] as const, - maxToolRounds: 0, // Don't auto-execute, just get the tool calls + 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! @@ -117,7 +117,7 @@ async function main() { model: "openai/gpt-4o-mini", input: "What's the weather in Tokyo?", tools: [weatherTool] as const, - maxToolRounds: 0, + stopWhen: ({ steps }) => steps.length >= 0, // Stop immediately }); // Stream tool calls with typed arguments diff --git a/examples/callModel.example.ts b/examples/call-model.example.ts similarity index 100% rename from examples/callModel.example.ts rename to examples/call-model.example.ts diff --git a/examples/tools-example.ts b/examples/tools-example.ts index f3e2d8cd..d5ec5276 100644 --- a/examples/tools-example.ts +++ b/examples/tools-example.ts @@ -6,22 +6,20 @@ * 1. Validated using Zod schemas * 2. Executed when the model calls them * 3. Results sent back to the model - * 4. Process repeats until no more tool calls (up to maxToolRounds) + * 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! * - * maxToolRounds can be: - * - A number: Maximum number of tool execution rounds (default: 5) - * - A function: (context: TurnContext) => boolean - * - Return true to allow another turn - * - Return false to stop execution - * - Context includes: numberOfTurns, messageHistory, model/models + * 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 } from '../src/index.js'; +import { OpenRouter, ToolType, stepCountIs } from '../src/index.js'; // Type declaration for ShadowRealm (TC39 Stage 3 proposal) // See: https://tc39.es/proposal-shadowrealm/ @@ -78,10 +76,8 @@ async function basicToolExample() { tools: [ weatherTool, ], - // Example: limit to 3 turns using a function - maxToolRounds: (context) => { - return context.numberOfTurns < 3; // Allow up to 3 turns - }, + // Example: limit to 3 steps + stopWhen: stepCountIs(3), }); // Tools are automatically executed! Just get the final message diff --git a/package.json b/package.json index ac2454d3..719534b5 100644 --- a/package.json +++ b/package.json @@ -67,10 +67,13 @@ "scripts": { "lint": "eslint --cache --max-warnings=0 src", "build": "tsc", - "prepublishOnly": "npm run build" + "typecheck": "tsc --noEmit", + "prepublishOnly": "npm run build", + "test": "vitest --run", + "test:watch": "vitest" }, "peerDependencies": { - + }, "devDependencies": { "@eslint/js": "^9.19.0", diff --git a/src/funcs/call-model.ts b/src/funcs/call-model.ts index 38893b25..bc2db9b6 100644 --- a/src/funcs/call-model.ts +++ b/src/funcs/call-model.ts @@ -1,18 +1,13 @@ import type { OpenRouterCore } from '../core.js'; +import type { CallModelInput } from '../lib/async-params.js'; import type { RequestOptions } from '../lib/sdks.js'; -import type { MaxToolRounds, Tool } from '../lib/tool-types.js'; -import type * as models from '../models/index.js'; +import type { Tool } from '../lib/tool-types.js'; -import { ModelResult } from '../lib/model-result.js'; +import { ModelResult, type GetResponseOptions } from '../lib/model-result.js'; import { convertToolsToAPIFormat } from '../lib/tool-executor.js'; -/** - * Input type for callModel function - */ -export type CallModelInput = Omit & { - tools?: Tool[]; - maxToolRounds?: MaxToolRounds; -}; +// Re-export CallModelInput for convenience +export type { CallModelInput } from '../lib/async-params.js'; /** * Get a response with multiple consumption patterns @@ -36,32 +31,123 @@ export type CallModelInput = Omit Math.min(ctx.numberOfTurns * 0.2, 1.0), // dynamic + * input: [{ type: 'text', text: 'Hello' }], // static + * }); + * ``` + * + * @example + * ```typescript + * // Switch models based on conversation length + * const result = callModel(client, { + * model: (ctx) => ctx.numberOfTurns > 3 ? 'gpt-4' : 'gpt-3.5-turbo', + * input: [{ type: 'text', text: 'Complex question' }], + * }); + * ``` + * + * @example + * ```typescript + * // Use async functions to fetch dynamic values + * const result = callModel(client, { + * model: 'gpt-4', + * instructions: async (ctx) => { + * const userPrefs = await fetchUserPreferences(); + * return `You are a helpful assistant. User preferences: ${userPrefs}`; + * }, + * input: [{ type: 'text', text: 'Help me' }], + * }); + * ``` + * + * Async functions receive `TurnContext` with: + * - `numberOfTurns`: Current turn number (0-indexed, 0 = initial request) + * - `messageHistory`: Current conversation messages + * - `model`: Current model selection (if set) + * - `models`: Current models array (if set) + * + * **Execution Order:** + * Functions are resolved at the START of each turn in this order: + * 1. Async functions (parallel resolution) + * 2. Tool execution (if tools called by model) + * 3. nextTurnParams functions (if defined on tools) + * 4. API request with resolved values + * + * **Stop Conditions:** + * + * Control when tool execution stops using the `stopWhen` parameter: + * + * @example + * ```typescript + * // Stop after 3 steps + * stopWhen: stepCountIs(3) + * + * // Stop when a specific tool is called + * stopWhen: hasToolCall('finalizeResults') + * + * // Multiple conditions (OR logic - stops if ANY is true) + * stopWhen: [ + * stepCountIs(10), // Safety: max 10 steps + * maxCost(0.50), // Budget: max $0.50 + * hasToolCall('finalize') // Logic: stop when finalize called + * ] + * + * // Custom condition with full step history + * stopWhen: ({ steps }) => { + * const totalCalls = steps.reduce((sum, s) => sum + s.toolCalls.length, 0); + * return totalCalls >= 20; // Stop after 20 total tool calls + * } + * ``` + * + * Available helper functions: + * - `stepCountIs(n)` - Stop after n steps + * - `hasToolCall(name)` - Stop when tool is called + * - `maxTokensUsed(n)` - Stop when token usage exceeds n + * - `maxCost(n)` - Stop when cost exceeds n dollars + * - `finishReasonIs(reason)` - Stop on specific finish reason + * + * Default: `stepCountIs(5)` if not specified */ -export function callModel( +export function callModel( client: OpenRouterCore, - request: CallModelInput, + request: CallModelInput, options?: RequestOptions, -): ModelResult { - const { tools, maxToolRounds, ...apiRequest } = request; +): ModelResult { + const { tools, stopWhen, ...apiRequest } = request; - // Convert tools to API format and extract enhanced tools if present + // 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 - const finalRequest: models.OpenResponsesRequest = { + // 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, - ...(apiTools !== undefined && { - tools: apiTools, - }), }; - return new ModelResult({ + if (apiTools !== undefined) { + finalRequest['tools'] = apiTools; + } + + return new ModelResult({ client, request: finalRequest, options: options ?? {}, - tools: tools ?? [], - ...(maxToolRounds !== undefined && { - maxToolRounds, + // Preserve the exact TTools type instead of widening to Tool[] + tools: tools as TTools | undefined, + ...(stopWhen !== undefined && { + stopWhen, }), - }); + } as GetResponseOptions); } diff --git a/src/index.ts b/src/index.ts index e07ef423..09e2ebf4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,71 +2,111 @@ * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT. */ -export * from "./lib/config.js"; -export * as files from "./lib/files.js"; -export { HTTPClient } from "./lib/http.js"; -export type { Fetcher, HTTPClientOptions } from "./lib/http.js"; -export * from "./sdk/sdk.js"; - -// Message format compatibility helpers -export { fromClaudeMessages, toClaudeMessage } from "./lib/anthropic-compat.js"; -export { fromChatMessages, toChatMessage } from "./lib/chat-compat.js"; -export { extractUnsupportedContent, hasUnsupportedContent, getUnsupportedContentSummary } from "./lib/stream-transformers.js"; - +// Async params support +export type { + CallModelInput, + FieldOrAsyncFunction, + ResolvedCallModelInput, +} from './lib/async-params.js'; +export type { Fetcher, HTTPClientOptions } from './lib/http.js'; +// Tool types +export type { + ChatStreamEvent, + ResponseStreamEvent as EnhancedResponseStreamEvent, + InferToolEvent, + InferToolEventsUnion, + InferToolInput, + InferToolOutput, + ManualTool, + NextTurnParamsContext, + NextTurnParamsFunctions, + ParsedToolCall, + StepResult, + StopCondition, + StopWhen, + Tool, + ToolExecutionResult, + ToolExecutionResultUnion, + ToolPreliminaryResultEvent, + ToolStreamEvent, + ToolWithExecute, + ToolWithGenerator, + TurnContext, + TypedToolCall, + TypedToolCallUnion, + Warning, +} from './lib/tool-types.js'; +export type { BuildTurnContextOptions } from './lib/turn-context.js'; // Claude message types export type { - ClaudeMessage, - ClaudeMessageParam, + ClaudeBase64ImageSource, + ClaudeCacheControl, + ClaudeCitationCharLocation, + ClaudeCitationContentBlockLocation, + ClaudeCitationPageLocation, + ClaudeCitationSearchResultLocation, + ClaudeCitationWebSearchResultLocation, ClaudeContentBlock, ClaudeContentBlockParam, - ClaudeTextBlock, - ClaudeThinkingBlock, + ClaudeImageBlockParam, + ClaudeMessage, + ClaudeMessageParam, ClaudeRedactedThinkingBlock, - ClaudeToolUseBlock, ClaudeServerToolUseBlock, - ClaudeTextBlockParam, - ClaudeImageBlockParam, - ClaudeToolUseBlockParam, - ClaudeToolResultBlockParam, ClaudeStopReason, - ClaudeUsage, - ClaudeCacheControl, + ClaudeTextBlock, + ClaudeTextBlockParam, ClaudeTextCitation, - ClaudeCitationCharLocation, - ClaudeCitationPageLocation, - ClaudeCitationContentBlockLocation, - ClaudeCitationWebSearchResultLocation, - ClaudeCitationSearchResultLocation, - ClaudeBase64ImageSource, + ClaudeThinkingBlock, + ClaudeToolResultBlockParam, + ClaudeToolUseBlock, + ClaudeToolUseBlockParam, ClaudeURLImageSource, -} from "./models/claude-message.js"; + ClaudeUsage, +} from './models/claude-message.js'; +// Message format compatibility helpers +export { fromClaudeMessages, toClaudeMessage } from './lib/anthropic-compat.js'; +export { + hasAsyncFunctions, + resolveAsyncFunctions, +} from './lib/async-params.js'; +export { fromChatMessages, toChatMessage } from './lib/chat-compat.js'; +// Claude constants and type guards +export { ClaudeContentBlockType, NonClaudeMessageRole } from './lib/claude-constants.js'; +export { isClaudeStyleMessages } from './lib/claude-type-guards.js'; +export * from './lib/config.js'; +export * as files from './lib/files.js'; +export { HTTPClient } from './lib/http.js'; +// Next turn params helpers +export { + applyNextTurnParamsToRequest, + buildNextTurnParamsContext, + executeNextTurnParamsFunctions, +} from './lib/next-turn-params.js'; +// Stop condition helpers +export { + finishReasonIs, + hasToolCall, + isStopConditionMet, + maxCost, + maxTokensUsed, + stepCountIs, +} from './lib/stop-conditions.js'; +export { + extractUnsupportedContent, + getUnsupportedContentSummary, + hasUnsupportedContent, +} from './lib/stream-transformers.js'; // Tool creation helpers -export { tool } from "./lib/tool.js"; - -// Tool types -export type { - Tool, - ToolWithExecute, - ToolWithGenerator, - ManualTool, - TurnContext, - InferToolInput, - InferToolOutput, - InferToolEvent, - InferToolEventsUnion, - TypedToolCall, - TypedToolCallUnion, - ToolStreamEvent, - ChatStreamEvent, - EnhancedResponseStreamEvent, - ToolPreliminaryResultEvent, -} from "./lib/tool-types.js"; - +export { tool } from './lib/tool.js'; export { - ToolType, hasExecuteFunction, isGeneratorTool, isRegularExecuteTool, isToolPreliminaryResultEvent, -} from "./lib/tool-types.js"; + ToolType, +} from './lib/tool-types.js'; +// Turn context helpers +export { buildTurnContext, normalizeInputToArray } from './lib/turn-context.js'; +export * from './sdk/sdk.js'; diff --git a/src/lib/anthropic-compat.ts b/src/lib/anthropic-compat.ts index 1fe68c25..57138bcf 100644 --- a/src/lib/anthropic-compat.ts +++ b/src/lib/anthropic-compat.ts @@ -98,20 +98,21 @@ export function fromClaudeMessages( for (const block of content) { switch (block.type) { case 'text': - textBlocks.push(block as models.ClaudeTextBlockParam); + textBlocks.push(block); break; case 'image': - imageBlocks.push(block as models.ClaudeImageBlockParam); + imageBlocks.push(block); break; case 'tool_use': - toolUseBlocks.push(block as models.ClaudeToolUseBlockParam); + toolUseBlocks.push(block); break; case 'tool_result': - toolResultBlocks.push(block as models.ClaudeToolResultBlockParam); + 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: ${exhaustiveCheck}`); + throw new Error(`Unhandled content block type: ${JSON.stringify(exhaustiveCheck)}`); } } } diff --git a/src/lib/async-params.ts b/src/lib/async-params.ts new file mode 100644 index 00000000..28c96c94 --- /dev/null +++ b/src/lib/async-params.ts @@ -0,0 +1,120 @@ +import type * as models from '../models/index.js'; +import type { StopWhen, Tool, TurnContext } 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); + +/** + * 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 + */ +export type CallModelInput = { + [K in keyof Omit]?: FieldOrAsyncFunction< + models.OpenResponsesRequest[K] + >; +} & { + tools?: TTools; + stopWhen?: StopWhen; +}; + +/** + * 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, messageHistory: [] } + * ); + * // resolved.temperature === 0.2 + * ``` + */ +export async function resolveAsyncFunctions( + input: CallModelInput, + context: TurnContext, +): Promise { + // Build array of resolved entries + const resolvedEntries: Array = []; + + // Iterate over all keys in the input + for (const [key, value] of Object.entries(input)) { + // Skip stopWhen - it's handled separately in ModelResult + // Note: tools are already in API format at this point (converted in callModel()), so we include them + if (key === 'stopWhen') { + 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/claude-constants.ts b/src/lib/claude-constants.ts new file mode 100644 index 00000000..c9d466cf --- /dev/null +++ b/src/lib/claude-constants.ts @@ -0,0 +1,26 @@ +/** + * 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 new file mode 100644 index 00000000..d20073d2 --- /dev/null +++ b/src/lib/claude-type-guards.ts @@ -0,0 +1,83 @@ +import type * as models from "../models/index.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 models.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/model-result.ts b/src/lib/model-result.ts index 8d19f5a1..b4a153f5 100644 --- a/src/lib/model-result.ts +++ b/src/lib/model-result.ts @@ -1,19 +1,25 @@ -import type { OpenRouterCore } from "../core.js"; -import type * as models from "../models/index.js"; -import type { EventStream } from "./event-streams.js"; -import type { RequestOptions } from "./sdks.js"; +import type { OpenRouterCore } from '../core.js'; +import type * as models from '../models/index.js'; +import type { CallModelInput } from './async-params.js'; +import type { EventStream } from './event-streams.js'; +import type { RequestOptions } from './sdks.js'; import type { - ChatStreamEvent, - EnhancedResponseStreamEvent, - Tool, - MaxToolRounds, + ResponseStreamEvent, + InferToolEventsUnion, ParsedToolCall, + StopWhen, + Tool, ToolStreamEvent, TurnContext, -} from "./tool-types.js"; +} from './tool-types.js'; -import { betaResponsesSend } from "../funcs/betaResponsesSend.js"; -import { ReusableReadableStream } from "./reusable-stream.js"; +import { betaResponsesSend } from '../funcs/betaResponsesSend.js'; +import { + hasAsyncFunctions, + resolveAsyncFunctions, + type ResolvedCallModelInput, +} from './async-params.js'; +import { ReusableReadableStream } from './reusable-stream.js'; import { buildResponsesMessageStream, buildToolCallStream, @@ -24,63 +30,53 @@ import { extractTextFromResponse, extractToolCallsFromResponse, extractToolDeltas, -} from "./stream-transformers.js"; -import { executeTool } from "./tool-executor.js"; -import { hasExecuteFunction } from "./tool-types.js"; +} from './stream-transformers.js'; +import { executeTool } from './tool-executor.js'; +import { executeNextTurnParamsFunctions, applyNextTurnParamsToRequest } from './next-turn-params.js'; +import { hasExecuteFunction } from './tool-types.js'; +import { isStopConditionMet } from './stop-conditions.js'; /** * Type guard for stream event with toReadableStream method */ -function isEventStream( - value: unknown -): value is EventStream { +function isEventStream(value: unknown): value is EventStream { return ( value !== null && - typeof value === "object" && - "toReadableStream" in value && - typeof (value as { toReadableStream: unknown }).toReadableStream === - "function" + typeof value === 'object' && + 'toReadableStream' in value && + typeof ( + value as { + toReadableStream: unknown; + } + ).toReadableStream === 'function' ); } -/** - * Type guard for response.output_text.delta events - */ -function isOutputTextDeltaEvent( - event: models.OpenResponsesStreamEvent -): event is models.OpenResponsesStreamEventResponseOutputTextDelta { - return "type" in event && event.type === "response.output_text.delta"; -} - -/** - * Type guard for response.completed events - */ -function isResponseCompletedEvent( - event: models.OpenResponsesStreamEvent -): event is models.OpenResponsesStreamEventResponseCompleted { - return "type" in event && event.type === "response.completed"; -} - /** * Type guard for output items with a type property */ -function hasTypeProperty( - item: unknown -): item is { type: string } { +function hasTypeProperty(item: unknown): item is { + type: string; +} { return ( - typeof item === "object" && + typeof item === 'object' && item !== null && - "type" in item && - typeof (item as { type: unknown }).type === "string" + 'type' in item && + typeof ( + item as { + type: unknown; + } + ).type === 'string' ); } -export interface GetResponseOptions { - request: models.OpenResponsesRequest; +export interface GetResponseOptions { + // Request can have async functions that will be resolved before sending to API + request: CallModelInput; client: OpenRouterCore; options?: RequestOptions; - tools?: Tool[]; - maxToolRounds?: MaxToolRounds; + tools?: TTools; + stopWhen?: StopWhen; } /** @@ -99,26 +95,28 @@ export interface GetResponseOptions { * * 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 */ -export class ModelResult { - private reusableStream: ReusableReadableStream | null = - null; - private streamPromise: Promise< - EventStream - > | null = null; +export class ModelResult { + private reusableStream: ReusableReadableStream | null = null; + private streamPromise: Promise> | null = null; private textPromise: Promise | null = null; - private options: GetResponseOptions; + private options: GetResponseOptions; private initPromise: Promise | null = null; private toolExecutionPromise: Promise | null = null; private finalResponse: models.OpenResponsesNonStreamingResponse | null = null; private preliminaryResults: Map = new Map(); private allToolExecutionRounds: Array<{ round: number; - toolCalls: ParsedToolCall[]; + toolCalls: ParsedToolCall[]; response: models.OpenResponsesNonStreamingResponse; + toolResults: Array; }> = []; + // Track resolved request after async function resolution + private resolvedRequest: models.OpenResponsesRequest | null = null; - constructor(options: GetResponseOptions) { + constructor(options: GetResponseOptions) { this.options = options; } @@ -126,15 +124,15 @@ export class ModelResult { * Type guard to check if a value is a non-streaming response */ private isNonStreamingResponse( - value: unknown + value: unknown, ): value is models.OpenResponsesNonStreamingResponse { return ( value !== null && - typeof value === "object" && - "id" in value && - "object" in value && - "output" in value && - !("toReadableStream" in value) + typeof value === 'object' && + 'id' in value && + 'object' in value && + 'output' in value && + !('toReadableStream' in value) ); } @@ -148,22 +146,51 @@ export class ModelResult { } this.initPromise = (async () => { - // Force stream mode - const request = { - ...this.options.request, + // Resolve async functions before initial request + // Build initial turn context (turn 0 for initial request) + const initialContext: TurnContext = { + numberOfTurns: 0, + }; + + // Resolve any async functions first + let baseRequest: ResolvedCallModelInput; + if (hasAsyncFunctions(this.options.request)) { + baseRequest = await resolveAsyncFunctions( + this.options.request, + initialContext, + ); + } else { + // Already resolved, extract non-function fields + // Since request is CallModelInput, we need to filter out stopWhen + // Note: tools are already in API format at this point (converted in callModel()) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { stopWhen, ...rest } = this.options.request; + // Cast to ResolvedCallModelInput - we know it's resolved if hasAsyncFunctions returned false + baseRequest = rest as ResolvedCallModelInput; + } + + // Store resolved request with stream mode + this.resolvedRequest = { + ...baseRequest, stream: true as const, }; + // Force stream mode for initial request + const request = this.resolvedRequest; + // Create the stream promise this.streamPromise = betaResponsesSend( this.options.client, request, - this.options.options + this.options.options, ).then((result) => { if (!result.ok) { throw result.error; } - return result.value; + // When stream: true, the API returns EventStream + // TypeScript can't narrow the union type based on runtime parameter values, + // so we assert the type here based on our knowledge that stream=true + return result.value as EventStream; }); // Wait for the stream and create the reusable stream @@ -187,20 +214,19 @@ export class ModelResult { await this.initStream(); if (!this.reusableStream) { - throw new Error("Stream not initialized"); + throw new Error('Stream not initialized'); } + // Note: Async functions already resolved in initStream() // Get the initial response - const initialResponse = await consumeStreamForCompletion( - this.reusableStream - ); + const initialResponse = await consumeStreamForCompletion(this.reusableStream); // Check if we have tools and if auto-execution is enabled const shouldAutoExecute = this.options.tools && this.options.tools.length > 0 && initialResponse.output.some( - (item) => hasTypeProperty(item) && item.type === "function_call" + (item) => hasTypeProperty(item) && item.type === 'function_call', ); if (!shouldAutoExecute) { @@ -214,9 +240,7 @@ export class ModelResult { // Check if any have execute functions const executableTools = toolCalls.filter((toolCall) => { - const tool = this.options.tools?.find( - (t) => t.function.name === toolCall.name - ); + const tool = this.options.tools?.find((t) => t.function.name === toolCall.name); return tool && hasExecuteFunction(tool); }); @@ -226,15 +250,38 @@ export class ModelResult { return; } - // Get maxToolRounds configuration - const maxToolRounds = this.options.maxToolRounds ?? 5; - let currentResponse = initialResponse; let currentRound = 0; - let currentInput: models.OpenResponsesInput = - this.options.request.input || []; while (true) { + // Check stopWhen conditions + if (this.options.stopWhen) { + const stopConditions = Array.isArray(this.options.stopWhen) + ? this.options.stopWhen + : [this.options.stopWhen]; + + const shouldStop = await 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: JSON.parse(tr.output), + })), + response: round.response, + usage: round.response.usage, + finishReason: undefined, // OpenResponsesNonStreamingResponse doesn't have finishReason + })), + }); + + if (shouldStop) { + break; + } + } + const currentToolCalls = extractToolCallsFromResponse(currentResponse); if (currentToolCalls.length === 0) { @@ -242,9 +289,7 @@ export class ModelResult { } const hasExecutable = currentToolCalls.some((toolCall) => { - const tool = this.options.tools?.find( - (t) => t.function.name === toolCall.name - ); + const tool = this.options.tools?.find((t) => t.function.name === toolCall.name); return tool && hasExecuteFunction(tool); }); @@ -252,55 +297,29 @@ export class ModelResult { break; } - // Check if we should continue based on maxToolRounds - if (typeof maxToolRounds === "number") { - if (currentRound >= maxToolRounds) { - break; - } - } else if (typeof maxToolRounds === "function") { - // Function signature: (context: TurnContext) => boolean - const turnContext: TurnContext = { - numberOfTurns: currentRound + 1, - messageHistory: currentInput, - ...(this.options.request.model && { - model: this.options.request.model, - }), - ...(this.options.request.models && { - models: this.options.request.models, - }), - }; - const shouldContinue = maxToolRounds(turnContext); - if (!shouldContinue) { - break; - } - } - - // Store execution round info - this.allToolExecutionRounds.push({ - round: currentRound, - toolCalls: currentToolCalls, - response: currentResponse, - }); - - // Build turn context for tool execution + // Build turn context for this round (for async parameter resolution only) const turnContext: TurnContext = { numberOfTurns: currentRound + 1, // 1-indexed - messageHistory: currentInput, - ...(this.options.request.model && { - model: this.options.request.model, - }), - ...(this.options.request.models && { - models: this.options.request.models, - }), }; + // Resolve async functions for this turn + if (hasAsyncFunctions(this.options.request)) { + const resolved = await resolveAsyncFunctions( + this.options.request, + turnContext, + ); + // Update resolved request with new values + this.resolvedRequest = { + ...resolved, + stream: false, // Tool execution turns don't need streaming + }; + } + // Execute all tool calls const toolResults: Array = []; for (const toolCall of currentToolCalls) { - const tool = this.options.tools?.find( - (t) => t.function.name === toolCall.name - ); + const tool = this.options.tools?.find((t) => t.function.name === toolCall.name); if (!tool || !hasExecuteFunction(tool)) { continue; @@ -309,40 +328,69 @@ export class ModelResult { const result = await executeTool(tool, toolCall, turnContext); // Store preliminary results - if ( - result.preliminaryResults && - result.preliminaryResults.length > 0 - ) { + if (result.preliminaryResults && result.preliminaryResults.length > 0) { this.preliminaryResults.set(toolCall.id, result.preliminaryResults); } toolResults.push({ - type: "function_call_output" as const, + type: 'function_call_output' as const, id: `output_${toolCall.id}`, callId: toolCall.id, output: result.error ? JSON.stringify({ - error: result.error.message, - }) + error: result.error.message, + }) : JSON.stringify(result.result), }); } + // Store execution round info including tool results + this.allToolExecutionRounds.push({ + round: currentRound, + toolCalls: currentToolCalls, + response: currentResponse, + toolResults, + }); + + // Execute nextTurnParams functions for tools that were called + if (this.options.tools && currentToolCalls.length > 0) { + if (!this.resolvedRequest) { + throw new Error('Request not initialized'); + } + + const computedParams = await executeNextTurnParamsFunctions( + currentToolCalls, + this.options.tools, + this.resolvedRequest + ); + + // Apply computed parameters to the resolved request for next turn + if (Object.keys(computedParams).length > 0) { + this.resolvedRequest = applyNextTurnParamsToRequest( + this.resolvedRequest, + computedParams + ); + } + } + // Build new input with tool results // For the Responses API, we need to include the tool results in the input const newInput: models.OpenResponsesInput = [ ...(Array.isArray(currentResponse.output) ? currentResponse.output - : [currentResponse.output]), + : [ + currentResponse.output, + ]), ...toolResults, ]; - // Update current input for next iteration - currentInput = newInput; - // Make new request with tool results + if (!this.resolvedRequest) { + throw new Error('Request not initialized'); + } + const newRequest: models.OpenResponsesRequest = { - ...this.options.request, + ...this.resolvedRequest, input: newInput, stream: false, }; @@ -350,7 +398,7 @@ export class ModelResult { const newResult = await betaResponsesSend( this.options.client, newRequest, - this.options.options + this.options.options, ); if (!newResult.ok) { @@ -366,7 +414,7 @@ export class ModelResult { } else if (this.isNonStreamingResponse(value)) { currentResponse = value; } else { - throw new Error("Unexpected response type from API"); + throw new Error('Unexpected response type from API'); } currentRound++; @@ -374,15 +422,12 @@ export class ModelResult { // Validate the final response has required fields if (!currentResponse || !currentResponse.id || !currentResponse.output) { - throw new Error("Invalid final response: missing required fields"); + throw new Error('Invalid final response: missing required fields'); } // Ensure the response is in a completed state (has output content) - if ( - !Array.isArray(currentResponse.output) || - currentResponse.output.length === 0 - ) { - throw new Error("Invalid final response: empty or invalid output"); + if (!Array.isArray(currentResponse.output) || currentResponse.output.length === 0) { + throw new Error('Invalid final response: empty or invalid output'); } this.finalResponse = currentResponse; @@ -398,7 +443,7 @@ export class ModelResult { await this.executeToolsIfNeeded(); if (!this.finalResponse) { - throw new Error("Response not available"); + throw new Error('Response not available'); } return extractTextFromResponse(this.finalResponse); @@ -426,7 +471,7 @@ export class ModelResult { await this.executeToolsIfNeeded(); if (!this.finalResponse) { - throw new Error("Response not available"); + throw new Error('Response not available'); } return this.finalResponse; @@ -437,11 +482,11 @@ export class ModelResult { * Multiple consumers can iterate over this stream concurrently. * Includes preliminary tool result events after tool execution. */ - getFullResponsesStream(): AsyncIterableIterator { - return async function* (this: ModelResult) { + getFullResponsesStream(): AsyncIterableIterator>> { + return async function* (this: ModelResult) { await this.initStream(); if (!this.reusableStream) { - throw new Error("Stream not initialized"); + throw new Error('Stream not initialized'); } const consumer = this.reusableStream.createConsumer(); @@ -458,9 +503,9 @@ export class ModelResult { for (const [toolCallId, results] of this.preliminaryResults) { for (const result of results) { yield { - type: "tool.preliminary_result" as const, + type: 'tool.preliminary_result' as const, toolCallId, - result, + result: result as InferToolEventsUnion, timestamp: Date.now(), }; } @@ -473,10 +518,10 @@ export class ModelResult { * This filters the full event stream to only yield text content. */ getTextStream(): AsyncIterableIterator { - return async function* (this: ModelResult) { + return async function* (this: ModelResult) { await this.initStream(); if (!this.reusableStream) { - throw new Error("Stream not initialized"); + throw new Error('Stream not initialized'); } yield* extractTextDeltas(this.reusableStream); @@ -492,10 +537,10 @@ export class ModelResult { getNewMessagesStream(): AsyncIterableIterator< models.ResponsesOutputMessage | models.OpenResponsesFunctionCallOutput > { - return async function* (this: ModelResult) { + return async function* (this: ModelResult) { await this.initStream(); if (!this.reusableStream) { - throw new Error("Stream not initialized"); + throw new Error('Stream not initialized'); } // First yield messages from the stream in responses format @@ -504,31 +549,10 @@ export class ModelResult { // Execute tools if needed await this.executeToolsIfNeeded(); - // Yield function call output for each executed tool + // Yield function call outputs for each executed tool for (const round of this.allToolExecutionRounds) { - for (const toolCall of round.toolCalls) { - // Find the tool to check if it was executed - const tool = this.options.tools?.find( - (t) => t.function.name === toolCall.name - ); - if (!tool || !hasExecuteFunction(tool)) { - continue; - } - - // Get the result from preliminary results or construct from the response - const prelimResults = this.preliminaryResults.get(toolCall.id); - const result = - prelimResults && prelimResults.length > 0 - ? prelimResults[prelimResults.length - 1] // Last result is the final output - : undefined; - - // Yield function call output in responses format - yield { - type: "function_call_output" as const, - id: `output_${toolCall.id}`, - callId: toolCall.id, - output: result !== undefined ? JSON.stringify(result) : "", - } as models.OpenResponsesFunctionCallOutput; + for (const toolResult of round.toolResults) { + yield toolResult; } } @@ -536,7 +560,7 @@ export class ModelResult { if (this.finalResponse && this.allToolExecutionRounds.length > 0) { // Check if the final response contains a message const hasMessage = this.finalResponse.output.some( - (item) => hasTypeProperty(item) && item.type === "message" + (item: unknown) => hasTypeProperty(item) && item.type === 'message', ); if (hasMessage) { yield extractResponsesMessageFromResponse(this.finalResponse); @@ -545,15 +569,16 @@ export class ModelResult { }.call(this); } + /** * Stream only reasoning deltas as they arrive. * This filters the full event stream to only yield reasoning content. */ getReasoningStream(): AsyncIterableIterator { - return async function* (this: ModelResult) { + return async function* (this: ModelResult) { await this.initStream(); if (!this.reusableStream) { - throw new Error("Stream not initialized"); + throw new Error('Stream not initialized'); } yield* extractReasoningDeltas(this.reusableStream); @@ -566,17 +591,17 @@ export class ModelResult { * - Tool call argument deltas as { type: "delta", content: string } * - Preliminary results as { type: "preliminary_result", toolCallId, result } */ - getToolStream(): AsyncIterableIterator { - return async function* (this: ModelResult) { + getToolStream(): AsyncIterableIterator>> { + return async function* (this: ModelResult) { await this.initStream(); if (!this.reusableStream) { - throw new Error("Stream not initialized"); + throw new Error('Stream not initialized'); } // Yield tool deltas as structured events for await (const delta of extractToolDeltas(this.reusableStream)) { yield { - type: "delta" as const, + type: 'delta' as const, content: delta, }; } @@ -588,69 +613,9 @@ export class ModelResult { for (const [toolCallId, results] of this.preliminaryResults) { for (const result of results) { yield { - type: "preliminary_result" as const, + type: 'preliminary_result' as const, toolCallId, - result, - }; - } - } - }.call(this); - } - - /** - * Stream events in chat format (compatibility layer). - * Note: This transforms responses API events into a chat-like format. - * Includes preliminary tool result events after tool execution. - * - * @remarks - * This is a compatibility method that attempts to transform the responses API - * stream into a format similar to the chat API. Due to differences in the APIs, - * this may not be a perfect mapping. - */ - getFullChatStream(): AsyncIterableIterator { - return async function* (this: ModelResult) { - await this.initStream(); - if (!this.reusableStream) { - throw new Error("Stream not initialized"); - } - - const consumer = this.reusableStream.createConsumer(); - - for await (const event of consumer) { - if (!("type" in event)) { - continue; - } - - // Transform responses events to chat-like format using type guards - if (isOutputTextDeltaEvent(event)) { - yield { - type: "content.delta" as const, - delta: event.delta, - }; - } else if (isResponseCompletedEvent(event)) { - yield { - type: "message.complete" as const, - response: event.response, - }; - } else { - // Pass through other events - yield { - type: event.type, - event, - }; - } - } - - // After stream completes, check if tools were executed and emit preliminary results - await this.executeToolsIfNeeded(); - - // Emit all preliminary results - for (const [toolCallId, results] of this.preliminaryResults) { - for (const result of results) { - yield { - type: "tool.preliminary_result" as const, - toolCallId, - result, + result: result as InferToolEventsUnion, }; } } @@ -663,30 +628,28 @@ export class ModelResult { * and this will return the tool calls from the initial response. * Returns structured tool calls with parsed arguments. */ - async getToolCalls(): Promise { + async getToolCalls(): Promise[]> { await this.initStream(); if (!this.reusableStream) { - throw new Error("Stream not initialized"); + throw new Error('Stream not initialized'); } - const completedResponse = await consumeStreamForCompletion( - this.reusableStream - ); - return extractToolCallsFromResponse(completedResponse); + 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) { + getToolCallsStream(): AsyncIterableIterator> { + return async function* (this: ModelResult) { await this.initStream(); if (!this.reusableStream) { - throw new Error("Stream not initialized"); + throw new Error('Stream not initialized'); } - yield* buildToolCallStream(this.reusableStream); + yield* buildToolCallStream(this.reusableStream) as AsyncIterableIterator>; }.call(this); } diff --git a/src/lib/next-turn-params.ts b/src/lib/next-turn-params.ts new file mode 100644 index 00000000..5811f9dc --- /dev/null +++ b/src/lib/next-turn-params.ts @@ -0,0 +1,171 @@ +import type * as models from '../models/index.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 OpenResponsesRequest + * @returns Context object with current parameter values + */ +export function buildNextTurnParamsContext( + request: models.OpenResponsesRequest +): 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: models.OpenResponsesRequest +): 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: models.OpenResponsesRequest, + computedParams: Partial +): models.OpenResponsesRequest { + return { + ...request, + ...computedParams, + }; +} diff --git a/src/lib/reusable-stream.ts b/src/lib/reusable-stream.ts index 715f5321..58871606 100644 --- a/src/lib/reusable-stream.ts +++ b/src/lib/reusable-stream.ts @@ -83,26 +83,27 @@ export class ReusableReadableStream { throw self.sourceError; } - // Wait for more data - but check conditions after setting up the promise - // to avoid race condition where source completes between check and wait + // 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, }; - }); - // Double-check conditions after setting up promise to handle race - if (self.sourceComplete || self.sourceError || consumer.position < self.buffer.length) { - // Resolve immediately if conditions changed - if (consumer.waitingPromise) { - consumer.waitingPromise.resolve(); - consumer.waitingPromise = null; + // 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(); }, diff --git a/src/lib/stop-conditions.ts b/src/lib/stop-conditions.ts new file mode 100644 index 00000000..96886239 --- /dev/null +++ b/src/lib/stop-conditions.ts @@ -0,0 +1,129 @@ +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 index cf65d1bb..0b4e4aec 100644 --- a/src/lib/stream-transformers.ts +++ b/src/lib/stream-transformers.ts @@ -1,6 +1,28 @@ import type * as models from '../models/index.js'; import type { ReusableReadableStream } from './reusable-stream.js'; -import type { ParsedToolCall } from './tool-types.js'; +import type { ParsedToolCall, Tool } from './tool-types.js'; +import { + isOutputTextDeltaEvent, + isReasoningDeltaEvent, + isFunctionCallArgumentsDeltaEvent, + isOutputItemAddedEvent, + isOutputItemDoneEvent, + isResponseCompletedEvent, + isResponseFailedEvent, + isResponseIncompleteEvent, + isFunctionCallArgumentsDoneEvent, + isOutputMessage, + isFunctionCallOutputItem, + isReasoningOutputItem, + isWebSearchCallOutputItem, + isFileSearchCallOutputItem, + isImageGenerationCallOutputItem, + isOutputTextPart, + isRefusalPart, + isFileCitationAnnotation, + isURLCitationAnnotation, + isFilePathAnnotation, +} from './stream-type-guards.js'; /** * Extract text deltas from responses stream events @@ -11,10 +33,9 @@ export async function* extractTextDeltas( const consumer = stream.createConsumer(); for await (const event of consumer) { - if ('type' in event && event.type === 'response.output_text.delta') { - const deltaEvent = event as models.OpenResponsesStreamEventResponseOutputTextDelta; - if (deltaEvent.delta) { - yield deltaEvent.delta; + if (isOutputTextDeltaEvent(event)) { + if (event.delta) { + yield event.delta; } } } @@ -29,10 +50,9 @@ export async function* extractReasoningDeltas( const consumer = stream.createConsumer(); for await (const event of consumer) { - if ('type' in event && event.type === 'response.reasoning_text.delta') { - const deltaEvent = event as models.OpenResponsesReasoningDeltaEvent; - if (deltaEvent.delta) { - yield deltaEvent.delta; + if (isReasoningDeltaEvent(event)) { + if (event.delta) { + yield event.delta; } } } @@ -47,22 +67,26 @@ export async function* extractToolDeltas( const consumer = stream.createConsumer(); for await (const event of consumer) { - if ('type' in event && event.type === 'response.function_call_arguments.delta') { - const deltaEvent = event as models.OpenResponsesStreamEventResponseFunctionCallArgumentsDelta; - if (deltaEvent.delta) { - yield deltaEvent.delta; + if (isFunctionCallArgumentsDeltaEvent(event)) { + if (event.delta) { + yield event.delta; } } } } /** - * Build incremental message updates from responses stream events - * Returns ResponsesOutputMessage (assistant/responses format) + * Core message stream builder - shared logic for both formats + * Accumulates text deltas and yields updates */ -export async function* buildResponsesMessageStream( +async function* buildMessageStreamCore( stream: ReusableReadableStream, -): AsyncIterableIterator { +): AsyncIterableIterator<{ + type: 'delta' | 'complete'; + text?: string; + messageId?: string; + completeMessage?: models.ResponsesOutputMessage; +}> { const consumer = stream.createConsumer(); // Track the accumulated text and message info @@ -77,52 +101,82 @@ export async function* buildResponsesMessageStream( switch (event.type) { case 'response.output_item.added': { - const itemEvent = event as models.OpenResponsesStreamEventResponseOutputItemAdded; - if (itemEvent.item && 'type' in itemEvent.item && itemEvent.item.type === 'message') { - hasStarted = true; - currentText = ''; - const msgItem = itemEvent.item as models.ResponsesOutputMessage; - currentId = msgItem.id; + if (isOutputItemAddedEvent(event)) { + if (event.item && isOutputMessage(event.item)) { + hasStarted = true; + currentText = ''; + currentId = event.item.id; + } } break; } case 'response.output_text.delta': { - const deltaEvent = event as models.OpenResponsesStreamEventResponseOutputTextDelta; - if (hasStarted && deltaEvent.delta) { - currentText += deltaEvent.delta; - - // Yield updated message in ResponsesOutputMessage format - yield { - id: currentId, - type: 'message' as const, - role: 'assistant' as const, - status: 'in_progress' as const, - content: [ - { - type: 'output_text' as const, - text: currentText, - annotations: [], - }, - ], - }; + 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': { - const itemDoneEvent = event as models.OpenResponsesStreamEventResponseOutputItemDone; - if ( - itemDoneEvent.item && - 'type' in itemDoneEvent.item && - itemDoneEvent.item.type === 'message' - ) { - // Yield final complete message in ResponsesOutputMessage format - const outputMessage = itemDoneEvent.item as models.ResponsesOutputMessage; - yield outputMessage; + 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 ResponsesOutputMessage (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 ResponsesOutputMessage 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; } } } @@ -134,54 +188,16 @@ export async function* buildResponsesMessageStream( export async function* buildMessageStream( stream: ReusableReadableStream, ): AsyncIterableIterator { - const consumer = stream.createConsumer(); - - // Track the accumulated text - let currentText = ''; - let hasStarted = false; - - for await (const event of consumer) { - if (!('type' in event)) { - continue; - } - - switch (event.type) { - case 'response.output_item.added': { - const itemEvent = event as models.OpenResponsesStreamEventResponseOutputItemAdded; - if (itemEvent.item && 'type' in itemEvent.item && itemEvent.item.type === 'message') { - hasStarted = true; - currentText = ''; - } - break; - } - - case 'response.output_text.delta': { - const deltaEvent = event as models.OpenResponsesStreamEventResponseOutputTextDelta; - if (hasStarted && deltaEvent.delta) { - currentText += deltaEvent.delta; - - // Yield updated message - yield { - role: 'assistant' as const, - content: currentText, - }; - } - break; - } - - case 'response.output_item.done': { - const itemDoneEvent = event as models.OpenResponsesStreamEventResponseOutputItemDone; - if ( - itemDoneEvent.item && - 'type' in itemDoneEvent.item && - itemDoneEvent.item.type === 'message' - ) { - // Yield final complete message - const outputMessage = itemDoneEvent.item as models.ResponsesOutputMessage; - yield convertToAssistantMessage(outputMessage); - } - break; - } + 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); } } } @@ -199,21 +215,18 @@ export async function consumeStreamForCompletion( continue; } - if (event.type === 'response.completed') { - const completedEvent = event as models.OpenResponsesStreamEventResponseCompleted; - return completedEvent.response; + if (isResponseCompletedEvent(event)) { + return event.response; } - if (event.type === 'response.failed') { - const failedEvent = event as models.OpenResponsesStreamEventResponseFailed; + if (isResponseFailedEvent(event)) { // The failed event contains the full response with error information - throw new Error(`Response failed: ${JSON.stringify(failedEvent.response.error)}`); + throw new Error(`Response failed: ${JSON.stringify(event.response.error)}`); } - if (event.type === 'response.incomplete') { - const incompleteEvent = event as models.OpenResponsesStreamEventResponseIncomplete; + if (isResponseIncompleteEvent(event)) { // Return the incomplete response - return incompleteEvent.response; + return event.response; } } @@ -285,6 +298,16 @@ export function extractTextFromResponse( return response.outputText; } + // Check if there's a message in the output + const hasMessage = response.output.some( + (item): item is models.ResponsesOutputMessage => '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 AssistantMessage which has string content) const message = extractMessageFromResponse(response); @@ -302,28 +325,31 @@ export function extractTextFromResponse( */ export function extractToolCallsFromResponse( response: models.OpenResponsesNonStreamingResponse, -): ParsedToolCall[] { - const toolCalls: ParsedToolCall[] = []; +): ParsedToolCall[] { + const toolCalls: ParsedToolCall[] = []; for (const item of response.output) { - if ('type' in item && item.type === 'function_call') { - const functionCallItem = item as models.ResponsesOutputItemFunctionCall; - + if (isFunctionCallOutputItem(item)) { try { - const parsedArguments = JSON.parse(functionCallItem.arguments); + const parsedArguments = JSON.parse(item.arguments); toolCalls.push({ - id: functionCallItem.callId, - name: functionCallItem.name, + id: item.callId, + name: item.name, arguments: parsedArguments, }); - } catch (_error) { + } 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: functionCallItem.callId, - name: functionCallItem.name, - arguments: functionCallItem.arguments, // Keep as string if parsing fails - }); + id: item.callId, + name: item.name, + arguments: item.arguments as unknown, // Keep as string if parsing fails + } as ParsedToolCall); } } } @@ -337,7 +363,7 @@ export function extractToolCallsFromResponse( */ export async function* buildToolCallStream( stream: ReusableReadableStream, -): AsyncIterableIterator { +): AsyncIterableIterator> { const consumer = stream.createConsumer(); // Track tool calls being built @@ -357,12 +383,10 @@ export async function* buildToolCallStream( switch (event.type) { case 'response.output_item.added': { - const itemEvent = event as models.OpenResponsesStreamEventResponseOutputItemAdded; - if (itemEvent.item && 'type' in itemEvent.item && itemEvent.item.type === 'function_call') { - const functionCallItem = itemEvent.item as models.ResponsesOutputItemFunctionCall; - toolCallsInProgress.set(functionCallItem.callId, { - id: functionCallItem.callId, - name: functionCallItem.name, + if (isOutputItemAddedEvent(event) && event.item && isFunctionCallOutputItem(event.item)) { + toolCallsInProgress.set(event.item.callId, { + id: event.item.callId, + name: event.item.name, argumentsAccumulated: '', }); } @@ -370,70 +394,69 @@ export async function* buildToolCallStream( } case 'response.function_call_arguments.delta': { - const deltaEvent = - event as models.OpenResponsesStreamEventResponseFunctionCallArgumentsDelta; - const toolCall = toolCallsInProgress.get(deltaEvent.itemId); - if (toolCall && deltaEvent.delta) { - toolCall.argumentsAccumulated += deltaEvent.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': { - const doneEvent = event as models.OpenResponsesStreamEventResponseFunctionCallArgumentsDone; - const toolCall = toolCallsInProgress.get(doneEvent.itemId); + if (isFunctionCallArgumentsDoneEvent(event)) { + const toolCall = toolCallsInProgress.get(event.itemId); - if (toolCall) { - // Parse complete arguments - try { - const parsedArguments = JSON.parse(doneEvent.arguments); - yield { - id: toolCall.id, - name: doneEvent.name, - arguments: parsedArguments, - }; - } catch (_error) { - // Yield with unparsed arguments if parsing fails - yield { - id: toolCall.id, - name: doneEvent.name, - arguments: doneEvent.arguments, - }; - } + if (toolCall) { + // Parse complete arguments + try { + const parsedArguments = JSON.parse(event.arguments); + 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(doneEvent.itemId); + // Clean up + toolCallsInProgress.delete(event.itemId); + } } break; } case 'response.output_item.done': { - const itemDoneEvent = event as models.OpenResponsesStreamEventResponseOutputItemDone; - if ( - itemDoneEvent.item && - 'type' in itemDoneEvent.item && - itemDoneEvent.item.type === 'function_call' - ) { - const functionCallItem = itemDoneEvent.item as models.ResponsesOutputItemFunctionCall; - + if (isOutputItemDoneEvent(event) && event.item && isFunctionCallOutputItem(event.item)) { // Yield final tool call if we haven't already - if (toolCallsInProgress.has(functionCallItem.callId)) { + if (toolCallsInProgress.has(event.item.callId)) { try { - const parsedArguments = JSON.parse(functionCallItem.arguments); + const parsedArguments = JSON.parse(event.item.arguments); yield { - id: functionCallItem.callId, - name: functionCallItem.name, + id: event.item.callId, + name: event.item.name, arguments: parsedArguments, }; } catch (_error) { yield { - id: functionCallItem.callId, - name: functionCallItem.name, - arguments: functionCallItem.arguments, - }; + id: event.item.callId, + name: event.item.name, + arguments: event.item.arguments as unknown, + } as ParsedToolCall; } - toolCallsInProgress.delete(functionCallItem.callId); + toolCallsInProgress.delete(event.item.callId); } } break; @@ -468,55 +491,56 @@ function mapAnnotationsToCitations( switch (annotation.type) { case 'file_citation': { - const fileCite = annotation as models.FileCitation; - citations.push({ - type: 'char_location', - cited_text: '', - document_index: fileCite.index, - document_title: fileCite.filename, - file_id: fileCite.fileId, - start_char_index: 0, - end_char_index: 0, - }); + 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': { - const urlCite = annotation as models.URLCitation; - citations.push({ - type: 'web_search_result_location', - cited_text: '', - title: urlCite.title, - url: urlCite.url, - encrypted_index: '', - }); + if (isURLCitationAnnotation(annotation)) { + citations.push({ + type: 'web_search_result_location', + cited_text: '', + title: annotation.title, + url: annotation.url, + encrypted_index: '', + }); + } break; } case 'file_path': { - const pathCite = annotation as models.FilePath; - citations.push({ - type: 'char_location', - cited_text: '', - document_index: pathCite.index, - document_title: '', - file_id: pathCite.fileId, - start_char_index: 0, - end_char_index: 0, - }); + 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: { - const _exhaustiveCheck: never = annotation; + // Exhaustiveness check - TypeScript will error if we don't handle all annotation types + const exhaustiveCheck: never = annotation; + // Cast to unknown for runtime debugging if type system bypassed + // This should never execute - throw with JSON of the unhandled value throw new Error( - `Unhandled annotation type: ${ - ( - _exhaustiveCheck as { - type: string; - } - ).type - }`, + `Unhandled annotation type. This indicates a new annotation type was added. ` + + `Annotation: ${JSON.stringify(exhaustiveCheck as unknown)}` ); } } @@ -570,9 +594,13 @@ export function convertToClaudeMessage( 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: item as Record, + data: itemData, reason: 'Output item missing type field', }); continue; @@ -580,159 +608,162 @@ export function convertToClaudeMessage( switch (item.type) { case 'message': { - const msgItem = item as models.ResponsesOutputMessage; - for (const part of msgItem.content) { - if (!('type' in part)) { - unsupportedContent.push({ - original_type: 'unknown_message_part', - data: part as Record, - reason: 'Message content part missing type field', - }); - continue; - } + 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 (part.type === 'output_text') { - const textPart = part as models.ResponseOutputText; - const citations = mapAnnotationsToCitations(textPart.annotations); + if (isOutputTextPart(part)) { + const citations = mapAnnotationsToCitations(part.annotations); - content.push({ - type: 'text', - text: textPart.text, - ...(citations && { - citations, - }), - }); - } else if (part.type === 'refusal') { - const refusalPart = part as models.OpenAIResponsesRefusalContent; - unsupportedContent.push({ - original_type: 'refusal', - data: { - refusal: refusalPart.refusal, - }, - reason: 'Claude does not have a native refusal content type', - }); - } else { - // Handle unknown message content types - unsupportedContent.push({ - original_type: `message_content_${ - ( - part as { - type: string; - } - ).type - }`, - data: part as Record, - reason: 'Unknown message content type', - }); + 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 { + // Exhaustiveness check - TypeScript will error if we don't handle all part types + const exhaustiveCheck: never = part; + // This should never execute - new content type was added + throw new Error( + `Unhandled message content type. This indicates a new content type was added. ` + + `Part: ${JSON.stringify(exhaustiveCheck)}` + ); + } } } break; } case 'function_call': { - const fnCall = item as models.ResponsesOutputItemFunctionCall; - let parsedInput: Record; - - try { - parsedInput = JSON.parse(fnCall.arguments); - } catch (error) { - // Preserve raw arguments if JSON parsing fails - // Log warning in development/debug environments - if (typeof process !== 'undefined' && process.env?.['NODE_ENV'] === 'development') { - // biome-ignore lint/suspicious/noConsole: needed for debugging in development - console.warn(`Failed to parse tool call arguments for ${fnCall.name}:`, error); + if (isFunctionCallOutputItem(item)) { + let parsedInput: Record; + + try { + parsedInput = JSON.parse(item.arguments); + } 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, + }; } - parsedInput = { - _raw_arguments: fnCall.arguments, - }; - } - content.push({ - type: 'tool_use', - id: fnCall.callId, - name: fnCall.name, - input: parsedInput, - }); + content.push({ + type: 'tool_use', + id: item.callId, + name: item.name, + input: parsedInput, + }); + } break; } case 'reasoning': { - const reasoningItem = item as models.ResponsesOutputItemReasoning; - - if (reasoningItem.summary && reasoningItem.summary.length > 0) { - for (const summaryItem of reasoningItem.summary) { - if (summaryItem.type === 'summary_text' && summaryItem.text) { - content.push({ - type: 'thinking', - thinking: summaryItem.text, - signature: '', - }); + 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 (reasoningItem.encryptedContent) { - unsupportedContent.push({ - original_type: 'reasoning_encrypted', - data: { - id: reasoningItem.id, - encrypted_content: reasoningItem.encryptedContent, - }, - reason: 'Encrypted reasoning content preserved for round-trip', - }); + 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': { - const webSearchItem = item as models.ResponsesWebSearchCallOutput; - content.push({ - type: 'server_tool_use', - id: webSearchItem.id, - name: 'web_search', - input: { - status: webSearchItem.status, - }, - }); + if (isWebSearchCallOutputItem(item)) { + content.push({ + type: 'server_tool_use', + id: item.id, + name: 'web_search', + input: { + status: item.status, + }, + }); + } break; } case 'file_search_call': { - const fileSearchItem = item as models.ResponsesOutputItemFileSearchCall; - content.push({ - type: 'tool_use', - id: fileSearchItem.id, - name: 'file_search', - input: { - queries: fileSearchItem.queries, - status: fileSearchItem.status, - }, - }); + 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': { - const imageGenItem = item as models.ResponsesImageGenerationCall; - unsupportedContent.push({ - original_type: 'image_generation_call', - data: { - id: imageGenItem.id, - result: imageGenItem.result, - status: imageGenItem.status, - }, - reason: 'Claude does not support image outputs in assistant messages', - }); + 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: { + // Exhaustiveness check - if a new output type is added, TypeScript will error here const exhaustiveCheck: never = item; - unsupportedContent.push({ - original_type: 'unknown_output_item', - data: exhaustiveCheck as Record, - reason: 'Unknown output item type', - }); - break; + // This line should never execute - it means a new type was added to the union + // Throw an error instead of silently continuing to ensure we catch new types + throw new Error( + `Unhandled output item type. This indicates a new output type was added to the API. ` + + `Item: ${JSON.stringify(exhaustiveCheck)}` + ); } } } diff --git a/src/lib/stream-type-guards.ts b/src/lib/stream-type-guards.ts new file mode 100644 index 00000000..473f3dd3 --- /dev/null +++ b/src/lib/stream-type-guards.ts @@ -0,0 +1,205 @@ +import type * as models from '../models/index.js'; + +/** + * Type guards for OpenResponses stream events + * These enable proper TypeScript narrowing without type casts + */ + +// Stream event type guards + +export function isOutputTextDeltaEvent( + event: models.OpenResponsesStreamEvent +): event is models.OpenResponsesStreamEventResponseOutputTextDelta { + return 'type' in event && event.type === 'response.output_text.delta'; +} + +export function isReasoningDeltaEvent( + event: models.OpenResponsesStreamEvent +): event is models.OpenResponsesReasoningDeltaEvent { + return 'type' in event && event.type === 'response.reasoning_text.delta'; +} + +export function isFunctionCallArgumentsDeltaEvent( + event: models.OpenResponsesStreamEvent +): event is models.OpenResponsesStreamEventResponseFunctionCallArgumentsDelta { + return 'type' in event && event.type === 'response.function_call_arguments.delta'; +} + +export function isOutputItemAddedEvent( + event: models.OpenResponsesStreamEvent +): event is models.OpenResponsesStreamEventResponseOutputItemAdded { + return 'type' in event && event.type === 'response.output_item.added'; +} + +export function isOutputItemDoneEvent( + event: models.OpenResponsesStreamEvent +): event is models.OpenResponsesStreamEventResponseOutputItemDone { + return 'type' in event && event.type === 'response.output_item.done'; +} + +export function isResponseCompletedEvent( + event: models.OpenResponsesStreamEvent +): event is models.OpenResponsesStreamEventResponseCompleted { + return 'type' in event && event.type === 'response.completed'; +} + +export function isResponseFailedEvent( + event: models.OpenResponsesStreamEvent +): event is models.OpenResponsesStreamEventResponseFailed { + return 'type' in event && event.type === 'response.failed'; +} + +export function isResponseIncompleteEvent( + event: models.OpenResponsesStreamEvent +): event is models.OpenResponsesStreamEventResponseIncomplete { + return 'type' in event && event.type === 'response.incomplete'; +} + +export function isFunctionCallArgumentsDoneEvent( + event: models.OpenResponsesStreamEvent +): event is models.OpenResponsesStreamEventResponseFunctionCallArgumentsDone { + return 'type' in event && event.type === 'response.function_call_arguments.done'; +} + +// Output item type guards + +export function isOutputMessage( + item: unknown +): item is models.ResponsesOutputMessage { + return ( + typeof item === 'object' && + item !== null && + 'type' in item && + item.type === 'message' + ); +} + +export function isFunctionCallOutputItem( + item: unknown +): item is models.ResponsesOutputItemFunctionCall { + return ( + typeof item === 'object' && + item !== null && + 'type' in item && + item.type === 'function_call' + ); +} + +export function isReasoningOutputItem( + item: unknown +): item is models.ResponsesOutputItemReasoning { + return ( + typeof item === 'object' && + item !== null && + 'type' in item && + item.type === 'reasoning' + ); +} + +export function isWebSearchCallOutputItem( + item: unknown +): item is models.ResponsesWebSearchCallOutput { + return ( + typeof item === 'object' && + item !== null && + 'type' in item && + item.type === 'web_search_call' + ); +} + +export function isFileSearchCallOutputItem( + item: unknown +): item is models.ResponsesOutputItemFileSearchCall { + return ( + typeof item === 'object' && + item !== null && + 'type' in item && + item.type === 'file_search_call' + ); +} + +export function isImageGenerationCallOutputItem( + item: unknown +): item is models.ResponsesImageGenerationCall { + 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 models.ResponseOutputText { + return ( + typeof part === 'object' && + part !== null && + 'type' in part && + part.type === 'output_text' + ); +} + +export function isRefusalPart( + part: unknown +): part is models.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 models.FileCitation { + return ( + typeof annotation === 'object' && + annotation !== null && + 'type' in annotation && + annotation.type === 'file_citation' + ); +} + +export function isURLCitationAnnotation( + annotation: unknown +): annotation is models.URLCitation { + return ( + typeof annotation === 'object' && + annotation !== null && + 'type' in annotation && + annotation.type === 'url_citation' + ); +} + +export function isFilePathAnnotation( + annotation: unknown +): annotation is models.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 as { + type: unknown; + } + ).type === 'string' + ); +} diff --git a/src/lib/tool-executor.ts b/src/lib/tool-executor.ts index c9f149f0..87901d37 100644 --- a/src/lib/tool-executor.ts +++ b/src/lib/tool-executor.ts @@ -17,13 +17,14 @@ export function convertZodToJsonSchema(zodSchema: ZodType): Record; + return jsonSchema; } /** * Convert tools to OpenRouter API format + * Accepts readonly arrays for better type compatibility */ -export function convertToolsToAPIFormat(tools: Tool[]): APITool[] { +export function convertToolsToAPIFormat(tools: readonly Tool[]): APITool[] { return tools.map((tool) => ({ type: 'function' as const, name: tool.function.name, @@ -57,8 +58,7 @@ export function parseToolCallArguments(argumentsString: string): unknown { return JSON.parse(argumentsString); } catch (error) { throw new Error( - `Failed to parse tool call arguments: ${ - error instanceof Error ? error.message : String(error) + `Failed to parse tool call arguments: ${error instanceof Error ? error.message : String(error) }`, ); } @@ -69,9 +69,9 @@ export function parseToolCallArguments(argumentsString: string): unknown { */ export async function executeRegularTool( tool: Tool, - toolCall: ParsedToolCall, + toolCall: ParsedToolCall, context: TurnContext, -): Promise { +): Promise> { if (!isRegularExecuteTool(tool)) { throw new Error( `Tool "${toolCall.name}" is not a regular execute tool or has no execute function`, @@ -80,10 +80,12 @@ export async function executeRegularTool( try { // Validate input - the schema validation ensures type safety at runtime + // validateToolInput returns z.infer + // which is exactly the type expected by execute const validatedInput = validateToolInput( tool.function.inputSchema, toolCall.arguments, - ) as Parameters[0]; + ); // Execute tool with context const result = await Promise.resolve(tool.function.execute(validatedInput, context)); @@ -122,20 +124,21 @@ export async function executeRegularTool( */ export async function executeGeneratorTool( tool: Tool, - toolCall: ParsedToolCall, + toolCall: ParsedToolCall, context: TurnContext, onPreliminaryResult?: (toolCallId: string, result: unknown) => void, -): Promise { +): Promise> { if (!isGeneratorTool(tool)) { throw new Error(`Tool "${toolCall.name}" is not a generator tool`); } try { // Validate input - the schema validation ensures type safety at runtime + // The inputSchema's inferred type matches the execute function's parameter type by construction const validatedInput = validateToolInput( tool.function.inputSchema, toolCall.arguments, - ) as Parameters[0]; + ); // Execute generator and collect all results const preliminaryResults: unknown[] = []; @@ -190,10 +193,10 @@ export async function executeGeneratorTool( */ export async function executeTool( tool: Tool, - toolCall: ParsedToolCall, + toolCall: ParsedToolCall, context: TurnContext, onPreliminaryResult?: (toolCallId: string, result: unknown) => void, -): Promise { +): Promise> { if (!hasExecuteFunction(tool)) { throw new Error(`Tool "${toolCall.name}" has no execute function. Use manual tool execution.`); } @@ -215,7 +218,7 @@ export function findToolByName(tools: Tool[], name: string): Tool | undefined { /** * Format tool execution result as a string for sending to the model */ -export function formatToolResultForModel(result: ToolExecutionResult): string { +export function formatToolResultForModel(result: ToolExecutionResult): string { if (result.error) { return JSON.stringify({ error: result.error.message, @@ -229,7 +232,7 @@ export function formatToolResultForModel(result: ToolExecutionResult): string { /** * Create a user-friendly error message for tool execution errors */ -export function formatToolExecutionError(error: Error, toolCall: ParsedToolCall): string { +export function formatToolExecutionError(error: Error, toolCall: ParsedToolCall): string { if (error instanceof ZodError) { const issues = error.issues.map((issue) => ({ path: issue.path.join('.'), diff --git a/src/lib/tool-orchestrator.ts b/src/lib/tool-orchestrator.ts index 18783860..8e503645 100644 --- a/src/lib/tool-orchestrator.ts +++ b/src/lib/tool-orchestrator.ts @@ -2,14 +2,16 @@ import type * as models from '../models/index.js'; import type { APITool, Tool, ToolExecutionResult } from './tool-types.js'; import { extractToolCallsFromResponse, responseHasToolCalls } from './stream-transformers.js'; +import { isFunctionCallOutputItem } 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 { - maxRounds?: number; onPreliminaryResult?: (toolCallId: string, result: unknown) => void; } @@ -19,7 +21,7 @@ export interface ToolExecutionOptions { export interface ToolOrchestrationResult { finalResponse: models.OpenResponsesNonStreamingResponse; allResponses: models.OpenResponsesNonStreamingResponse[]; - toolExecutionResults: ToolExecutionResult[]; + toolExecutionResults: ToolExecutionResult[]; conversationInput: models.OpenResponsesInput; } @@ -29,6 +31,7 @@ export interface ToolOrchestrationResult { * * @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 @@ -40,16 +43,17 @@ export async function executeToolLoop( tools: APITool[], ) => Promise, initialInput: models.OpenResponsesInput, + initialRequest: models.OpenResponsesRequest, tools: Tool[], apiTools: APITool[], options: ToolExecutionOptions = {}, ): Promise { - const maxRounds = options.maxRounds ?? 5; const onPreliminaryResult = options.onPreliminaryResult; const allResponses: models.OpenResponsesNonStreamingResponse[] = []; - const toolExecutionResults: ToolExecutionResult[] = []; + const toolExecutionResults: ToolExecutionResult[] = []; let conversationInput: models.OpenResponsesInput = initialInput; + let currentRequest: models.OpenResponsesRequest = { ...initialRequest }; let currentRound = 0; let currentResponse: models.OpenResponsesNonStreamingResponse; @@ -58,8 +62,8 @@ export async function executeToolLoop( currentResponse = await sendRequest(conversationInput, apiTools); allResponses.push(currentResponse); - // Loop until no more tool calls or max rounds reached - while (responseHasToolCalls(currentResponse) && currentRound < maxRounds) { + // Loop until no more tool calls (model decides when to stop) + while (responseHasToolCalls(currentResponse)) { currentRound++; // Extract tool calls from response @@ -91,7 +95,7 @@ export async function executeToolLoop( toolName: toolCall.name, result: null, error: new Error(`Tool "${toolCall.name}" not found in tool definitions`), - } as ToolExecutionResult; + } as ToolExecutionResult; } if (!hasExecuteFunction(tool)) { @@ -99,12 +103,33 @@ export async function executeToolLoop( return null; } - // Build turn context - const turnContext: import('./tool-types.js').TurnContext = { - numberOfTurns: currentRound, - messageHistory: conversationInput, + // Find the raw tool call from the response output + const rawToolCall = currentResponse.output.find( + (item): item is models.ResponsesOutputItemFunctionCall => + isFunctionCallOutputItem(item) && item.callId === toolCall.id, + ); + + if (!rawToolCall) { + throw new Error(`Could not find raw tool call for ${toolCall.id}`); + } + + // Convert to OpenResponsesFunctionToolCall format + const openResponsesToolCall: models.OpenResponsesFunctionToolCall = { + 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); }); @@ -113,7 +138,7 @@ export async function executeToolLoop( const settledResults = await Promise.allSettled(toolCallPromises); // Process settled results, handling both fulfilled and rejected promises - const roundResults: ToolExecutionResult[] = []; + const roundResults: ToolExecutionResult[] = []; settledResults.forEach((settled, i) => { const toolCall = toolCalls[i]; if (!toolCall) return; @@ -137,10 +162,23 @@ export async function executeToolLoop( 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 = initialInput; // Keep original input + // 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 @@ -161,7 +199,7 @@ export async function executeToolLoop( /** * Convert tool execution results to a map for easy lookup */ -export function toolResultsToMap(results: ToolExecutionResult[]): Map< +export function toolResultsToMap(results: ToolExecutionResult[]): Map< string, { result: unknown; @@ -183,7 +221,7 @@ export function toolResultsToMap(results: ToolExecutionResult[]): Map< /** * Build a summary of tool executions for debugging/logging */ -export function summarizeToolExecutions(results: ToolExecutionResult[]): string { +export function summarizeToolExecutions(results: ToolExecutionResult[]): string { const lines: string[] = []; for (const result of results) { @@ -202,13 +240,17 @@ export function summarizeToolExecutions(results: ToolExecutionResult[]): string /** * Check if any tool executions had errors */ -export function hasToolExecutionErrors(results: ToolExecutionResult[]): boolean { +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.error !== undefined).map((result) => result.error!); +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 index 7fc41105..816be826 100644 --- a/src/lib/tool-types.ts +++ b/src/lib/tool-types.ts @@ -11,20 +11,53 @@ export enum ToolType { } /** - * Turn context passed to tool execute functions + * Turn context passed to tool execute functions and async parameter resolution * Contains information about the current conversation state */ export interface TurnContext { - /** Number of tool execution turns so far (1-indexed: first turn = 1) */ + /** The specific tool call being executed (only available during tool execution) */ + toolCall?: models.OpenResponsesFunctionToolCall; + /** Number of tool execution turns so far (1-indexed: first turn = 1, 0 = initial request) */ numberOfTurns: number; - /** Current message history being sent to the API */ - messageHistory: models.OpenResponsesInput; - /** Model name if request.model is set */ - model?: string; - /** Model names if request.models is set */ - models?: string[]; + /** The full request being sent to the API (only available during tool execution) */ + turnRequest?: models.OpenResponsesRequest; } +/** + * 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: models.OpenResponsesInput; + /** 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; +}; + /** * Base tool function interface with inputSchema */ @@ -32,6 +65,7 @@ export interface BaseToolFunction> { name: string; description?: string; inputSchema: TInput; + nextTurnParams?: NextTurnParamsFunctions>; } /** @@ -53,6 +87,10 @@ export interface ToolFunctionWithExecute< * 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 * { @@ -73,7 +111,8 @@ export interface ToolFunctionWithGenerator< > extends BaseToolFunction { eventSchema: TEvent; outputSchema: TOutput; - execute: (params: z.infer, context?: TurnContext) => AsyncGenerator>; + // Generator can yield both events (TEvent) and the final output (TOutput) + execute: (params: z.infer, context?: TurnContext) => AsyncGenerator | z.infer>; } /** @@ -133,8 +172,8 @@ export type Tool = */ export type InferToolInput = T extends { function: { inputSchema: infer S } } ? S extends ZodType - ? z.infer - : unknown + ? z.infer + : unknown : unknown; /** @@ -142,8 +181,8 @@ export type InferToolInput = T extends { function: { inputSchema: infer S } } */ export type InferToolOutput = T extends { function: { outputSchema: infer S } } ? S extends ZodType - ? z.infer - : unknown + ? z.infer + : unknown : unknown; /** @@ -162,14 +201,21 @@ 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]; + /** * 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 - ? z.infer - : never + ? z.infer + : never : never; /** @@ -203,37 +249,86 @@ 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 { +export interface ParsedToolCall { id: string; - name: string; - arguments: unknown; // Parsed from JSON 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 { +export interface ToolExecutionResult { toolCallId: string; toolName: string; - result: unknown; // Final result (sent to model) - preliminaryResults?: unknown[]; // All yielded values from generator + result: T extends ToolWithExecute | ToolWithGenerator + ? z.infer + : unknown; // Final result (sent to model) + preliminaryResults?: T extends ToolWithGenerator + ? z.infer[] + : undefined; // All yielded values from generator error?: Error; } /** - * Type for maxToolRounds - can be a number or a function that determines if execution should continue + * Warning from step execution */ -export type MaxToolRounds = number | ((context: TurnContext) => boolean); // Return true to allow another turn, false to stop +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: models.OpenResponsesNonStreamingResponse; + readonly usage?: models.OpenResponsesUsage | 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[]; +export interface ExecuteToolsResult { + finalResponse: ModelResult; + allResponses: ModelResult[]; toolResults: Map< string, { @@ -273,7 +368,7 @@ export type ToolPreliminaryResultEvent = { * Extends OpenResponsesStreamEvent with tool preliminary results * @template TEvent - The event type from generator tools */ -export type EnhancedResponseStreamEvent = +export type ResponseStreamEvent = | OpenResponsesStreamEvent | ToolPreliminaryResultEvent; @@ -281,7 +376,7 @@ export type EnhancedResponseStreamEvent = * Type guard to check if an event is a tool preliminary result event */ export function isToolPreliminaryResultEvent( - event: EnhancedResponseStreamEvent, + event: ResponseStreamEvent, ): event is ToolPreliminaryResultEvent { return event.type === 'tool.preliminary_result'; } @@ -293,14 +388,14 @@ export function isToolPreliminaryResultEvent( */ export type ToolStreamEvent = | { - type: 'delta'; - content: string; - } + type: 'delta'; + content: string; + } | { - type: 'preliminary_result'; - toolCallId: string; - result: TEvent; - }; + type: 'preliminary_result'; + toolCallId: string; + result: TEvent; + }; /** * Chat stream event types for getFullChatStream @@ -309,19 +404,19 @@ export type ToolStreamEvent = */ export type ChatStreamEvent = | { - type: 'content.delta'; - delta: string; - } + type: 'content.delta'; + delta: string; + } | { - type: 'message.complete'; - response: models.OpenResponsesNonStreamingResponse; - } + type: 'message.complete'; + response: models.OpenResponsesNonStreamingResponse; + } | { - type: 'tool.preliminary_result'; - toolCallId: string; - result: TEvent; - } + type: 'tool.preliminary_result'; + toolCallId: string; + result: TEvent; + } | { - type: string; - event: OpenResponsesStreamEvent; - }; // Pass-through for other events + type: string; + event: OpenResponsesStreamEvent; + }; // Pass-through for other events diff --git a/src/lib/tool.ts b/src/lib/tool.ts index 23445feb..4823db79 100644 --- a/src/lib/tool.ts +++ b/src/lib/tool.ts @@ -5,6 +5,7 @@ import { type ToolWithExecute, type ToolWithGenerator, type ManualTool, + type NextTurnParamsFunctions, } from "./tool-types.js"; /** @@ -19,6 +20,7 @@ type RegularToolConfigWithOutput< inputSchema: TInput; outputSchema: TOutput; eventSchema?: undefined; + nextTurnParams?: NextTurnParamsFunctions>; execute: ( params: z.infer, context?: TurnContext @@ -37,6 +39,7 @@ type RegularToolConfigWithoutOutput< inputSchema: TInput; outputSchema?: undefined; eventSchema?: undefined; + nextTurnParams?: NextTurnParamsFunctions>; execute: ( params: z.infer, context?: TurnContext @@ -56,6 +59,7 @@ type GeneratorToolConfig< inputSchema: TInput; eventSchema: TEvent; outputSchema: TOutput; + nextTurnParams?: NextTurnParamsFunctions>; execute: ( params: z.infer, context?: TurnContext @@ -69,6 +73,7 @@ type ManualToolConfig> = { name: string; description?: string; inputSchema: TInput; + nextTurnParams?: NextTurnParamsFunctions>; execute: false; }; @@ -206,6 +211,10 @@ export function tool< fn.description = config.description; } + if (config.nextTurnParams !== undefined) { + fn.nextTurnParams = config.nextTurnParams; + } + return { type: ToolType.Function, function: fn, @@ -219,19 +228,18 @@ export function tool< inputSchema: config.inputSchema, eventSchema: config.eventSchema, outputSchema: config.outputSchema, - // The config execute allows yielding both events and output, - // but the interface only types for events (output is extracted separately) - execute: config.execute as ToolWithGenerator< - TInput, - TEvent, - TOutput - >["function"]["execute"], + // Types now align - config.execute matches the interface type + execute: config.execute, }; if (config.description !== undefined) { fn.description = config.description; } + if (config.nextTurnParams !== undefined) { + fn.nextTurnParams = config.nextTurnParams; + } + return { type: ToolType.Function, function: fn, @@ -239,24 +247,20 @@ export function tool< } // Regular tool (has execute function, no eventSchema) - // Type assertion needed because we have two overloads (with/without outputSchema) - // and the implementation needs to handle both cases - const fn = { + // TypeScript can't infer the relationship between TReturn and TOutput + // So we build the object without type annotation, then return with correct type + const functionObj = { name: config.name, inputSchema: config.inputSchema, execute: config.execute, - } as ToolWithExecute["function"]; - - if (config.description !== undefined) { - fn.description = config.description; - } - - if (config.outputSchema !== undefined) { - fn.outputSchema = config.outputSchema; - } + ...(config.description !== undefined && { description: config.description }), + ...(config.outputSchema !== undefined && { outputSchema: config.outputSchema }), + ...(config.nextTurnParams !== undefined && { nextTurnParams: config.nextTurnParams }), + }; + // The function signature guarantees this is type-safe via overloads return { type: ToolType.Function, - function: fn, + function: functionObj, }; } diff --git a/src/lib/turn-context.ts b/src/lib/turn-context.ts new file mode 100644 index 00000000..aad125cd --- /dev/null +++ b/src/lib/turn-context.ts @@ -0,0 +1,78 @@ +import * as models from '../models/index.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?: models.OpenResponsesFunctionToolCall; + /** The full request being sent to the API (optional for initial/async resolution contexts) */ + turnRequest?: models.OpenResponsesRequest; +} + +/** + * 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: models.OpenResponsesInput +): Array { + if (typeof input === 'string') { + // Construct object with all required fields - type is optional + const message: models.OpenResponsesEasyInputMessage = { + role: models.OpenResponsesEasyInputMessageRoleUser.User, + content: input, + }; + return [message]; + } + return input; +} diff --git a/src/sdk/sdk.ts b/src/sdk/sdk.ts index fa0219a8..61fc4085 100644 --- a/src/sdk/sdk.ts +++ b/src/sdk/sdk.ts @@ -24,10 +24,9 @@ import { } from "../funcs/call-model.js"; import type { ModelResult } from "../lib/model-result.js"; import type { RequestOptions } from "../lib/sdks.js"; -import { type MaxToolRounds, ToolType } from "../lib/tool-types.js"; +import { ToolType, type Tool } from "../lib/tool-types.js"; export { ToolType }; -export type { MaxToolRounds }; // #endregion imports export class OpenRouter extends ClientSDK { @@ -97,10 +96,10 @@ export class OpenRouter extends ClientSDK { } // #region sdk-class-body - callModel( - request: CallModelInput, + callModel( + request: CallModelInput, options?: RequestOptions, - ): ModelResult { + ): ModelResult { return callModelFunc(this, request, options); } // #endregion sdk-class-body diff --git a/tests/e2e/call-model-tools.test.ts b/tests/e2e/call-model-tools.test.ts index 0fe1dcc2..f0fe3249 100644 --- a/tests/e2e/call-model-tools.test.ts +++ b/tests/e2e/call-model-tools.test.ts @@ -1,7 +1,7 @@ import * as dotenv from 'dotenv'; import { beforeAll, describe, expect, it } from 'vitest'; import { toJSONSchema, z } from 'zod/v4'; -import { OpenRouter, ToolType, toChatMessage } from '../../src/index.js'; +import { OpenRouter, ToolType, toChatMessage, stepCountIs } from '../../src/index.js'; dotenv.config(); @@ -547,7 +547,7 @@ describe('Enhanced Tool Support for callModel', () => { }); describe('Integration with OpenRouter API', () => { - it.skip('should send tool call to API and receive tool call response', async () => { + it('should send tool call to API and receive tool call response', async () => { // This test requires actual API integration which we'll implement const weatherTool = { type: ToolType.Function, @@ -593,7 +593,7 @@ describe('Enhanced Tool Support for callModel', () => { expect(message).toBeDefined(); }, 30000); - it.skip('should handle multi-turn conversation with tool execution', async () => { + it('should handle multi-turn conversation with tool execution', async () => { // This will test the full loop: request -> tool call -> execute -> send result -> final response const calculatorTool = { type: ToolType.Function, @@ -634,7 +634,7 @@ describe('Enhanced Tool Support for callModel', () => { tools: [ calculatorTool, ], - maxToolRounds: 3, + stopWhen: stepCountIs(3), }, ); diff --git a/tests/e2e/call-model.test.ts b/tests/e2e/call-model.test.ts index aa726581..e7943b99 100644 --- a/tests/e2e/call-model.test.ts +++ b/tests/e2e/call-model.test.ts @@ -1,4 +1,4 @@ -import type { ChatStreamEvent, EnhancedResponseStreamEvent } from '../../src/lib/tool-types.js'; +import type { ChatStreamEvent, ResponseStreamEvent } from '../../src/lib/tool-types.js'; import type { ClaudeMessageParam } from '../../src/models/claude-message.js'; import type { ResponsesOutputMessage } from '../../src/models/responsesoutputmessage.js'; import type { OpenResponsesFunctionCallOutput } from '../../src/models/openresponsesfunctioncalloutput.js'; @@ -10,6 +10,32 @@ import { fromChatMessages, toChatMessage } from '../../src/lib/chat-compat.js'; import { fromClaudeMessages } from '../../src/lib/anthropic-compat.js'; import { OpenResponsesNonStreamingResponse } from '../../src/models/openresponsesnonstreamingresponse.js'; import { OpenResponsesStreamEvent } from '../../src/models/openresponsesstreamevent.js'; +import { stepCountIs } from '../../src/lib/stop-conditions.js'; +import { + isOutputTextDeltaEvent, + isResponseCompletedEvent, + isResponseIncompleteEvent, +} from '../../src/lib/stream-type-guards.js'; +import { isToolPreliminaryResultEvent } from '../../src/lib/tool-types.js'; + +/** + * Helper to transform ResponseStreamEvent to ChatStreamEvent + */ +function transformToChatStreamEvent(event: ResponseStreamEvent): ChatStreamEvent { + if (isToolPreliminaryResultEvent(event)) { + // Pass through tool preliminary results as-is + return event; + } else if (isOutputTextDeltaEvent(event)) { + // Transform text deltas to content.delta + return { type: 'content.delta', delta: event.delta }; + } else if (isResponseCompletedEvent(event) || isResponseIncompleteEvent(event)) { + // Transform completion events to message.complete + return { type: 'message.complete', response: event.response }; + } else { + // Pass-through all other events with original event wrapped + return { type: event.type, event }; + } +} describe('callModel E2E Tests', () => { let client: OpenRouter; @@ -96,13 +122,14 @@ describe('callModel E2E Tests', () => { it('should accept chat-style tools (ToolDefinitionJson)', async () => { const response = client.callModel({ - model: 'qwen/qwen3-vl-8b-instruct', + model: 'anthropic/claude-sonnet-4.5', input: fromChatMessages([ { role: 'user', - content: "What's the weather in Paris? Use the get_weather tool.", + content: "What's the weather in Paris?", }, ]), + toolChoice: 'required', tools: [ { type: ToolType.Function, @@ -116,12 +143,8 @@ describe('callModel E2E Tests', () => { temperature: z.number(), condition: z.string(), }), - execute: async (_params) => { - return { - temperature: 22, - condition: 'Sunny', - }; - }, + // Don't auto-execute so we can test getToolCalls() + execute: false, }, }, ], @@ -135,7 +158,7 @@ describe('callModel E2E Tests', () => { expect(toolCalls[0].arguments).toBeDefined(); }, 30000); - it.skip('should work with chat-style messages and chat-style tools together', async () => { + it('should work with chat-style messages and chat-style tools together', async () => { const response = client.callModel({ model: 'meta-llama/llama-3.1-8b-instruct', input: fromChatMessages([ @@ -148,6 +171,7 @@ describe('callModel E2E Tests', () => { content: 'Get the weather in Tokyo using the weather tool.', }, ]), + toolChoice: "required", tools: [ { type: ToolType.Function, @@ -346,8 +370,8 @@ describe('callModel E2E Tests', () => { expect(message.role).toBe('assistant'); expect( Array.isArray(message.content) || - typeof message.content === 'string' || - message.content === null, + typeof message.content === 'string' || + message.content === null, ).toBe(true); if (Array.isArray(message.content)) { @@ -575,13 +599,16 @@ describe('callModel E2E Tests', () => { it('should include OpenResponsesFunctionCallOutput with correct shape when tools are executed', async () => { const response = client.callModel({ - model: 'openai/gpt-4o-mini', + model: 'anthropic/claude-sonnet-4.5', + instructions: 'You are a weather assistant. You can use the get_weather tool to get the weather for a location.', input: fromChatMessages([ { role: 'user', - content: "What's the weather in Tokyo? Use the get_weather tool.", + content: "What's the weather in Tokyo?", }, ]), + toolChoice: 'required', + stopWhen: stepCountIs(2), tools: [ { type: ToolType.Function, @@ -594,11 +621,15 @@ describe('callModel E2E Tests', () => { outputSchema: z.object({ temperature: z.number(), condition: z.string(), + location: z.string().describe('City name'), }), - execute: async (_params) => { + // Enable auto-execution so we test the full flow + execute: async (params) => { + // Return weather data that will be yielded return { temperature: 22, condition: 'Sunny', + location: params.location, }; }, }, @@ -611,6 +642,7 @@ describe('callModel E2E Tests', () => { let hasFunctionCallOutput = false; for await (const message of response.getNewMessagesStream()) { + console.log('Message received:', message); messages.push(message); // Validate each message has correct shape based on type @@ -669,7 +701,7 @@ describe('callModel E2E Tests', () => { expect(lastMessageIndex).toBeGreaterThan(lastFnOutputIndex); } } - }, 30000); + }, 6000); it('should return messages with all required fields and correct types', async () => { const response = client.callModel({ @@ -688,7 +720,7 @@ describe('callModel E2E Tests', () => { expect(['message', 'function_call_output']).toContain(message.type); if (message.type === 'message') { - const outputMessage = message as ResponsesOutputMessage; + const outputMessage = message; // ResponsesOutputMessage specific validations expect(outputMessage.role).toBe('assistant'); expect(outputMessage.id).toBeDefined(); @@ -712,7 +744,7 @@ describe('callModel E2E Tests', () => { }); describe('response.reasoningStream - Streaming reasoning deltas', () => { - it.skip('should successfully stream reasoning deltas for reasoning models', async () => { + it('should successfully stream reasoning deltas for reasoning models', async () => { const response = client.callModel({ model: 'minimax/minimax-m2', input: fromChatMessages([ @@ -738,7 +770,7 @@ describe('callModel E2E Tests', () => { // Just verify the stream works without error expect(Array.isArray(reasoningDeltas)).toBe(true); expect(reasoningDeltas.length).toBeGreaterThan(0); - }, 30000); + }, 60000); }); describe('response.toolStream - Streaming tool call deltas', () => { @@ -809,7 +841,7 @@ describe('callModel E2E Tests', () => { ]), }); - const events: EnhancedResponseStreamEvent[] = []; + const events: ResponseStreamEvent[] = []; for await (const event of response.getFullResponsesStream()) { expect(event).toBeDefined(); @@ -841,7 +873,7 @@ describe('callModel E2E Tests', () => { ]), }); - const textDeltaEvents: EnhancedResponseStreamEvent[] = []; + const textDeltaEvents: ResponseStreamEvent[] = []; for await (const event of response.getFullResponsesStream()) { if (event.type === 'response.output_text.delta') { @@ -853,7 +885,7 @@ describe('callModel E2E Tests', () => { // Verify delta events have the expected structure const firstDelta = textDeltaEvents[0]; - if(firstDelta.type === 'response.output_text.delta') { + if (firstDelta.type === 'response.output_text.delta') { expect(firstDelta.delta).toBeDefined(); expect(typeof firstDelta.delta).toBe('string'); } else { @@ -876,7 +908,8 @@ describe('callModel E2E Tests', () => { const chunks: ChatStreamEvent[] = []; - for await (const chunk of response.getFullChatStream()) { + for await (const rawEvent of response.getFullResponsesStream()) { + const chunk = transformToChatStreamEvent(rawEvent); expect(chunk).toBeDefined(); expect(chunk.type).toBeDefined(); chunks.push(chunk); @@ -903,7 +936,8 @@ describe('callModel E2E Tests', () => { let hasContentDelta = false; let _hasMessageComplete = false; - for await (const event of response.getFullChatStream()) { + for await (const rawEvent of response.getFullResponsesStream()) { + const event = transformToChatStreamEvent(rawEvent); // Every event must have a type expect(event).toHaveProperty('type'); expect(typeof event.type).toBe('string'); @@ -963,7 +997,8 @@ describe('callModel E2E Tests', () => { const contentDeltas: ChatStreamEvent[] = []; - for await (const event of response.getFullChatStream()) { + for await (const rawEvent of response.getFullResponsesStream()) { + const event = transformToChatStreamEvent(rawEvent); if (event.type === 'content.delta') { contentDeltas.push(event); @@ -1034,7 +1069,8 @@ describe('callModel E2E Tests', () => { let hasPreliminaryResult = false; const preliminaryResults: ChatStreamEvent[] = []; - for await (const event of response.getFullChatStream()) { + for await (const rawEvent of response.getFullResponsesStream()) { + const event = transformToChatStreamEvent(rawEvent); expect(event).toHaveProperty('type'); expect(typeof event.type).toBe('string'); @@ -1072,7 +1108,7 @@ describe('callModel E2E Tests', () => { // The stream should complete without errors regardless of tool execution expect(true).toBe(true); - }, 30000); + }, 45000); }); describe('Multiple concurrent consumption patterns', () => {