Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 26 additions & 8 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
23 changes: 11 additions & 12 deletions src/providers/AnthropicProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,19 +27,18 @@ const TOOL_CAPABLE_PREFIXES = ['claude-3', 'claude-4', 'claude-sonnet', 'claude-
export class AnthropicProvider extends AiSdkProvider<AnthropicProviderConfig> {
readonly name = 'anthropic';
protected readonly fallbackModel = 'claude-3-5-sonnet-latest';
private provider: ReturnType<typeof createAnthropic>;

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<LanguageModel> {
return (await this.loadProvider())(modelId);
}

/**
Expand Down
18 changes: 11 additions & 7 deletions src/providers/OllamaProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -19,19 +19,23 @@ export interface OllamaProviderConfig extends AiSdkProviderConfig {
export class OllamaProvider extends AiSdkProvider<OllamaProviderConfig> {
readonly name = 'ollama';
protected readonly fallbackModel = 'llama3.1';
private provider: ReturnType<typeof createOllama>;
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<LanguageModel> {
return (await this.loadProvider())(modelId, {
simulateStreaming: true,
structuredOutputs: true,
});
Expand Down
25 changes: 12 additions & 13 deletions src/providers/OpenAIProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,20 +20,19 @@ export interface OpenAIProviderConfig extends AiSdkProviderConfig {
export class OpenAIProvider extends AiSdkProvider<OpenAIProviderConfig> {
readonly name = 'openai';
protected readonly fallbackModel = 'gpt-4';
private provider: ReturnType<typeof createOpenAI>;

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<LanguageModel> {
return (await this.loadProvider())(modelId);
}

/**
Expand Down
24 changes: 13 additions & 11 deletions src/providers/OpenRouterProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -48,23 +48,25 @@ function buildHeaders(config: OpenRouterProviderConfig): Record<string, string>
export class OpenRouterProvider extends AiSdkProvider<OpenRouterProviderConfig> {
readonly name = 'openrouter';
protected readonly fallbackModel = 'openai/gpt-3.5-turbo';
private provider: ReturnType<typeof createOpenAI>;
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<LanguageModel> {
return (await this.loadProvider())(modelId);
}

/**
Expand Down
14 changes: 7 additions & 7 deletions src/providers/aiSdkProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -248,17 +248,17 @@ export abstract class AiSdkProvider<TConfig extends AiSdkProviderConfig> impleme
}

/** Build the 'ai' SDK language model for a model id. */
protected abstract createModel(modelId: string): LanguageModel;
protected abstract createModel(modelId: string): LanguageModel | Promise<LanguageModel>;

/** Convert our messages to 'ai' SDK CoreMessages. */
protected convertMessages(messages: Message[]): CoreMessage[] {
return toCoreMessages(messages);
}

/** 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,
Expand All @@ -276,7 +276,7 @@ export abstract class AiSdkProvider<TConfig extends AiSdkProviderConfig> impleme
* Generate text without streaming
*/
async generate(options: GenerateOptions): Promise<GenerateResult> {
const result = await generateText(this.buildCallSettings(options));
const result = await generateText(await this.buildCallSettings(options));

return {
text: result.text,
Expand All @@ -291,7 +291,7 @@ export abstract class AiSdkProvider<TConfig extends AiSdkProviderConfig> impleme
* Generate text with streaming
*/
async stream(options: GenerateOptions): Promise<StreamResult> {
const result = await streamText(this.buildCallSettings(options));
const result = await streamText(await this.buildCallSettings(options));
return toStreamResult(result);
}

Expand Down
63 changes: 63 additions & 0 deletions src/providers/importGraph.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
}
}
});
5 changes: 4 additions & 1 deletion src/providers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading