diff --git a/docs/installation.md b/docs/installation.md index a211428be..06f537d86 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -30,14 +30,32 @@ Each real LLM provider is backed by an optional peer dependency: | Anthropic | `@ai-sdk/anthropic` | `^0.0.42` | | Ollama | `ollama-ai-provider` | `^1.2.0` | -> **Current limitation:** the package's root entry point -> (`@loushy/build-ai-agent`) loads every provider module when it is -> imported, so today all three provider packages must be installed even if -> you only use one provider (or only the built-in mock provider): -> -> ```bash -> npm install @ai-sdk/openai@^0.0.42 @ai-sdk/anthropic@^0.0.42 ollama-ai-provider@^1.2.0 -> ``` +Peers are loaded on demand: importing `@loushy/build-ai-agent` (or any of +its sub-entries) never loads a provider package, so you only need to install +the ones you use. Each provider package is loaded the first time that +provider makes a call; if it is missing, that call fails with a +`MissingPeerDependencyError` that carries the exact command to run: + +```bash +npm install @ai-sdk/openai@^0.0.42 +``` + +```ts no-run +import { MissingPeerDependencyError, resolveProvider } from '@loushy/build-ai-agent'; + +const provider = resolveProvider('openai/gpt-4o-mini'); // needs OPENAI_API_KEY; does not load the peer +try { + await provider.generate({ messages: [{ role: 'user', content: 'hello' }] }); +} catch (error) { + if (error instanceof MissingPeerDependencyError) { + console.error(error.packageName, error.installCommand); + } +} +``` + +The same applies to the dependencies the SDK ships with but only loads when a +feature needs them: `undici` (the built-in `http` tool), `dockerode` +(`SubprocessSandbox`) and `@modelcontextprotocol/sdk` (`serveMcp`). ## Installing from a local build diff --git a/llms-full.txt b/llms-full.txt index f6cf9bb16..44a60ab1e 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -724,14 +724,32 @@ Each real LLM provider is backed by an optional peer dependency: | Anthropic | `@ai-sdk/anthropic` | `^0.0.42` | | Ollama | `ollama-ai-provider` | `^1.2.0` | -> **Current limitation:** the package's root entry point -> (`@loushy/build-ai-agent`) loads every provider module when it is -> imported, so today all three provider packages must be installed even if -> you only use one provider (or only the built-in mock provider): -> -> ```bash -> npm install @ai-sdk/openai@^0.0.42 @ai-sdk/anthropic@^0.0.42 ollama-ai-provider@^1.2.0 -> ``` +Peers are loaded on demand: importing `@loushy/build-ai-agent` (or any of +its sub-entries) never loads a provider package, so you only need to install +the ones you use. Each provider package is loaded the first time that +provider makes a call; if it is missing, that call fails with a +`MissingPeerDependencyError` that carries the exact command to run: + +```bash +npm install @ai-sdk/openai@^0.0.42 +``` + +```ts no-run +import { MissingPeerDependencyError, resolveProvider } from '@loushy/build-ai-agent'; + +const provider = resolveProvider('openai/gpt-4o-mini'); // needs OPENAI_API_KEY; does not load the peer +try { + await provider.generate({ messages: [{ role: 'user', content: 'hello' }] }); +} catch (error) { + if (error instanceof MissingPeerDependencyError) { + console.error(error.packageName, error.installCommand); + } +} +``` + +The same applies to the dependencies the SDK ships with but only loads when a +feature needs them: `undici` (the built-in `http` tool), `dockerode` +(`SubprocessSandbox`) and `@modelcontextprotocol/sdk` (`serveMcp`). ## Installing from a local build diff --git a/src/providers/AnthropicProvider.ts b/src/providers/AnthropicProvider.ts index afda44f39..916cf053e 100644 --- a/src/providers/AnthropicProvider.ts +++ b/src/providers/AnthropicProvider.ts @@ -8,9 +8,9 @@ * only the underlying 'ai' SDK model factory and model-name lists differ. */ -import { createAnthropic } from '@ai-sdk/anthropic'; import { LanguageModel } from 'ai'; import { AiSdkProvider, AiSdkProviderConfig } from './aiSdkProvider'; +import { lazyValue, loadOptionalPeer } from './optionalPeer'; export interface AnthropicProviderConfig extends AiSdkProviderConfig { apiKey: string; @@ -27,19 +27,18 @@ const TOOL_CAPABLE_PREFIXES = ['claude-3', 'claude-4', 'claude-sonnet', 'claude- export class AnthropicProvider extends AiSdkProvider { readonly name = 'anthropic'; protected readonly fallbackModel = 'claude-3-5-sonnet-latest'; - private provider: ReturnType; - - constructor(config: AnthropicProviderConfig) { - super(config); - this.provider = createAnthropic({ - apiKey: config.apiKey, - baseURL: config.baseURL, - headers: config.headers, + /** Loads `@ai-sdk/anthropic` on first use (it is an optional peer). */ + private readonly loadProvider = lazyValue(async () => { + const { createAnthropic } = await loadOptionalPeer('@ai-sdk/anthropic', () => import('@ai-sdk/anthropic')); + return createAnthropic({ + apiKey: this.config.apiKey, + baseURL: this.config.baseURL, + headers: this.config.headers, }); - } + }); - protected createModel(modelId: string): LanguageModel { - return this.provider(modelId); + protected async createModel(modelId: string): Promise { + return (await this.loadProvider())(modelId); } /** diff --git a/src/providers/OllamaProvider.ts b/src/providers/OllamaProvider.ts index d5163c71e..cd84c8566 100644 --- a/src/providers/OllamaProvider.ts +++ b/src/providers/OllamaProvider.ts @@ -3,9 +3,9 @@ * Uses the 'ai' SDK with ollama-ai-provider (shared logic in ./aiSdkProvider) */ -import { createOllama } from 'ollama-ai-provider'; import { LanguageModel } from 'ai'; import { AiSdkProvider, AiSdkProviderConfig } from './aiSdkProvider'; +import { lazyValue, loadOptionalPeer } from './optionalPeer'; import { Logger, noopLogger } from '../execution/logger'; export interface OllamaProviderConfig extends AiSdkProviderConfig { @@ -19,19 +19,23 @@ export interface OllamaProviderConfig extends AiSdkProviderConfig { export class OllamaProvider extends AiSdkProvider { readonly name = 'ollama'; protected readonly fallbackModel = 'llama3.1'; - private provider: ReturnType; private logger: Logger; + /** Loads `ollama-ai-provider` on first use (it is an optional peer). */ + private readonly loadProvider = lazyValue(async () => { + const { createOllama } = await loadOptionalPeer('ollama-ai-provider', () => import('ollama-ai-provider')); + return createOllama({ + baseURL: this.config.baseURL || 'http://localhost:11434', + }); + }); + constructor(config: OllamaProviderConfig, logger: Logger = noopLogger) { super(config); this.logger = logger; - this.provider = createOllama({ - baseURL: config.baseURL || 'http://localhost:11434', - }); } - protected createModel(modelId: string): LanguageModel { - return this.provider(modelId, { + protected async createModel(modelId: string): Promise { + return (await this.loadProvider())(modelId, { simulateStreaming: true, structuredOutputs: true, }); diff --git a/src/providers/OpenAIProvider.ts b/src/providers/OpenAIProvider.ts index 2c66db38b..88abf7f51 100644 --- a/src/providers/OpenAIProvider.ts +++ b/src/providers/OpenAIProvider.ts @@ -3,9 +3,9 @@ * Uses the 'ai' SDK for unified interface (shared logic in ./aiSdkProvider) */ -import { createOpenAI } from '@ai-sdk/openai'; import { LanguageModel } from 'ai'; import { AiSdkProvider, AiSdkProviderConfig } from './aiSdkProvider'; +import { lazyValue, loadOptionalPeer } from './optionalPeer'; export interface OpenAIProviderConfig extends AiSdkProviderConfig { apiKey: string; @@ -20,20 +20,19 @@ export interface OpenAIProviderConfig extends AiSdkProviderConfig { export class OpenAIProvider extends AiSdkProvider { readonly name = 'openai'; protected readonly fallbackModel = 'gpt-4'; - private provider: ReturnType; - - constructor(config: OpenAIProviderConfig) { - super(config); - this.provider = createOpenAI({ - apiKey: config.apiKey, - organization: config.organization, - baseURL: config.baseURL, - headers: config.headers, + /** Loads `@ai-sdk/openai` on first use (it is an optional peer). */ + private readonly loadProvider = lazyValue(async () => { + const { createOpenAI } = await loadOptionalPeer('@ai-sdk/openai', () => import('@ai-sdk/openai')); + return createOpenAI({ + apiKey: this.config.apiKey, + organization: this.config.organization, + baseURL: this.config.baseURL, + headers: this.config.headers, }); - } + }); - protected createModel(modelId: string): LanguageModel { - return this.provider(modelId); + protected async createModel(modelId: string): Promise { + return (await this.loadProvider())(modelId); } /** diff --git a/src/providers/OpenRouterProvider.ts b/src/providers/OpenRouterProvider.ts index 352f32428..203ea710a 100644 --- a/src/providers/OpenRouterProvider.ts +++ b/src/providers/OpenRouterProvider.ts @@ -3,9 +3,9 @@ * Uses OpenAI-compatible API through the 'ai' SDK (shared logic in ./aiSdkProvider) */ -import { createOpenAI } from '@ai-sdk/openai'; import { LanguageModel } from 'ai'; import { AiSdkProvider, AiSdkProviderConfig } from './aiSdkProvider'; +import { lazyValue, loadOptionalPeer } from './optionalPeer'; import { Logger, noopLogger } from '../execution/logger'; export interface OpenRouterProviderConfig extends AiSdkProviderConfig { @@ -48,23 +48,25 @@ function buildHeaders(config: OpenRouterProviderConfig): Record export class OpenRouterProvider extends AiSdkProvider { readonly name = 'openrouter'; protected readonly fallbackModel = 'openai/gpt-3.5-turbo'; - private provider: ReturnType; private logger: Logger; + /** Loads `@ai-sdk/openai` on first use (it is an optional peer) and points it at OpenRouter. */ + private readonly loadProvider = lazyValue(async () => { + const { createOpenAI } = await loadOptionalPeer('@ai-sdk/openai', () => import('@ai-sdk/openai')); + return createOpenAI({ + apiKey: this.config.apiKey, + baseURL: OPENROUTER_API_URL, + headers: buildHeaders(this.config), + }); + }); + constructor(config: OpenRouterProviderConfig, logger: Logger = noopLogger) { super(config); this.logger = logger; - - // Create OpenAI provider with OpenRouter endpoint - this.provider = createOpenAI({ - apiKey: config.apiKey, - baseURL: OPENROUTER_API_URL, - headers: buildHeaders(config), - }); } - protected createModel(modelId: string): LanguageModel { - return this.provider(modelId); + protected async createModel(modelId: string): Promise { + return (await this.loadProvider())(modelId); } /** diff --git a/src/providers/aiSdkProvider.ts b/src/providers/aiSdkProvider.ts index 5a76e57e2..53c7b7cdf 100644 --- a/src/providers/aiSdkProvider.ts +++ b/src/providers/aiSdkProvider.ts @@ -9,8 +9,8 @@ * finish-reason mapping, and the StreamResult/StreamChunk shape - lives here. * * This module deliberately imports only from 'ai' (never from the optional - * peer deps `@ai-sdk/openai`, `@ai-sdk/anthropic` or `ollama-ai-provider`), so - * importing one provider never pulls in another provider's optional peer. + * peer deps `@ai-sdk/openai`, `@ai-sdk/anthropic` or `ollama-ai-provider`). + * Subclasses load their peer lazily inside `createModel()`, on first use. */ import { @@ -248,7 +248,7 @@ export abstract class AiSdkProvider impleme } /** Build the 'ai' SDK language model for a model id. */ - protected abstract createModel(modelId: string): LanguageModel; + protected abstract createModel(modelId: string): LanguageModel | Promise; /** Convert our messages to 'ai' SDK CoreMessages. */ protected convertMessages(messages: Message[]): CoreMessage[] { @@ -256,9 +256,9 @@ export abstract class AiSdkProvider impleme } /** The call settings shared by generate() and stream(). */ - private buildCallSettings(options: GenerateOptions) { + private async buildCallSettings(options: GenerateOptions) { return { - model: this.createModel(options.model || this.defaultModel), + model: await this.createModel(options.model || this.defaultModel), messages: this.convertMessages(options.messages), temperature: options.temperature, maxTokens: options.maxTokens, @@ -276,7 +276,7 @@ export abstract class AiSdkProvider impleme * Generate text without streaming */ async generate(options: GenerateOptions): Promise { - const result = await generateText(this.buildCallSettings(options)); + const result = await generateText(await this.buildCallSettings(options)); return { text: result.text, @@ -291,7 +291,7 @@ export abstract class AiSdkProvider impleme * Generate text with streaming */ async stream(options: GenerateOptions): Promise { - const result = await streamText(this.buildCallSettings(options)); + const result = await streamText(await this.buildCallSettings(options)); return toStreamResult(result); } diff --git a/src/providers/importGraph.test.ts b/src/providers/importGraph.test.ts new file mode 100644 index 000000000..26393464b --- /dev/null +++ b/src/providers/importGraph.test.ts @@ -0,0 +1,63 @@ +/** + * Import-graph test (LOU-D10, LOU-D19): importing a built entry point must + * not load any optional peer or heavy optional dependency. They are loaded on + * first use instead, so a missing one cannot break `import`. + * + * Needs `dist/` (`npm run build`), like the other dist-dependent tests; CI + * builds before running tests. + */ +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; + +const REPO_ROOT = path.resolve(__dirname, '..', '..'); + +const NEVER_LOADED_AT_IMPORT = [ + '@ai-sdk/openai', + '@ai-sdk/anthropic', + 'ollama-ai-provider', + 'undici', + 'dockerode', + '@modelcontextprotocol/sdk', +]; + +const ENTRY_POINTS = ['index', 'core/index', 'tools/index', 'tools/mcp/index', 'flows/index', 'testing/index']; + +/** Records every module specifier resolved while `body` runs, via synchronous module hooks. */ +const RECORDER = ` +const { registerHooks } = require('node:module'); +globalThis.__requested = new Set(); +registerHooks({ + resolve(specifier, context, nextResolve) { + globalThis.__requested.add(specifier); + return nextResolve(specifier, context); + }, +}); +`; + +function requestedBySpecifier(format: 'cjs' | 'esm', entry: string): string[] { + const extension = format === 'cjs' ? 'js' : 'mjs'; + const file = path.join(REPO_ROOT, 'dist', `${entry}.${extension}`); + const load = format === 'cjs' ? `require(${JSON.stringify(file)});` : `await import(${JSON.stringify('file:///' + file.replace(/\\/g, '/'))});`; + const script = `${RECORDER}\n(async () => { ${load} console.log(JSON.stringify([...globalThis.__requested])); })();`; + const output = execFileSync(process.execPath, ['-e', script], { cwd: REPO_ROOT, encoding: 'utf8' }); + return JSON.parse(output.trim().split('\n').pop() as string) as string[]; +} + +function loadedForbidden(requested: string[]): string[] { + return NEVER_LOADED_AT_IMPORT.filter((pkg) => + requested.some((specifier) => specifier === pkg || specifier.startsWith(`${pkg}/`)) + ); +} + +describe('built entry points load no optional peer at import time', () => { + for (const format of ['cjs', 'esm'] as const) { + for (const entry of ENTRY_POINTS) { + it(`dist/${entry}.${format === 'cjs' ? 'js' : 'mjs'}`, () => { + const requested = requestedBySpecifier(format, entry); + expect(requested.length).toBeGreaterThan(0); // the hook really saw the entry load + expect(loadedForbidden(requested)).toEqual([]); + }); + } + } +}); diff --git a/src/providers/index.ts b/src/providers/index.ts index e2b305689..362978b4e 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -10,8 +10,11 @@ export * from './OllamaProvider'; export * from './OpenRouterProvider'; export * from './AnthropicProvider'; export * from './resolveProvider'; +export { MissingPeerDependencyError } from './optionalPeer'; -// Auto-register built-in providers +// Auto-register built-in providers. The factories only construct provider +// objects; each provider loads its optional peer SDK on first use (LOU-D10, +// LOU-D19), so this registration never loads (or requires) a peer package. import { LLMProviderRegistry } from './llm'; import { OpenAIProvider, OpenAIProviderConfig } from './OpenAIProvider'; import { OllamaProvider, OllamaProviderConfig } from './OllamaProvider'; diff --git a/src/providers/missingPeer.test.ts b/src/providers/missingPeer.test.ts new file mode 100644 index 000000000..e26e7d635 --- /dev/null +++ b/src/providers/missingPeer.test.ts @@ -0,0 +1,133 @@ +/** + * Missing optional peers (LOU-D10, LOU-D19): constructing a provider always + * works; the first generate()/stream() rejects with a typed error carrying + * the install command. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { MissingPeerDependencyError, loadOptionalPeer, lazyValue } from './optionalPeer'; + +function moduleNotFound(packageName: string): Error { + return Object.assign(new Error(`Cannot find module '${packageName}'`), { code: 'MODULE_NOT_FOUND' }); +} + +afterEach(() => { + vi.doUnmock('@ai-sdk/openai'); + vi.doUnmock('@ai-sdk/anthropic'); + vi.doUnmock('ollama-ai-provider'); + vi.doUnmock('dockerode'); + vi.resetModules(); +}); + +const messages = [{ role: 'user' as const, content: 'hi' }]; + +interface Case { + label: string; + packageName: string; + installCommand: string; + load: () => Promise<{ generate(o: { messages: typeof messages }): Promise; stream(o: { messages: typeof messages }): Promise }>; +} + +const CASES: Case[] = [ + { + label: 'OpenAIProvider', + packageName: '@ai-sdk/openai', + installCommand: 'npm install @ai-sdk/openai@^0.0.42', + load: async () => new (await import('./OpenAIProvider')).OpenAIProvider({ name: 'openai', apiKey: 'k' }), + }, + { + label: 'OpenRouterProvider', + packageName: '@ai-sdk/openai', + installCommand: 'npm install @ai-sdk/openai@^0.0.42', + load: async () => new (await import('./OpenRouterProvider')).OpenRouterProvider({ name: 'openrouter', apiKey: 'k' }), + }, + { + label: 'AnthropicProvider', + packageName: '@ai-sdk/anthropic', + installCommand: 'npm install @ai-sdk/anthropic@^0.0.42', + load: async () => new (await import('./AnthropicProvider')).AnthropicProvider({ name: 'anthropic', apiKey: 'k' }), + }, + { + label: 'OllamaProvider', + packageName: 'ollama-ai-provider', + installCommand: 'npm install ollama-ai-provider@^1.2.0', + load: async () => new (await import('./OllamaProvider')).OllamaProvider({ name: 'ollama' }), + }, +]; + +describe.each(CASES)('$label with its peer not installed', ({ packageName, installCommand, load }) => { + function mockMissing(): void { + vi.resetModules(); + vi.doMock(packageName, () => { + throw moduleNotFound(packageName); + }); + } + + it('constructs fine and generate() rejects with MissingPeerDependencyError', async () => { + mockMissing(); + const provider = await load(); + + const error = await provider.generate({ messages }).catch((e: unknown) => e); + + // vi.resetModules() gave the provider a fresh copy of the class, so load the same one. + expect(error).toBeInstanceOf((await import('./optionalPeer')).MissingPeerDependencyError); + expect(error).toMatchObject({ packageName, installCommand }); + expect((error as Error).message).toContain(`Run: ${installCommand}`); + }); + + it('stream() rejects the same way', async () => { + mockMissing(); + const provider = await load(); + + await expect(provider.stream({ messages })).rejects.toMatchObject({ + name: 'MissingPeerDependencyError', + packageName, + installCommand, + }); + }); +}); + +describe('SubprocessSandbox with dockerode not installed', () => { + it('constructs fine and run() rejects with MissingPeerDependencyError', async () => { + vi.resetModules(); + vi.doMock('dockerode', () => { + throw moduleNotFound('dockerode'); + }); + const { SubprocessSandbox } = await import('../security/sandbox'); + const sandbox = new SubprocessSandbox(); + + await expect(sandbox.run('echo', ['hi'])).rejects.toMatchObject({ + name: 'MissingPeerDependencyError', + packageName: 'dockerode', + installCommand: 'npm install dockerode', + }); + }); +}); + +describe('loadOptionalPeer', () => { + it('rethrows errors that are not a missing package, and a missing transitive module as is', async () => { + const boom = new Error('boom'); + await expect(loadOptionalPeer('x', () => Promise.reject(boom))).rejects.toBe(boom); + + const transitive = moduleNotFound('some-other-package'); + await expect(loadOptionalPeer('x', () => Promise.reject(transitive))).rejects.toBe(transitive); + }); + + it('recognises an ESM "Cannot find package" error', async () => { + const esm = Object.assign(new Error("Cannot find package 'x' imported from /app/a.mjs"), { + code: 'ERR_MODULE_NOT_FOUND', + }); + await expect(loadOptionalPeer('x', () => Promise.reject(esm))).rejects.toBeInstanceOf(MissingPeerDependencyError); + }); +}); + +describe('lazyValue', () => { + it('initialises once, and retries after a failure', async () => { + const init = vi.fn().mockRejectedValueOnce(new Error('first')).mockResolvedValue('ok'); + const get = lazyValue(init); + + await expect(get()).rejects.toThrow('first'); + expect(await get()).toBe('ok'); + expect(await get()).toBe('ok'); + expect(init).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/providers/optionalPeer.ts b/src/providers/optionalPeer.ts new file mode 100644 index 000000000..34c872392 --- /dev/null +++ b/src/providers/optionalPeer.ts @@ -0,0 +1,80 @@ +/** + * Lazy loading of optional packages (LOU-D10, LOU-D19). + * + * Importing the SDK never loads a provider SDK: each is imported with a + * dynamic `import()` on first use, and a missing package becomes a + * {@link MissingPeerDependencyError} with the exact install command. + */ + +import { peerInstallCommand } from './providerSpec'; + +/** + * Thrown on first use of a feature whose optional package is not installed. + * + * @example + * ```ts + * try { + * await provider.generate({ messages }); + * } catch (error) { + * if (error instanceof MissingPeerDependencyError) console.error(error.installCommand); + * } + * ``` + */ +export class MissingPeerDependencyError extends Error { + readonly name = 'MissingPeerDependencyError'; + + constructor( + /** The npm package that could not be loaded, e.g. `@ai-sdk/openai`. */ + readonly packageName: string, + /** The command that installs it, e.g. `npm install @ai-sdk/openai@^0.0.42`. */ + readonly installCommand: string, + options?: { cause?: unknown } + ) { + super( + `The optional package '${packageName}' is not installed, but this feature needs it. Run: ${installCommand}`, + options + ); + } +} + +/** + * True when `error` (or an error it wraps as `cause`, as test runners and + * loaders do) says `packageName` itself could not be resolved (CommonJS or ESM). + */ +function isMissingPackage(error: unknown, packageName: string): boolean { + const { code, message, cause } = (error ?? {}) as { code?: unknown; message?: unknown; cause?: unknown }; + const text = typeof message === 'string' ? message : ''; + const notFound = code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND' || /cannot find (module|package)/i.test(text); + // A missing *transitive* module is a broken install, not a missing peer: report it as is. + if (notFound && text.includes(packageName)) return true; + return cause !== undefined && cause !== error && isMissingPackage(cause, packageName); +} + +/** + * Run `importer` (which must contain a literal `import('')` so + * bundlers keep it external) and translate "package not found" into a + * {@link MissingPeerDependencyError}. Other errors are rethrown unchanged. + */ +export async function loadOptionalPeer(packageName: string, importer: () => Promise): Promise { + try { + return await importer(); + } catch (error) { + if (!isMissingPackage(error, packageName)) throw error; + throw new MissingPeerDependencyError(packageName, peerInstallCommand(packageName), { cause: error }); + } +} + +/** + * Run `init` once, on first call, and share the result. A failed attempt is + * not cached, so installing the missing package and retrying works. + */ +export function lazyValue(init: () => Promise): () => Promise { + let pending: Promise | undefined; + return () => { + pending ??= init().catch((error: unknown) => { + pending = undefined; + throw error; + }); + return pending; + }; +} diff --git a/src/providers/providerSpec.ts b/src/providers/providerSpec.ts index e1fffbd78..9225cc327 100644 --- a/src/providers/providerSpec.ts +++ b/src/providers/providerSpec.ts @@ -39,11 +39,26 @@ export function listProviders(): ProviderInfo[] { name, envKey: entry.envKey, envRequired: entry.envRequired, - peerPackage: entry.peer.slice(0, entry.peer.lastIndexOf('@')), + peerPackage: peerPackageName(entry.peer), peerInstall: entry.peer, })); } +/** + * The `npm install` command for an optional peer package, with the version + * range the SDK supports (single source of truth for every install hint). + * Packages that are not a provider peer get a plain `npm install `. + */ +export function peerInstallCommand(packageName: string): string { + const entry = Object.values(PROVIDERS).find((e) => peerPackageName(e.peer) === packageName); + return `npm install ${entry ? entry.peer : packageName}`; +} + +/** `@ai-sdk/openai@^0.0.42` -> `@ai-sdk/openai`. */ +function peerPackageName(peer: string): string { + return peer.slice(0, peer.lastIndexOf('@')); +} + /** Providers in env-detection order (see `modelFromEnv()`). */ const PROVIDERS: Record = { openai: { @@ -143,7 +158,7 @@ function createProvider(caller: string, providerName: string, entry: ProviderEnt if (!isModuleNotFound(error)) throw error; throw new Error( `${caller}: the '${providerName}' provider needs an optional peer dependency that is not installed. ` + - `Run: npm install ${entry.peer}`, + `Run: ${peerInstallCommand(peerPackageName(entry.peer))}`, { cause: error } ); } diff --git a/src/security/sandbox.ts b/src/security/sandbox.ts index e2184dbdf..fab4d0fd8 100644 --- a/src/security/sandbox.ts +++ b/src/security/sandbox.ts @@ -27,7 +27,8 @@ import { writeFile as fsWriteFile } from 'node:fs/promises'; import { PassThrough } from 'node:stream'; -import Docker from 'dockerode'; +import type Docker from 'dockerode'; +import { lazyValue, loadOptionalPeer } from '../providers/optionalPeer'; import { SandboxAdapter, SandboxResult, SandboxRunOptions } from './sandboxCore'; export * from './sandboxCore'; @@ -146,11 +147,15 @@ export interface SubprocessSandboxOptions { */ export class SubprocessSandbox implements SandboxAdapter { readonly name = 'docker'; - private readonly docker: Docker; + /** dockerode is loaded on first `run()`, not at import or construction time (LOU-D19). */ + private readonly getDocker: () => Promise; private readonly image: string; constructor(options: SubprocessSandboxOptions = {}) { - this.docker = new Docker(options.dockerOptions); + this.getDocker = lazyValue(async () => { + const { default: DockerClient } = await loadOptionalPeer('dockerode', () => import('dockerode')); + return new DockerClient(options.dockerOptions); + }); this.image = options.image ?? 'node:20-alpine'; } @@ -166,26 +171,27 @@ export class SubprocessSandbox implements SandboxAdapter { * top-of-file Docker-prerequisite note). */ private async createContainer( + docker: Docker, options: Docker.ContainerCreateOptions ): Promise { try { - return await this.docker.createContainer(options); + return await docker.createContainer(options); } catch (err) { if (!isNoSuchImageError(err, this.image)) { throw err; } await new Promise((resolve, reject) => { - this.docker.pull(this.image, (pullErr: Error | null, stream?: NodeJS.ReadableStream) => { + docker.pull(this.image, (pullErr: Error | null, stream?: NodeJS.ReadableStream) => { if (pullErr || !stream) { reject(pullErr ?? new Error(`SubprocessSandbox: failed to start pulling image '${this.image}'`)); return; } - this.docker.modem.followProgress(stream, (followErr: Error | null) => + docker.modem.followProgress(stream, (followErr: Error | null) => followErr ? reject(followErr) : resolve() ); }); }); - return this.docker.createContainer(options); + return docker.createContainer(options); } } @@ -194,11 +200,12 @@ export class SubprocessSandbox implements SandboxAdapter { * resolves with its captured stdout/stderr/exitCode. */ async run(cmd: string, args: string[], opts: SandboxRunOptions = {}): Promise { - const container = await this.createContainer(buildContainerOptions(this.image, cmd, args, opts)); + const docker = await this.getDocker(); + const container = await this.createContainer(docker, buildContainerOptions(this.image, cmd, args, opts)); const output = captureOutput(); const attachStream = await container.attach({ stream: true, stdout: true, stderr: true }); - this.docker.modem.demuxStream(attachStream, output.stdout, output.stderr); + docker.modem.demuxStream(attachStream, output.stdout, output.stderr); await container.start(); const result = await waitForExit(container, opts.timeoutMs); diff --git a/src/tools/built-in/http.ts b/src/tools/built-in/http.ts index 3bfe68054..df3d4b8a4 100644 --- a/src/tools/built-in/http.ts +++ b/src/tools/built-in/http.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { tool } from 'ai'; import { isIP } from 'net'; import { promises as dnsPromises } from 'dns'; -import { Agent, fetch as undiciFetch } from 'undici'; +import { lazyValue, loadOptionalPeer } from '../../providers/optionalPeer'; import { ToolDescriptor } from '../../types'; import { SandboxAdapter } from '../../security/sandboxCore'; import { sandboxHttpFetch } from './sandboxFetch'; @@ -168,10 +168,21 @@ type HttpTransport = ( * requests. */ function createDirectTransport(validateSSL: boolean): { transport: HttpTransport; close: () => Promise } { - const dispatcher = new Agent({ connect: { rejectUnauthorized: validateSSL } }); + // undici is loaded on first request, not at import time (LOU-D19). + let started = false; + const load = lazyValue(async () => { + const { Agent, fetch } = await loadOptionalPeer('undici', () => import('undici')); + started = true; + return { fetch, dispatcher: new Agent({ connect: { rejectUnauthorized: validateSSL } }) }; + }); return { - transport: (url, init) => undiciFetch(url, { ...init, dispatcher }) as unknown as Promise, - close: () => dispatcher.close(), + transport: async (url, init) => { + const { fetch, dispatcher } = await load(); + return fetch(url, { ...init, dispatcher }) as unknown as Promise; + }, + close: async () => { + if (started) await (await load()).dispatcher.close(); + }, }; } diff --git a/src/tools/mcp/server/buildServer.ts b/src/tools/mcp/server/buildServer.ts index 8f37b3db8..d053cd035 100644 --- a/src/tools/mcp/server/buildServer.ts +++ b/src/tools/mcp/server/buildServer.ts @@ -2,12 +2,14 @@ * Builds the `McpServer` that fronts an agent (and optionally some of its * tools). Transport-agnostic: `serveMcp` connects the result to stdio or HTTP. */ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import { z } from 'zod'; import type { SimpleAgent } from '../../../createAgent'; import type { ExecutionResult } from '../../../execution/AgentExecutor'; import type { DefinedTool } from '../../defineTool'; +import { loadOptionalPeer } from '../../../providers/optionalPeer'; +import { needsApprovalGate } from './toolNames'; /** What {@link buildServer} needs, already validated by `serveMcp`. */ export interface ServerSpec { @@ -21,18 +23,7 @@ export interface ServerSpec { allowApprovalTools: boolean; } -const TOOL_NAME_MAX = 64; - -/** Turns a server name into a valid MCP tool name (`[A-Za-z0-9_-]`, at most 64 characters). */ -export function sanitizeToolName(name: string): string { - const cleaned = name.trim().replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, ''); - return cleaned.slice(0, TOOL_NAME_MAX) || 'agent'; -} - -/** True when a tool pauses for a human before running (a flag or a predicate). */ -export function needsApprovalGate(tool: DefinedTool): boolean { - return tool.needsApproval !== undefined && tool.needsApproval !== false; -} +export { sanitizeToolName } from './toolNames'; function textResult(text: string, isError = false): CallToolResult { return { content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) }; @@ -108,8 +99,15 @@ function registerDirectTool(server: McpServer, tool: DefinedTool): void { ); } -/** Builds an `McpServer` exposing the agent tool plus the directly exposed tools. */ -export function buildServer(spec: ServerSpec): McpServer { +/** + * Builds an `McpServer` exposing the agent tool plus the directly exposed + * tools. Async because `@modelcontextprotocol/sdk` is loaded on first use, + * not when the SDK is imported (LOU-D19). + */ +export async function buildServer(spec: ServerSpec): Promise { + const { McpServer } = await loadOptionalPeer('@modelcontextprotocol/sdk', () => + import('@modelcontextprotocol/sdk/server/mcp.js') + ); const server = new McpServer({ name: spec.name, version: spec.version }); registerAgentTool(server, spec); for (const tool of spec.tools) { diff --git a/src/tools/mcp/server/httpTransport.ts b/src/tools/mcp/server/httpTransport.ts index 7d60c1fc0..1b7a55f8f 100644 --- a/src/tools/mcp/server/httpTransport.ts +++ b/src/tools/mcp/server/httpTransport.ts @@ -5,8 +5,8 @@ */ import * as http from 'node:http'; import type { AddressInfo } from 'node:net'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { loadOptionalPeer } from '../../../providers/optionalPeer'; import { checkWebhookAuth } from '../../../triggers/webhookAuth'; /** Options of the `{ type: 'http' }` transport. */ @@ -75,12 +75,15 @@ function readJsonBody(req: http.IncomingMessage): Promise { } async function serveRequest( - createServer: () => McpServer, + createServer: () => Promise, req: http.IncomingMessage, res: http.ServerResponse ): Promise { const body = await readJsonBody(req); - const mcp = createServer(); + const mcp = await createServer(); + const { StreamableHTTPServerTransport } = await loadOptionalPeer('@modelcontextprotocol/sdk', () => + import('@modelcontextprotocol/sdk/server/streamableHttp.js') + ); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true }); res.on('close', () => { void transport.close(); @@ -91,7 +94,7 @@ async function serveRequest( } async function handle( - createServer: () => McpServer, + createServer: () => Promise, path: string, auth: McpHttpTransportOptions['auth'], req: http.IncomingMessage, @@ -126,7 +129,7 @@ function assertAuth(auth: McpHttpTransportOptions['auth']): void { /** Starts the HTTP listener. Resolves once it is accepting connections. */ export function listenHttp( options: McpHttpTransportOptions, - createServer: () => McpServer, + createServer: () => Promise, warn: (message: string) => void ): Promise { const host = options.host ?? '127.0.0.1'; diff --git a/src/tools/mcp/server/serveMcp.test.ts b/src/tools/mcp/server/serveMcp.test.ts index 8cc03bfb5..d8cc670c7 100644 --- a/src/tools/mcp/server/serveMcp.test.ts +++ b/src/tools/mcp/server/serveMcp.test.ts @@ -51,7 +51,7 @@ afterEach(async () => { }); async function connect(serverSpec: ServerSpec): Promise { - const server = buildServer(serverSpec); + const server = await buildServer(serverSpec); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: 'test-client', version: '1.0.0' }); await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); diff --git a/src/tools/mcp/server/serveMcp.ts b/src/tools/mcp/server/serveMcp.ts index 183d4270d..7893de632 100644 --- a/src/tools/mcp/server/serveMcp.ts +++ b/src/tools/mcp/server/serveMcp.ts @@ -3,11 +3,11 @@ * a Model Context Protocol server, so Claude Code, Cursor and other MCP * clients can call it (LOU-Z3). */ -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import type { SimpleAgent } from '../../../createAgent'; +import { loadOptionalPeer } from '../../../providers/optionalPeer'; import type { DefinedTool } from '../../defineTool'; -import { buildServer, needsApprovalGate, sanitizeToolName } from './buildServer'; +import { needsApprovalGate, sanitizeToolName } from './toolNames'; +import { buildServer, type ServerSpec } from './buildServer'; import { listenHttp, type McpHttpTransportOptions } from './httpTransport'; export type { McpHttpTransportOptions } from './httpTransport'; @@ -91,15 +91,19 @@ interface Running { async function listen( transport: ServeMcpOptions['transport'], - create: () => McpServer, + spec: ServerSpec, warn: (message: string) => void ): Promise { + const create = () => buildServer(spec); if (transport !== undefined && transport !== 'stdio') { const http = await listenHttp(transport, create, warn); const host = http.host.includes(':') ? `[${http.host}]` : http.host; return { port: http.port, url: `http://${host}:${http.port}${http.path}`, close: http.close }; } - const server = create(); + const { StdioServerTransport } = await loadOptionalPeer('@modelcontextprotocol/sdk', () => + import('@modelcontextprotocol/sdk/server/stdio.js') + ); + const server = await create(); await server.connect(new StdioServerTransport()); return { close: () => server.close() }; } @@ -134,6 +138,6 @@ export async function serveMcp(options: ServeMcpOptions): Promise buildServer(spec), warn); + const running = await listen(options.transport, spec, warn); return { agentToolName, url: running.url, port: running.port, close: running.close }; } diff --git a/src/tools/mcp/server/toolNames.ts b/src/tools/mcp/server/toolNames.ts new file mode 100644 index 000000000..135694ed0 --- /dev/null +++ b/src/tools/mcp/server/toolNames.ts @@ -0,0 +1,19 @@ +/** + * Pure helpers for `serveMcp`. Kept apart from `buildServer` so that importing + * `serveMcp` does not load `@modelcontextprotocol/sdk` (it is loaded on first + * `serveMcp()` call, LOU-D19). + */ +import type { DefinedTool } from '../../defineTool'; + +const TOOL_NAME_MAX = 64; + +/** Turns a server name into a valid MCP tool name (`[A-Za-z0-9_-]`, at most 64 characters). */ +export function sanitizeToolName(name: string): string { + const cleaned = name.trim().replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, ''); + return cleaned.slice(0, TOOL_NAME_MAX) || 'agent'; +} + +/** True when a tool pauses for a human before running (a flag or a predicate). */ +export function needsApprovalGate(tool: DefinedTool): boolean { + return tool.needsApproval !== undefined && tool.needsApproval !== false; +}