From c61e0a3aca51400120d6ae87ab23a462b78e8b43 Mon Sep 17 00:00:00 2001 From: Matt Apperson Date: Sat, 8 Nov 2025 07:03:49 -0500 Subject: [PATCH] Revert "Add Apache 2.0 license (#65)" This reverts commit 45b67e58da543e93365aed6e7ea3058b75632b38, reversing changes made to fe3f52cb2cec28458cb05bd6578dcb3067f89034. --- .github/actions/validate-sdk/action.yaml | 2 +- .speakeasy/gen.yaml | 1 - LICENSE | 201 -------- examples/tools-example.ts | 353 -------------- package-lock.json | 6 +- pnpm-lock.yaml | 28 +- src/funcs/getResponse.ts | 124 ----- src/index.ts | 20 - src/lib/response-wrapper.ts | 545 --------------------- src/lib/reusable-stream.ts | 196 -------- src/lib/stream-transformers.ts | 366 -------------- src/lib/tool-executor.ts | 267 ---------- src/lib/tool-orchestrator.ts | 206 -------- src/lib/tool-types.ts | 265 ---------- src/sdk/sdk.ts | 52 -- tests/e2e/getResponse-tools.test.ts | 573 ---------------------- tests/e2e/getResponse.test.ts | 590 ----------------------- tests/e2e/responses.test.ts | 3 +- 18 files changed, 10 insertions(+), 3788 deletions(-) delete mode 100644 LICENSE delete mode 100644 examples/tools-example.ts delete mode 100644 src/funcs/getResponse.ts delete mode 100644 src/lib/response-wrapper.ts delete mode 100644 src/lib/reusable-stream.ts delete mode 100644 src/lib/stream-transformers.ts delete mode 100644 src/lib/tool-executor.ts delete mode 100644 src/lib/tool-orchestrator.ts delete mode 100644 src/lib/tool-types.ts delete mode 100644 tests/e2e/getResponse-tools.test.ts delete mode 100644 tests/e2e/getResponse.test.ts diff --git a/.github/actions/validate-sdk/action.yaml b/.github/actions/validate-sdk/action.yaml index 1f13ed14..a4c3d992 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 + run: npx tsc --noEmit --skipLibCheck --esModuleInterop --moduleResolution node --module esnext --target es2020 *.ts - name: Install nextjs-example dependencies shell: bash diff --git a/.speakeasy/gen.yaml b/.speakeasy/gen.yaml index 0c95b88f..0e9d84ea 100644 --- a/.speakeasy/gen.yaml +++ b/.speakeasy/gen.yaml @@ -67,7 +67,6 @@ 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 deleted file mode 100644 index 4caa2028..00000000 --- a/LICENSE +++ /dev/null @@ -1,201 +0,0 @@ - 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 deleted file mode 100644 index 887a7d15..00000000 --- a/examples/tools-example.ts +++ /dev/null @@ -1,353 +0,0 @@ -/** - * OpenRouter SDK - Enhanced Tool Support Examples - * - * This file demonstrates the automatic tool execution feature. - * When you provide tools with `execute` functions, they are automatically: - * 1. Validated using Zod schemas - * 2. Executed when the model calls them - * 3. Results sent back to the model - * 4. Process repeats until 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 236774fc..3edc336d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3303,9 +3303,9 @@ } }, "node_modules/zod": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", - "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70df4015..cbe25e0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ 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 @@ -31,8 +28,8 @@ importers: specifier: ^18.3.12 version: 18.3.26 dotenv: - specifier: ^16.4.7 - version: 16.6.1 + specifier: ^17.2.3 + version: 17.2.3 eslint: specifier: ^9.19.0 version: 9.38.0 @@ -593,8 +590,8 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - dotenv@16.6.1: - resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} es-module-lexer@1.7.0: @@ -880,11 +877,6 @@ 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'} @@ -905,9 +897,6 @@ 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'} @@ -1553,7 +1542,7 @@ snapshots: deep-is@0.1.4: {} - dotenv@16.6.1: {} + dotenv@17.2.3: {} es-module-lexer@1.7.0: {} @@ -1843,11 +1832,6 @@ 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: {} @@ -1886,8 +1870,6 @@ 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 deleted file mode 100644 index 5f03f0ba..00000000 --- a/src/funcs/getResponse.ts +++ /dev/null @@ -1,124 +0,0 @@ -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 8d98038a..dbcba164 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,24 +6,4 @@ 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 deleted file mode 100644 index 7e2f0849..00000000 --- a/src/lib/response-wrapper.ts +++ /dev/null @@ -1,545 +0,0 @@ -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 deleted file mode 100644 index 7a8e78db..00000000 --- a/src/lib/reusable-stream.ts +++ /dev/null @@ -1,196 +0,0 @@ -/** - * A reusable readable stream that allows multiple consumers to read from the same source stream - * concurrently while it's actively streaming, without forcing consumers to wait for full buffering. - * - * Key features: - * - Multiple concurrent consumers with independent read positions - * - New consumers can attach while streaming is active - * - Efficient memory management with automatic cleanup - * - Each consumer can read at their own pace - */ -export class ReusableReadableStream { - private buffer: T[] = []; - private consumers = new Map(); - private nextConsumerId = 0; - private sourceReader: ReadableStreamDefaultReader | null = null; - private sourceComplete = false; - private sourceError: Error | null = null; - private pumpStarted = false; - - constructor(private sourceStream: ReadableStream) {} - - /** - * Create a new consumer that can independently iterate over the stream. - * Multiple consumers can be created and will all receive the same data. - */ - createConsumer(): AsyncIterableIterator { - const consumerId = this.nextConsumerId++; - const state: ConsumerState = { - position: 0, - waitingPromise: null, - cancelled: false, - }; - this.consumers.set(consumerId, state); - - // Start pumping the source stream if not already started - if (!this.pumpStarted) { - this.startPump(); - } - - 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 deleted file mode 100644 index 9e7dca6f..00000000 --- a/src/lib/stream-transformers.ts +++ /dev/null @@ -1,366 +0,0 @@ -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 deleted file mode 100644 index 557ff7c1..00000000 --- a/src/lib/tool-executor.ts +++ /dev/null @@ -1,267 +0,0 @@ -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 deleted file mode 100644 index d5524bd7..00000000 --- a/src/lib/tool-orchestrator.ts +++ /dev/null @@ -1,206 +0,0 @@ -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 deleted file mode 100644 index ef6bb089..00000000 --- a/src/lib/tool-types.ts +++ /dev/null @@ -1,265 +0,0 @@ -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 9858519a..1c6677a3 100644 --- a/src/sdk/sdk.ts +++ b/src/sdk/sdk.ts @@ -3,13 +3,6 @@ */ 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"; @@ -89,49 +82,4 @@ 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 deleted file mode 100644 index 3977b2c5..00000000 --- a/tests/e2e/getResponse-tools.test.ts +++ /dev/null @@ -1,573 +0,0 @@ -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 deleted file mode 100644 index 7e9970f7..00000000 --- a/tests/e2e/getResponse.test.ts +++ /dev/null @@ -1,590 +0,0 @@ -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 c7065a15..eccf2da4 100644 --- a/tests/e2e/responses.test.ts +++ b/tests/e2e/responses.test.ts @@ -1,6 +1,5 @@ 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; @@ -46,7 +45,7 @@ describe("Beta Responses E2E Tests", () => { const firstOutput = response.output[0]; expect(firstOutput).toBeDefined(); expect(firstOutput?.type).toBe("message"); - expect((firstOutput as ResponsesOutputMessage).role).toBe("assistant"); + expect(firstOutput?.role).toBe("assistant"); // Verify usage information expect(response.usage).toBeDefined();