diff --git a/package.json b/package.json index 0b11c50..40b75ca 100644 --- a/package.json +++ b/package.json @@ -38,13 +38,12 @@ "@modelcontextprotocol/sdk": "catalog:prod", "@orama/orama": "catalog:prod", "defu": "catalog:prod", - "json-schema": "catalog:prod", "zod": "catalog:dev" }, "devDependencies": { + "@ai-sdk/provider": "catalog:dev", "@ai-sdk/provider-utils": "catalog:dev", "@hono/mcp": "catalog:dev", - "@types/json-schema": "catalog:dev", "@types/node": "catalog:dev", "@typescript/native-preview": "catalog:dev", "@vitest/coverage-v8": "catalog:dev", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 935fb0b..b7cbcfb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,9 @@ catalogs: '@ai-sdk/openai': specifier: ^2.0.80 version: 2.0.80 + '@ai-sdk/provider': + specifier: ^2.0.0 + version: 2.0.0 '@ai-sdk/provider-utils': specifier: ^3.0.18 version: 3.0.18 @@ -18,9 +21,6 @@ catalogs: '@hono/mcp': specifier: ^0.1.4 version: 0.1.5 - '@types/json-schema': - specifier: ^7.0.15 - version: 7.0.15 '@types/node': specifier: ^22.13.5 version: 22.19.1 @@ -89,9 +89,6 @@ catalogs: defu: specifier: ^6.1.4 version: 6.1.4 - json-schema: - specifier: ^0.4.0 - version: 0.4.0 importers: .: @@ -108,22 +105,19 @@ importers: defu: specifier: catalog:prod version: 6.1.4 - json-schema: - specifier: catalog:prod - version: 0.4.0 zod: specifier: catalog:dev version: 4.1.13 devDependencies: + '@ai-sdk/provider': + specifier: catalog:dev + version: 2.0.0 '@ai-sdk/provider-utils': specifier: catalog:dev version: 3.0.18(zod@4.1.13) '@hono/mcp': specifier: catalog:dev version: 0.1.5(@modelcontextprotocol/sdk@1.24.3(zod@4.1.13))(hono@4.10.7) - '@types/json-schema': - specifier: catalog:dev - version: 7.0.15 '@types/node': specifier: catalog:dev version: 22.19.1 @@ -150,7 +144,7 @@ importers: version: 2.12.3(@types/node@22.19.1)(typescript@5.9.3) node: specifier: runtime:^24.11.0 - version: runtime:24.11.1 + version: runtime:24.12.0 openai: specifier: catalog:peer version: 6.9.1(zod@4.1.13) @@ -202,7 +196,7 @@ importers: version: 5.0.108(zod@4.1.13) node: specifier: runtime:^24.11.0 - version: runtime:24.11.1 + version: runtime:24.12.0 openai: specifier: catalog:peer version: 6.9.1(zod@4.1.13) @@ -1676,12 +1670,6 @@ packages: integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==, } - '@types/json-schema@7.0.15': - resolution: - { - integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==, - } - '@types/node@22.19.1': resolution: { @@ -2789,94 +2777,94 @@ packages: } engines: { node: '>= 0.6' } - node@runtime:24.11.1: + node@runtime:24.12.0: resolution: type: variations variants: - resolution: archive: tarball bin: bin/node - integrity: sha256-mLqRmgOQ2MQi1LsxBexbd3I89UFDtMHkudd2kPoHUgY= + integrity: sha256-MfPQZbuE8GnlIuVdDTA1vTZMul2KIiM/9oAF0oHou3g= type: binary - url: https://nodejs.org/download/release/v24.11.1/node-v24.11.1-aix-ppc64.tar.gz + url: https://nodejs.org/download/release/v24.12.0/node-v24.12.0-aix-ppc64.tar.gz targets: - cpu: ppc64 os: aix - resolution: archive: tarball bin: bin/node - integrity: sha256-sFqjpm7+aAAj+TC9WvP9u9VCeU2lZEyirXEdaMvU3DU= + integrity: sha256-MZ8iGtxeRP8O1X6KRBsihPArjcb8h7jrkqapNkP9gIA= type: binary - url: https://nodejs.org/download/release/v24.11.1/node-v24.11.1-darwin-arm64.tar.gz + url: https://nodejs.org/download/release/v24.12.0/node-v24.12.0-darwin-arm64.tar.gz targets: - cpu: arm64 os: darwin - resolution: archive: tarball bin: bin/node - integrity: sha256-CWCBttb83T9boPXx1EpH6DA3rS546tomZxwlL+ZN0RE= + integrity: sha256-uC6kxi/QjiUMq1nWJeddd8xbCj1gxmmOvuRUXIihacU= type: binary - url: https://nodejs.org/download/release/v24.11.1/node-v24.11.1-darwin-x64.tar.gz + url: https://nodejs.org/download/release/v24.12.0/node-v24.12.0-darwin-x64.tar.gz targets: - cpu: x64 os: darwin - resolution: archive: tarball bin: bin/node - integrity: sha256-Dck+xceYsNNH8GjbbSBdA96ppxdl5qU5IraCuRJl1x8= + integrity: sha256-myou65io6zc2EiTiodBgMArS3RQ69Y39sW3nhd8PEig= type: binary - url: https://nodejs.org/download/release/v24.11.1/node-v24.11.1-linux-arm64.tar.gz + url: https://nodejs.org/download/release/v24.12.0/node-v24.12.0-linux-arm64.tar.gz targets: - cpu: arm64 os: linux - resolution: archive: tarball bin: bin/node - integrity: sha256-zUFAfzNS3i8GbqJsXF0OqbY2I3TWthg4Wp8una0iBhY= + integrity: sha256-Zux5tNZPQQmu34IhCHFdC2CXEY35FZwvYyFHfaTqF6o= type: binary - url: https://nodejs.org/download/release/v24.11.1/node-v24.11.1-linux-ppc64le.tar.gz + url: https://nodejs.org/download/release/v24.12.0/node-v24.12.0-linux-ppc64le.tar.gz targets: - cpu: ppc64le os: linux - resolution: archive: tarball bin: bin/node - integrity: sha256-XUyLyl+PJZP5CB3uOYNHYOhaFvphyVDz6G7IWZbwBVA= + integrity: sha256-jclgolVdsap3/RMcJb5XG594RLyLJ454cyufWA/n1YA= type: binary - url: https://nodejs.org/download/release/v24.11.1/node-v24.11.1-linux-s390x.tar.gz + url: https://nodejs.org/download/release/v24.12.0/node-v24.12.0-linux-s390x.tar.gz targets: - cpu: s390x os: linux - resolution: archive: tarball bin: bin/node - integrity: sha256-WKX/XMjyIA5Fi+oi4ynVwZlKobER1JnKRuwkEdWCOco= + integrity: sha256-YVkifgr318PGuy+pAEUrBKbLiEGnAqeazGEyCdcLBNA= type: binary - url: https://nodejs.org/download/release/v24.11.1/node-v24.11.1-linux-x64.tar.gz + url: https://nodejs.org/download/release/v24.12.0/node-v24.12.0-linux-x64.tar.gz targets: - cpu: x64 os: linux - resolution: archive: zip bin: node.exe - integrity: sha256-zp7k5Ufr3/NVvrSOMJsWbCTfa+ApHJ6vEDzhXz3p5bQ= - prefix: node-v24.11.1-win-arm64 + integrity: sha256-sF5+Bm+BPTWtPNnCTu2u4HTAEqx+AAcSl2CP3S6UiuM= + prefix: node-v24.12.0-win-arm64 type: binary - url: https://nodejs.org/download/release/v24.11.1/node-v24.11.1-win-arm64.zip + url: https://nodejs.org/download/release/v24.12.0/node-v24.12.0-win-arm64.zip targets: - cpu: arm64 os: win32 - resolution: archive: zip bin: node.exe - integrity: sha256-U1WubXxJ7dz959NKw0hoIGAKgxv4HcO9ylyNtqm7DnY= - prefix: node-v24.11.1-win-x64 + integrity: sha256-nBJfYa6Ue1LneQlYMPnKwmeEagQ+9xkhg8hAFqqtKBI= + prefix: node-v24.12.0-win-x64 type: binary - url: https://nodejs.org/download/release/v24.11.1/node-v24.11.1-win-x64.zip + url: https://nodejs.org/download/release/v24.12.0/node-v24.12.0-win-x64.zip targets: - cpu: x64 os: win32 - version: 24.11.1 + version: 24.12.0 hasBin: true object-assign@4.1.1: @@ -4330,8 +4318,6 @@ snapshots: '@types/estree@1.0.8': {} - '@types/json-schema@7.0.15': {} - '@types/node@22.19.1': dependencies: undici-types: 6.21.0 @@ -5001,7 +4987,7 @@ snapshots: negotiator@1.0.0: {} - node@runtime:24.11.1: {} + node@runtime:24.12.0: {} object-assign@4.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f2c4844..5aa9cf9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,10 +7,10 @@ catalogMode: strict catalogs: dev: '@ai-sdk/openai': ^2.0.80 - '@clack/prompts': ^0.11.0 + '@ai-sdk/provider': ^2.0.0 '@ai-sdk/provider-utils': ^3.0.18 + '@clack/prompts': ^0.11.0 '@hono/mcp': ^0.1.4 - '@types/json-schema': ^7.0.15 '@types/node': ^22.13.5 '@typescript/native-preview': ^7.0.0-dev.20251209.1 '@vitest/coverage-v8': ^4.0.15 @@ -35,7 +35,6 @@ catalogs: '@modelcontextprotocol/sdk': ^1.24.3 '@orama/orama': ^3.1.11 defu: ^6.1.4 - json-schema: ^0.4.0 enablePrePostScripts: true diff --git a/src/tool.test.ts b/src/tool.test.ts index 9a70e44..b1961da 100644 --- a/src/tool.test.ts +++ b/src/tool.test.ts @@ -1,7 +1,11 @@ import { jsonSchema } from 'ai'; -import type { JSONSchema7 } from 'json-schema'; import { BaseTool, type MetaToolSearchResult, StackOneTool, Tools } from './tool'; -import { type ExecuteConfig, ParameterLocation, type ToolParameters } from './types'; +import { + type ExecuteConfig, + type JSONSchema, + ParameterLocation, + type ToolParameters, +} from './types'; import { StackOneAPIError } from './utils/errors'; // Create a mock tool for testing @@ -1107,11 +1111,10 @@ describe('Schema Validation', () => { ); const parameters = tool.toOpenAI().function.parameters; - expect(parameters).toBeDefined(); - const properties = parameters?.properties as Record; + const properties = parameters?.properties as Record; expect(properties.arrayWithItems.items).toBeDefined(); - expect((properties.arrayWithItems.items as JSONSchema7).type).toBe('number'); + expect((properties.arrayWithItems.items as JSONSchema).type).toBe('number'); }); it('should handle nested object structure', () => { @@ -1144,7 +1147,7 @@ describe('Schema Validation', () => { const parameters = tool.toOpenAI().function.parameters; expect(parameters).toBeDefined(); - const properties = parameters?.properties as Record; + const properties = parameters?.properties as Record; const nestedObject = properties.nestedObject; expect(nestedObject.type).toBe('object'); @@ -1185,7 +1188,7 @@ describe('Schema Validation', () => { // @ts-ignore - jsonSchema is available on Schema wrapper from ai sdk const arrayWithItems = toolObj.inputSchema.jsonSchema.properties?.arrayWithItems; expect(arrayWithItems?.type).toBe('array'); - expect((arrayWithItems?.items as JSONSchema7)?.type).toBe('string'); + expect((arrayWithItems?.items as JSONSchema)?.type).toBe('string'); }); it('should handle nested filter object for AI SDK', async () => { @@ -1220,14 +1223,14 @@ describe('Schema Validation', () => { const parameters = tool.toOpenAI().function.parameters; expect(parameters).toBeDefined(); - const aiSchema = jsonSchema(parameters as JSONSchema7); + const aiSchema = jsonSchema(parameters as JSONSchema); expect(aiSchema).toBeDefined(); const aiSdkTool = await tool.toAISDK(); // TODO: Remove ts-ignore once AISDKToolDefinition properly types inputSchema.jsonSchema // @ts-ignore - jsonSchema is available on Schema wrapper from ai sdk const filterProp = aiSdkTool[tool.name].inputSchema.jsonSchema.properties?.filter as - | (JSONSchema7 & { properties: Record }) + | (JSONSchema & { properties: Record }) | undefined; expect(filterProp?.type).toBe('object'); diff --git a/src/tool.ts b/src/tool.ts index 221e121..2264a47 100644 --- a/src/tool.ts +++ b/src/tool.ts @@ -1,7 +1,9 @@ +import type { JSONSchema7 as AISDKJSONSchema } from '@ai-sdk/provider'; import type { Tool as AnthropicTool } from '@anthropic-ai/sdk/resources'; import * as orama from '@orama/orama'; import type { ChatCompletionFunctionTool } from 'openai/resources/chat/completions'; import type { FunctionTool as OpenAIResponsesFunctionTool } from 'openai/resources/responses/responses'; +import type { OverrideProperties } from 'type-fest'; import { DEFAULT_HYBRID_ALPHA } from './consts'; import { RequestBuilder } from './requestBuilder'; import type { @@ -13,13 +15,22 @@ import type { Experimental_ToolCreationOptions, HttpExecuteConfig, JsonDict, + JSONSchema, LocalExecuteConfig, RpcExecuteConfig, ToolExecution, ToolParameters, } from './types'; + import { StackOneError } from './utils/errors'; import { TfidfIndex } from './utils/tfidf-index'; +import { tryImport } from './utils/try-import'; + +/** + * JSON Schema with type narrowed to 'object' + * Used for tool parameter schemas which are always objects + */ +type ObjectJSONSchema = OverrideProperties; /** * Base class for all tools. Provides common functionality for executing API calls @@ -165,6 +176,18 @@ export class BaseTool { } } + /** + * Convert the tool parameters to a pure JSON Schema format + * This is framework-agnostic and can be used with any LLM that accepts JSON Schema + */ + toJsonSchema(): ObjectJSONSchema { + return { + type: 'object', + properties: this.parameters.properties, + required: this.parameters.required, + }; + } + /** * Convert the tool to OpenAI Chat Completions API format */ @@ -174,11 +197,7 @@ export class BaseTool { function: { name: this.name, description: this.description, - parameters: { - type: 'object', - properties: this.parameters.properties, - required: this.parameters.required, - }, + parameters: this.toJsonSchema(), }, }; } @@ -191,11 +210,7 @@ export class BaseTool { return { name: this.name, description: this.description, - input_schema: { - type: 'object', - properties: this.parameters.properties, - required: this.parameters.required, - }, + input_schema: this.toJsonSchema(), }; } @@ -211,9 +226,7 @@ export class BaseTool { description: this.description, strict, parameters: { - type: 'object', - properties: this.parameters.properties, - required: this.parameters.required, + ...this.toJsonSchema(), ...(strict ? { additionalProperties: false } : {}), }, }; @@ -228,32 +241,16 @@ export class BaseTool { }, ): Promise { const schema = { - type: 'object' as const, - properties: this.parameters.properties || {}, - required: this.parameters.required || [], + ...this.toJsonSchema(), additionalProperties: false, - }; + } satisfies AISDKJSONSchema; /** AI SDK is optional dependency, import only when needed */ - let jsonSchema: typeof import('ai').jsonSchema; - try { - const ai = await import('ai'); - jsonSchema = ai.jsonSchema; - } catch { - throw new StackOneError( - 'AI SDK is not installed. Please install it with: npm install ai@4.x|5.x or pnpm add ai@4.x|5.x', - ); - } - - const schemaObject = jsonSchema(schema); - // TODO: Remove ts-ignore once AISDKToolDefinition properly types the inputSchema and parameters - // We avoid defining our own types as much as possible, so we use the AI SDK Tool type - // but need to suppress errors for backward compatibility properties - const toolDefinition = { - inputSchema: schemaObject, - parameters: schemaObject, // v4 (backward compatibility) - description: this.description, - } as AISDKToolDefinition; + const ai = await tryImport( + 'ai', + 'npm install ai@4.x|5.x or pnpm add ai@4.x|5.x', + ); + const schemaObject = ai.jsonSchema(schema); const executionOption = options.execution !== undefined @@ -262,19 +259,21 @@ export class BaseTool { ? this.createExecutionMetadata() : false; - if (executionOption !== false) { - toolDefinition.execution = executionOption; - } - - if (options.executable ?? true) { - toolDefinition.execute = async (args: Record) => { - try { - return await this.execute(args as JsonDict); - } catch (error) { - return `Error executing tool: ${error instanceof Error ? error.message : String(error)}`; - } - }; - } + const toolDefinition = { + inputSchema: schemaObject, + description: this.description, + execution: executionOption !== false ? executionOption : undefined, + execute: + (options.executable ?? true) + ? async (args: Record) => { + try { + return await this.execute(args as JsonDict); + } catch (error) { + return `Error executing tool: ${error instanceof Error ? error.message : String(error)}`; + } + } + : undefined, + } satisfies AISDKToolDefinition; return { [this.name]: toolDefinition, @@ -389,6 +388,18 @@ export class Tools implements Iterable { return this.tools.filter((tool): tool is StackOneTool => tool instanceof StackOneTool); } + /** + * Convert all tools to pure JSON Schema format + * Returns an array of objects with name, description, and schema + */ + toJsonSchema(): Array<{ name: string; description: string; parameters: JSONSchema }> { + return this.tools.map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: tool.toJsonSchema(), + })); + } + /** * Convert all tools to OpenAI Chat Completions API format */ diff --git a/src/types.ts b/src/types.ts index 0645de4..fa4a22a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,7 +4,6 @@ import type { Tool } from '@ai-sdk/provider-utils'; import type { ToolSet } from 'ai'; -import type { JSONSchema7, JSONSchema7Definition } from 'json-schema'; import type { ValueOf } from 'type-fest'; /** @@ -17,15 +16,62 @@ export type JsonDict = Record; */ type Headers = Record; +/** + * JSON Schema type for defining tool input/output schemas as raw JSON Schema objects. + * This allows tools to be defined without Zod when you have JSON Schema definitions available. + * + * @see https://github.com/TanStack/ai/blob/049eb8acd83e6d566c6040c0c4cb53dbe222d46a/packages/typescript/ai/src/types.ts#L5C1-L49C1 + */ +export interface JSONSchema { + type?: string | Array; + properties?: Record; + items?: JSONSchema | Array; + required?: Array; + enum?: Array; + const?: unknown; + description?: string; + default?: unknown; + $ref?: string; + $defs?: Record; + definitions?: Record; + allOf?: Array; + anyOf?: Array; + oneOf?: Array; + not?: JSONSchema; + if?: JSONSchema; + then?: JSONSchema; + else?: JSONSchema; + minimum?: number; + maximum?: number; + exclusiveMinimum?: number; + exclusiveMaximum?: number; + minLength?: number; + maxLength?: number; + pattern?: string; + format?: string; + minItems?: number; + maxItems?: number; + uniqueItems?: boolean; + additionalProperties?: boolean | JSONSchema; + additionalItems?: boolean | JSONSchema; + patternProperties?: Record; + propertyNames?: JSONSchema; + minProperties?: number; + maxProperties?: number; + title?: string; + examples?: Array; + [key: string]: unknown; // Allow additional properties for extensibility +} + /** * JSON Schema properties type */ -export type JsonSchemaProperties = Record; +export type JsonSchemaProperties = Record; /** - * JSON Schema type + * JSON Schema type union */ -type JsonSchemaType = JSONSchema7['type']; +type JsonSchemaType = JSONSchema['type']; /** * EXPERIMENTAL: Function to override the tool schema at creation time diff --git a/src/utils/try-import.test.ts b/src/utils/try-import.test.ts new file mode 100644 index 0000000..1bdbbe0 --- /dev/null +++ b/src/utils/try-import.test.ts @@ -0,0 +1,23 @@ +import { StackOneError } from './errors'; +import { tryImport } from './try-import'; + +describe('tryImport', () => { + it('should successfully import an existing module', async () => { + const result = await tryImport('node:path', 'n/a'); + expect(result).toHaveProperty('join'); + expect(typeof result.join).toBe('function'); + }); + + it('should throw StackOneError for non-existent module', async () => { + await expect( + tryImport('non-existent-module-xyz', 'npm install non-existent-module-xyz'), + ).rejects.toThrow(StackOneError); + }); + + it('should include module name and install hint in error message', async () => { + const installHint = 'npm install my-package or pnpm add my-package'; + await expect(tryImport('non-existent-module-xyz', installHint)).rejects.toThrow( + 'non-existent-module-xyz is not installed. Please install it with: npm install my-package or pnpm add my-package', + ); + }); +}); diff --git a/src/utils/try-import.ts b/src/utils/try-import.ts new file mode 100644 index 0000000..338c32d --- /dev/null +++ b/src/utils/try-import.ts @@ -0,0 +1,25 @@ +import { StackOneError } from './errors'; + +/** + * Dynamically import an optional dependency with a friendly error message + * + * @param moduleName - The name of the module to import + * @param installHint - Installation instructions shown in error message + * @returns The imported module + * @throws StackOneError if the module is not installed + * + * @example + * ```ts + * const ai = await tryImport('ai', 'npm install ai@4.x|5.x'); + * const { jsonSchema } = ai; + * ``` + */ +export async function tryImport(moduleName: string, installHint: string): Promise { + try { + return await import(moduleName); + } catch { + throw new StackOneError( + `${moduleName} is not installed. Please install it with: ${installHint}`, + ); + } +}