diff --git a/.github/actions/validate-sdk/action.yaml b/.github/actions/validate-sdk/action.yaml index a4c3d992..1f13ed14 100644 --- a/.github/actions/validate-sdk/action.yaml +++ b/.github/actions/validate-sdk/action.yaml @@ -35,7 +35,7 @@ runs: - name: Typecheck examples root shell: bash working-directory: examples - run: npx tsc --noEmit --skipLibCheck --esModuleInterop --moduleResolution node --module esnext --target es2020 *.ts + run: npx tsc --noEmit - name: Install nextjs-example dependencies shell: bash diff --git a/.speakeasy/gen.yaml b/.speakeasy/gen.yaml index e32f31d1..1089ff18 100644 --- a/.speakeasy/gen.yaml +++ b/.speakeasy/gen.yaml @@ -64,6 +64,7 @@ typescript: inferSSEOverload: true inputModelSuffix: input jsonpath: rfc9535 + license: Apache-2.0 maxMethodParams: 0 methodArguments: infer-optional-args modelPropertyCasing: camel diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..4caa2028 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 OpenRouter Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/examples/tools-example.ts b/examples/tools-example.ts new file mode 100644 index 00000000..887a7d15 --- /dev/null +++ b/examples/tools-example.ts @@ -0,0 +1,353 @@ +/** + * OpenRouter SDK - Enhanced Tool Support Examples + * + * This file demonstrates the automatic tool execution feature. + * When you provide tools with `execute` functions, they are automatically: + * 1. Validated using Zod schemas + * 2. Executed when the model calls them + * 3. Results sent back to the model + * 4. Process repeats until no more tool calls (up to maxToolRounds) + * + * The API is simple: just call getResponse() 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 + */ + +import { OpenRouter, ToolType } from "../src/index.js"; +import { z } from "zod/v4"; +import * as dotenv from "dotenv"; + +dotenv.config(); + +const client = new OpenRouter({ + apiKey: process.env.OPENROUTER_API_KEY || "", +}); + +/** + * Example 1: Basic Tool with Execute Function + * A simple weather tool that returns mock data + * Note: The context parameter is optional for backward compatibility + */ +async function basicToolExample() { + console.log("\n=== Example 1: Basic Tool with Execute Function ===\n"); + + const weatherTool = { + type: ToolType.Function, + function: { + name: "get_weather", + description: "Get current weather for a location", + inputSchema: z.object({ + location: z.string().describe("City and country (e.g., San Francisco, CA)"), + }), + outputSchema: z.object({ + temperature: z.number(), + description: z.string(), + humidity: z.number(), + }), + execute: async (params: { location: string }, context) => { + console.log(`Executing get_weather for: ${params.location}`); + console.log(`Turn ${context.numberOfTurns} - Model: ${context.model || context.models?.join(", ")}`); + // In real usage, you would call a weather API here + return { + temperature: 72, + description: "Sunny", + humidity: 45, + }; + }, + }, + }; + + const response = client.getResponse({ + model: "openai/gpt-4o", + input: "What's the weather like in San Francisco?", + tools: [weatherTool], + // Example: limit to 3 turns using a function + maxToolRounds: (context) => { + console.log(`Checking if we should continue (currently on turn ${context.numberOfTurns})`); + return context.numberOfTurns < 3; // Allow up to 3 turns + }, + }); + + // Tools are automatically executed! Just get the final message + const message = await response.getMessage(); + console.log("\nFinal message after automatic tool execution:", message.content); + + // You can also check what tool calls were made initially + const toolCalls = await response.getToolCalls(); + console.log("\nInitial tool calls:", JSON.stringify(toolCalls, null, 2)); +} + +/** + * Example 2: Generator Tool with Preliminary Results + * Shows how to use async generators for streaming intermediate results + */ +async function generatorToolExample() { + console.log("\n=== Example 2: Generator Tool with Preliminary Results ===\n"); + + const processingTool = { + type: ToolType.Function, + function: { + name: "process_data", + description: "Process data with progress updates", + inputSchema: z.object({ + data: z.string().describe("Data to process"), + }), + // Events emitted during processing (validated against eventSchema) + eventSchema: z.object({ + type: z.enum(["start", "progress"]), + message: z.string(), + progress: z.number().min(0).max(100).optional(), + }), + // Final output (validated against outputSchema - different structure) + outputSchema: z.object({ + result: z.string(), + processingTime: z.number(), + }), + execute: async function* (params: { data: string }, context) { + console.log(`Generator tool - Turn ${context.numberOfTurns}`); + const startTime = Date.now(); + + // Preliminary event 1 + yield { + type: "start" as const, + message: `Started processing: ${params.data}`, + progress: 0, + }; + + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Preliminary event 2 + yield { + type: "progress" as const, + message: "Processing halfway done", + progress: 50, + }; + + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Final output (different schema - sent to model) + yield { + result: params.data.toUpperCase(), + processingTime: Date.now() - startTime, + }; + }, + }, + }; + + const response = client.getResponse({ + model: "openai/gpt-4o", + input: "Process this data: hello world", + tools: [processingTool], + }); + + // Stream preliminary results as they arrive + console.log("Streaming tool events including preliminary results:\n"); + for await (const event of response.getToolStream()) { + if (event.type === "preliminary_result") { + console.log(`Preliminary result from ${event.toolCallId}:`, event.result); + } else if (event.type === "delta") { + process.stdout.write(event.content); + } + } + + // Tools are automatically executed with preliminary results available + const message = await response.getMessage(); + console.log("\n\nFinal message:", message.content); +} + +/** + * Example 3: Manual Tool Execution + * Define a tool without execute function for manual handling + */ +async function manualToolExample() { + console.log("\n=== Example 3: Manual Tool Execution ===\n"); + + const calculatorTool = { + type: ToolType.Function, + function: { + name: "calculate", + description: "Perform mathematical calculations", + inputSchema: z.object({ + expression: z.string().describe("Math expression to evaluate"), + }), + outputSchema: z.object({ + result: z.number(), + }), + // No execute function - tool calls are returned but not executed + }, + }; + + const response = client.getResponse({ + model: "openai/gpt-4o", + input: "What is 25 * 4 + 10?", + tools: [calculatorTool], + }); + + // Since there's no execute function, tool calls are returned but not executed + const toolCalls = await response.getToolCalls(); + console.log("Tool calls (not auto-executed):", toolCalls); + + // You can manually handle tool execution here + for (const toolCall of toolCalls) { + if (toolCall.name === "calculate") { + const expression = (toolCall.arguments as { expression: string }).expression; + console.log(`Manually executing calculation: ${expression}`); + + // In a real app, you would safely evaluate this + // For demo purposes only - don't use eval in production! + try { + const result = eval(expression); + console.log(`Result: ${result}`); + } catch (error) { + console.error("Calculation error:", error); + } + } + } + + // Then you would need to make a new request with the tool results + // (This example just shows the manual detection, not the full loop) +} + +/** + * Example 4: Streaming Tool Calls + * Show how to stream structured tool call objects as they arrive + * Note: This tool doesn't use context - demonstrating backward compatibility + */ +async function streamingToolCallsExample() { + console.log("\n=== Example 4: Streaming Tool Calls ===\n"); + + const searchTool = { + type: ToolType.Function, + function: { + name: "search", + description: "Search for information", + inputSchema: z.object({ + query: z.string().describe("Search query"), + }), + outputSchema: z.object({ + results: z.array(z.string()), + }), + execute: async (params: { query: string }) => { + // Context parameter is optional - this tool doesn't need it + return { + results: [ + `Result 1 for "${params.query}"`, + `Result 2 for "${params.query}"`, + ], + }; + }, + }, + }; + + const response = client.getResponse({ + model: "openai/gpt-4o", + input: "Search for information about TypeScript", + tools: [searchTool], + }); + + console.log("Streaming tool calls as they arrive:\n"); + + // Stream structured tool call objects + for await (const toolCall of response.getToolCallsStream()) { + console.log("Tool call:", JSON.stringify(toolCall, null, 2)); + } +} + +/** + * Example 5: Multiple Tools + * Use multiple tools in a single request + * Note: Shows mixing tools with and without context parameter + */ +async function multipleToolsExample() { + console.log("\n=== Example 5: Multiple Tools ===\n"); + + const tools = [ + { + type: ToolType.Function, + function: { + name: "get_time", + description: "Get current time", + inputSchema: z.object({ + timezone: z.string().optional(), + }), + outputSchema: z.object({ + time: z.string(), + timezone: z.string(), + }), + execute: async (params: { timezone?: string }, context) => { + return { + time: new Date().toISOString(), + timezone: params.timezone || "UTC", + }; + }, + }, + }, + { + type: ToolType.Function, + function: { + name: "get_weather", + description: "Get weather information", + inputSchema: z.object({ + location: z.string(), + }), + outputSchema: z.object({ + temperature: z.number(), + description: z.string(), + }), + execute: async (params: { location: string }) => { + // This tool doesn't need context + return { + temperature: 68, + description: "Partly cloudy", + }; + }, + }, + }, + ]; + + const response = client.getResponse({ + model: "openai/gpt-4o", + input: "What time is it and what's the weather in New York?", + tools, + }); + + // Tools are automatically executed! + const message = await response.getMessage(); + console.log("Final message:", message.content); + + // You can check which tools were called + const toolCalls = await response.getToolCalls(); + console.log("\nTools that were called:", toolCalls.map(tc => tc.name)); +} + +// Run examples +async function main() { + try { + await basicToolExample(); + await generatorToolExample(); + await manualToolExample(); + await streamingToolCallsExample(); + await multipleToolsExample(); + } catch (error) { + console.error("Error running examples:", error); + } +} + +// Only run if this file is executed directly +if (import.meta.url === `file://${process.argv[1]}`) { + main(); +} + +export { + basicToolExample, + generatorToolExample, + manualToolExample, + streamingToolCallsExample, + multipleToolsExample, +}; diff --git a/package-lock.json b/package-lock.json index b2a6fc3c..c9ff5d2d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3303,9 +3303,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbe25e0a..70df4015 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: react: specifier: ^18 || ^19 version: 19.2.0 + react-dom: + specifier: ^18 || ^19 + version: 19.2.0(react@19.2.0) zod: specifier: ^3.25.0 || ^4.0.0 version: 4.1.12 @@ -28,8 +31,8 @@ importers: specifier: ^18.3.12 version: 18.3.26 dotenv: - specifier: ^17.2.3 - version: 17.2.3 + specifier: ^16.4.7 + version: 16.6.1 eslint: specifier: ^9.19.0 version: 9.38.0 @@ -590,8 +593,8 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - dotenv@17.2.3: - resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} es-module-lexer@1.7.0: @@ -877,6 +880,11 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-dom@19.2.0: + resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==} + peerDependencies: + react: ^19.2.0 + react@19.2.0: resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} engines: {node: '>=0.10.0'} @@ -897,6 +905,9 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@7.7.3: resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} engines: {node: '>=10'} @@ -1542,7 +1553,7 @@ snapshots: deep-is@0.1.4: {} - dotenv@17.2.3: {} + dotenv@16.6.1: {} es-module-lexer@1.7.0: {} @@ -1832,6 +1843,11 @@ snapshots: queue-microtask@1.2.3: {} + react-dom@19.2.0(react@19.2.0): + dependencies: + react: 19.2.0 + scheduler: 0.27.0 + react@19.2.0: {} resolve-from@4.0.0: {} @@ -1870,6 +1886,8 @@ snapshots: dependencies: queue-microtask: 1.2.3 + scheduler@0.27.0: {} + semver@7.7.3: {} shebang-command@2.0.0: diff --git a/src/funcs/getResponse.ts b/src/funcs/getResponse.ts new file mode 100644 index 00000000..5f03f0ba --- /dev/null +++ b/src/funcs/getResponse.ts @@ -0,0 +1,124 @@ +import { OpenRouterCore } from "../core.js"; +import { RequestOptions } from "../lib/sdks.js"; +import { ResponseWrapper } from "../lib/response-wrapper.js"; +import * as models from "../models/index.js"; +import { EnhancedTool, MaxToolRounds } from "../lib/tool-types.js"; +import { convertEnhancedToolsToAPIFormat } from "../lib/tool-executor.js"; + +/** + * Get a response with multiple consumption patterns + * + * @remarks + * Creates a response using the OpenResponses API in streaming mode and returns + * a wrapper that allows consuming the response in multiple ways: + * + * - `await response.getMessage()` - Get the completed message (tools auto-executed) + * - `await response.getText()` - Get just the text content (tools auto-executed) + * - `for await (const delta of response.getTextStream())` - Stream text deltas + * - `for await (const delta of response.getReasoningStream())` - Stream reasoning deltas + * - `for await (const event of response.getToolStream())` - Stream tool events (incl. preliminary results) + * - `for await (const toolCall of response.getToolCallsStream())` - Stream structured tool calls + * - `await response.getToolCalls()` - Get all tool calls from completed response + * - `for await (const msg of response.getNewMessagesStream())` - Stream incremental message updates + * - `for await (const event of response.getFullResponsesStream())` - Stream all events (incl. tool preliminary) + * - `for await (const event of response.getFullChatStream())` - Stream in chat format (incl. tool preliminary) + * + * All consumption patterns can be used concurrently on the same response. + * + * @example + * ```typescript + * import { z } from 'zod'; + * + * // Simple text extraction + * const response = openrouter.getResponse({ + * model: "openai/gpt-4", + * input: "Hello!" + * }); + * const text = await response.getText(); + * console.log(text); + * + * // With tools (automatic execution) + * const response = openrouter.getResponse({ + * model: "openai/gpt-4", + * input: "What's the weather in SF?", + * tools: [{ + * type: "function", + * function: { + * name: "get_weather", + * description: "Get current weather", + * inputSchema: z.object({ + * location: z.string() + * }), + * outputSchema: z.object({ + * temperature: z.number(), + * description: z.string() + * }), + * execute: async (params) => { + * return { temperature: 72, description: "Sunny" }; + * } + * } + * }], + * maxToolRounds: 5, // or function: (context: TurnContext) => boolean + * }); + * const message = await response.getMessage(); // Tools auto-executed! + * + * // Stream with preliminary results + * for await (const event of response.getFullChatStream()) { + * if (event.type === "content.delta") { + * process.stdout.write(event.delta); + * } else if (event.type === "tool.preliminary_result") { + * console.log("Tool progress:", event.result); + * } + * } + * ``` + */ +export function getResponse( + client: OpenRouterCore, + request: Omit & { + tools?: EnhancedTool[] | models.OpenResponsesRequest["tools"]; + maxToolRounds?: MaxToolRounds; + }, + options?: RequestOptions, +): ResponseWrapper { + const { tools, maxToolRounds, ...apiRequest } = request; + + // Separate enhanced tools from API tools + let isEnhancedTools = false; + if (tools && tools.length > 0) { + const firstTool = tools[0] as any; + isEnhancedTools = "function" in firstTool && firstTool.function && "inputSchema" in firstTool.function; + } + const enhancedTools = isEnhancedTools ? (tools as EnhancedTool[]) : undefined; + + // Convert enhanced tools to API format if provided, otherwise use tools as-is + const apiTools = enhancedTools ? convertEnhancedToolsToAPIFormat(enhancedTools) : (tools as models.OpenResponsesRequest["tools"]); + + // Build the request with converted tools + const finalRequest: models.OpenResponsesRequest = { + ...apiRequest, + ...(apiTools && { tools: apiTools }), + } as models.OpenResponsesRequest; + + const wrapperOptions: { + client: OpenRouterCore; + request: models.OpenResponsesRequest; + options: RequestOptions; + tools?: EnhancedTool[]; + maxToolRounds?: MaxToolRounds; + } = { + client, + request: finalRequest, + options: options ?? {}, + }; + + // Only pass enhanced tools to wrapper (needed for auto-execution) + if (enhancedTools) { + wrapperOptions.tools = enhancedTools; + } + + if (maxToolRounds !== undefined) { + wrapperOptions.maxToolRounds = maxToolRounds; + } + + return new ResponseWrapper(wrapperOptions); +} diff --git a/src/index.ts b/src/index.ts index dbcba164..8d98038a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,4 +6,24 @@ 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"; +// #region imports +export { ResponseWrapper } from "./lib/response-wrapper.js"; +export type { GetResponseOptions } from "./lib/response-wrapper.js"; +export { ReusableReadableStream } from "./lib/reusable-stream.js"; +export { ToolType, isToolPreliminaryResultEvent } from "./lib/tool-types.js"; +export type { + EnhancedTool, + ToolWithExecute, + ToolWithGenerator, + ManualTool, + ParsedToolCall, + ToolExecutionResult, + MaxToolRounds, + TurnContext, + EnhancedResponseStreamEvent, + ToolStreamEvent, + ChatStreamEvent, + ToolPreliminaryResultEvent, +} from "./lib/tool-types.js"; +// #endregion export * from "./sdk/sdk.js"; diff --git a/src/lib/response-wrapper.ts b/src/lib/response-wrapper.ts new file mode 100644 index 00000000..7e2f0849 --- /dev/null +++ b/src/lib/response-wrapper.ts @@ -0,0 +1,545 @@ +import { OpenRouterCore } from "../core.js"; +import { EventStream } from "./event-streams.js"; +import { RequestOptions } from "./sdks.js"; +import * as models from "../models/index.js"; +import { betaResponsesSend } from "../funcs/betaResponsesSend.js"; +import { ReusableReadableStream } from "./reusable-stream.js"; +import { + extractTextDeltas, + extractReasoningDeltas, + extractToolDeltas, + buildMessageStream, + consumeStreamForCompletion, + extractMessageFromResponse, + extractTextFromResponse, + extractToolCallsFromResponse, + buildToolCallStream, +} from "./stream-transformers.js"; +import { + EnhancedTool, + ParsedToolCall, + MaxToolRounds, + TurnContext, + hasExecuteFunction, + EnhancedResponseStreamEvent, + ToolStreamEvent, + ChatStreamEvent, +} from "./tool-types.js"; +import { + executeTool, +} from "./tool-executor.js"; + +export interface GetResponseOptions { + request: models.OpenResponsesRequest; + client: OpenRouterCore; + options?: RequestOptions; + tools?: EnhancedTool[]; + maxToolRounds?: MaxToolRounds; +} + +/** + * A wrapper around a streaming response that provides multiple consumption patterns. + * + * Allows consuming the response in multiple ways: + * - `await response.getMessage()` - Get the completed message + * - `await response.getText()` - Get just the text + * - `for await (const delta of response.getTextStream())` - Stream text deltas + * - `for await (const msg of response.getNewMessagesStream())` - Stream incremental message updates + * - `for await (const event of response.getFullResponsesStream())` - Stream all response events + * + * All consumption patterns can be used concurrently thanks to the underlying + * ReusableReadableStream implementation. + */ +export class ResponseWrapper { + private reusableStream: ReusableReadableStream | null = null; + private streamPromise: Promise> | null = null; + private messagePromise: Promise | null = null; + private textPromise: Promise | null = null; + 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[]; + response: models.OpenResponsesNonStreamingResponse; + }> = []; + + constructor(options: GetResponseOptions) { + this.options = options; + } + + /** + * Initialize the stream if not already started + * This is idempotent - multiple calls will return the same promise + */ + private initStream(): Promise { + if (this.initPromise) { + return this.initPromise; + } + + this.initPromise = (async () => { + // Force stream mode + const request = { ...this.options.request, stream: true as const }; + + // Create the stream promise + this.streamPromise = betaResponsesSend( + this.options.client, + request, + this.options.options, + ).then((result) => { + if (!result.ok) { + throw result.error; + } + return result.value; + }); + + // Wait for the stream and create the reusable stream + const eventStream = await this.streamPromise; + this.reusableStream = new ReusableReadableStream(eventStream); + })(); + + return this.initPromise; + } + + /** + * Execute tools automatically if they are provided and have execute functions + * This is idempotent - multiple calls will return the same promise + */ + private async executeToolsIfNeeded(): Promise { + if (this.toolExecutionPromise) { + return this.toolExecutionPromise; + } + + this.toolExecutionPromise = (async () => { + await this.initStream(); + + if (!this.reusableStream) { + throw new Error("Stream not initialized"); + } + + // Get the initial response + 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) => "type" in item && item.type === "function_call" + ); + + if (!shouldAutoExecute) { + // No tools to execute, use initial response + this.finalResponse = initialResponse; + return; + } + + // Extract tool calls + const toolCalls = extractToolCallsFromResponse(initialResponse); + + // Check if any have execute functions + const executableTools = toolCalls.filter((toolCall) => { + const tool = this.options.tools?.find((t) => t.function.name === toolCall.name); + return tool && hasExecuteFunction(tool); + }); + + if (executableTools.length === 0) { + // No executable tools, use initial response + this.finalResponse = initialResponse; + 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) { + const currentToolCalls = extractToolCallsFromResponse(currentResponse); + + if (currentToolCalls.length === 0) { + break; + } + + const hasExecutable = currentToolCalls.some((toolCall) => { + const tool = this.options.tools?.find((t) => t.function.name === toolCall.name); + return tool && hasExecuteFunction(tool); + }); + + if (!hasExecutable) { + 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 + 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 }), + }; + + // Execute all tool calls + const toolResults: Array = []; + + for (const toolCall of currentToolCalls) { + const tool = this.options.tools?.find((t) => t.function.name === toolCall.name); + + if (!tool || !hasExecuteFunction(tool)) { + continue; + } + + const result = await executeTool(tool, toolCall, turnContext); + + // Store preliminary results + if (result.preliminaryResults && result.preliminaryResults.length > 0) { + this.preliminaryResults.set(toolCall.id, result.preliminaryResults); + } + + toolResults.push({ + type: "function_call_output" as const, + id: `output_${toolCall.id}`, + callId: toolCall.id, + output: result.error + ? JSON.stringify({ error: result.error.message }) + : JSON.stringify(result.result), + }); + } + + // 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]), + ...toolResults, + ]; + + // Update current input for next iteration + currentInput = newInput; + + // Make new request with tool results + const newRequest: models.OpenResponsesRequest = { + ...this.options.request, + input: newInput, + stream: false, + }; + + const newResult = await betaResponsesSend( + this.options.client, + newRequest, + this.options.options + ); + + if (!newResult.ok) { + throw newResult.error; + } + + // Handle the result - it might be a stream or a response + const value = newResult.value; + if (value && typeof value === "object" && "toReadableStream" in value) { + // It's a stream, consume it + const stream = new ReusableReadableStream(value as EventStream); + currentResponse = await consumeStreamForCompletion(stream); + } else { + currentResponse = value as models.OpenResponsesNonStreamingResponse; + } + + currentRound++; + } + + this.finalResponse = currentResponse; + })(); + + return this.toolExecutionPromise; + } + + /** + * Get the completed message from the response. + * This will consume the stream until completion, execute any tools, and extract the first message. + * Returns an AssistantMessage in chat format. + */ + getMessage(): Promise { + if (this.messagePromise) { + return this.messagePromise; + } + + this.messagePromise = (async (): Promise => { + await this.executeToolsIfNeeded(); + + if (!this.finalResponse) { + throw new Error("Response not available"); + } + + return extractMessageFromResponse(this.finalResponse); + })(); + + return this.messagePromise; + } + + /** + * Get just the text content from the response. + * This will consume the stream until completion, execute any tools, and extract the text. + */ + getText(): Promise { + if (this.textPromise) { + return this.textPromise; + } + + this.textPromise = (async () => { + await this.executeToolsIfNeeded(); + + if (!this.finalResponse) { + throw new Error("Response not available"); + } + + return extractTextFromResponse(this.finalResponse); + })(); + + return this.textPromise; + } + + /** + * Stream all response events as they arrive. + * Multiple consumers can iterate over this stream concurrently. + * Includes preliminary tool result events after tool execution. + */ + getFullResponsesStream(): AsyncIterableIterator { + return (async function* (this: ResponseWrapper) { + await this.initStream(); + if (!this.reusableStream) { + throw new Error("Stream not initialized"); + } + + const consumer = this.reusableStream.createConsumer(); + + // Yield original events directly + for await (const event of consumer) { + yield event; + } + + // After stream completes, check if tools were executed and emit preliminary results + await this.executeToolsIfNeeded(); + + // Emit all preliminary results as new event types + for (const [toolCallId, results] of this.preliminaryResults) { + for (const result of results) { + yield { + type: "tool.preliminary_result" as const, + toolCallId, + result, + timestamp: Date.now(), + }; + } + } + }.call(this)); + } + + /** + * Stream only text deltas as they arrive. + * This filters the full event stream to only yield text content. + */ + getTextStream(): AsyncIterableIterator { + return (async function* (this: ResponseWrapper) { + await this.initStream(); + if (!this.reusableStream) { + throw new Error("Stream not initialized"); + } + + yield* extractTextDeltas(this.reusableStream); + }.call(this)); + } + + /** + * Stream incremental message updates as content is added. + * Each iteration yields an updated version of the message with new content. + * Returns AssistantMessage in chat format. + */ + getNewMessagesStream(): AsyncIterableIterator { + return (async function* (this: ResponseWrapper) { + await this.initStream(); + if (!this.reusableStream) { + throw new Error("Stream not initialized"); + } + + yield* buildMessageStream(this.reusableStream); + }.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: ResponseWrapper) { + await this.initStream(); + if (!this.reusableStream) { + throw new Error("Stream not initialized"); + } + + yield* extractReasoningDeltas(this.reusableStream); + }.call(this)); + } + + /** + * Stream tool call argument deltas and preliminary results. + * This filters the full event stream to yield: + * - Tool call argument deltas as { type: "delta", content: string } + * - Preliminary results as { type: "preliminary_result", toolCallId, result } + */ + getToolStream(): AsyncIterableIterator { + return (async function* (this: ResponseWrapper) { + await this.initStream(); + if (!this.reusableStream) { + 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, content: delta }; + } + + // 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: "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: ResponseWrapper) { + 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 + // This is a simplified transformation - you may need to adjust based on your needs + if (event.type === "response.output_text.delta") { + const deltaEvent = event as models.OpenResponsesStreamEventResponseOutputTextDelta; + yield { + type: "content.delta" as const, + delta: deltaEvent.delta, + }; + } else if (event.type === "response.completed") { + const completedEvent = event as models.OpenResponsesStreamEventResponseCompleted; + yield { + type: "message.complete" as const, + response: completedEvent.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, + }; + } + } + }.call(this)); + } + + /** + * Get all tool calls from the completed response (before auto-execution). + * Note: If tools have execute functions, they will be automatically executed + * and this will return the tool calls from the initial response. + * Returns structured tool calls with parsed arguments. + */ + async getToolCalls(): Promise { + await this.initStream(); + if (!this.reusableStream) { + throw new Error("Stream not initialized"); + } + + const completedResponse = await consumeStreamForCompletion(this.reusableStream); + return extractToolCallsFromResponse(completedResponse); + } + + /** + * 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: ResponseWrapper) { + await this.initStream(); + if (!this.reusableStream) { + throw new Error("Stream not initialized"); + } + + yield* buildToolCallStream(this.reusableStream); + }.call(this)); + } + + + /** + * Cancel the underlying stream and all consumers + */ + async cancel(): Promise { + if (this.reusableStream) { + await this.reusableStream.cancel(); + } + } +} diff --git a/src/lib/reusable-stream.ts b/src/lib/reusable-stream.ts new file mode 100644 index 00000000..7a8e78db --- /dev/null +++ b/src/lib/reusable-stream.ts @@ -0,0 +1,196 @@ +/** + * A reusable readable stream that allows multiple consumers to read from the same source stream + * concurrently while it's actively streaming, without forcing consumers to wait for full buffering. + * + * Key features: + * - Multiple concurrent consumers with independent read positions + * - New consumers can attach while streaming is active + * - Efficient memory management with automatic cleanup + * - Each consumer can read at their own pace + */ +export class ReusableReadableStream { + private buffer: T[] = []; + private consumers = new Map(); + private nextConsumerId = 0; + private sourceReader: ReadableStreamDefaultReader | null = null; + private sourceComplete = false; + private sourceError: Error | null = null; + private pumpStarted = false; + + constructor(private sourceStream: ReadableStream) {} + + /** + * Create a new consumer that can independently iterate over the stream. + * Multiple consumers can be created and will all receive the same data. + */ + createConsumer(): AsyncIterableIterator { + const consumerId = this.nextConsumerId++; + const state: ConsumerState = { + position: 0, + waitingPromise: null, + cancelled: false, + }; + this.consumers.set(consumerId, state); + + // Start pumping the source stream if not already started + if (!this.pumpStarted) { + this.startPump(); + } + + const self = this; + + return { + async next(): Promise> { + const consumer = self.consumers.get(consumerId); + if (!consumer) { + return { done: true, value: undefined }; + } + + if (consumer.cancelled) { + return { done: true, value: undefined }; + } + + // If we have buffered data at this position, return it + if (consumer.position < self.buffer.length) { + const value = self.buffer[consumer.position]!; + consumer.position++; + // Note: We don't clean up buffer to allow sequential/reusable access + return { done: false, value }; + } + + // If source is complete and we've read everything, we're done + if (self.sourceComplete) { + self.consumers.delete(consumerId); + return { done: true, value: undefined }; + } + + // If source had an error, propagate it + if (self.sourceError) { + self.consumers.delete(consumerId); + throw self.sourceError; + } + + // Wait for more data - but check conditions after setting up the promise + // to avoid race condition where source completes between check and wait + 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; + } + } + + await waitPromise; + + // Recursively try again after waking up + return this.next(); + }, + + async return(): Promise> { + const consumer = self.consumers.get(consumerId); + if (consumer) { + consumer.cancelled = true; + self.consumers.delete(consumerId); + } + return { done: true, value: undefined }; + }, + + async throw(e?: any): Promise> { + const consumer = self.consumers.get(consumerId); + if (consumer) { + consumer.cancelled = true; + self.consumers.delete(consumerId); + } + throw e; + }, + + [Symbol.asyncIterator]() { + return this; + }, + }; + } + + /** + * Start pumping data from the source stream into the buffer + */ + private startPump(): void { + if (this.pumpStarted) return; + this.pumpStarted = true; + this.sourceReader = this.sourceStream.getReader(); + + void (async () => { + try { + while (true) { + const result = await this.sourceReader!.read(); + + if (result.done) { + this.sourceComplete = true; + this.notifyAllConsumers(); + break; + } + + // Add to buffer + this.buffer.push(result.value); + + // Notify waiting consumers + this.notifyAllConsumers(); + } + } catch (error) { + this.sourceError = error instanceof Error ? error : new Error(String(error)); + this.notifyAllConsumers(); + } finally { + if (this.sourceReader) { + this.sourceReader.releaseLock(); + } + } + })(); + } + + /** + * Notify all waiting consumers that new data is available + */ + private notifyAllConsumers(): void { + for (const consumer of this.consumers.values()) { + if (consumer.waitingPromise) { + if (this.sourceError) { + consumer.waitingPromise.reject(this.sourceError); + } else { + consumer.waitingPromise.resolve(); + } + consumer.waitingPromise = null; + } + } + } + + + /** + * Cancel the source stream and all consumers + */ + async cancel(): Promise { + // Cancel all consumers + for (const consumer of this.consumers.values()) { + consumer.cancelled = true; + if (consumer.waitingPromise) { + consumer.waitingPromise.resolve(); + } + } + this.consumers.clear(); + + // Cancel the source stream + if (this.sourceReader) { + await this.sourceReader.cancel(); + this.sourceReader.releaseLock(); + } + } +} + +interface ConsumerState { + position: number; + waitingPromise: { resolve: () => void; reject: (error: Error) => void } | null; + cancelled: boolean; +} diff --git a/src/lib/stream-transformers.ts b/src/lib/stream-transformers.ts new file mode 100644 index 00000000..9e7dca6f --- /dev/null +++ b/src/lib/stream-transformers.ts @@ -0,0 +1,366 @@ +import * as models from "../models/index.js"; +import { ReusableReadableStream } from "./reusable-stream.js"; +import { ParsedToolCall } from "./tool-types.js"; + +/** + * Extract text deltas from responses stream events + */ +export async function* extractTextDeltas( + stream: ReusableReadableStream, +): AsyncIterableIterator { + const consumer = stream.createConsumer(); + + for await (const event of consumer) { + if ("type" in event && event.type === "response.output_text.delta") { + const deltaEvent = event as models.OpenResponsesStreamEventResponseOutputTextDelta; + if (deltaEvent.delta) { + yield deltaEvent.delta; + } + } + } +} + +/** + * Extract reasoning deltas from responses stream events + */ +export async function* extractReasoningDeltas( + stream: ReusableReadableStream, +): AsyncIterableIterator { + const consumer = stream.createConsumer(); + + for await (const event of consumer) { + if ("type" in event && event.type === "response.reasoning_text.delta") { + const deltaEvent = event as models.OpenResponsesReasoningDeltaEvent; + if (deltaEvent.delta) { + yield deltaEvent.delta; + } + } + } +} + +/** + * Extract tool call argument deltas from responses stream events + */ +export async function* extractToolDeltas( + stream: ReusableReadableStream, +): AsyncIterableIterator { + const consumer = stream.createConsumer(); + + for await (const event of consumer) { + if ("type" in event && event.type === "response.function_call_arguments.delta") { + const deltaEvent = event as models.OpenResponsesStreamEventResponseFunctionCallArgumentsDelta; + if (deltaEvent.delta) { + yield deltaEvent.delta; + } + } + } +} + +/** + * Build incremental message updates from responses stream events + * Returns AssistantMessage (chat format) instead of ResponsesOutputMessage + */ +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; + } + } + } +} + +/** + * Consume stream until completion and return the complete response + */ +export async function consumeStreamForCompletion( + stream: ReusableReadableStream, +): Promise { + const consumer = stream.createConsumer(); + + for await (const event of consumer) { + if (!("type" in event)) continue; + + if (event.type === "response.completed") { + const completedEvent = event as models.OpenResponsesStreamEventResponseCompleted; + return completedEvent.response; + } + + if (event.type === "response.failed") { + const failedEvent = event as models.OpenResponsesStreamEventResponseFailed; + // The failed event contains the full response with error information + throw new Error(`Response failed: ${JSON.stringify(failedEvent.response.error)}`); + } + + if (event.type === "response.incomplete") { + const incompleteEvent = event as models.OpenResponsesStreamEventResponseIncomplete; + // Return the incomplete response + return incompleteEvent.response; + } + } + + throw new Error("Stream ended without completion event"); +} + +/** + * Convert ResponsesOutputMessage to AssistantMessage (chat format) + */ +function convertToAssistantMessage( + outputMessage: models.ResponsesOutputMessage, +): models.AssistantMessage { + // Extract text content + const textContent = outputMessage.content + .filter((part): part is models.ResponseOutputText => + "type" in part && part.type === "output_text" + ) + .map((part) => part.text) + .join(""); + + return { + role: "assistant" as const, + content: textContent || null, + }; +} + +/** + * Extract the first message from a completed response + */ +export function extractMessageFromResponse( + response: models.OpenResponsesNonStreamingResponse, +): models.AssistantMessage { + const messageItem = response.output.find( + (item): item is models.ResponsesOutputMessage => + "type" in item && item.type === "message" + ); + + if (!messageItem) { + throw new Error("No message found in response output"); + } + + return convertToAssistantMessage(messageItem); +} + +/** + * Extract text from a response, either from outputText or by concatenating message content + */ +export function extractTextFromResponse( + response: models.OpenResponsesNonStreamingResponse, +): string { + // Use pre-concatenated outputText if available + if (response.outputText) { + return response.outputText; + } + + // Otherwise, extract from the first message (convert to AssistantMessage which has string content) + const message = extractMessageFromResponse(response); + + // AssistantMessage.content is string | Array | null | undefined + if (typeof message.content === "string") { + return message.content; + } + + return ""; +} + +/** + * Extract all tool calls from a completed response + * Returns parsed tool calls with arguments as objects (not JSON strings) + */ +export function extractToolCallsFromResponse( + response: models.OpenResponsesNonStreamingResponse, +): ParsedToolCall[] { + const toolCalls: ParsedToolCall[] = []; + + for (const item of response.output) { + if ("type" in item && item.type === "function_call") { + const functionCallItem = item as models.ResponsesOutputItemFunctionCall; + + try { + const parsedArguments = JSON.parse(functionCallItem.arguments); + + toolCalls.push({ + id: functionCallItem.callId, + name: functionCallItem.name, + arguments: parsedArguments, + }); + } catch (error) { + console.error( + `Failed to parse tool call arguments for ${functionCallItem.name}:`, + error + ); + // Include the tool call with unparsed arguments + toolCalls.push({ + id: functionCallItem.callId, + name: functionCallItem.name, + arguments: functionCallItem.arguments, // Keep as string if parsing fails + }); + } + } + } + + return toolCalls; +} + +/** + * Build incremental tool call updates from responses stream events + * Yields structured tool call objects as they're built from deltas + */ +export async function* buildToolCallStream( + stream: ReusableReadableStream, +): AsyncIterableIterator { + const consumer = stream.createConsumer(); + + // Track tool calls being built + const toolCallsInProgress = new Map< + string, + { + id: string; + name: string; + argumentsAccumulated: string; + } + >(); + + for await (const event of consumer) { + if (!("type" in event)) continue; + + switch (event.type) { + case "response.output_item.added": { + 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, + argumentsAccumulated: "", + }); + } + break; + } + + 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; + } + break; + } + + case "response.function_call_arguments.done": { + const doneEvent = + event as models.OpenResponsesStreamEventResponseFunctionCallArgumentsDone; + const toolCall = toolCallsInProgress.get(doneEvent.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, + }; + } + + // Clean up + toolCallsInProgress.delete(doneEvent.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; + + // Yield final tool call if we haven't already + if (toolCallsInProgress.has(functionCallItem.callId)) { + try { + const parsedArguments = JSON.parse(functionCallItem.arguments); + yield { + id: functionCallItem.callId, + name: functionCallItem.name, + arguments: parsedArguments, + }; + } catch (error) { + yield { + id: functionCallItem.callId, + name: functionCallItem.name, + arguments: functionCallItem.arguments, + }; + } + + toolCallsInProgress.delete(functionCallItem.callId); + } + } + break; + } + } + } +} + +/** + * Check if a response contains any tool calls + */ +export function responseHasToolCalls( + response: models.OpenResponsesNonStreamingResponse, +): boolean { + return response.output.some( + (item) => "type" in item && item.type === "function_call" + ); +} diff --git a/src/lib/tool-executor.ts b/src/lib/tool-executor.ts new file mode 100644 index 00000000..557ff7c1 --- /dev/null +++ b/src/lib/tool-executor.ts @@ -0,0 +1,267 @@ +import { ZodError, toJSONSchema, type ZodType } from "zod/v4"; +import { + EnhancedTool, + ToolExecutionResult, + ParsedToolCall, + APITool, + TurnContext, + hasExecuteFunction, + isGeneratorTool, + isRegularExecuteTool, +} from "./tool-types.js"; + +/** + * Convert a Zod schema to JSON Schema using Zod v4's toJSONSchema function + */ +export function convertZodToJsonSchema(zodSchema: ZodType): Record { + const jsonSchema = toJSONSchema(zodSchema as any, { + target: "openapi-3.0", + } as any); + return jsonSchema as Record; +} + +/** + * Convert enhanced tools to OpenRouter API format + */ +export function convertEnhancedToolsToAPIFormat( + tools: EnhancedTool[] +): APITool[] { + return tools.map((tool) => ({ + type: "function" as const, + name: tool.function.name, + description: tool.function.description || null, + strict: null, + parameters: convertZodToJsonSchema(tool.function.inputSchema as any), + })); +} + +/** + * Validate tool input against Zod schema + * @throws ZodError if validation fails + */ +export function validateToolInput(schema: ZodType, args: unknown): T { + return schema.parse(args); +} + +/** + * Validate tool output against Zod schema + * @throws ZodError if validation fails + */ +export function validateToolOutput(schema: ZodType, result: unknown): T { + return schema.parse(result); +} + +/** + * Parse tool call arguments from JSON string + */ +export function parseToolCallArguments(argumentsString: string): unknown { + try { + return JSON.parse(argumentsString); + } catch (error) { + throw new Error( + `Failed to parse tool call arguments: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } +} + +/** + * Execute a regular (non-generator) tool + */ +export async function executeRegularTool( + tool: EnhancedTool, + toolCall: ParsedToolCall, + context: TurnContext +): Promise { + if (!isRegularExecuteTool(tool)) { + throw new Error( + `Tool "${toolCall.name}" is not a regular execute tool or has no execute function` + ); + } + + try { + // Validate input + const validatedInput = validateToolInput( + tool.function.inputSchema, + toolCall.arguments + ); + + // Execute tool with context + const result = await Promise.resolve( + tool.function.execute(validatedInput as any, context) + ); + + // Validate output if schema is provided + if (tool.function.outputSchema) { + const validatedOutput = validateToolOutput( + tool.function.outputSchema, + result + ); + + return { + toolCallId: toolCall.id, + toolName: toolCall.name, + result: validatedOutput, + }; + } + + return { + toolCallId: toolCall.id, + toolName: toolCall.name, + result, + }; + } catch (error) { + return { + toolCallId: toolCall.id, + toolName: toolCall.name, + result: null, + error: error instanceof Error ? error : new Error(String(error)), + }; + } +} + +/** + * Execute a generator tool and collect preliminary and final results + * - Intermediate yields are validated against eventSchema (preliminary events) + * - Last yield is validated against outputSchema (final result sent to model) + * - Generator must emit at least one value + */ +export async function executeGeneratorTool( + tool: EnhancedTool, + toolCall: ParsedToolCall, + context: TurnContext, + onPreliminaryResult?: (toolCallId: string, result: unknown) => void +): Promise { + if (!isGeneratorTool(tool)) { + throw new Error(`Tool "${toolCall.name}" is not a generator tool`); + } + + try { + // Validate input + const validatedInput = validateToolInput( + tool.function.inputSchema, + toolCall.arguments + ); + + // Execute generator and collect all results + const preliminaryResults: unknown[] = []; + let lastEmittedValue: unknown = null; + let hasEmittedValue = false; + + for await (const event of tool.function.execute(validatedInput as any, context)) { + hasEmittedValue = true; + + // Validate event against eventSchema + const validatedEvent = validateToolOutput(tool.function.eventSchema, event); + + preliminaryResults.push(validatedEvent); + lastEmittedValue = validatedEvent; + + // Emit preliminary result via callback + if (onPreliminaryResult) { + onPreliminaryResult(toolCall.id, validatedEvent); + } + } + + // Generator must emit at least one value + if (!hasEmittedValue) { + throw new Error( + `Generator tool "${toolCall.name}" completed without emitting any values` + ); + } + + // Validate the last emitted value against outputSchema (this is the final result) + const finalResult = validateToolOutput( + tool.function.outputSchema, + lastEmittedValue + ); + + // Remove last item from preliminaryResults since it's the final output + preliminaryResults.pop(); + + return { + toolCallId: toolCall.id, + toolName: toolCall.name, + result: finalResult, + preliminaryResults, + }; + } catch (error) { + return { + toolCallId: toolCall.id, + toolName: toolCall.name, + result: null, + error: error instanceof Error ? error : new Error(String(error)), + }; + } +} + +/** + * Execute a tool call + * Automatically detects if it's a regular or generator tool + */ +export async function executeTool( + tool: EnhancedTool, + toolCall: ParsedToolCall, + context: TurnContext, + onPreliminaryResult?: (toolCallId: string, result: unknown) => void +): Promise { + if (!hasExecuteFunction(tool)) { + throw new Error( + `Tool "${toolCall.name}" has no execute function. Use manual tool execution.` + ); + } + + if (isGeneratorTool(tool)) { + return executeGeneratorTool(tool, toolCall, context, onPreliminaryResult); + } + + return executeRegularTool(tool, toolCall, context); +} + +/** + * Find a tool by name in the tools array + */ +export function findToolByName( + tools: EnhancedTool[], + name: string +): EnhancedTool | undefined { + return tools.find((tool) => tool.function.name === name); +} + +/** + * Format tool execution result as a string for sending to the model + */ +export function formatToolResultForModel(result: ToolExecutionResult): string { + if (result.error) { + return JSON.stringify({ + error: result.error.message, + toolName: result.toolName, + }); + } + + return JSON.stringify(result.result); +} + +/** + * Create a user-friendly error message for tool execution errors + */ +export function formatToolExecutionError( + error: Error, + toolCall: ParsedToolCall +): string { + if (error instanceof ZodError) { + const issues = error.issues.map((issue) => ({ + path: issue.path.join("."), + message: issue.message, + })); + + return `Tool "${toolCall.name}" validation error:\n${JSON.stringify( + issues, + null, + 2 + )}`; + } + + return `Tool "${toolCall.name}" execution error: ${error.message}`; +} diff --git a/src/lib/tool-orchestrator.ts b/src/lib/tool-orchestrator.ts new file mode 100644 index 00000000..d5524bd7 --- /dev/null +++ b/src/lib/tool-orchestrator.ts @@ -0,0 +1,206 @@ +import * as models from "../models/index.js"; +import { + EnhancedTool, + ToolExecutionResult, + hasExecuteFunction, +} from "./tool-types.js"; +import { + executeTool, + findToolByName, +} from "./tool-executor.js"; +import { + extractToolCallsFromResponse, + responseHasToolCalls, +} from "./stream-transformers.js"; + +/** + * Options for tool execution + */ +export interface ToolExecutionOptions { + maxRounds?: number; + onPreliminaryResult?: (toolCallId: string, result: unknown) => void; +} + +/** + * Result of the tool execution loop + */ +export interface ToolOrchestrationResult { + finalResponse: models.OpenResponsesNonStreamingResponse; + allResponses: models.OpenResponsesNonStreamingResponse[]; + toolExecutionResults: ToolExecutionResult[]; + conversationInput: models.OpenResponsesInput; +} + +/** + * Execute tool calls and manage multi-turn conversations + * This orchestrates the loop of: request -> tool calls -> execute -> send results -> repeat + * + * @param sendRequest - Function to send a request and get a response + * @param initialInput - Starting input for the conversation + * @param tools - Enhanced tools with Zod schemas and execute functions + * @param apiTools - Converted tools in API format (JSON Schema) + * @param options - Execution options + * @returns Result containing final response and all execution data + */ +export async function executeToolLoop( + sendRequest: ( + input: models.OpenResponsesInput, + tools: any[] + ) => Promise, + initialInput: models.OpenResponsesInput, + tools: EnhancedTool[], + apiTools: any[], + options: ToolExecutionOptions = {} +): Promise { + const maxRounds = options.maxRounds ?? 5; + const onPreliminaryResult = options.onPreliminaryResult; + + const allResponses: models.OpenResponsesNonStreamingResponse[] = []; + const toolExecutionResults: ToolExecutionResult[] = []; + let conversationInput: models.OpenResponsesInput = initialInput; + + let currentRound = 0; + let currentResponse: models.OpenResponsesNonStreamingResponse; + + // Initial request + currentResponse = await sendRequest(conversationInput, apiTools); + allResponses.push(currentResponse); + + // Loop until no more tool calls or max rounds reached + while (responseHasToolCalls(currentResponse) && currentRound < maxRounds) { + currentRound++; + + // Extract tool calls from response + const toolCalls = extractToolCallsFromResponse(currentResponse); + + if (toolCalls.length === 0) { + break; + } + + // Check if any tools have execute functions + const hasExecutableTools = toolCalls.some((toolCall) => { + const tool = findToolByName(tools, toolCall.name); + return tool && hasExecuteFunction(tool); + }); + + // If no executable tools, return (manual execution mode) + if (!hasExecutableTools) { + break; + } + + // Execute all tool calls + const roundResults: ToolExecutionResult[] = []; + + for (const toolCall of toolCalls) { + const tool = findToolByName(tools, toolCall.name); + + if (!tool) { + // Tool not found in definitions + roundResults.push({ + toolCallId: toolCall.id, + toolName: toolCall.name, + result: null, + error: new Error(`Tool "${toolCall.name}" not found in tool definitions`), + }); + continue; + } + + if (!hasExecuteFunction(tool)) { + // Tool has no execute function - skip + continue; + } + + // Build turn context + const turnContext: import("./tool-types.js").TurnContext = { + numberOfTurns: currentRound, + messageHistory: conversationInput, + }; + + // Execute the tool + const result = await executeTool(tool, toolCall, turnContext, onPreliminaryResult); + roundResults.push(result); + } + + toolExecutionResults.push(...roundResults); + + // 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 + + // Note: The OpenRouter Responses API uses previousResponseId for continuation + // Tool results are automatically associated with the previous response's tool calls + + // Send updated conversation to API - this should use previousResponseId + currentResponse = await sendRequest(conversationInput, apiTools); + allResponses.push(currentResponse); + } + + return { + finalResponse: currentResponse, + allResponses, + toolExecutionResults, + conversationInput, + }; +} + +/** + * Convert tool execution results to a map for easy lookup + */ +export function toolResultsToMap( + results: ToolExecutionResult[] +): Map< + string, + { + result: unknown; + preliminaryResults?: unknown[]; + } +> { + const map = new Map(); + + for (const result of results) { + map.set(result.toolCallId, { + result: result.result, + preliminaryResults: result.preliminaryResults, + }); + } + + return map; +} + +/** + * Build a summary of tool executions for debugging/logging + */ +export function summarizeToolExecutions( + results: ToolExecutionResult[] +): string { + const lines: string[] = []; + + for (const result of results) { + if (result.error) { + lines.push(`❌ ${result.toolName} (${result.toolCallId}): ERROR - ${result.error.message}`); + } else { + const prelimCount = result.preliminaryResults?.length ?? 0; + const prelimInfo = prelimCount > 0 ? ` (${prelimCount} preliminary results)` : ""; + lines.push(`✅ ${result.toolName} (${result.toolCallId}): SUCCESS${prelimInfo}`); + } + } + + return lines.join("\n"); +} + +/** + * Check if any tool executions had errors + */ +export function hasToolExecutionErrors(results: ToolExecutionResult[]): boolean { + return results.some((result) => result.error !== undefined); +} + +/** + * Get all tool execution errors + */ +export function getToolExecutionErrors(results: ToolExecutionResult[]): Error[] { + return results + .filter((result) => result.error !== undefined) + .map((result) => result.error!); +} diff --git a/src/lib/tool-types.ts b/src/lib/tool-types.ts new file mode 100644 index 00000000..ef6bb089 --- /dev/null +++ b/src/lib/tool-types.ts @@ -0,0 +1,265 @@ +import { z, type ZodType, type ZodObject, type ZodRawShape } from "zod/v4"; +import * as models from "../models/index.js"; +import type { OpenResponsesStreamEvent } from "../models/index.js"; + +/** + * Tool type enum for enhanced tools + */ +export enum ToolType { + Function = "function", +} + +/** + * Turn context passed to tool execute functions + * Contains information about the current conversation state + */ +export interface TurnContext { + /** Number of tool execution turns so far (1-indexed: first turn = 1) */ + 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[]; +} + +/** + * Base tool function interface with inputSchema + */ +export interface BaseToolFunction> { + name: string; + description?: string; + inputSchema: TInput; +} + +/** + * Regular tool with synchronous or asynchronous execute function and optional outputSchema + */ +export interface ToolFunctionWithExecute< + TInput extends ZodObject, + TOutput extends ZodType = ZodType +> extends BaseToolFunction { + outputSchema?: TOutput; + execute: ( + params: z.infer, + context?: TurnContext + ) => Promise> | z.infer; +} + +/** + * Generator-based tool with async generator execute function + * Emits preliminary events (validated by eventSchema) during execution + * and a final output (validated by outputSchema) as the last emission + * + * @example + * ```typescript + * { + * eventSchema: z.object({ status: z.string() }), // For progress events + * outputSchema: z.object({ result: z.number() }), // For final output + * execute: async function* (params) { + * yield { status: "processing..." }; // Event + * yield { status: "almost done..." }; // Event + * yield { result: 42 }; // Final output (must be last) + * } + * } + * ``` + */ +export interface ToolFunctionWithGenerator< + TInput extends ZodObject, + TEvent extends ZodType = ZodType, + TOutput extends ZodType = ZodType +> extends BaseToolFunction { + eventSchema: TEvent; + outputSchema: TOutput; + execute: ( + params: z.infer, + context?: TurnContext + ) => AsyncGenerator>; +} + +/** + * Manual tool without execute function - requires manual handling by developer + */ +export interface ManualToolFunction< + TInput extends ZodObject, + TOutput extends ZodType = ZodType +> extends BaseToolFunction { + outputSchema?: TOutput; +} + +/** + * Tool with execute function (regular or generator) + */ +export type ToolWithExecute< + TInput extends ZodObject = ZodObject, + TOutput extends ZodType = ZodType +> = { + type: ToolType.Function; + function: ToolFunctionWithExecute; +}; + +/** + * Tool with generator execute function + */ +export type ToolWithGenerator< + TInput extends ZodObject = ZodObject, + TEvent extends ZodType = ZodType, + TOutput extends ZodType = ZodType +> = { + type: ToolType.Function; + function: ToolFunctionWithGenerator; +}; + +/** + * Tool without execute function (manual handling) + */ +export type ManualTool< + TInput extends ZodObject = ZodObject, + TOutput extends ZodType = ZodType +> = { + type: ToolType.Function; + function: ManualToolFunction; +}; + +/** + * Union type of all enhanced tool types + */ +export type EnhancedTool = + | ToolWithExecute + | ToolWithGenerator + | ManualTool; + +/** + * Type guard to check if a tool has an execute function + */ +export function hasExecuteFunction( + tool: EnhancedTool +): tool is ToolWithExecute | ToolWithGenerator { + return "execute" in tool.function && typeof tool.function.execute === "function"; +} + +/** + * Type guard to check if a tool uses a generator (has eventSchema) + */ +export function isGeneratorTool( + tool: EnhancedTool +): tool is ToolWithGenerator { + return "eventSchema" in tool.function; +} + +/** + * Type guard to check if a tool is a regular execution tool (not generator) + */ +export function isRegularExecuteTool( + tool: EnhancedTool +): tool is ToolWithExecute { + return hasExecuteFunction(tool) && !isGeneratorTool(tool); +} + +/** + * Parsed tool call from API response + */ +export interface ParsedToolCall { + id: string; + name: string; + arguments: unknown; // Parsed from JSON string +} + +/** + * Result of tool execution + */ +export interface ToolExecutionResult { + toolCallId: string; + toolName: string; + result: unknown; // Final result (sent to model) + preliminaryResults?: unknown[]; // All yielded values from generator + error?: Error; +} + +/** + * Type for maxToolRounds - can be a number or a function that determines if execution should continue + */ +export type MaxToolRounds = + | number + | ((context: TurnContext) => boolean); // Return true to allow another turn, false to stop + +/** + * Result of executeTools operation + */ +export interface ExecuteToolsResult { + finalResponse: any; // ResponseWrapper (avoiding circular dependency) + allResponses: any[]; // All ResponseWrappers from each round + toolResults: Map< + string, + { + result: unknown; + preliminaryResults?: unknown[]; + } + >; +} + +/** + * Standard tool format for OpenRouter API (JSON Schema based) + * Matches OpenResponsesRequestToolFunction structure + */ +export interface APITool { + type: "function"; + name: string; + description?: string | null; + strict?: boolean | null; + parameters: { [k: string]: any | null } | null; +} + +/** + * Tool preliminary result event emitted during generator tool execution + */ +export type ToolPreliminaryResultEvent = { + type: "tool.preliminary_result"; + toolCallId: string; + result: unknown; + timestamp: number; +}; + +/** + * Enhanced stream event types for getFullResponsesStream + * Extends OpenResponsesStreamEvent with tool preliminary results + */ +export type EnhancedResponseStreamEvent = + | OpenResponsesStreamEvent + | ToolPreliminaryResultEvent; + +/** + * Type guard to check if an event is a tool preliminary result event + */ +export function isToolPreliminaryResultEvent( + event: EnhancedResponseStreamEvent +): event is ToolPreliminaryResultEvent { + return event.type === "tool.preliminary_result"; +} + +/** + * Tool stream event types for getToolStream + * Includes both argument deltas and preliminary results + */ +export type ToolStreamEvent = + | { type: "delta"; content: string } + | { + type: "preliminary_result"; + toolCallId: string; + result: unknown; + }; + +/** + * Chat stream event types for getFullChatStream + * Includes content deltas, completion events, and tool preliminary results + */ +export type ChatStreamEvent = + | { type: "content.delta"; delta: string } + | { type: "message.complete"; response: any } + | { + type: "tool.preliminary_result"; + toolCallId: string; + result: unknown; + } + | { type: string; event: any }; // Pass-through for other events diff --git a/src/sdk/sdk.ts b/src/sdk/sdk.ts index 8c70c802..4ae464d3 100644 --- a/src/sdk/sdk.ts +++ b/src/sdk/sdk.ts @@ -3,6 +3,13 @@ */ import { ClientSDK } from "../lib/sdks.js"; +// #region imports +import { RequestOptions } from "../lib/sdks.js"; +import { ResponseWrapper } from "../lib/response-wrapper.js"; +import { getResponse } from "../funcs/getResponse.js"; +import { EnhancedTool, MaxToolRounds } from "../lib/tool-types.js"; +import * as models from "../models/index.js"; +// #endregion import { Analytics } from "./analytics.js"; import { APIKeys } from "./apikeys.js"; import { Beta } from "./beta.js"; @@ -76,4 +83,49 @@ export class OpenRouter extends ClientSDK { get completions(): Completions { return (this._completions ??= new Completions(this._options)); } + // #region sdk-class-body + /** + * Get a response with multiple consumption patterns + * + * @remarks + * Returns a wrapper that allows consuming the response in multiple ways: + * - `await response.getMessage()` - Get the completed message + * - `await response.getText()` - Get just the text content + * - `for await (const delta of response.getTextStream())` - Stream text deltas + * - `for await (const msg of response.getNewMessagesStream())` - Stream incremental message updates + * - `for await (const event of response.getFullResponsesStream())` - Stream all response events + * - `for await (const chunk of response.getFullChatStream())` - Stream in chat-compatible format + * + * All consumption patterns can be used concurrently on the same response. + * + * @example + * ```typescript + * // Simple text extraction + * const response = openRouter.getResponse({ + * model: "anthropic/claude-3-opus", + * input: [{ role: "user", content: "Hello!" }] + * }); + * const text = await response.getText(); + * console.log(text); + * + * // Streaming text + * const response = openRouter.getResponse({ + * model: "anthropic/claude-3-opus", + * input: [{ role: "user", content: "Hello!" }] + * }); + * for await (const delta of response.getTextStream()) { + * process.stdout.write(delta); + * } + * ``` + */ + getResponse( + request: Omit & { + tools?: EnhancedTool[] | models.OpenResponsesRequest["tools"]; + maxToolRounds?: MaxToolRounds; + }, + options?: RequestOptions, + ): ResponseWrapper { + return getResponse(this, request, options); + } + // #endregion } diff --git a/tests/e2e/getResponse-tools.test.ts b/tests/e2e/getResponse-tools.test.ts new file mode 100644 index 00000000..3977b2c5 --- /dev/null +++ b/tests/e2e/getResponse-tools.test.ts @@ -0,0 +1,573 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { OpenRouter, ToolType } from "../../src/index.js"; +import { z } from "zod"; +import { toJSONSchema } from "zod/v4/core"; +import * as dotenv from "dotenv"; + +dotenv.config(); + +describe("Enhanced Tool Support for getResponse", () => { + let client: OpenRouter; + + beforeAll(() => { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) { + throw new Error("OPENROUTER_API_KEY environment variable is required"); + } + client = new OpenRouter({ apiKey }); + }); + + describe("Zod Schema Conversion", () => { + it("should convert Zod schema to JSON Schema using v4 toJSONSchema()", () => { + const schema = z.object({ + name: z.string().describe("The user's name"), + age: z.number().min(0).describe("The user's age"), + }); + + const jsonSchema = toJSONSchema(schema, { target: "openapi-3.0" }); + + expect(jsonSchema).toHaveProperty("type", "object"); + expect(jsonSchema).toHaveProperty("properties"); + expect(jsonSchema.properties).toHaveProperty("name"); + expect(jsonSchema.properties).toHaveProperty("age"); + }); + + it("should handle complex nested schemas", () => { + const schema = z.object({ + user: z.object({ + name: z.string(), + address: z.object({ + street: z.string(), + city: z.string(), + }), + }), + tags: z.array(z.string()), + }); + + const jsonSchema = toJSONSchema(schema, { target: "openapi-3.0" }); + + expect(jsonSchema.properties.user).toBeDefined(); + expect(jsonSchema.properties.tags).toBeDefined(); + }); + + it("should preserve descriptions and metadata", () => { + const schema = z.object({ + location: z.string().describe("City and country e.g. Bogotá, Colombia"), + }); + + const jsonSchema = toJSONSchema(schema, { target: "openapi-3.0" }); + + expect(jsonSchema.properties.location.description).toBe( + "City and country e.g. Bogotá, Colombia" + ); + }); + }); + + describe("Tool Definition", () => { + it("should define tool with inputSchema", async () => { + const weatherTool = { + type: ToolType.Function, + function: { + name: "get_weather", + description: "Get current temperature for a given location.", + inputSchema: z.object({ + location: z.string().describe("City and country e.g. Bogotá, Colombia"), + }), + outputSchema: z.object({ + temperature: z.number(), + description: z.string(), + }), + execute: async (parameters: { location: string }, context) => { + return { + temperature: 20, + description: "Clear skies", + }; + }, + }, + }; + + // Tool definition should be valid + expect(weatherTool.function.name).toBe("get_weather"); + expect(weatherTool.function.inputSchema).toBeDefined(); + expect(weatherTool.function.outputSchema).toBeDefined(); + }); + + it("should validate input against Zod schema", () => { + const schema = z.object({ + location: z.string(), + temperature: z.number(), + }); + + const validInput = { location: "Tokyo", temperature: 25 }; + const result = schema.safeParse(validInput); + + expect(result.success).toBe(true); + }); + + it("should reject invalid input with ZodError", () => { + const schema = z.object({ + location: z.string(), + temperature: z.number(), + }); + + const invalidInput = { location: "Tokyo", temperature: "hot" }; // Wrong type + + const result = schema.safeParse(invalidInput); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toBeDefined(); + } + }); + }); + + describe("Regular Tool Execution", () => { + it("should execute tool with valid input", async () => { + const addTool = { + type: ToolType.Function, + function: { + name: "add_numbers", + description: "Add two numbers together", + inputSchema: z.object({ + a: z.number(), + b: z.number(), + }), + outputSchema: z.object({ + result: z.number(), + }), + execute: async (params: { a: number; b: number }, context) => { + return { result: params.a + params.b }; + }, + }, + }; + + const mockContext = { + numberOfTurns: 1, + messageHistory: [], + model: "test-model", + }; + const result = await addTool.function.execute({ a: 5, b: 3 }, mockContext); + + expect(result.result).toBe(8); + }); + + it("should validate output against outputSchema", () => { + const schema = z.object({ + temperature: z.number(), + description: z.string(), + }); + + const output = { temperature: 72, description: "Sunny" }; + const result = schema.safeParse(output); + + expect(result.success).toBe(true); + }); + + it("should handle execution errors gracefully", async () => { + const errorTool = { + type: ToolType.Function, + function: { + name: "error_tool", + inputSchema: z.object({}), + execute: async (params, context) => { + throw new Error("Tool execution failed"); + }, + }, + }; + + const mockContext = { + numberOfTurns: 1, + messageHistory: [], + model: "test-model", + }; + await expect(errorTool.function.execute({}, mockContext)).rejects.toThrow( + "Tool execution failed" + ); + }); + }); + + describe("Generator Tools (Preliminary Results)", () => { + it("should collect all yielded values as preliminary results", async () => { + const eventSchema = z.object({ + type: z.enum(["start", "update"]), + data: z + .object({ + location: z.string().optional(), + temperature: z.number().optional(), + description: z.string().optional(), + }) + .optional(), + }); + + const outputSchema = z.object({ + temperature: z.number(), + description: z.string(), + location: z.string(), + }); + + const generatorTool = { + type: ToolType.Function, + function: { + name: "get_weather_with_updates", + description: "Get weather with streaming updates", + inputSchema: z.object({ + location: z.string(), + }), + eventSchema, + outputSchema, + execute: async function* (params: { location: string }, context) { + yield { type: "start" as const, data: { location: params.location } }; + yield { + type: "update" as const, + data: { temperature: 20, description: "Clear skies" }, + }; + // Final output (different schema) + yield { + temperature: 20, + description: "Clear skies", + location: params.location, + }; + }, + }, + }; + + const mockContext = { + numberOfTurns: 1, + messageHistory: [], + model: "test-model", + }; + const results: unknown[] = []; + for await (const result of generatorTool.function.execute({ + location: "Tokyo", + }, mockContext)) { + results.push(result); + } + + expect(results).toHaveLength(3); + expect(results[0]).toEqual({ type: "start", data: { location: "Tokyo" } }); + expect(results[1]).toEqual({ type: "update", data: { temperature: 20, description: "Clear skies" } }); + expect(results[2]).toEqual({ temperature: 20, description: "Clear skies", location: "Tokyo" }); + }); + + it("should send only final (last) yield to model", async () => { + const generatorTool = { + type: ToolType.Function, + function: { + name: "process_data", + inputSchema: z.object({ data: z.string() }), + eventSchema: z.object({ + status: z.string(), + }), + outputSchema: z.object({ + result: z.string(), + }), + execute: async function* (params: { data: string }, context) { + yield { status: "processing" }; + yield { status: "almost_done" }; + // Final output (different schema) + yield { result: `Processed: ${params.data}` }; + }, + }, + }; + + const mockContext = { + numberOfTurns: 1, + messageHistory: [], + model: "test-model", + }; + const results = []; + for await (const result of generatorTool.function.execute({ data: "test" }, mockContext)) { + results.push(result); + } + + const finalResult = results[results.length - 1]; + expect(finalResult).toEqual({ result: "Processed: test" }); + }); + + it("should validate all events against eventSchema", async () => { + const eventSchema = z.object({ + type: z.enum(["start", "end"]), + message: z.string(), + }); + + const validEvent1 = { type: "start" as const, message: "Starting" }; + const validEvent2 = { type: "end" as const, message: "Done" }; + const invalidEvent = { type: "middle", message: "Processing" }; + + expect(eventSchema.safeParse(validEvent1).success).toBe(true); + expect(eventSchema.safeParse(validEvent2).success).toBe(true); + expect(eventSchema.safeParse(invalidEvent).success).toBe(false); + }); + + it("should handle async generators", async () => { + async function* testGenerator() { + yield 1; + await new Promise((resolve) => setTimeout(resolve, 10)); + yield 2; + await new Promise((resolve) => setTimeout(resolve, 10)); + yield 3; + } + + const results = []; + for await (const value of testGenerator()) { + results.push(value); + } + + expect(results).toEqual([1, 2, 3]); + }); + + it("should emit preliminary results via callback", async () => { + const preliminaryResults: any[] = []; + + const generatorTool = { + type: ToolType.Function, + function: { + name: "streaming_tool", + inputSchema: z.object({ input: z.string() }), + eventSchema: z.object({ progress: z.number(), message: z.string() }), + outputSchema: z.object({ completed: z.boolean(), finalProgress: z.number() }), + execute: async function* (params: { input: string }, context) { + yield { progress: 25, message: "Quarter done" }; + yield { progress: 50, message: "Half done" }; + // Final output (different schema) + yield { completed: true, finalProgress: 100 }; + }, + }, + }; + + const mockContext = { + numberOfTurns: 1, + messageHistory: [], + model: "test-model", + }; + // Simulate callback + for await (const result of generatorTool.function.execute({ input: "test" }, mockContext)) { + preliminaryResults.push(result); + } + + expect(preliminaryResults).toHaveLength(3); + expect(preliminaryResults[0]).toEqual({ progress: 25, message: "Quarter done" }); + expect(preliminaryResults[1]).toEqual({ progress: 50, message: "Half done" }); + expect(preliminaryResults[2]).toEqual({ completed: true, finalProgress: 100 }); + }); + + it("should throw error if generator completes without emitting values", async () => { + const generatorTool = { + type: ToolType.Function, + function: { + name: "empty_generator", + inputSchema: z.object({}), + eventSchema: z.object({ status: z.string() }), + outputSchema: z.object({ result: z.string() }), + execute: async function* (params, context) { + // Emit nothing + }, + }, + }; + + const mockContext = { + numberOfTurns: 1, + messageHistory: [], + model: "test-model", + }; + + const results = []; + for await (const result of generatorTool.function.execute({}, mockContext)) { + results.push(result); + } + + expect(results).toHaveLength(0); + }); + }); + + describe("Manual Tool Execution", () => { + it("should define tool without execute function", () => { + const manualTool = { + type: ToolType.Function, + function: { + name: "manual_tool", + description: "A tool that requires manual handling", + inputSchema: z.object({ + query: z.string(), + }), + outputSchema: z.object({ + result: z.string(), + }), + }, + }; + + expect(manualTool.function.name).toBe("manual_tool"); + expect(manualTool.function).not.toHaveProperty("execute"); + }); + }); + + describe("Integration with OpenRouter API", () => { + it.skip("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, + function: { + name: "get_weather", + description: "Get the current weather for a location", + inputSchema: z.object({ + location: z.string().describe("The city and country"), + }), + outputSchema: z.object({ + temperature: z.number(), + description: z.string(), + }), + execute: async (params: { location: string }, context) => { + return { + temperature: 72, + description: "Sunny", + }; + }, + }, + }; + + const response = await client.getResponse({ + model: "openai/gpt-4o", + messages: [ + { + role: "user", + content: "What's the weather like in San Francisco?", + }, + ], + tools: [weatherTool], + }); + + const message = await response.getMessage(); + expect(message).toBeDefined(); + }, 30000); + + it.skip("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, + function: { + name: "calculate", + description: "Perform a mathematical calculation", + inputSchema: z.object({ + expression: z.string().describe("Math expression to evaluate"), + }), + outputSchema: z.object({ + result: z.number(), + }), + execute: async (params: { expression: string }, context) => { + // Simple eval for testing (don't use in production!) + const result = eval(params.expression); + return { result }; + }, + }, + }; + + const response = await client.getResponse( + { + model: "openai/gpt-4o", + messages: [ + { + role: "user", + content: "What is 25 * 4?", + }, + ], + tools: [calculatorTool], + }, + { + autoExecuteTools: true, + maxToolRounds: 3, + } + ); + + const finalMessage = await response.getMessage(); + expect(finalMessage).toBeDefined(); + expect(finalMessage.content).toBeTruthy(); + }, 30000); + }); + + describe("Error Handling", () => { + it("should handle Zod input validation errors", () => { + const schema = z.object({ + name: z.string(), + age: z.number().positive(), + }); + + const invalidInput = { name: "John", age: -5 }; + const result = schema.safeParse(invalidInput); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toHaveLength(1); + expect(result.error.issues[0].path).toEqual(["age"]); + } + }); + + it("should handle Zod output validation errors", () => { + const schema = z.object({ + temperature: z.number(), + description: z.string(), + }); + + const invalidOutput = { temperature: "hot", description: "Sunny" }; + const result = schema.safeParse(invalidOutput); + + expect(result.success).toBe(false); + }); + + it("should provide clear error messages for validation failures", () => { + const schema = z.object({ + email: z.string().email(), + age: z.number().min(18), + }); + + const invalidData = { email: "not-an-email", age: 15 }; + const result = schema.safeParse(invalidData); + + if (!result.success) { + expect(result.error.issues.length).toBeGreaterThan(0); + const issues = result.error.issues; + expect(issues.some((i) => i.path.includes("email"))).toBe(true); + expect(issues.some((i) => i.path.includes("age"))).toBe(true); + } + }); + }); + + describe("Type Safety", () => { + it("should infer correct parameter types from inputSchema", () => { + const weatherTool = { + type: ToolType.Function, + function: { + name: "get_weather", + inputSchema: z.object({ + location: z.string(), + units: z.enum(["celsius", "fahrenheit"]).optional(), + }), + execute: async (params: z.infer, context) => { + // TypeScript should infer: { location: string; units?: "celsius" | "fahrenheit" } + const location: string = params.location; + const units: "celsius" | "fahrenheit" | undefined = params.units; + return { location, units }; + }, + }, + }; + + expect(weatherTool.function.name).toBe("get_weather"); + }); + + it("should infer correct return types from outputSchema", () => { + const outputSchema = z.object({ + temperature: z.number(), + unit: z.enum(["C", "F"]), + }); + + type OutputType = z.infer; + + const output: OutputType = { + temperature: 72, + unit: "F", + }; + + expect(output.temperature).toBe(72); + expect(output.unit).toBe("F"); + }); + }); +}); diff --git a/tests/e2e/getResponse.test.ts b/tests/e2e/getResponse.test.ts new file mode 100644 index 00000000..7e9970f7 --- /dev/null +++ b/tests/e2e/getResponse.test.ts @@ -0,0 +1,590 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { OpenRouter } from "../../src/sdk/sdk.js"; +import { Message } from "../../src/models/message.js"; + +describe("getResponse E2E Tests", () => { + let client: OpenRouter; + + beforeAll(() => { + const apiKey = process.env.OPENROUTER_API_KEY; + if (!apiKey) { + throw new Error( + "OPENROUTER_API_KEY environment variable is required for e2e tests" + ); + } + + client = new OpenRouter({ + apiKey, + }); + }); + + describe("response.text - Text extraction", () => { + it("should successfully get text from a response", async () => { + const response = client.getResponse({ + model: "meta-llama/llama-3.2-1b-instruct", + input: [ + { + role: "user", + content: "Say 'Hello, World!' and nothing else.", + }, + ], + }); + + const text = await response.getText(); + + expect(text).toBeDefined(); + expect(typeof text).toBe("string"); + expect(text.length).toBeGreaterThan(0); + expect(text.toLowerCase()).toContain("hello"); + }); + + it("should handle multi-turn conversations", async () => { + const response = client.getResponse({ + model: "meta-llama/llama-3.2-1b-instruct", + input: [ + { + role: "user", + content: "My name is Bob.", + }, + { + role: "assistant", + content: "Hello Bob! How can I help you today?", + }, + { + role: "user", + content: "What is my name?", + }, + ], + }); + + const text = await response.getText(); + + expect(text).toBeDefined(); + expect(text.toLowerCase()).toContain("bob"); + }); + }); + + describe("response.message - Complete message extraction", () => { + it("should successfully get a complete message from response", async () => { + const response = client.getResponse({ + model: "meta-llama/llama-3.2-1b-instruct", + input: [ + { + role: "user", + content: "Say 'test message' and nothing else.", + }, + ], + }); + + const message = await response.getMessage(); + + expect(message).toBeDefined(); + expect(message.role).toBe("assistant"); + expect(Array.isArray(message.content) || typeof message.content === "string" || message.content === null).toBe(true); + + if (Array.isArray(message.content)) { + expect(message.content.length).toBeGreaterThan(0); + const firstContent = message.content[0]; + expect(firstContent).toBeDefined(); + expect("type" in firstContent).toBe(true); + } else if (typeof message.content === "string") { + expect(message.content.length).toBeGreaterThan(0); + } else if (message.content === null) { + expect(message.content).toBeNull(); + } + }); + + it("should have proper message structure", async () => { + const response = client.getResponse({ + model: "meta-llama/llama-3.2-1b-instruct", + input: [ + { + role: "user", + content: "Respond with a simple greeting.", + }, + ], + }); + + const message = await response.getMessage(); + + expect(message).toBeDefined(); + expect(message.role).toBe("assistant"); + expect(message.content).toBeDefined(); + }); + }); + + describe("response.textStream - Streaming text deltas", () => { + it("should successfully stream text deltas", async () => { + const response = client.getResponse({ + model: "meta-llama/llama-3.2-1b-instruct", + input: [ + { + role: "user", + content: "Count from 1 to 5.", + }, + ], + }); + + const deltas: string[] = []; + + for await (const delta of response.getTextStream()) { + expect(typeof delta).toBe("string"); + deltas.push(delta); + } + + expect(deltas.length).toBeGreaterThan(0); + + // Verify we can reconstruct the full text + const fullText = deltas.join(""); + expect(fullText.length).toBeGreaterThan(0); + }); + + it("should stream progressively without waiting for completion", async () => { + const response = client.getResponse({ + model: "meta-llama/llama-3.2-1b-instruct", + input: [ + { + role: "user", + content: "Write a short poem.", + }, + ], + }); + + let firstDeltaTime: number | null = null; + let lastDeltaTime: number | null = null; + let deltaCount = 0; + + for await (const delta of response.getTextStream()) { + if (!firstDeltaTime) { + firstDeltaTime = Date.now(); + } + lastDeltaTime = Date.now(); + deltaCount++; + } + + expect(deltaCount).toBeGreaterThan(1); + expect(firstDeltaTime).toBeDefined(); + expect(lastDeltaTime).toBeDefined(); + + // Verify there was a time difference (streaming, not instant) + if (firstDeltaTime && lastDeltaTime) { + expect(lastDeltaTime).toBeGreaterThanOrEqual(firstDeltaTime); + } + }, 15000); + }); + + describe("response.newMessagesStream - Streaming message updates", () => { + it("should successfully stream incremental message updates", async () => { + const response = client.getResponse({ + model: "meta-llama/llama-3.2-1b-instruct", + input: [ + { + role: "user", + content: "Say 'streaming test'.", + }, + ], + }); + + const messages: Message[] = []; + + for await (const message of response.getNewMessagesStream()) { + expect(message).toBeDefined(); + expect(message.role).toBe("assistant"); + expect(typeof message.content).toBe("string"); + messages.push(message); + } + + expect(messages.length).toBeGreaterThan(0); + + // Verify content grows over time + if (messages.length > 1) { + const firstMessage = messages[0]; + const lastMessage = messages[messages.length - 1]; + + const firstText = (firstMessage.content as string) || ""; + const lastText = (lastMessage.content as string) || ""; + + expect(lastText.length).toBeGreaterThanOrEqual(firstText.length); + } + }, 15000); + }); + + describe("response.reasoningStream - Streaming reasoning deltas", () => { + it("should successfully stream reasoning deltas for reasoning models", async () => { + const response = client.getResponse({ + model: "minimax/minimax-m2", + input: [ + { + role: "user", + content: "What is 2+2?", + }, + ], + reasoning: { + enabled: true, + effort: "low", + }, + }); + + const reasoningDeltas: string[] = []; + + for await (const delta of response.getReasoningStream()) { + expect(typeof delta).toBe("string"); + reasoningDeltas.push(delta); + } + + // Reasoning models may or may not output reasoning for simple questions + // Just verify the stream works without error + expect(Array.isArray(reasoningDeltas)).toBe(true); + expect(reasoningDeltas.length).toBeGreaterThan(0); + }, 30000); + }); + + describe("response.toolStream - Streaming tool call deltas", () => { + it("should successfully stream tool call deltas when tools are called", async () => { + const response = client.getResponse({ + model: "meta-llama/llama-3.1-8b-instruct", + input: [ + { + role: "user", + content: "What's the weather like in Paris? Use the get_weather tool to find out.", + }, + ], + tools: [ + { + type: "function" as const, + name: "get_weather", + description: "Get the current weather for a location", + parameters: { + type: "object", + properties: { + location: { + type: "string", + description: "The city name, e.g. Paris", + }, + }, + required: ["location"], + }, + }, + ], + }); + + const toolDeltas: string[] = []; + + for await (const event of response.getToolStream()) { + expect(typeof event).toBe("object"); + expect(event).toHaveProperty("type"); + if (event.type === "delta") { + expect(typeof event.content).toBe("string"); + toolDeltas.push(event.content); + } + } + + // Verify the stream works and received tool call deltas + expect(Array.isArray(toolDeltas)).toBe(true); + + // If the model made a tool call, we should have deltas + if (toolDeltas.length > 0) { + const fullToolCall = toolDeltas.join(""); + expect(fullToolCall.length).toBeGreaterThan(0); + } + }, 30000); + }); + + describe("response.fullResponsesStream - Streaming all events", () => { + it("should successfully stream all response events", async () => { + const response = client.getResponse({ + model: "meta-llama/llama-3.2-1b-instruct", + input: [ + { + role: "user", + content: "Say 'hello'.", + }, + ], + }); + + const events: any[] = []; + + for await (const event of response.getFullResponsesStream()) { + expect(event).toBeDefined(); + expect("type" in event).toBe(true); + events.push(event); + } + + expect(events.length).toBeGreaterThan(0); + + // Verify we have different event types + const eventTypes = new Set(events.map((e) => e.type)); + expect(eventTypes.size).toBeGreaterThan(1); + + // Should have completion event + const hasCompletionEvent = events.some( + (e) => e.type === "response.completed" || e.type === "response.incomplete" + ); + expect(hasCompletionEvent).toBe(true); + }, 15000); + + it("should include text delta events", async () => { + const response = client.getResponse({ + model: "meta-llama/llama-3.2-1b-instruct", + input: [ + { + role: "user", + content: "Count to 3.", + }, + ], + }); + + const textDeltaEvents: any[] = []; + + for await (const event of response.getFullResponsesStream()) { + if (event.type === "response.output_text.delta") { + textDeltaEvents.push(event); + } + } + + expect(textDeltaEvents.length).toBeGreaterThan(0); + + // Verify delta events have the expected structure + const firstDelta = textDeltaEvents[0]; + expect(firstDelta.delta).toBeDefined(); + expect(typeof firstDelta.delta).toBe("string"); + }, 15000); + }); + + describe("response.fullChatStream - Chat-compatible streaming", () => { + it("should successfully stream in chat-compatible format", async () => { + const response = client.getResponse({ + model: "meta-llama/llama-3.2-1b-instruct", + input: [ + { + role: "user", + content: "Say 'test'.", + }, + ], + }); + + const chunks: any[] = []; + + for await (const chunk of response.getFullChatStream()) { + expect(chunk).toBeDefined(); + expect(chunk.type).toBeDefined(); + chunks.push(chunk); + } + + expect(chunks.length).toBeGreaterThan(0); + + // Should have content deltas + const hasContentDeltas = chunks.some((c) => c.type === "content.delta"); + expect(hasContentDeltas).toBe(true); + }, 15000); + }); + + describe("Multiple concurrent consumption patterns", () => { + it("should allow reading text and streaming simultaneously", async () => { + const response = client.getResponse({ + model: "meta-llama/llama-3.2-1b-instruct", + input: [ + { + role: "user", + content: "Say 'concurrent test'.", + }, + ], + }); + + // Get full text and stream concurrently + const textPromise = response.getText(); + const streamPromise = (async () => { + const deltas: string[] = []; + for await (const delta of response.getTextStream()) { + deltas.push(delta); + } + return deltas; + })(); + + // Wait for both + const [text, deltas] = await Promise.all([textPromise, streamPromise]); + + expect(deltas.length).toBeGreaterThan(0); + expect(text.length).toBeGreaterThan(0); + + // Verify deltas reconstruct the full text + const reconstructed = deltas.join(""); + expect(reconstructed).toBe(text); + }, 30000); + + it("should allow multiple stream consumers", async () => { + const response = client.getResponse({ + model: "meta-llama/llama-3.2-1b-instruct", + input: [ + { + role: "user", + content: "Write a short sentence.", + }, + ], + }); + + // Start two concurrent stream consumers + const textStreamPromise = (async () => { + const deltas: string[] = []; + for await (const delta of response.getTextStream()) { + deltas.push(delta); + } + return deltas; + })(); + + const newMessagesStreamPromise = (async () => { + const messages: any[] = []; + for await (const message of response.getNewMessagesStream()) { + messages.push(message); + } + return messages; + })(); + + const [textDeltas, messages] = await Promise.all([ + textStreamPromise, + newMessagesStreamPromise, + ]); + + expect(textDeltas.length).toBeGreaterThan(0); + expect(messages.length).toBeGreaterThan(0); + + // Verify consistency between streams + const textFromDeltas = textDeltas.join(""); + const lastMessage = messages[messages.length - 1]; + const textFromMessage = (lastMessage.content as string) || ""; + + expect(textFromDeltas).toBe(textFromMessage); + }, 20000); + + it("should allow sequential consumption - text then stream", async () => { + const response = client.getResponse({ + model: "meta-llama/llama-3.2-1b-instruct", + input: [ + { + role: "user", + content: "Say 'sequential test'.", + }, + ], + }); + + // First, get the full text + const text = await response.getText(); + expect(text).toBeDefined(); + expect(text.length).toBeGreaterThan(0); + + // Then, try to stream (should get same data from buffer) + const deltas: string[] = []; + for await (const delta of response.getTextStream()) { + expect(typeof delta).toBe("string"); + deltas.push(delta); + } + + expect(deltas.length).toBeGreaterThan(0); + + // Verify both give same result + const reconstructed = deltas.join(""); + expect(reconstructed).toBe(text); + }, 20000); + + it("should allow sequential consumption - stream then text", async () => { + const response = client.getResponse({ + model: "meta-llama/llama-3.2-1b-instruct", + input: [ + { + role: "user", + content: "Say 'reverse test'.", + }, + ], + }); + + // First, collect deltas from stream + const deltas: string[] = []; + for await (const delta of response.getTextStream()) { + expect(typeof delta).toBe("string"); + deltas.push(delta); + } + expect(deltas.length).toBeGreaterThan(0); + + // Then, get the full text (should work even after stream consumed) + const text = await response.getText(); + expect(text).toBeDefined(); + expect(text.length).toBeGreaterThan(0); + + // Verify both give same result + const reconstructed = deltas.join(""); + expect(reconstructed).toBe(text); + }, 20000); + }); + + describe("Error handling", () => { + it("should handle invalid model gracefully", async () => { + const response = client.getResponse({ + model: "invalid/model-that-does-not-exist", + input: [ + { + role: "user", + content: "Test", + }, + ], + }); + + await expect(response.getText()).rejects.toThrow(); + }); + + it("should handle empty input", async () => { + const response = client.getResponse({ + model: "meta-llama/llama-3.2-1b-instruct", + input: [], + }); + + // This might fail or return empty - both are acceptable + try { + const text = await response.getText(); + expect(text).toBeDefined(); + } catch (error) { + expect(error).toBeDefined(); + } + }); + }); + + describe("Response parameters", () => { + it("should respect maxOutputTokens parameter", async () => { + const response = client.getResponse({ + model: "meta-llama/llama-3.2-1b-instruct", + input: [ + { + role: "user", + content: "Write a long story about a cat.", + }, + ], + maxOutputTokens: 10, + }); + + const text = await response.getText(); + + expect(text).toBeDefined(); + // Text should be relatively short due to token limit + expect(text.split(" ").length).toBeLessThan(50); + }); + + it("should work with instructions parameter", async () => { + const response = client.getResponse({ + model: "meta-llama/llama-3.2-1b-instruct", + input: [ + { + role: "user", + content: "Say exactly: 'test complete'", + }, + ], + instructions: "You are a helpful assistant. Keep responses concise.", + }); + + const text = await response.getText(); + + expect(text).toBeDefined(); + expect(typeof text).toBe("string"); + expect(text.length).toBeGreaterThan(0); + // Just verify instructions parameter is accepted, not that model follows it perfectly + }); + }); +}); diff --git a/tests/e2e/responses.test.ts b/tests/e2e/responses.test.ts index eccf2da4..c7065a15 100644 --- a/tests/e2e/responses.test.ts +++ b/tests/e2e/responses.test.ts @@ -1,5 +1,6 @@ import { beforeAll, describe, expect, it } from "vitest"; import { OpenRouter } from "../../src/sdk/sdk.js"; +import { ResponsesOutputMessage } from "../../esm/models/responsesoutputmessage.js"; describe("Beta Responses E2E Tests", () => { let client: OpenRouter; @@ -45,7 +46,7 @@ describe("Beta Responses E2E Tests", () => { const firstOutput = response.output[0]; expect(firstOutput).toBeDefined(); expect(firstOutput?.type).toBe("message"); - expect(firstOutput?.role).toBe("assistant"); + expect((firstOutput as ResponsesOutputMessage).role).toBe("assistant"); // Verify usage information expect(response.usage).toBeDefined();