From 11d6ad0f3ae1ff8f65011ce50bc8a80f7d078095 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Thu, 1 Oct 2026 09:37:03 +0300 Subject: [PATCH] LOU-D1: five-line hello world createAgent now accepts a 'provider/model' string (resolved with the existing resolveProvider, key from the conventional env var) as an alternative to a provider instance. With neither, it uses LOUSHY_MODEL, else the first present key (OPENAI, ANTHROPIC, OPENROUTER, OLLAMA_BASE_URL), else throws listing every fix. 'instructions' is the preferred, optional system prompt; 'prompt' stays as an alias; both is an error. Options are proper unions (no any). Errors: missing key, unknown prefix (supported list + closest match by edit distance), missing optional peer (exact npm install), nothing configured. Docs and the create-loushy-agent template lead with the short form; verify-docs-snippets gains a no-run fence flag for snippets that need a real key. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 31 +++- docs/api-overview.md | 2 +- docs/configuration.md | 23 ++- docs/quick-start.md | 54 ++++-- packages/create-loushy-agent/src/template.ts | 7 +- scripts/verify-docs-snippets.ts | 13 +- src/createAgent.test.ts | 137 +++++++++++++- src/createAgent.ts | 129 ++++++++++--- src/providers/providerSpec.ts | 179 +++++++++++++++++++ src/providers/resolveProvider.test.ts | 60 +++++++ src/providers/resolveProvider.ts | 60 ++----- 11 files changed, 584 insertions(+), 111 deletions(-) create mode 100644 src/providers/providerSpec.ts diff --git a/README.md b/README.md index dcf664f3f..a029aace2 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ dashboard — any provider, any deploy target, no lock-in. ## Features -- **Zero-config to full control** — `createAgent({ prompt, provider })` in one line, or the full `AgentBuilder` + `AgentExecutor` API when you need `maxSteps`, checkpoints, or tracing hooks +- **Zero-config to full control** — `createAgent({ model: 'openai/gpt-4o-mini' })` in one line, or the full `AgentBuilder` + `AgentExecutor` API when you need `maxSteps`, checkpoints, or tracing hooks - **Human-in-the-loop** — flag a tool `needsApproval` and pause execution until a human approves or rejects it, then `resumeAfterApproval()` from any process - **Durable execution** — pass a `sessionId` + `checkpointStore` and a crash mid-conversation resumes instead of restarting - **Cancellation** — pass an `AbortSignal` (`agent.send(input, { signal })`) to stop a run; it resolves with `finishReason: 'aborted'` and the transcript so far, and the signal reaches the provider, tools and delegated agents @@ -33,19 +33,32 @@ dashboard — any provider, any deploy target, no lock-in. ## Quick example -The zero-config path — one function call, no manually-wired executor or -registry: +Hello world in five lines (reads `OPENAI_API_KEY` from your environment): ```typescript -import { createAgent, resolveProvider } from '@loushy/build-ai-agent'; +import { createAgent } from '@loushy/build-ai-agent'; + +const agent = createAgent({ model: 'openai/gpt-4o-mini', instructions: 'You are a helpful assistant.' }); +const { text } = await agent.send('Hello!'); +console.log(text); +``` + +`model` is a `provider/model` string (`openai`, `anthropic`, `openrouter`, +`ollama`); the key comes from the provider's conventional env var. Leave +`model` out and the agent uses `LOUSHY_MODEL` if set, otherwise the first of +`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`, +`OLLAMA_BASE_URL` that is present. + +When you need a custom provider (your own `LLMProvider`, a mock, extra +config), pass the instance instead: + +```typescript +import { createAgent, createMockProvider } from '@loushy/build-ai-agent'; const agent = createAgent({ - prompt: 'You are a helpful customer support assistant.', - provider: resolveProvider('openai/gpt-4o-mini'), // reads OPENAI_API_KEY + instructions: 'You are a helpful customer support assistant.', + provider: createMockProvider({ responses: ['Hi! How can I help?'] }), }); - -const result = await agent.send('Hello!'); -console.log(result.text); ``` The same agent with an approval gate on a sensitive tool — `defineTool()` diff --git a/docs/api-overview.md b/docs/api-overview.md index 726fe9834..7e051331f 100644 --- a/docs/api-overview.md +++ b/docs/api-overview.md @@ -12,7 +12,7 @@ npm run docs:build # writes docs/api/index.html | Export | Description | | ----------------------------- | --------------------------------------------------------------------------- | -| `createAgent(config)` | Zero-config `{ send(message) }` agent from a prompt + provider (+ tools). | +| `createAgent(config)` | Zero-config `{ send(message) }` agent from a `model` string or provider (+ instructions, tools). | | `AgentBuilder` | Fluent builder for an `AgentConfig` (`AgentBuilder.create().setName(...)...build()`). | | `AgentExecutor.execute(opts)` | Static executor: runs an agent (LLM + tool-calling loop) and resolves to an `ExecutionResult`. | | `AgentType` | Agent type enum (e.g. `AgentType.SmartAssistant`). | diff --git a/docs/configuration.md b/docs/configuration.md index 73b1b86d9..d09702b4f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -75,14 +75,29 @@ what the examples and the Quick Start use by default. ## `createAgent()` options -| Option | Description | -| ---------- | ------------------------------------------------------------------ | -| `prompt` | System prompt (required). | -| `provider` | An `LLMProvider` instance (required). | +| Option | Description | +| -------------- | -------------------------------------------------------------- | +| `model` | A `'provider/model'` string such as `'openai/gpt-4o-mini'`, resolved with `resolveProvider()` (key from the env var above). Alternative to `provider`. | +| `provider` | An `LLMProvider` instance (real or mock). Alternative to `model`. If you pass both, `provider` is used and `model` becomes the agent's per-run model setting (a bare model id such as `'gpt-4o'`). | +| `instructions` | System prompt. Optional (defaults to `'You are a helpful assistant.'`). | +| `prompt` | Working alias of `instructions`; passing both is an error. | | `tools` | `Record`, keyed by the name the agent uses. | | `name` | Agent name (default `'agent'`). | | `maxSteps` | Passed through to `AgentExecutor.execute()`. | +With neither `model` nor `provider`, `createAgent()` resolves from the +environment: `LOUSHY_MODEL` (a `'provider/model'` string) if set, otherwise +the first provider whose variable is set, checked in this order: +`OPENAI_API_KEY` (`openai/gpt-4o-mini`), `ANTHROPIC_API_KEY` +(`anthropic/claude-3-5-sonnet-latest`), `OPENROUTER_API_KEY` +(`openrouter/openai/gpt-4o-mini`), `OLLAMA_BASE_URL` (`ollama/llama3`). If none +is set it throws an error listing exactly which options or variables fix it. + +Misconfiguration errors say how to fix themselves: a missing key names the +variable (`createAgent: OPENAI_API_KEY is not set. ...`), an unknown prefix +lists the supported ones and suggests the closest, and a missing optional peer +dependency prints the exact `npm install` command. + ## `AgentExecutor.execute()` options `AgentExecutor` is static: call `AgentExecutor.execute(options)`. Only diff --git a/docs/quick-start.md b/docs/quick-start.md index 7889e8f94..d753584a4 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -1,10 +1,11 @@ # Quick Start Every TypeScript snippet on this page is a complete, standalone ES module -(`.mts`) that runs as-is with the SDK's built-in mock provider - no API key -needed. They are executed against a locally packed build of the SDK by -`npx tsx scripts/verify-docs-snippets.ts`, so they are kept in sync with the -real API. +(`.mts`). They are type-checked and executed against a locally packed build of +the SDK by `npx tsx scripts/verify-docs-snippets.ts`, so they are kept in sync +with the real API. All of them run as-is with the SDK's built-in mock +provider - no API key needed - except the first, which talks to a real model +and is therefore only type-checked. Install first (see [Installation](./installation.md)): @@ -13,16 +14,40 @@ npm install @loushy/build-ai-agent ai zod npm install @ai-sdk/openai@^0.0.42 @ai-sdk/anthropic@^0.0.42 ollama-ai-provider@^1.2.0 ``` -## 1. One-liner agent with `createAgent()` +## 1. Hello world in five lines -`createAgent()` is the zero-config entry point: a prompt and a provider in, a -`{ send }` agent out. +`createAgent()` is the zero-config entry point: a `provider/model` string and +instructions in, a `{ send }` agent out. The API key is read from the +provider's conventional environment variable (`OPENAI_API_KEY` here; +`ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY` or `OLLAMA_BASE_URL` for the other +providers). + +```ts no-run +import { createAgent } from '@loushy/build-ai-agent'; + +const agent = createAgent({ model: 'openai/gpt-4o-mini', instructions: 'You are a helpful assistant.' }); +const { text } = await agent.send('Hello!'); +console.log(text); +``` + +Leave `model` out to let the environment decide: `LOUSHY_MODEL` (a +`provider/model` string) if set, otherwise the first of `OPENAI_API_KEY`, +`ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`, `OLLAMA_BASE_URL` that is present. +If a key is missing or the prefix is misspelled, the error tells you exactly +what to set or fix. `instructions` is optional; `prompt` is accepted as an +alias for it. + +## 2. When you need a custom provider + +Pass a provider instance instead of `model` when you have your own +`LLMProvider`, want the mock provider for tests, or need extra provider +config. This one needs no API key: ```ts import { createAgent, createMockProvider } from '@loushy/build-ai-agent'; const agent = createAgent({ - prompt: 'You are a helpful assistant.', + instructions: 'You are a helpful assistant.', provider: createMockProvider({ responses: ['Hello! How can I help you today?'] }), }); @@ -30,14 +55,11 @@ const result = await agent.send('Hi there'); console.log(result.text); // "Hello! How can I help you today?" ``` -## 2. Switching to a real provider - -`resolveProvider('/')` builds a real provider, reading its -credential from the environment (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, -`OPENROUTER_API_KEY`, or `OLLAMA_BASE_URL` for Ollama). This snippet uses -OpenAI when `OPENAI_API_KEY` is set and falls back to the mock provider -otherwise - the same pattern the runnable [examples](../examples/README.md) -use. +`resolveProvider('/')` is the function `model` uses under +the hood; call it yourself when you want the provider object, as in this +snippet, which uses OpenAI when `OPENAI_API_KEY` is set and falls back to the +mock provider otherwise - the same pattern the runnable +[examples](../examples/README.md) use. ```ts import { createAgent, createMockProvider, resolveProvider } from '@loushy/build-ai-agent'; diff --git a/packages/create-loushy-agent/src/template.ts b/packages/create-loushy-agent/src/template.ts index 79af7bceb..95ce2f333 100644 --- a/packages/create-loushy-agent/src/template.ts +++ b/packages/create-loushy-agent/src/template.ts @@ -122,14 +122,11 @@ function buildAgentSource(answers: AnswerConfig): string { const toolsSource = toolsObjectSource(answers.tools); return `import { createAgent } from '@loushy/build-ai-agent'; -import { resolveProvider } from '@loushy/build-ai-agent'; ${toolImports} -const provider = resolveProvider('${answers.provider}/${defaultModelFor(answers.provider)}'); - const agent = createAgent({ - prompt: 'You are a helpful assistant built with @loushy/build-ai-agent.', - provider, + model: '${answers.provider}/${defaultModelFor(answers.provider)}', + instructions: 'You are a helpful assistant built with @loushy/build-ai-agent.', ${toolsSource ? ` tools: {\n${toolsSource}\n },\n` : ''}}); async function main() { diff --git a/scripts/verify-docs-snippets.ts b/scripts/verify-docs-snippets.ts index a088f7e8b..2ca0aac13 100644 --- a/scripts/verify-docs-snippets.ts +++ b/scripts/verify-docs-snippets.ts @@ -44,7 +44,9 @@ * Snippets run with provider credential env vars (OPENAI_API_KEY, ...) * removed, so they exercise the mock-provider path deterministically and * never make network calls. A fence whose info string contains - * `no-verify` (e.g. ```ts no-verify) is skipped. + * `no-verify` (e.g. ```ts no-verify) is skipped. One containing `no-run` + * (e.g. ```ts no-run) is still type-checked by both stages but not executed, + * for snippets that need a real API key to run (LOU-D1). */ import { execFileSync, spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; @@ -82,6 +84,8 @@ export interface Snippet { /** 1-based line number of the opening fence. */ line: number; source: string; + /** The fence says `no-run`: type-check only, never execute. */ + noRun: boolean; } /** Index of the closing fence at or after `from`, or lines.length when the block is unterminated. */ @@ -99,7 +103,8 @@ export function extractSnippets(markdown: string, file: string): Snippet[] { if (!open) continue; const end = findFenceEnd(lines, i + 1); if (!open[2].includes('no-verify')) { - snippets.push({ file, line: i + 1, source: lines.slice(i + 1, end).join('\n') + '\n' }); + const source = lines.slice(i + 1, end).join('\n') + '\n'; + snippets.push({ file, line: i + 1, source, noRun: open[2].includes('no-run') }); } i = end; } @@ -264,6 +269,10 @@ function runSnippets(projectDir: string, files: string[], snippets: Snippet[], t const failures: Failure[] = []; files.forEach((name, index) => { const snippet = snippets[index]; + if (snippet.noRun) { + console.log(` SKIP (no-run) ${snippet.file}:${snippet.line} (${name})`); + return; + } const typeOk = !typeFailures.some((f) => f.snippet === snippet); const failure = runSnippet(projectDir, env, name, snippet, typeOk); if (failure) failures.push(failure); diff --git a/src/createAgent.test.ts b/src/createAgent.test.ts index eed9ab6c5..409969b95 100644 --- a/src/createAgent.test.ts +++ b/src/createAgent.test.ts @@ -1,12 +1,28 @@ /** * LOU-H1 test: only imports createAgent() and a mock provider - zero other * Loushy imports - to prove the one-liner surface is self-contained. + * LOU-D1 additions: `model` strings, env fallback, `instructions` alias and + * error messages (no network: env is stubbed and the registry's create() is + * the same boundary resolveProvider.test.ts mocks). */ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { createAgent } from './createAgent'; import { createMockProvider } from './providers/mock'; +import { LLMProviderRegistry } from './providers/llm'; +import { mockModel } from './testing'; + +const ENV_VARS = ['LOUSHY_MODEL', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'OPENROUTER_API_KEY', 'OLLAMA_BASE_URL']; describe('createAgent', () => { + beforeEach(() => { + for (const name of ENV_VARS) vi.stubEnv(name, ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + it('sends a message and gets a non-empty response', async () => { const agent = createAgent({ prompt: 'You are a helpful assistant.', @@ -20,8 +36,121 @@ describe('createAgent', () => { }); it('throws a guiding error when provider is missing', () => { - expect(() => - createAgent({ prompt: 'x', provider: undefined as any }) - ).toThrow(/provider/i); + expect(() => createAgent({ prompt: 'x', provider: undefined })).toThrow(/provider/i); + }); + + describe('model string', () => { + it('resolves "provider/model" with the key from the conventional env var', async () => { + vi.stubEnv('OPENAI_API_KEY', 'sk-test'); + const provider = mockModel(['Hi from the model']); + const createSpy = vi.spyOn(LLMProviderRegistry, 'create').mockReturnValue(provider); + + const agent = createAgent({ model: 'openai/gpt-4o-mini', instructions: 'Be brief.' }); + const { text } = await agent.send('Hello!'); + + expect(createSpy).toHaveBeenCalledWith('openai', { defaultModel: 'gpt-4o-mini', apiKey: 'sk-test' }); + expect(text).toBe('Hi from the model'); + }); + + it('works with instructions omitted (minimal default prompt)', async () => { + vi.stubEnv('OPENAI_API_KEY', 'sk-test'); + const provider = mockModel(['ok']); + vi.spyOn(LLMProviderRegistry, 'create').mockReturnValue(provider); + + await createAgent({ model: 'openai/gpt-4o-mini' }).send('hi'); + + expect(JSON.stringify(provider.lastCall?.messages)).toContain('You are a helpful assistant.'); + }); + + it('with both provider and model: uses provider, and model is the per-agent model setting', async () => { + const provider = mockModel(['ok']); + const createSpy = vi.spyOn(LLMProviderRegistry, 'create'); + + await createAgent({ provider, model: 'gpt-4o' }).send('hi'); + + expect(createSpy).not.toHaveBeenCalled(); + expect(provider.lastCall?.model).toBe('gpt-4o'); + }); + }); + + describe('environment fallback', () => { + it('uses LOUSHY_MODEL when set, even if provider keys are present', () => { + vi.stubEnv('LOUSHY_MODEL', 'anthropic/claude-3-5-haiku-latest'); + vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant'); + vi.stubEnv('OPENAI_API_KEY', 'sk-openai'); + const createSpy = vi.spyOn(LLMProviderRegistry, 'create').mockReturnValue(mockModel([])); + + createAgent({ instructions: 'x' }); + + expect(createSpy).toHaveBeenCalledWith('anthropic', { + defaultModel: 'claude-3-5-haiku-latest', + apiKey: 'sk-ant', + }); + }); + + it.each([ + [{ OPENAI_API_KEY: 'o', ANTHROPIC_API_KEY: 'a', OPENROUTER_API_KEY: 'r' }, 'openai'], + [{ ANTHROPIC_API_KEY: 'a', OPENROUTER_API_KEY: 'r', OLLAMA_BASE_URL: 'http://x' }, 'anthropic'], + [{ OPENROUTER_API_KEY: 'r', OLLAMA_BASE_URL: 'http://x' }, 'openrouter'], + [{ OLLAMA_BASE_URL: 'http://x' }, 'ollama'], + ])('with keys %j picks %s first', (env, expected) => { + for (const [name, value] of Object.entries(env)) vi.stubEnv(name, value); + const createSpy = vi.spyOn(LLMProviderRegistry, 'create').mockReturnValue(mockModel([])); + + createAgent(); + + expect(createSpy.mock.calls[0][0]).toBe(expected); + }); + + it('throws an error listing every fix when nothing is configured', () => { + expect(() => createAgent({ instructions: 'x' })).toThrow( + /createAgent: no model configured.*model: 'openai\/gpt-4o-mini'.*provider: \.\.\..*LOUSHY_MODEL.*OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY, OLLAMA_BASE_URL/ + ); + }); + }); + + describe('instructions / prompt', () => { + it('accepts instructions as the system prompt', async () => { + const provider = mockModel(['ok']); + await createAgent({ provider, instructions: 'Answer in French.' }).send('hi'); + expect(JSON.stringify(provider.lastCall?.messages)).toContain('Answer in French.'); + }); + + it('still accepts prompt as an alias', async () => { + const provider = mockModel(['ok']); + await createAgent({ provider, prompt: 'Answer in German.' }).send('hi'); + expect(JSON.stringify(provider.lastCall?.messages)).toContain('Answer in German.'); + }); + + it('throws naming both when instructions and prompt are given', () => { + const provider = mockModel([]); + const both = { provider, instructions: 'a', prompt: 'b' } as unknown as Parameters[0]; + expect(() => createAgent(both)).toThrow(/both 'instructions' and 'prompt'/); + }); + }); + + describe('error messages', () => { + it('names the missing API key and the provider escape hatch', () => { + expect(() => createAgent({ model: 'openai/gpt-4o-mini' })).toThrow( + 'createAgent: OPENAI_API_KEY is not set. Set it in your environment, or pass a provider instance: createAgent({ provider: ... })' + ); + }); + + it('lists supported prefixes and suggests the closest for an unknown provider', () => { + expect(() => createAgent({ model: 'opnai/gpt-4o' })).toThrow( + /createAgent: unrecognized provider 'opnai'.*Supported prefixes: openai, anthropic, openrouter, ollama\. Did you mean 'openai\/gpt-4o'\?/ + ); + }); + + it('gives the exact npm install command when a peer dependency is missing', () => { + vi.stubEnv('ANTHROPIC_API_KEY', 'sk-ant'); + vi.spyOn(LLMProviderRegistry, 'create').mockImplementation(() => { + throw Object.assign(new Error("Cannot find module '@ai-sdk/anthropic'"), { code: 'MODULE_NOT_FOUND' }); + }); + + expect(() => createAgent({ model: 'anthropic/claude-3-5-sonnet-latest' })).toThrow( + /createAgent: .*'anthropic' provider.*Run: npm install @ai-sdk\/anthropic@\^0\.0\.42/ + ); + }); }); }); diff --git a/src/createAgent.ts b/src/createAgent.ts index c7214fd70..6c5145aa3 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -21,18 +21,16 @@ import { AgentExecutor, ExecutionResult } from './execution/AgentExecutor'; import { LLMProvider } from './providers/llm'; import { ToolRegistry } from './tools/ToolRegistry'; import { ToolDescriptor } from './types'; +import { modelFromEnv, resolveProviderSpec } from './providers/providerSpec'; import type { DefinedTool } from './tools/defineTool'; /** - * Configuration for createAgent(). Tools are keyed by the name the agent - * (and AgentConfig.tools) will refer to them by - createAgent() registers - * each one into a fresh ToolRegistry under that key. + * Options for createAgent() that do not depend on how the instructions and + * model are given. Tools are keyed by the name the agent (and + * AgentConfig.tools) will refer to them by - createAgent() registers each + * one into a fresh ToolRegistry under that key. */ -export interface CreateAgentConfig { - /** System prompt for the agent. */ - prompt: string; - /** LLM provider instance (real or mock) used to generate responses. */ - provider: LLMProvider; +export interface CreateAgentBase { /** * Optional tools: an array of `defineTool()` results (named by the tool), * or a record of descriptors keyed by the name the agent should call them by. @@ -47,6 +45,67 @@ export interface CreateAgentConfig { maxSteps?: number; } +/** + * The system prompt. `instructions` is the preferred name; `prompt` is a + * working alias. Give at most one - both is an error. Omit both for a + * minimal default ("You are a helpful assistant."). + */ +export type CreateAgentInstructions = + | { + /** System prompt for the agent (preferred name). */ + instructions?: string; + /** Alias of `instructions`; do not pass both. */ + prompt?: undefined; + } + | { + /** Preferred name of `prompt`; do not pass both. */ + instructions?: undefined; + /** System prompt for the agent (alias of `instructions`). */ + prompt?: string; + }; + +/** + * How the LLM is chosen - exactly one of these alternatives: + * + * 1. `model: 'provider/model'` - resolved with `resolveProvider()`, the key + * read from the provider's conventional env var (`OPENAI_API_KEY`, + * `ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`, or `OLLAMA_BASE_URL`). + * 2. `provider: ` - your own (or the mock) provider instance. + * You may also pass `model` here (a bare model id, e.g. `'gpt-4o'`): it + * becomes this agent's model setting, overriding the provider's default + * model. + * 3. Neither - resolved from the environment: `LOUSHY_MODEL` (a + * `provider/model` string) if set, otherwise the first provider whose key + * is set, checked in the order OPENAI_API_KEY, ANTHROPIC_API_KEY, + * OPENROUTER_API_KEY, OLLAMA_BASE_URL. Throws, listing the fixes, when + * none is set. + */ +export type CreateAgentModelSource = + | { + /** A `provider/model` string, e.g. `'openai/gpt-4o-mini'`. */ + model: string; + provider?: undefined; + } + | { + /** An LLM provider instance (real or mock). */ + provider: LLMProvider; + /** Per-agent model id sent to `provider`, overriding its default model. */ + model?: string; + } + | { + model?: undefined; + provider?: undefined; + }; + +/** + * Configuration for createAgent(): the common options plus one way to give + * the instructions and one way to choose the model (see the two unions). + * + * @example + * createAgent({ model: 'openai/gpt-4o-mini', instructions: 'You are a helpful assistant.' }); + */ +export type CreateAgentConfig = CreateAgentBase & CreateAgentInstructions & CreateAgentModelSource; + /** Per-call options for `SimpleAgent.send()`. */ export interface SendOptions { /** @@ -68,28 +127,40 @@ export interface SimpleAgent { } /** - * Build a ready-to-use agent from a prompt + provider (+ optional tools) in - * one call. Does NOT modify AgentBuilder or AgentExecutor - it is purely a - * thin composition of the existing public APIs. + * Build a ready-to-use agent in one call. The smallest useful form is a + * model string, instructions, and `send()`. + * + * See `CreateAgentModelSource` for how the model is chosen (a + * `provider/model` string, a provider instance, or the environment) and + * `CreateAgentInstructions` for `instructions` vs its alias `prompt`. Does + * NOT modify AgentBuilder or AgentExecutor - it is purely a thin + * composition of the existing public APIs. + * + * @example + * const agent = createAgent({ model: 'openai/gpt-4o-mini', instructions: 'You are a helpful assistant.' }); + * const { text } = await agent.send('Hello!'); */ -export function createAgent(config: CreateAgentConfig): SimpleAgent { - assertCreateAgentConfig(config); +export function createAgent(config: CreateAgentConfig = {}): SimpleAgent { + const instructions = resolveInstructions(config); + const provider = resolveModelSource(config); const { toolRegistry, toolsConfig } = registerTools(config.tools ?? {}); - const agent = AgentBuilder.create() + const builder = AgentBuilder.create() .setType(AgentType.SmartAssistant) .setName(config.name || 'agent') - .setPrompt(config.prompt) - .setTools(toolsConfig) - .build(); + .setPrompt(instructions) + .setTools(toolsConfig); + // With an explicit provider, `model` is a per-agent model setting. + if (config.provider && config.model) builder.setSettings({ model: config.model }); + const agent = builder.build(); return { async send(message: string, options: SendOptions = {}): Promise { return AgentExecutor.execute({ agent, input: message, - provider: config.provider, + provider, toolRegistry, maxSteps: config.maxSteps, signal: options.signal, @@ -98,17 +169,23 @@ export function createAgent(config: CreateAgentConfig): SimpleAgent { }; } -function assertCreateAgentConfig(config: CreateAgentConfig): void { - if (!config || !config.provider) { - throw new Error( - "createAgent: 'provider' is required. Example: createAgent({ prompt: '...', provider: myProvider })" - ); - } - if (!config.prompt) { +/** Used when neither `instructions` nor `prompt` is given. */ +const DEFAULT_INSTRUCTIONS = 'You are a helpful assistant.'; + +function resolveInstructions(config: CreateAgentConfig): string { + if (config.instructions !== undefined && config.prompt !== undefined) { throw new Error( - "createAgent: 'prompt' is required. Example: createAgent({ prompt: 'You are a helpful assistant', provider: myProvider })" + "createAgent: both 'instructions' and 'prompt' were given. They are the same option - " + + "use 'instructions' (and drop 'prompt', its alias)." ); } + return config.instructions ?? config.prompt ?? DEFAULT_INSTRUCTIONS; +} + +/** The provider to run with: the given instance, else `model` resolved from env, else the env's choice. */ +function resolveModelSource(config: CreateAgentConfig): LLMProvider { + if (config.provider) return config.provider; + return resolveProviderSpec(config.model ?? modelFromEnv('createAgent'), 'createAgent'); } /** diff --git a/src/providers/providerSpec.ts b/src/providers/providerSpec.ts new file mode 100644 index 000000000..8acb7f596 --- /dev/null +++ b/src/providers/providerSpec.ts @@ -0,0 +1,179 @@ +/** + * Internal "/" resolution shared by `resolveProvider()` and + * `createAgent()` (LOU-D1). Not re-exported from the providers barrel: the + * public surface is `resolveProvider()` and `createAgent({ model })`. The + * `caller` argument only decides the prefix of error messages, so each entry + * point reports errors in its own name. + */ + +import { LLMProvider, LLMProviderConfig, LLMProviderRegistry } from './llm'; + +interface ProviderEntry { + /** Env var holding the credential (or, for Ollama, the base URL). */ + envKey: string; + /** LLMProviderConfig field the env var's value belongs in. */ + configField: 'apiKey' | 'baseURL'; + /** Whether the provider cannot work without the env var (Ollama has a local default). */ + envRequired: boolean; + /** Optional npm peer dependency to install, with the version range the SDK supports. */ + peer: string; + /** Model used when the provider is picked from the environment alone. */ + envDefaultModel: string; +} + +/** Providers in env-detection order (see `modelFromEnv()`). */ +const PROVIDERS: Record = { + openai: { + envKey: 'OPENAI_API_KEY', + configField: 'apiKey', + envRequired: true, + peer: '@ai-sdk/openai@^0.0.42', + envDefaultModel: 'gpt-4o-mini', + }, + anthropic: { + envKey: 'ANTHROPIC_API_KEY', + configField: 'apiKey', + envRequired: true, + peer: '@ai-sdk/anthropic@^0.0.42', + envDefaultModel: 'claude-3-5-sonnet-latest', + }, + openrouter: { + envKey: 'OPENROUTER_API_KEY', + configField: 'apiKey', + envRequired: true, + peer: '@ai-sdk/openai@^0.0.42', + envDefaultModel: 'openai/gpt-4o-mini', + }, + ollama: { + envKey: 'OLLAMA_BASE_URL', + configField: 'baseURL', + envRequired: false, + peer: 'ollama-ai-provider@^1.2.0', + envDefaultModel: 'llama3', + }, +}; + +const PROVIDER_NAMES = Object.keys(PROVIDERS); + +/** Env var that overrides the automatic provider choice, e.g. `LOUSHY_MODEL=anthropic/claude-3-5-sonnet-latest`. */ +const MODEL_ENV_VAR = 'LOUSHY_MODEL'; + +/** Levenshtein edit distance between two short strings. */ +function editDistance(a: string, b: string): number { + let previous = Array.from({ length: b.length + 1 }, (_, j) => j); + for (let i = 1; i <= a.length; i++) { + const current = [i]; + for (let j = 1; j <= b.length; j++) { + const substitution = previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1); + current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, substitution); + } + previous = current; + } + return previous[b.length]; +} + +/** The supported provider prefix closest to `name`, if it is plausibly a typo of one. */ +function closestProvider(name: string): string | undefined { + const lower = name.toLowerCase(); + let best: string | undefined; + let bestDistance = Infinity; + for (const candidate of PROVIDER_NAMES) { + const distance = editDistance(lower, candidate); + if (distance < bestDistance) { + best = candidate; + bestDistance = distance; + } + } + return bestDistance <= Math.max(2, Math.floor(lower.length / 3)) ? best : undefined; +} + +function unknownProviderError(caller: string, providerName: string, spec: string): Error { + const suggestion = closestProvider(providerName); + return new Error( + `${caller}: unrecognized provider '${providerName}' in spec '${spec}'. ` + + `Supported prefixes: ${PROVIDER_NAMES.join(', ')}.` + + (suggestion ? ` Did you mean '${suggestion}/${spec.slice(providerName.length + 1)}'?` : '') + ); +} + +function missingKeyError(caller: string, envKey: string): Error { + return new Error( + `${caller}: ${envKey} is not set. Set it in your environment, ` + + 'or pass a provider instance: createAgent({ provider: ... })' + ); +} + +/** True when `error` says an npm package could not be loaded (CommonJS or ESM resolution). */ +function isModuleNotFound(error: unknown): boolean { + const { code, message } = error as { code?: unknown; message?: unknown }; + return ( + code === 'MODULE_NOT_FOUND' || + code === 'ERR_MODULE_NOT_FOUND' || + (typeof message === 'string' && /cannot find (module|package)/i.test(message)) + ); +} + +function createProvider(caller: string, providerName: string, entry: ProviderEntry, config: LLMProviderConfig): LLMProvider { + try { + return LLMProviderRegistry.create(providerName, config); + } catch (error) { + 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}`, + { cause: error } + ); + } +} + +/** + * Resolve a "/" spec into a configured LLMProvider, with + * errors reported in `caller`'s name. See `resolveProvider()`. + */ +export function resolveProviderSpec(spec: string, caller: string): LLMProvider { + const separatorIndex = spec.indexOf('/'); + if (separatorIndex <= 0 || separatorIndex === spec.length - 1) { + throw new Error( + `${caller}: expected a "/" spec, got '${spec}'. ` + + `Example: 'openai/gpt-4o-mini'. Supported prefixes: ${PROVIDER_NAMES.join(', ')}.` + ); + } + + const providerName = spec.slice(0, separatorIndex).toLowerCase(); + const model = spec.slice(separatorIndex + 1); + + const entry = PROVIDERS[providerName]; + if (!entry) throw unknownProviderError(caller, spec.slice(0, separatorIndex), spec); + + const envValue = process.env[entry.envKey]; + if (!envValue && entry.envRequired) throw missingKeyError(caller, entry.envKey); + + return createProvider(caller, providerName, entry, { + defaultModel: model, + [entry.configField]: envValue, + }); +} + +/** + * Pick a "/" spec from the environment: `LOUSHY_MODEL` if + * set, otherwise the first provider (in the order openai, anthropic, + * openrouter, ollama) whose env var is set. Throws, listing every fix, when + * nothing is configured. + */ +export function modelFromEnv(caller: string): string { + const explicit = process.env[MODEL_ENV_VAR]; + if (explicit) return explicit; + + for (const [name, entry] of Object.entries(PROVIDERS)) { + if (process.env[entry.envKey]) return `${name}/${entry.envDefaultModel}`; + } + + const envKeys = Object.values(PROVIDERS).map((p) => p.envKey); + throw new Error( + `${caller}: no model configured. Do one of the following: ` + + "(1) pass a model: createAgent({ model: 'openai/gpt-4o-mini' }); " + + '(2) pass a provider instance: createAgent({ provider: ... }); ' + + `(3) set ${MODEL_ENV_VAR} (e.g. ${MODEL_ENV_VAR}=openai/gpt-4o-mini); ` + + `(4) set one of ${envKeys.join(', ')} (checked in that order).` + ); +} diff --git a/src/providers/resolveProvider.test.ts b/src/providers/resolveProvider.test.ts index 389f34f30..6789e0909 100644 --- a/src/providers/resolveProvider.test.ts +++ b/src/providers/resolveProvider.test.ts @@ -89,3 +89,63 @@ describe('resolveProvider', () => { expect(() => resolveProvider('openai/')).toThrow(); }); }); + +describe('resolveProvider errors (LOU-D1)', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('names the env var when the API key is missing, without reaching the registry', () => { + vi.stubEnv('OPENAI_API_KEY', ''); + const createSpy = vi.spyOn(LLMProviderRegistry, 'create'); + + expect(() => resolveProvider('openai/gpt-4o')).toThrow( + 'resolveProvider: OPENAI_API_KEY is not set. Set it in your environment, or pass a provider instance: createAgent({ provider: ... })' + ); + expect(createSpy).not.toHaveBeenCalled(); + }); + + it('does not require an env var for ollama (it has a local default)', () => { + vi.stubEnv('OLLAMA_BASE_URL', ''); + vi.spyOn(LLMProviderRegistry, 'create').mockReturnValue(fakeProvider('ollama')); + + expect(() => resolveProvider('ollama/llama3')).not.toThrow(); + }); + + it('lists supported prefixes and suggests the closest match for a typo', () => { + expect(() => resolveProvider('anthopic/claude-3')).toThrow( + "unrecognized provider 'anthopic' in spec 'anthopic/claude-3'. Supported prefixes: openai, anthropic, openrouter, ollama. Did you mean 'anthropic/claude-3'?" + ); + }); + + it('lists supported prefixes without a suggestion when nothing is close', () => { + expect(() => resolveProvider('zzzzzzzzzz/m')).toThrow(/Supported prefixes: openai, anthropic, openrouter, ollama\.$/); + }); + + it('shows an example for a spec without a provider prefix', () => { + expect(() => resolveProvider('gpt-4o')).toThrow(/Example: 'openai\/gpt-4o-mini'/); + }); + + it.each([ + ['openai/gpt-4o', 'OPENAI_API_KEY', '@ai-sdk/openai@^0.0.42'], + ['openrouter/some-model', 'OPENROUTER_API_KEY', '@ai-sdk/openai@^0.0.42'], + ['ollama/llama3', 'OLLAMA_BASE_URL', 'ollama-ai-provider@^1.2.0'], + ])('tells you the exact npm install command when the peer for %s is missing', (spec, envKey, pkg) => { + vi.stubEnv(envKey, 'value'); + vi.spyOn(LLMProviderRegistry, 'create').mockImplementation(() => { + throw Object.assign(new Error('Cannot find module'), { code: 'MODULE_NOT_FOUND' }); + }); + + expect(() => resolveProvider(spec)).toThrow(`Run: npm install ${pkg}`); + }); + + it('rethrows unrelated registry errors unchanged', () => { + vi.stubEnv('OPENAI_API_KEY', 'k'); + vi.spyOn(LLMProviderRegistry, 'create').mockImplementation(() => { + throw new Error('boom'); + }); + + expect(() => resolveProvider('openai/gpt-4o')).toThrow('boom'); + }); +}); diff --git a/src/providers/resolveProvider.ts b/src/providers/resolveProvider.ts index b2a0e7c09..366714e9b 100644 --- a/src/providers/resolveProvider.ts +++ b/src/providers/resolveProvider.ts @@ -8,56 +8,28 @@ * AnthropicProvider, OllamaProvider and OpenRouterProvider register * themselves into - see providers/index.ts) to construct the provider * instance, pre-configured with `defaultModel` set to the parsed model. + * The parsing, env table and error messages live in ./providerSpec (LOU-D1). */ -import { LLMProvider, LLMProviderConfig, LLMProviderRegistry } from './llm'; - -/** - * Which env var holds a given provider's credential/config, and which - * LLMProviderConfig field that value belongs in. Most providers need an - * API key; Ollama instead needs a base URL (it has no API key concept). - */ -const PROVIDER_ENV_TABLE: Record = { - openai: { envKey: 'OPENAI_API_KEY', configField: 'apiKey' }, - anthropic: { envKey: 'ANTHROPIC_API_KEY', configField: 'apiKey' }, - ollama: { envKey: 'OLLAMA_BASE_URL', configField: 'baseURL' }, - openrouter: { envKey: 'OPENROUTER_API_KEY', configField: 'apiKey' }, -}; +import { LLMProvider } from './llm'; +import { resolveProviderSpec } from './providerSpec'; /** * Resolve a "/" spec into a configured LLMProvider - * instance. + * instance, reading the provider's credential from its conventional env var + * (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`, or + * `OLLAMA_BASE_URL`). Most callers can skip this and pass the string to + * `createAgent({ model })` instead. * - * Throws a clear error for a spec that isn't "provider/model" shaped, or - * whose provider prefix isn't recognized - both checked BEFORE any call - * into LLMProviderRegistry, so an unrecognized provider never reaches - * (and never gets a confusing error from) the registry itself. + * Throws an actionable error for a malformed spec, an unrecognized provider + * prefix (listing the supported ones and suggesting the closest), a missing + * API key, or a missing optional peer dependency (with the exact + * `npm install` command). Spec and prefix are checked BEFORE any call into + * LLMProviderRegistry, so a bad prefix never reaches the registry. + * + * @example + * const provider = resolveProvider('openai/gpt-4o-mini'); */ export function resolveProvider(spec: string): LLMProvider { - const separatorIndex = spec.indexOf('/'); - if (separatorIndex <= 0 || separatorIndex === spec.length - 1) { - throw new Error( - `resolveProvider: expected a "/" spec, got '${spec}'` - ); - } - - const providerName = spec.slice(0, separatorIndex); - const model = spec.slice(separatorIndex + 1); - - const envEntry = PROVIDER_ENV_TABLE[providerName.toLowerCase()]; - if (!envEntry) { - throw new Error( - `resolveProvider: unrecognized provider '${providerName}' in spec '${spec}'. ` + - `Known providers: ${Object.keys(PROVIDER_ENV_TABLE).join(', ')}` - ); - } - - const envValue = process.env[envEntry.envKey]; - - const config: LLMProviderConfig = { - defaultModel: model, - [envEntry.configField]: envValue, - }; - - return LLMProviderRegistry.create(providerName.toLowerCase(), config); + return resolveProviderSpec(spec, 'resolveProvider'); }