From adfb9cc12b9bb34c583e4e6b7759b8643c3f48a5 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Fri, 16 Oct 2026 23:31:15 +0300 Subject: [PATCH] feat(providers): google/ model prefix, vendor routes on unknown prefixes, doctor ai@5 hint - `google/` resolves to a new GoogleProvider backed by the optional peer @ai-sdk/google (^1.2.0 / ^3.0.0 / ^4.0.0 for ai 4 / 6 / 7), reading GOOGLE_GENERATIVE_AI_API_KEY. Covered by init, doctor (pairing, key, --ping), the missing-peer hint, and the Worker entry shim. - An unknown prefix now suggests openrouter//, pi// when pi serves the vendor, and fromAiSdk(). - `lousho doctor` fails an installed ai 5 with an upgrade hint; README notes the supported ai majors. Co-Authored-By: Claude Opus 5.5 --- .fallowrc.json | 2 + CHANGELOG.md | 3 + README.md | 1 + api/index.api.md | 26 +++++- api/mcp.api.md | 2 +- api/testing.api.md | 2 +- api/tools.api.md | 2 +- api/worker.api.md | 2 +- docs/errors.md | 7 +- docs/installation.md | 10 +-- docs/providers.md | 46 +++++++--- llms-full.txt | 64 ++++++++++---- llms.txt | 2 +- package-lock.json | 4 + package.json | 4 + src/cli/doctorChecks.ts | 13 +++ src/cli/doctorCore.test.ts | 33 +++++++- src/cli/init.test.ts | 2 +- src/cli/init.ts | 2 +- .../init/__snapshots__/templates.test.ts.snap | 84 +++++++++++++++++++ src/cli/init/options.ts | 2 +- src/cli/init/templates.test.ts | 2 + src/cli/init/templates.ts | 2 +- src/createAgent.test.ts | 4 +- src/createAgent.ts | 2 +- src/deploy/adapters/node-server.ts | 1 + src/deploy/bundle.ts | 11 ++- src/deploy/shims/google.worker.ts | 19 +++++ src/deploy/worker.test.ts | 3 + src/providers/GoogleProvider.test.ts | 71 ++++++++++++++++ src/providers/GoogleProvider.ts | 55 ++++++++++++ src/providers/builtinProviders.ts | 4 +- src/providers/importGraph.test.ts | 1 + src/providers/index.ts | 1 + src/providers/missingPeer.test.ts | 7 ++ src/providers/providerSpec.ts | 53 +++++++++++- src/providers/resolveProvider.test.ts | 63 +++++++++++++- src/spec/specToAgent.ts | 2 +- src/utils/errorCodes.ts | 3 +- typings/optional-peers.d.ts | 12 ++- 40 files changed, 564 insertions(+), 65 deletions(-) create mode 100644 src/deploy/shims/google.worker.ts create mode 100644 src/providers/GoogleProvider.test.ts create mode 100644 src/providers/GoogleProvider.ts diff --git a/.fallowrc.json b/.fallowrc.json index ec6d98582..542e40a3e 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -17,6 +17,7 @@ "src/deploy/shims/node.worker.ts", "src/deploy/shims/quickjs.worker.ts", "src/deploy/shims/ollama.worker.ts", + "src/deploy/shims/google.worker.ts", "src/deploy/shims/mcp.worker.ts", "src/deploy/shims/pi.worker.ts", "src/agentDir/__fixtures__/**/*.{ts,js}", @@ -25,6 +26,7 @@ ], "ignoreDependencies": [ "@ai-sdk/anthropic", + "@ai-sdk/google", "@ai-sdk/openai", "ollama-ai-provider", "ollama-ai-provider-v2", diff --git a/CHANGELOG.md b/CHANGELOG.md index c571344c8..b75358a4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ opt-out. - `lousho eval` without vitest installed now says it runs `*.eval.ts` files through vitest, the directory it searched from, and the install command; [Evals](docs/evals.md) states the requirement up front. - `result.usage.delegated.toolErrors`: the tool calls that failed inside a run's sub-agents (and theirs), which the lead's own `result.toolErrors` does not count. `ToolCallContext.onDelegatedUsage` takes the child's `toolErrors` as an optional second argument. - The session HTTP API (`createRouteHandler()`, the deployed node server, the Worker, `lousho dev`) writes an SSE comment (`: ping`) after 15 seconds without an event, so proxies and client idle timeouts no longer drop a turn whose model or tool is slow; `heartbeatMs` changes the interval (`0` turns it off). See [HTTP API](docs/deployment.md#http-api). +- `google/` model strings (e.g. `createAgent({ model: 'google/gemini-2.5-flash' })`) and a `GoogleProvider`, backed by the optional peer `@ai-sdk/google` (`^1.2.0` with `ai` 4, `^3.0.0` with `ai` 6, `^4.0.0` with `ai` 7) and reading `GOOGLE_GENERATIVE_AI_API_KEY`. `lousho init --provider google`, `lousho doctor` (package pairing, key, `--ping`) and the missing-peer install hint cover it. See [Providers](docs/providers.md). +- An unknown model prefix (`mistral/...`, `xai/...`) now says how to reach the vendor anyway: `openrouter//`, `pi//` when pi serves it, or `fromAiSdk()` with the vendor's AI SDK package. See [Vendors without a prefix](docs/providers.md#vendors-without-a-prefix). +- `lousho doctor` fails an installed `ai` 5 (AI SDK v5 is not supported) with an upgrade hint to `ai` 6 or 7 and the matching `@ai-sdk/*` majors. ### Fixed - MCP tool schemas with OpenAPI's `nullable: true` (any type) now accept `null`; the model's valid `null` was rejected with `ToolArgumentsValidationError`. diff --git a/README.md b/README.md index 86cdc02bf..9da9d0ae7 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ npm install @lousho/build-ai-agent ai@^7.0.0 zod npm install @ai-sdk/openai@^4.0.0 @ai-sdk/anthropic@^4.0.0 ``` +Supported `ai` majors are 4 (`^4.3.19`), 6 and 7; AI SDK v5 is not supported. For Gemini add `@ai-sdk/google@^4.0.0` (`google/`). For Ollama, use `ai@^7.0.0` with `ollama-ai-provider-v2@^4.0.0` and zod 4 (or `ai@^4.3.19` with `ollama-ai-provider@^1.2.0` and zod 3); [Installation](docs/installation.md#provider-packages) lists every pairing. `npx lousho doctor` checks Node, peers and API keys and diff --git a/api/index.api.md b/api/index.api.md index 2f71ae49d..7ee80263d 100644 --- a/api/index.api.md +++ b/api/index.api.md @@ -1899,7 +1899,7 @@ export const ERROR_CODES: { readonly LOUSHO_CONFIG_MISSING_CHECKPOINT_STORE: "Pass createAgent({ store }) with `checkpoints` (e.g. memoryStore() or a SqliteStore), or drop `sessionId`."; readonly LOUSHO_CONFIG_RESOLVER_FAILED: "Fix the createAgent() option function named in the message; its own error is the `cause`."; readonly LOUSHO_PROVIDER_SPEC_INVALID: "Write the model as '/', e.g. 'openai/gpt-4.1-mini'."; - readonly LOUSHO_PROVIDER_UNKNOWN: "Use one of the supported provider prefixes listed in the message."; + readonly LOUSHO_PROVIDER_UNKNOWN: "Use one of the supported provider prefixes listed in the message; reach any other vendor through openrouter//, pi// or fromAiSdk()."; readonly LOUSHO_PROVIDER_MISSING_API_KEY: "Set the environment variable named in the message, or pass a provider instance."; readonly LOUSHO_PROVIDER_REQUEST_FAILED: "Check the provider status, your API key and the request; retryable failures are retried by withRetry()."; readonly LOUSHO_PROVIDER_RATE_LIMITED: "Wait and retry (withRetry() honours Retry-After), or lower the request rate."; @@ -2834,6 +2834,30 @@ export interface GitHubTarget { // @public export type GitHubTriggers = readonly string[] | ((commenter: GitHubCommenter) => boolean | Promise); +// @public +export class GoogleProvider extends AiSdkProvider { + // (undocumented) + protected createModel(modelId: string): Promise; + // (undocumented) + protected readonly fallbackModel = "gemini-2.5-flash"; + protected fileMediaTypes(): readonly string[]; + getModels(): Promise; + // (undocumented) + readonly name = "google"; + // (undocumented) + supportsStreaming(_model: string): boolean; + supportsTools(model: string): boolean; +} + +// @public +export interface GoogleProviderConfig extends AiSdkProviderConfig { + apiKey?: string; + // (undocumented) + baseURL?: string; + // (undocumented) + defaultModel?: string; +} + // @public export interface GroupDecision { approved: boolean; diff --git a/api/mcp.api.md b/api/mcp.api.md index 1b112846b..15d7115e1 100644 --- a/api/mcp.api.md +++ b/api/mcp.api.md @@ -539,7 +539,7 @@ const ERROR_CODES: { readonly LOUSHO_CONFIG_MISSING_CHECKPOINT_STORE: "Pass createAgent({ store }) with `checkpoints` (e.g. memoryStore() or a SqliteStore), or drop `sessionId`."; readonly LOUSHO_CONFIG_RESOLVER_FAILED: "Fix the createAgent() option function named in the message; its own error is the `cause`."; readonly LOUSHO_PROVIDER_SPEC_INVALID: "Write the model as '/', e.g. 'openai/gpt-4.1-mini'."; - readonly LOUSHO_PROVIDER_UNKNOWN: "Use one of the supported provider prefixes listed in the message."; + readonly LOUSHO_PROVIDER_UNKNOWN: "Use one of the supported provider prefixes listed in the message; reach any other vendor through openrouter//, pi// or fromAiSdk()."; readonly LOUSHO_PROVIDER_MISSING_API_KEY: "Set the environment variable named in the message, or pass a provider instance."; readonly LOUSHO_PROVIDER_REQUEST_FAILED: "Check the provider status, your API key and the request; retryable failures are retried by withRetry()."; readonly LOUSHO_PROVIDER_RATE_LIMITED: "Wait and retry (withRetry() honours Retry-After), or lower the request rate."; diff --git a/api/testing.api.md b/api/testing.api.md index fc0a15f30..a21272ad7 100644 --- a/api/testing.api.md +++ b/api/testing.api.md @@ -61,7 +61,7 @@ const ERROR_CODES: { readonly LOUSHO_CONFIG_MISSING_CHECKPOINT_STORE: "Pass createAgent({ store }) with `checkpoints` (e.g. memoryStore() or a SqliteStore), or drop `sessionId`."; readonly LOUSHO_CONFIG_RESOLVER_FAILED: "Fix the createAgent() option function named in the message; its own error is the `cause`."; readonly LOUSHO_PROVIDER_SPEC_INVALID: "Write the model as '/', e.g. 'openai/gpt-4.1-mini'."; - readonly LOUSHO_PROVIDER_UNKNOWN: "Use one of the supported provider prefixes listed in the message."; + readonly LOUSHO_PROVIDER_UNKNOWN: "Use one of the supported provider prefixes listed in the message; reach any other vendor through openrouter//, pi// or fromAiSdk()."; readonly LOUSHO_PROVIDER_MISSING_API_KEY: "Set the environment variable named in the message, or pass a provider instance."; readonly LOUSHO_PROVIDER_REQUEST_FAILED: "Check the provider status, your API key and the request; retryable failures are retried by withRetry()."; readonly LOUSHO_PROVIDER_RATE_LIMITED: "Wait and retry (withRetry() honours Retry-After), or lower the request rate."; diff --git a/api/tools.api.md b/api/tools.api.md index 493c93fdc..4a49a8bf6 100644 --- a/api/tools.api.md +++ b/api/tools.api.md @@ -634,7 +634,7 @@ const ERROR_CODES: { readonly LOUSHO_CONFIG_MISSING_CHECKPOINT_STORE: "Pass createAgent({ store }) with `checkpoints` (e.g. memoryStore() or a SqliteStore), or drop `sessionId`."; readonly LOUSHO_CONFIG_RESOLVER_FAILED: "Fix the createAgent() option function named in the message; its own error is the `cause`."; readonly LOUSHO_PROVIDER_SPEC_INVALID: "Write the model as '/', e.g. 'openai/gpt-4.1-mini'."; - readonly LOUSHO_PROVIDER_UNKNOWN: "Use one of the supported provider prefixes listed in the message."; + readonly LOUSHO_PROVIDER_UNKNOWN: "Use one of the supported provider prefixes listed in the message; reach any other vendor through openrouter//, pi// or fromAiSdk()."; readonly LOUSHO_PROVIDER_MISSING_API_KEY: "Set the environment variable named in the message, or pass a provider instance."; readonly LOUSHO_PROVIDER_REQUEST_FAILED: "Check the provider status, your API key and the request; retryable failures are retried by withRetry()."; readonly LOUSHO_PROVIDER_RATE_LIMITED: "Wait and retry (withRetry() honours Retry-After), or lower the request rate."; diff --git a/api/worker.api.md b/api/worker.api.md index d3a0ca56d..a9f663846 100644 --- a/api/worker.api.md +++ b/api/worker.api.md @@ -1274,7 +1274,7 @@ const ERROR_CODES: { readonly LOUSHO_CONFIG_MISSING_CHECKPOINT_STORE: "Pass createAgent({ store }) with `checkpoints` (e.g. memoryStore() or a SqliteStore), or drop `sessionId`."; readonly LOUSHO_CONFIG_RESOLVER_FAILED: "Fix the createAgent() option function named in the message; its own error is the `cause`."; readonly LOUSHO_PROVIDER_SPEC_INVALID: "Write the model as '/', e.g. 'openai/gpt-4.1-mini'."; - readonly LOUSHO_PROVIDER_UNKNOWN: "Use one of the supported provider prefixes listed in the message."; + readonly LOUSHO_PROVIDER_UNKNOWN: "Use one of the supported provider prefixes listed in the message; reach any other vendor through openrouter//, pi// or fromAiSdk()."; readonly LOUSHO_PROVIDER_MISSING_API_KEY: "Set the environment variable named in the message, or pass a provider instance."; readonly LOUSHO_PROVIDER_REQUEST_FAILED: "Check the provider status, your API key and the request; retryable failures are retried by withRetry()."; readonly LOUSHO_PROVIDER_RATE_LIMITED: "Wait and retry (withRetry() honours Retry-After), or lower the request rate."; diff --git a/docs/errors.md b/docs/errors.md index fd5bb360e..dc733fea6 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -198,8 +198,11 @@ names the option and `error.cause` is what the function threw. **Means:** the provider prefix of a model string is not one the SDK knows. The message lists the supported prefixes and suggests the closest one. -**Fix:** use a supported prefix (`openai`, `anthropic`, `openrouter`, `ollama`), -or pass your own `provider` instance. +**Fix:** use a supported prefix (`openai`, `anthropic`, `google`, `openrouter`, +`ollama`, `pi`, `mock`), or pass your own `provider` instance. For a vendor with no +prefix, the message lists the other routes: `openrouter//`, +`pi//` when pi serves the vendor, or the vendor's AI SDK package +through `fromAiSdk()` (see [Providers](./providers.md#vendors-without-a-prefix)). **Example:** `createAgent({ model: 'opnai/gpt-4o' })` says "Did you mean 'openai/gpt-4o'?". diff --git a/docs/installation.md b/docs/installation.md index b61e941d5..c19aaa184 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -33,11 +33,11 @@ provider packages declare zod 3 as a peer, so pair zod 4 with `ai` 6 or 7. Each real LLM provider is backed by an optional peer dependency, in the major that pairs with your `ai` major. Install the pair from one row: -| `ai` | OpenAI, OpenRouter: `@ai-sdk/openai` | Anthropic: `@ai-sdk/anthropic` | Ollama | -| --------- | ------------------------------------ | ------------------------------ | -------------------------------------- | -| `^4.3.19` | `^0.0.42` (or `^1.0.0`) | `^0.0.42` (or `^1.0.0`) | `ollama-ai-provider@^1.2.0` | -| `^6.0.0` | `^3.0.0` | `^3.0.0` | `ollama-ai-provider-v2@^3.0.0` (zod 4) | -| `^7.0.0` | `^4.0.0` | `^4.0.0` | `ollama-ai-provider-v2@^4.0.0` (zod 4) | +| `ai` | OpenAI, OpenRouter: `@ai-sdk/openai` | Anthropic: `@ai-sdk/anthropic` | Google: `@ai-sdk/google` | Ollama | +| --------- | ------------------------------------ | ------------------------------ | ------------------------ | -------------------------------------- | +| `^4.3.19` | `^0.0.42` (or `^1.0.0`) | `^0.0.42` (or `^1.0.0`) | `^1.2.0` | `ollama-ai-provider@^1.2.0` | +| `^6.0.0` | `^3.0.0` | `^3.0.0` | `^3.0.0` | `ollama-ai-provider-v2@^3.0.0` (zod 4) | +| `^7.0.0` | `^4.0.0` | `^4.0.0` | `^4.0.0` | `ollama-ai-provider-v2@^4.0.0` (zod 4) | For example, on the current `ai` major: diff --git a/docs/providers.md b/docs/providers.md index 4c926a88a..fe6a06fc7 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -1,7 +1,7 @@ # Providers An `LLMProvider` is what an agent generates with. The SDK ships OpenAI, -Anthropic, OpenRouter, Ollama and `pi` providers (each backed by an optional +Anthropic, Google (Gemini), OpenRouter, Ollama and `pi` providers (each backed by an optional peer package, see [Installation](./installation.md#provider-packages)) and a deterministic mock for tests. Name one with a `provider/model` string, or pass a provider instance. The `vendor/` model prefix chooses the provider; with @@ -14,6 +14,7 @@ import { resolveProvider, LLMProviderRegistry } from '@lousho/build-ai-agent'; // The convenient way — reads the credential from the environment const openai = resolveProvider('openai/gpt-4o-mini'); // OPENAI_API_KEY const anthropic = resolveProvider('anthropic/claude-sonnet-5-5'); // ANTHROPIC_API_KEY +const google = resolveProvider('google/gemini-2.5-flash'); // GOOGLE_GENERATIVE_AI_API_KEY const openrouter = resolveProvider('openrouter/openai/gpt-4o-mini'); // OPENROUTER_API_KEY const ollama = resolveProvider('ollama/llama3.1'); // OLLAMA_BASE_URL // pi runs one catalog through `pi//` (nested slashes ok): @@ -26,6 +27,26 @@ const custom = LLMProviderRegistry.create('openai', { }); ``` +The `google/` prefix needs the optional `@ai-sdk/google` package (`^1.2.0` with +`ai` 4, `^3.0.0` with `ai` 6, `^4.0.0` with `ai` 7) and reads +`GOOGLE_GENERATIVE_AI_API_KEY`, the variable `@ai-sdk/google` itself uses. + +### Vendors without a prefix + +A prefix the SDK does not know fails with +[`LOUSHO_PROVIDER_UNKNOWN`](./errors.md#lousho_provider_unknown). A near-miss +(`anthopic/...`) gets a "did you mean"; any other vendor gets the ways to reach +it anyway: + +- `openrouter//`: OpenRouter serves most vendors with one + `OPENROUTER_API_KEY` (e.g. `openrouter/mistralai/mistral-large`; OpenRouter's + own vendor names can differ from the AI SDK's). +- `pi//`, when pi serves that vendor (`xai`, `groq`, `mistral`, + `deepseek`, `amazon-bedrock`, `azure`, ...); see [the `pi` provider](#the-pi-provider). +- the vendor's own AI SDK package with + [`fromAiSdk()`](#any-ai-sdk-model-fromaisdk), e.g. + `createAgent({ provider: fromAiSdk(mistral('mistral-large-latest')) })`. + ## Choosing the provider in `createAgent()` `createAgent()` takes exactly one of: @@ -148,6 +169,7 @@ console.log(textOf(messages[0]), '->', result.text); | -------- | ------ | ---------- | | `openai` | none | `application/pdf` | | `anthropic` | none | `application/pdf`, `text/plain` | + | `google` | none | `application/pdf`, `text/plain` | | `openrouter` | none | `application/pdf` | | `ollama` | none | none | | `pi` | none (always a text note) | none (always a text note; pi has no file part) | @@ -201,7 +223,7 @@ console.log(result.text); | ------ | ----------- | | `resolveProvider('p/model')` | Build a real provider from environment credentials. | | `LLMProviderRegistry` | Registry of provider factories: `create(type, config)`, `register(type, factory)`, `has(type)`. | -| `OpenAIProvider`, `AnthropicProvider`, `OllamaProvider`, `OpenRouterProvider`, `PiProvider` | The provider classes. See [examples/openrouter](../examples/openrouter) for OpenRouter-specific features. `PiProvider` is the `pi/...` adapter below. | +| `OpenAIProvider`, `AnthropicProvider`, `GoogleProvider`, `OllamaProvider`, `OpenRouterProvider`, `PiProvider` | The provider classes. See [examples/openrouter](../examples/openrouter) for OpenRouter-specific features. `PiProvider` is the `pi/...` adapter below. | | `createMockProvider()`, `MockLLMProvider` | Canned responses for demos; simulates a tool call when the message names a tool. | | `mockModel(script)` | Scripted, asserting provider for tests (from `/testing`); see [Testing](./testing.md). | | `withRetry()`, `withFallback()` | Retry transient failures with backoff and fall back to the next provider; see [Provider retries and fallback](./configuration.md#provider-retries-and-fallback). | @@ -218,7 +240,7 @@ own credentials. A factory can take its own config type, an interface that extends `LLMProviderConfig` (for example `BedrockConfig` with a `region`): `register('bedrock', (config: BedrockConfig) => new BedrockProvider(config))`, then `LLMProviderRegistry.create('bedrock', { region: 'eu-west-1' })`. -A built-in name (`'openai'`, `'anthropic'`, `'openrouter'`, `'ollama'`, `'pi'`, +A built-in name (`'openai'`, `'anthropic'`, `'google'`, `'openrouter'`, `'ollama'`, `'pi'`, `'mock'`) takes that provider's own config type (`BuiltinProviderConfigs`), so `create('openai', { baseUrl: '...' })` is a type error; if you registered your own factory under a built-in name, pass its config type: @@ -491,11 +513,11 @@ on each major today: The peer ranges accept all three majors. Pair each with its provider packages: -| `ai` | `@ai-sdk/openai`, `@ai-sdk/anthropic` | Ollama package | -| ---- | ------------------------------------- | -------------- | -| `^4.3.19` | `^0.0.42` or `^1.0.0` | `ollama-ai-provider@^1.2.0` | -| `^6.0.0` | `^3.0.0` | `ollama-ai-provider-v2@^3.0.0` | -| `^7.0.0` | `^4.0.0` | `ollama-ai-provider-v2@^4.0.0` | +| `ai` | `@ai-sdk/openai`, `@ai-sdk/anthropic` | `@ai-sdk/google` | Ollama package | +| ---- | ------------------------------------- | ---------------- | -------------- | +| `^4.3.19` | `^0.0.42` or `^1.0.0` | `^1.0.0` | `ollama-ai-provider@^1.2.0` | +| `^6.0.0` | `^3.0.0` | `^3.0.0` | `ollama-ai-provider-v2@^3.0.0` | +| `^7.0.0` | `^4.0.0` | `^4.0.0` | `ollama-ai-provider-v2@^4.0.0` | The install hint of a missing provider package and `lousho doctor` name the version for the `ai` you have installed, and `lousho doctor` flags a mismatched @@ -506,7 +528,7 @@ a mismatch before the first call. `OllamaProvider` loads `ollama-ai-provider` on `ai` 4 and `ollama-ai-provider-v2` on `ai` 6/7; the v2 package peers on zod 4, which the SDK accepts, so install zod 4 with it (zod 3 projects use Ollama with `ai` 4). `lousho init` scaffolds `ai@^7.0.0` with `@ai-sdk/*@^4.0.0` for -OpenAI, Anthropic and OpenRouter, and `ai@^7.0.0` with `ollama-ai-provider-v2@^4.0.0` and zod 4 for Ollama. +OpenAI, Anthropic, Google and OpenRouter, and `ai@^7.0.0` with `ollama-ai-provider-v2@^4.0.0` and zod 4 for Ollama. CI installs and tests `ai` 4, 6 and 7, each with zod 3 and zod 4 (six pairings; `ai` 4 with zod 4 is the one pairing the provider packages do not declare), with @@ -553,11 +575,13 @@ or `StreamResult`: On v6/v7 the model must come from a provider package for that major (the table above); the `0.0.x`/`1.x` packages produce models `ai` v7 rejects. Image parts are sent as `file` parts with an image media type (the shape `ai` v6/v7 -takes for images, so there is no deprecation warning). `ai` v5 is not supported. +takes for images, so there is no deprecation warning). `ai` v5 is not supported: +`lousho doctor` fails an installed `ai` 5 with an upgrade hint (move to `ai` 6 +or 7 and the matching `@ai-sdk/*` major). ## Where each provider runs All providers run on Node. The `cloudflare-worker` deploy target supports `mock`, `openai`, `anthropic` and `openrouter` (key from the -`OPENROUTER_API_KEY` binding); `ollama` and `pi` need the `node-server` or +`OPENROUTER_API_KEY` binding); `google`, `ollama` and `pi` need the `node-server` or `docker` target (see [Cloudflare Workers](./cloudflare-workers.md)). diff --git a/llms-full.txt b/llms-full.txt index 5335153ef..adc21ae44 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -54,6 +54,7 @@ npm install @lousho/build-ai-agent ai@^7.0.0 zod npm install @ai-sdk/openai@^4.0.0 @ai-sdk/anthropic@^4.0.0 ``` +Supported `ai` majors are 4 (`^4.3.19`), 6 and 7; AI SDK v5 is not supported. For Gemini add `@ai-sdk/google@^4.0.0` (`google/`). For Ollama, use `ai@^7.0.0` with `ollama-ai-provider-v2@^4.0.0` and zod 4 (or `ai@^4.3.19` with `ollama-ai-provider@^1.2.0` and zod 3); [Installation](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/installation.md#provider-packages) lists every pairing. `npx lousho doctor` checks Node, peers and API keys and @@ -392,11 +393,11 @@ provider packages declare zod 3 as a peer, so pair zod 4 with `ai` 6 or 7. Each real LLM provider is backed by an optional peer dependency, in the major that pairs with your `ai` major. Install the pair from one row: -| `ai` | OpenAI, OpenRouter: `@ai-sdk/openai` | Anthropic: `@ai-sdk/anthropic` | Ollama | -| --------- | ------------------------------------ | ------------------------------ | -------------------------------------- | -| `^4.3.19` | `^0.0.42` (or `^1.0.0`) | `^0.0.42` (or `^1.0.0`) | `ollama-ai-provider@^1.2.0` | -| `^6.0.0` | `^3.0.0` | `^3.0.0` | `ollama-ai-provider-v2@^3.0.0` (zod 4) | -| `^7.0.0` | `^4.0.0` | `^4.0.0` | `ollama-ai-provider-v2@^4.0.0` (zod 4) | +| `ai` | OpenAI, OpenRouter: `@ai-sdk/openai` | Anthropic: `@ai-sdk/anthropic` | Google: `@ai-sdk/google` | Ollama | +| --------- | ------------------------------------ | ------------------------------ | ------------------------ | -------------------------------------- | +| `^4.3.19` | `^0.0.42` (or `^1.0.0`) | `^0.0.42` (or `^1.0.0`) | `^1.2.0` | `ollama-ai-provider@^1.2.0` | +| `^6.0.0` | `^3.0.0` | `^3.0.0` | `^3.0.0` | `ollama-ai-provider-v2@^3.0.0` (zod 4) | +| `^7.0.0` | `^4.0.0` | `^4.0.0` | `^4.0.0` | `ollama-ai-provider-v2@^4.0.0` (zod 4) | For example, on the current `ai` major: @@ -8191,8 +8192,11 @@ names the option and `error.cause` is what the function threw. **Means:** the provider prefix of a model string is not one the SDK knows. The message lists the supported prefixes and suggests the closest one. -**Fix:** use a supported prefix (`openai`, `anthropic`, `openrouter`, `ollama`), -or pass your own `provider` instance. +**Fix:** use a supported prefix (`openai`, `anthropic`, `google`, `openrouter`, +`ollama`, `pi`, `mock`), or pass your own `provider` instance. For a vendor with no +prefix, the message lists the other routes: `openrouter//`, +`pi//` when pi serves the vendor, or the vendor's AI SDK package +through `fromAiSdk()` (see [Providers](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/providers.md#vendors-without-a-prefix)). **Example:** `createAgent({ model: 'opnai/gpt-4o' })` says "Did you mean 'openai/gpt-4o'?". @@ -14244,7 +14248,7 @@ probability, choice with a distribution, rubric score — as one call, roughly Source: docs/providers.md An `LLMProvider` is what an agent generates with. The SDK ships OpenAI, -Anthropic, OpenRouter, Ollama and `pi` providers (each backed by an optional +Anthropic, Google (Gemini), OpenRouter, Ollama and `pi` providers (each backed by an optional peer package, see [Installation](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/installation.md#provider-packages)) and a deterministic mock for tests. Name one with a `provider/model` string, or pass a provider instance. The `vendor/` model prefix chooses the provider; with @@ -14257,6 +14261,7 @@ import { resolveProvider, LLMProviderRegistry } from '@lousho/build-ai-agent'; // The convenient way — reads the credential from the environment const openai = resolveProvider('openai/gpt-4o-mini'); // OPENAI_API_KEY const anthropic = resolveProvider('anthropic/claude-sonnet-5-5'); // ANTHROPIC_API_KEY +const google = resolveProvider('google/gemini-2.5-flash'); // GOOGLE_GENERATIVE_AI_API_KEY const openrouter = resolveProvider('openrouter/openai/gpt-4o-mini'); // OPENROUTER_API_KEY const ollama = resolveProvider('ollama/llama3.1'); // OLLAMA_BASE_URL // pi runs one catalog through `pi//` (nested slashes ok): @@ -14269,6 +14274,26 @@ const custom = LLMProviderRegistry.create('openai', { }); ``` +The `google/` prefix needs the optional `@ai-sdk/google` package (`^1.2.0` with +`ai` 4, `^3.0.0` with `ai` 6, `^4.0.0` with `ai` 7) and reads +`GOOGLE_GENERATIVE_AI_API_KEY`, the variable `@ai-sdk/google` itself uses. + +### Vendors without a prefix + +A prefix the SDK does not know fails with +[`LOUSHO_PROVIDER_UNKNOWN`](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/errors.md#lousho_provider_unknown). A near-miss +(`anthopic/...`) gets a "did you mean"; any other vendor gets the ways to reach +it anyway: + +- `openrouter//`: OpenRouter serves most vendors with one + `OPENROUTER_API_KEY` (e.g. `openrouter/mistralai/mistral-large`; OpenRouter's + own vendor names can differ from the AI SDK's). +- `pi//`, when pi serves that vendor (`xai`, `groq`, `mistral`, + `deepseek`, `amazon-bedrock`, `azure`, ...); see [the `pi` provider](#the-pi-provider). +- the vendor's own AI SDK package with + [`fromAiSdk()`](#any-ai-sdk-model-fromaisdk), e.g. + `createAgent({ provider: fromAiSdk(mistral('mistral-large-latest')) })`. + ## Choosing the provider in `createAgent()` `createAgent()` takes exactly one of: @@ -14391,6 +14416,7 @@ console.log(textOf(messages[0]), '->', result.text); | -------- | ------ | ---------- | | `openai` | none | `application/pdf` | | `anthropic` | none | `application/pdf`, `text/plain` | + | `google` | none | `application/pdf`, `text/plain` | | `openrouter` | none | `application/pdf` | | `ollama` | none | none | | `pi` | none (always a text note) | none (always a text note; pi has no file part) | @@ -14444,7 +14470,7 @@ console.log(result.text); | ------ | ----------- | | `resolveProvider('p/model')` | Build a real provider from environment credentials. | | `LLMProviderRegistry` | Registry of provider factories: `create(type, config)`, `register(type, factory)`, `has(type)`. | -| `OpenAIProvider`, `AnthropicProvider`, `OllamaProvider`, `OpenRouterProvider`, `PiProvider` | The provider classes. See [examples/openrouter](https://github.com/LinuxDevil/agent-sdk/blob/main/examples/openrouter) for OpenRouter-specific features. `PiProvider` is the `pi/...` adapter below. | +| `OpenAIProvider`, `AnthropicProvider`, `GoogleProvider`, `OllamaProvider`, `OpenRouterProvider`, `PiProvider` | The provider classes. See [examples/openrouter](https://github.com/LinuxDevil/agent-sdk/blob/main/examples/openrouter) for OpenRouter-specific features. `PiProvider` is the `pi/...` adapter below. | | `createMockProvider()`, `MockLLMProvider` | Canned responses for demos; simulates a tool call when the message names a tool. | | `mockModel(script)` | Scripted, asserting provider for tests (from `/testing`); see [Testing](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/testing.md). | | `withRetry()`, `withFallback()` | Retry transient failures with backoff and fall back to the next provider; see [Provider retries and fallback](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/configuration.md#provider-retries-and-fallback). | @@ -14461,7 +14487,7 @@ own credentials. A factory can take its own config type, an interface that extends `LLMProviderConfig` (for example `BedrockConfig` with a `region`): `register('bedrock', (config: BedrockConfig) => new BedrockProvider(config))`, then `LLMProviderRegistry.create('bedrock', { region: 'eu-west-1' })`. -A built-in name (`'openai'`, `'anthropic'`, `'openrouter'`, `'ollama'`, `'pi'`, +A built-in name (`'openai'`, `'anthropic'`, `'google'`, `'openrouter'`, `'ollama'`, `'pi'`, `'mock'`) takes that provider's own config type (`BuiltinProviderConfigs`), so `create('openai', { baseUrl: '...' })` is a type error; if you registered your own factory under a built-in name, pass its config type: @@ -14734,11 +14760,11 @@ on each major today: The peer ranges accept all three majors. Pair each with its provider packages: -| `ai` | `@ai-sdk/openai`, `@ai-sdk/anthropic` | Ollama package | -| ---- | ------------------------------------- | -------------- | -| `^4.3.19` | `^0.0.42` or `^1.0.0` | `ollama-ai-provider@^1.2.0` | -| `^6.0.0` | `^3.0.0` | `ollama-ai-provider-v2@^3.0.0` | -| `^7.0.0` | `^4.0.0` | `ollama-ai-provider-v2@^4.0.0` | +| `ai` | `@ai-sdk/openai`, `@ai-sdk/anthropic` | `@ai-sdk/google` | Ollama package | +| ---- | ------------------------------------- | ---------------- | -------------- | +| `^4.3.19` | `^0.0.42` or `^1.0.0` | `^1.0.0` | `ollama-ai-provider@^1.2.0` | +| `^6.0.0` | `^3.0.0` | `^3.0.0` | `ollama-ai-provider-v2@^3.0.0` | +| `^7.0.0` | `^4.0.0` | `^4.0.0` | `ollama-ai-provider-v2@^4.0.0` | The install hint of a missing provider package and `lousho doctor` name the version for the `ai` you have installed, and `lousho doctor` flags a mismatched @@ -14749,7 +14775,7 @@ a mismatch before the first call. `OllamaProvider` loads `ollama-ai-provider` on `ai` 4 and `ollama-ai-provider-v2` on `ai` 6/7; the v2 package peers on zod 4, which the SDK accepts, so install zod 4 with it (zod 3 projects use Ollama with `ai` 4). `lousho init` scaffolds `ai@^7.0.0` with `@ai-sdk/*@^4.0.0` for -OpenAI, Anthropic and OpenRouter, and `ai@^7.0.0` with `ollama-ai-provider-v2@^4.0.0` and zod 4 for Ollama. +OpenAI, Anthropic, Google and OpenRouter, and `ai@^7.0.0` with `ollama-ai-provider-v2@^4.0.0` and zod 4 for Ollama. CI installs and tests `ai` 4, 6 and 7, each with zod 3 and zod 4 (six pairings; `ai` 4 with zod 4 is the one pairing the provider packages do not declare), with @@ -14796,13 +14822,15 @@ or `StreamResult`: On v6/v7 the model must come from a provider package for that major (the table above); the `0.0.x`/`1.x` packages produce models `ai` v7 rejects. Image parts are sent as `file` parts with an image media type (the shape `ai` v6/v7 -takes for images, so there is no deprecation warning). `ai` v5 is not supported. +takes for images, so there is no deprecation warning). `ai` v5 is not supported: +`lousho doctor` fails an installed `ai` 5 with an upgrade hint (move to `ai` 6 +or 7 and the matching `@ai-sdk/*` major). ## Where each provider runs All providers run on Node. The `cloudflare-worker` deploy target supports `mock`, `openai`, `anthropic` and `openrouter` (key from the -`OPENROUTER_API_KEY` binding); `ollama` and `pi` need the `node-server` or +`OPENROUTER_API_KEY` binding); `google`, `ollama` and `pi` need the `node-server` or `docker` target (see [Cloudflare Workers](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/cloudflare-workers.md)). # Queued input and steering diff --git a/llms.txt b/llms.txt index b58ef932b..a21e2232b 100644 --- a/llms.txt +++ b/llms.txt @@ -45,7 +45,7 @@ - [Tracing and observability](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/observability.md): `AgentExecutor.execute()` and `FlowExecutor.execute()` emit spans through a `TraceExporter`. Span names and attributes follow the OpenTelemetry GenAI semantic conventions, so GenAI-aware observability backends can recognise them without custom mapping. - [OpenAPI tools](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/openapi-tools.md): Most services publish an OpenAPI document and no MCP server. `openApiTools()` turns that document into one typed tool per API operation, so an agent can call the API without a hand-written `defineTool()` per endpoint and without the raw `http` tool. Operations that change data ask for approval by default. - [Permission modes](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/permission-modes.md): A permission mode puts an agent in a named state instead of writing rules: look but do not touch (`plan`), edit files without asking (`acceptEdits`), or never pause (`dontAsk`). Switch it in the middle of a session, the way you would work with a coding agent: plan first, then let it edit. -- [Providers](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/providers.md): An `LLMProvider` is what an agent generates with. The SDK ships OpenAI, Anthropic, OpenRouter, Ollama and `pi` providers (each backed by an optional peer package, see Installation) and a deterministic mock for tests. Name one with a `provider/model` string, or pass a provider instance. +- [Providers](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/providers.md): An `LLMProvider` is what an agent generates with. The SDK ships OpenAI, Anthropic, Google (Gemini), OpenRouter, Ollama and `pi` providers (each backed by an optional peer package, see Installation) and a deterministic mock for tests. Name one with a `provider/model` string, or pass a provider instance. - [Queued input and steering](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/queue-and-steer.md): Add input to a run that is still going, or redirect it. Both are methods of the run handle that `agent.stream()` returns; see Streaming. - [React](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/react.md): `useLoushoAgent()` puts an agent behind a chat UI. It runs a turn, reads the typed event stream as it arrives and turns it into render-ready state: messages with their text and tool calls, a `status` for the composer, and the tool call waiting for approval, if any. - [Reasoning](https://github.com/LinuxDevil/agent-sdk/blob/main/docs/reasoning.md): Reasoning models think before they answer. The `reasoning` option sets how much, and the run reports what the model thought separately from its answer: as `reasoning.*` events in `agent.stream()`, and as `result.reasoning`. The `vendor/` model prefix chooses the provider; with OpenRouter use `openrouter//` (e.g. `openrouter/anthropic/claude-sonnet-4.5`). diff --git a/package-lock.json b/package-lock.json index d02809038..41825edaa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,6 +62,7 @@ }, "peerDependencies": { "@ai-sdk/anthropic": "^0.0.42 || ^1.0.0 || ^3.0.0 || ^4.0.0", + "@ai-sdk/google": "^1.0.0 || ^3.0.0 || ^4.0.0", "@ai-sdk/openai": "^0.0.42 || ^1.0.0 || ^3.0.0 || ^4.0.0", "@earendil-works/pi-ai": "^1.0.3", "@earendil-works/pi-coding-agent": "^1.0.2", @@ -82,6 +83,9 @@ "@ai-sdk/anthropic": { "optional": true }, + "@ai-sdk/google": { + "optional": true + }, "@ai-sdk/openai": { "optional": true }, diff --git a/package.json b/package.json index d06642162..1d28f0ac1 100644 --- a/package.json +++ b/package.json @@ -392,6 +392,7 @@ }, "peerDependencies": { "@ai-sdk/anthropic": "^0.0.42 || ^1.0.0 || ^3.0.0 || ^4.0.0", + "@ai-sdk/google": "^1.0.0 || ^3.0.0 || ^4.0.0", "@ai-sdk/openai": "^0.0.42 || ^1.0.0 || ^3.0.0 || ^4.0.0", "@earendil-works/pi-ai": "^1.0.3", "@earendil-works/pi-coding-agent": "^1.0.2", @@ -412,6 +413,9 @@ "@ai-sdk/anthropic": { "optional": true }, + "@ai-sdk/google": { + "optional": true + }, "@ai-sdk/openai": { "optional": true }, diff --git a/src/cli/doctorChecks.ts b/src/cli/doctorChecks.ts index bd34bbf5f..fc42782c3 100644 --- a/src/cli/doctorChecks.ts +++ b/src/cli/doctorChecks.ts @@ -66,6 +66,15 @@ function checkRequiredPeer(env: DoctorEnvironment, name: string): DoctorCheck { return { ...base, status: 'fail', finding: 'not installed', fix: installCommand(env, name) }; } const range = env.sdk.peerDependencies?.[name]; + if (name === 'ai' && Number.parseInt(version, 10) === 5) { + // AI SDK v5 sits between two supported majors, so say which way to go rather than only print the range. + return { + ...base, + status: 'fail', + finding: `${version} installed, but AI SDK v5 is not supported (the SDK runs on ai 4.3.19+, 6 or 7)`, + fix: `${installCommand(env, name)} (upgrade to ai 6 or 7, and move each @ai-sdk/* provider package to the matching major: 3.x for ai 6, 4.x for ai 7)`, + }; + } if (range && !satisfiesRange(version, range)) { return { ...base, @@ -288,6 +297,10 @@ function pingTargets(env: DoctorEnvironment): PingTarget[] { const headers = { 'x-api-key': vars.ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01' }; targets.push({ provider: 'anthropic', envKey: 'ANTHROPIC_API_KEY', base: 'https://api.anthropic.com/v1', headers }); } + if (vars.GOOGLE_GENERATIVE_AI_API_KEY) { + const headers = { 'x-goog-api-key': vars.GOOGLE_GENERATIVE_AI_API_KEY }; + targets.push({ provider: 'google', envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', base: 'https://generativelanguage.googleapis.com/v1beta', headers }); + } if (vars.OPENROUTER_API_KEY) { targets.push({ provider: 'openrouter', envKey: 'OPENROUTER_API_KEY', base: 'https://openrouter.ai/api/v1', headers: bearer(vars.OPENROUTER_API_KEY) }); } diff --git a/src/cli/doctorCore.test.ts b/src/cli/doctorCore.test.ts index bbffb66f8..2031cd279 100644 --- a/src/cli/doctorCore.test.ts +++ b/src/cli/doctorCore.test.ts @@ -172,9 +172,26 @@ describe('ai / provider package pairing', () => { expect(result.fix).toMatch(/npm install ollama-ai-provider-v2@\^4\.0\.0 \(note: .*zod 4/); }); + it('ai 5 fails with an upgrade hint that names the supported majors and the provider packages to move', async () => { + const result = await check(envWith({ ai: '5.0.60' }), 'peer.ai'); + expect(result).toMatchObject({ + status: 'fail', + finding: '5.0.60 installed, but AI SDK v5 is not supported (the SDK runs on ai 4.3.19+, 6 or 7)', + }); + expect(result.fix).toBe( + 'npm install ai@^7.0.0 (upgrade to ai 6 or 7, and move each @ai-sdk/* provider package to the matching major: 3.x for ai 6, 4.x for ai 7)' + ); + }); + + it('checks @ai-sdk/google against the installed ai major', async () => { + expect((await check(envWith({ '@ai-sdk/google': '1.2.22' }), 'optional-peer.@ai-sdk/google')).status).toBe('ok'); + const result = await check(envWith({ ai: '7.0.0', '@ai-sdk/google': '1.2.22' }), 'optional-peer.@ai-sdk/google'); + expect(result).toMatchObject({ status: 'warn', finding: '1.2.22 installed, but ai 7 needs ^4.0.0', fix: 'npm install @ai-sdk/google@^4.0.0' }); + }); + it('a missing or unsupported ai gets one installable range, and provider hints for ai 7', async () => { for (const ai of [null, '5.0.0']) { - expect((await check(envWith({ ai }), 'peer.ai')).fix).toBe('npm install ai@^7.0.0'); + expect((await check(envWith({ ai }), 'peer.ai')).fix).toMatch(/^npm install ai@\^7\.0\.0($| )/); expect((await check(envWith({ ai, '@ai-sdk/openai': null }), 'optional-peers.unused')).fix).toContain( 'npm install @ai-sdk/openai@^4.0.0' ); @@ -464,6 +481,14 @@ describe('OpenAI base URL and --ping', () => { expect(result?.finding).toContain('key was rejected'); }); + it('--ping GETs the Gemini models endpoint with GOOGLE_GENERATIVE_AI_API_KEY as a header, never in the URL', async () => { + const fetch = vi.fn(async (_url: string, _init: { signal: AbortSignal; headers?: Record }) => ({ ok: true, status: 200 })); + const report = await runDoctor(makeEnv({ env: { GOOGLE_GENERATIVE_AI_API_KEY: SECRET }, fetch }), { ping: true }); + expect(fetch.mock.calls.map(([url]) => url)).toEqual(['https://generativelanguage.googleapis.com/v1beta/models']); + expect(fetch.mock.calls[0][1].headers).toEqual({ 'x-goog-api-key': SECRET }); + expect(report.checks.find((c) => c.id === 'ping.google')).toMatchObject({ status: 'ok', finding: 'reachable (HTTP 200)' }); + }); + it('--ping with no provider configured says so', async () => { const report = await runDoctor(makeEnv(), { ping: true }); expect(report.checks.find((c) => c.id === 'ping')).toMatchObject({ status: 'ok', finding: expect.stringContaining('nothing to ping') }); @@ -519,18 +544,20 @@ describe('report, rendering and exit codes', () => { [ ok ] Optional package dockerode: 5.0.1 installed - enables Docker sandboxing (SubprocessSandbox) [ ok ] Optional package @modelcontextprotocol/sdk: 1.31.0 installed - enables MCP (serveMcp, \`lousho mcp\` and MCP client connections) [ ok ] Optional package prompts: 2.4.2 installed - enables the interactive prompts of \`lousho init\` (pass --yes to skip them) - [ ok ] Optional packages not installed: 3 not needed by this agent (ollama-ai-provider, @earendil-works/pi-ai, quickjs-emscripten); install one only if you use its feature + [ ok ] Optional packages not installed: 4 not needed by this agent (@ai-sdk/google, ollama-ai-provider, @earendil-works/pi-ai, quickjs-emscripten); install one only if you use its feature [ ok ] openai (OPENAI_API_KEY): set [warn] anthropic (ANTHROPIC_API_KEY): not set fix: Set ANTHROPIC_API_KEY in your environment, e.g. export ANTHROPIC_API_KEY= [warn] openrouter (OPENROUTER_API_KEY): not set fix: Set OPENROUTER_API_KEY in your environment, e.g. export OPENROUTER_API_KEY= + [warn] google (GOOGLE_GENERATIVE_AI_API_KEY): not set + fix: Set GOOGLE_GENERATIVE_AI_API_KEY in your environment, e.g. export GOOGLE_GENERATIVE_AI_API_KEY= [ ok ] ollama (OLLAMA_BASE_URL): not set (optional; the provider default endpoint is used) [ ok ] pi (OPENROUTER_API_KEY): not set (only needed by the matching nested provider; each pi provider reads its own env key) [ ok ] Default provider for createAgent(): would use 'openai/gpt-4.1-mini' [ ok ] Docker: daemon not reachable (only needed for sandboxed tools; none configured) - 13 ok, 2 warnings, 1 failure" + 13 ok, 3 warnings, 1 failure" `); expect(text).not.toMatch(/[^\x20-\x7e\n]/); }); diff --git a/src/cli/init.test.ts b/src/cli/init.test.ts index 96f2a2a97..e13481702 100644 --- a/src/cli/init.test.ts +++ b/src/cli/init.test.ts @@ -160,7 +160,7 @@ describe('runInit guards', () => { }); it.each([ - [['--provider', 'gemini'], /invalid --provider 'gemini'.*openai, anthropic, openrouter, ollama/], + [['--provider', 'gemini'], /invalid --provider 'gemini'.*openai, anthropic, openrouter, google, ollama/], [['--template', 'huge'], /invalid --template 'huge'.*minimal, tools, yaml/], [['--package-manager', 'deno'], /invalid --package-manager 'deno'.*npm, pnpm, yarn, bun/], [['--wat'], /--wat/], diff --git a/src/cli/init.ts b/src/cli/init.ts index 56a11536c..a114f09ff 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -129,7 +129,7 @@ const INSTALL_FAILED_HINT = 're-run with `--sdk-path ` (see docs/installation.md#installing-from-a-local-build).\n'; /** Providers whose first run needs an API key from the environment (ollama and pi do not). */ -const KEYED_PROVIDERS = new Set(['openai', 'anthropic', 'openrouter']); +const KEYED_PROVIDERS = new Set(['openai', 'anthropic', 'google', 'openrouter']); function nextSteps(dir: string, cwd: string, pm: PackageManager, envKey: string, installed: boolean, keyMissing: boolean): string { const relative = path.relative(cwd, dir) || '.'; diff --git a/src/cli/init/__snapshots__/templates.test.ts.snap b/src/cli/init/__snapshots__/templates.test.ts.snap index 9506381fe..47f072560 100644 --- a/src/cli/init/__snapshots__/templates.test.ts.snap +++ b/src/cli/init/__snapshots__/templates.test.ts.snap @@ -84,6 +84,90 @@ export function buildAgent(provider?: LLMProvider) { " `; +exports[`renderProject > snapshot: google / minimal agent.ts 1`] = ` +"import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent'; +import { z } from 'zod'; + +/** An example tool: the model calls it when it needs the current date or time. */ +export const currentTime = defineTool({ + name: 'current_time', + description: 'Get the current date and time, optionally in an IANA time zone.', + input: z.object({ + timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'), + }), + execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }), +}); + +const instructions = 'You are a helpful assistant. Use your tools when they help.'; +const tools = [currentTime]; + +/** + * The agent's options. The default export is what \`lousho dev src/agent.ts\` + * (and \`lousho chat\`, \`mcp\`, \`acp\`, \`build\`) serve. + */ +const config: CreateAgentConfig = { model: 'google/gemini-2.5-flash', instructions, tools }; +export default config; + +/** + * The agent. \`createAgent({ model, instructions })\` is the whole idea; the + * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts). + */ +export function buildAgent(provider?: LLMProvider) { + return provider ? createAgent({ provider, instructions, tools }) : createAgent(config); +} +" +`; + +exports[`renderProject > snapshot: google / tools agent.ts 1`] = ` +"import { createAgent, defineTool, type CreateAgentConfig, type LLMProvider } from '@lousho/build-ai-agent'; +import { z } from 'zod'; + +/** An example tool: the model calls it when it needs the current date or time. */ +export const currentTime = defineTool({ + name: 'current_time', + description: 'Get the current date and time, optionally in an IANA time zone.', + input: z.object({ + timeZone: z.string().optional().describe('IANA time zone, e.g. "Europe/Paris". Defaults to UTC.'), + }), + execute: async ({ timeZone }) => ({ now: new Date().toLocaleString('en-US', { timeZone: timeZone ?? 'UTC' }) }), +}); + +/** Rolls an n-sided die. */ +export const rollDie = defineTool({ + name: 'roll_die', + description: 'Roll a die with the given number of sides and return the result.', + input: z.object({ sides: z.number().int().min(2).max(1000).default(6) }), + execute: async ({ sides }) => ({ sides, result: 1 + Math.floor(Math.random() * sides) }), +}); + +/** Counts the words in a piece of text. */ +export const wordCount = defineTool({ + name: 'word_count', + description: 'Count the words in a piece of text.', + input: z.object({ text: z.string() }), + execute: async ({ text }) => ({ words: text.split(/\\s+/).filter(Boolean).length }), +}); + +const instructions = 'You are a helpful assistant. Use your tools when they help.'; +const tools = [currentTime, rollDie, wordCount]; + +/** + * The agent's options. The default export is what \`lousho dev src/agent.ts\` + * (and \`lousho chat\`, \`mcp\`, \`acp\`, \`build\`) serve. + */ +const config: CreateAgentConfig = { model: 'google/gemini-2.5-flash', instructions, tools }; +export default config; + +/** + * The agent. \`createAgent({ model, instructions })\` is the whole idea; the + * optional \`provider\` lets tests swap in a scripted model (see agent.test.ts). + */ +export function buildAgent(provider?: LLMProvider) { + return provider ? createAgent({ provider, instructions, tools }) : createAgent(config); +} +" +`; + exports[`renderProject > snapshot: minimal package.json 1`] = ` "{ "name": "demo-agent", diff --git a/src/cli/init/options.ts b/src/cli/init/options.ts index 30b80249d..9d9e4268c 100644 --- a/src/cli/init/options.ts +++ b/src/cli/init/options.ts @@ -10,7 +10,7 @@ export type Template = (typeof TEMPLATES)[number]; export const PACKAGE_MANAGERS = ['npm', 'pnpm', 'yarn', 'bun'] as const; export type PackageManager = (typeof PACKAGE_MANAGERS)[number]; -/** Provider names `--provider` accepts (openai, anthropic, openrouter, ollama). */ +/** Provider names `--provider` accepts (openai, anthropic, openrouter, google, ollama, pi). */ export const PROVIDER_NAMES: readonly string[] = listProviders().map((info) => info.name); export const INIT_USAGE = `Usage: lousho init [dir] [options] diff --git a/src/cli/init/templates.test.ts b/src/cli/init/templates.test.ts index 29d22f2a0..69e25ea0d 100644 --- a/src/cli/init/templates.test.ts +++ b/src/cli/init/templates.test.ts @@ -23,6 +23,7 @@ const ENV_KEYS: Record = { openai: 'OPENAI_API_KEY', anthropic: 'ANTHROPIC_API_KEY', openrouter: 'OPENROUTER_API_KEY', + google: 'GOOGLE_GENERATIVE_AI_API_KEY', ollama: 'OLLAMA_BASE_URL', pi: 'OPENROUTER_API_KEY', }; @@ -32,6 +33,7 @@ const SCAFFOLD_PAIRINGS: Record> = { openai: { ai: '^7.0.0', '@ai-sdk/openai': '^4.0.0' }, anthropic: { ai: '^7.0.0', '@ai-sdk/anthropic': '^4.0.0' }, openrouter: { ai: '^7.0.0', '@ai-sdk/openai': '^4.0.0' }, + google: { ai: '^7.0.0', '@ai-sdk/google': '^4.0.0' }, ollama: { ai: '^7.0.0', 'ollama-ai-provider-v2': '^4.0.0' }, pi: { ai: '^7.0.0', '@earendil-works/pi-ai': '^1.0.3' }, }; diff --git a/src/cli/init/templates.ts b/src/cli/init/templates.ts index 1687c1dac..7f8717130 100644 --- a/src/cli/init/templates.ts +++ b/src/cli/init/templates.ts @@ -35,7 +35,7 @@ function sortKeys(record: Record): Record { * Ollama is on 7 too since `ollama-ai-provider-v2` is installed and tested in CI * (`ai7-zod4`), and needs zod 4, so its scaffold narrows zod to `^4.0.0`. */ -const SCAFFOLD_AI_MAJOR: Readonly> = { openai: 7, anthropic: 7, openrouter: 7, ollama: 7, pi: 7 }; +const SCAFFOLD_AI_MAJOR: Readonly> = { openai: 7, anthropic: 7, google: 7, openrouter: 7, ollama: 7, pi: 7 }; /** `ai` and only the chosen provider's package (the SDK loads provider packages on first use), as one pairing. */ function aiPackages(provider: string): Record { diff --git a/src/createAgent.test.ts b/src/createAgent.test.ts index ed4868093..dcff0a517 100644 --- a/src/createAgent.test.ts +++ b/src/createAgent.test.ts @@ -106,7 +106,7 @@ describe('createAgent', () => { it('throws an error listing every fix when nothing is configured', () => { expect(() => createAgent({ instructions: 'x' })).toThrow( - /createAgent: no model configured.*model: 'openai\/gpt-4.1-mini'.*provider: \.\.\..*LOUSHO_MODEL.*OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY, OLLAMA_BASE_URL/ + /createAgent: no model configured.*model: 'openai\/gpt-4.1-mini'.*provider: \.\.\..*LOUSHO_MODEL.*OPENAI_API_KEY, ANTHROPIC_API_KEY, OPENROUTER_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, OLLAMA_BASE_URL/ ); expect(() => createAgent({ instructions: 'x' })).toThrow( expect.objectContaining({ code: 'LOUSHO_CONFIG_MISSING_PROVIDER', hint: expect.stringContaining('createAgent') }) @@ -144,7 +144,7 @@ describe('createAgent', () => { 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, pi, mock\. Did you mean 'openai\/gpt-4o'\?/ + /createAgent: unrecognized provider 'opnai'.*Supported prefixes: openai, anthropic, openrouter, google, ollama, pi, mock\. Did you mean 'openai\/gpt-4o'\?/ ); }); diff --git a/src/createAgent.ts b/src/createAgent.ts index ebbd257f9..4cd17139e 100644 --- a/src/createAgent.ts +++ b/src/createAgent.ts @@ -620,7 +620,7 @@ export type CreateAgentInstructions = * 3. Neither - resolved from the environment: `LOUSHO_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 + * OPENROUTER_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, OLLAMA_BASE_URL. Throws, listing the fixes, when * none is set. * * `model` may be a function of the run (see `PerRun`) returning diff --git a/src/deploy/adapters/node-server.ts b/src/deploy/adapters/node-server.ts index f37bfb014..4a3ac08bf 100644 --- a/src/deploy/adapters/node-server.ts +++ b/src/deploy/adapters/node-server.ts @@ -152,6 +152,7 @@ import * as agentModule from ${file};`, const PROVIDER_CLASSES: Record = { OpenAIProvider: 'openai', AnthropicProvider: 'anthropic', + GoogleProvider: 'google', OpenRouterProvider: 'openrouter', OllamaProvider: 'ollama', PiProvider: 'pi', diff --git a/src/deploy/bundle.ts b/src/deploy/bundle.ts index 16e0812ca..6417695a2 100644 --- a/src/deploy/bundle.ts +++ b/src/deploy/bundle.ts @@ -153,7 +153,8 @@ export function workerNodeShimPlugin(): Plugin { export function workerUnsupportedPeerPlugin(): Plugin { // H2 adds `@earendil-works/pi-ai` (subpaths included): same "stay a lazy // runtime specifier" treatment, and equally unreachable in a Worker. - const filter = /^(?:ollama-ai-provider(?:-v2)?|@earendil-works\/pi-ai)(?:\/.*)?$/; + // So is `@ai-sdk/google`: 'google' is not in WORKER_SUPPORTED_PROVIDERS either. + const filter = /^(?:ollama-ai-provider(?:-v2)?|@earendil-works\/pi-ai|@ai-sdk\/google)(?:\/.*)?$/; return { name: 'lousho-worker-unsupported-peer', setup(build) { @@ -198,6 +199,7 @@ export function workerEntryPlugins(): Plugin[] { const ollama = path.join(findSdkRoot(), 'src', 'deploy', 'shims', 'ollama.worker.ts'); const mcp = path.join(findSdkRoot(), 'src', 'deploy', 'shims', 'mcp.worker.ts'); const pi = path.join(findSdkRoot(), 'src', 'deploy', 'shims', 'pi.worker.ts'); + const google = path.join(findSdkRoot(), 'src', 'deploy', 'shims', 'google.worker.ts'); return [ workerSandboxShimPlugin(), workerNodeShimPlugin(), @@ -207,6 +209,13 @@ export function workerEntryPlugins(): Plugin[] { build.onResolve({ filter: /^ollama-ai-provider(?:-v2)?$/ }, () => ({ path: ollama })); }, }, + { + // 'google' is not a Worker provider either: its lazy `@ai-sdk/google` import becomes the shim's coded failure. + name: 'lousho-worker-google-shim', + setup(build) { + build.onResolve({ filter: /^@ai-sdk\/google$/ }, () => ({ path: google })); + }, + }, { // H2: the 'pi' provider's lazy `@earendil-works/pi-ai` imports become // the shim's coded failure (pi is Node-only). diff --git a/src/deploy/shims/google.worker.ts b/src/deploy/shims/google.worker.ts new file mode 100644 index 000000000..a760c83ae --- /dev/null +++ b/src/deploy/shims/google.worker.ts @@ -0,0 +1,19 @@ +/** + * Worker-safe stand-in for the optional peer `@ai-sdk/google`. + * + * `GoogleProvider` lazy-imports `@ai-sdk/google`. The `/worker` subpath + * entry is bundled by the user's own build, where that specifier would fail + * with "Could not resolve" (the package is optional and usually not + * installed), so the entry's build redirects it here instead, the way it + * redirects the Ollama packages. 'google' is not one of the + * WORKER_SUPPORTED_PROVIDERS, so `createGoogleGenerativeAI()` fails on first use. + */ +import { SDKError } from '../../execution/errors'; + +export function createGoogleGenerativeAI(): never { + throw new SDKError( + "provider 'google' is not available on Cloudflare Workers yet. Use the 'mock', 'openai', 'anthropic' or 'openrouter' providers " + + "(Gemini through OpenRouter: 'openrouter/google/'), or the node-server/docker targets.", + 'LOUSHO_DEPLOY_FAILED' + ); +} diff --git a/src/deploy/worker.test.ts b/src/deploy/worker.test.ts index 5ff8e972a..7c3f2e625 100644 --- a/src/deploy/worker.test.ts +++ b/src/deploy/worker.test.ts @@ -50,6 +50,9 @@ describe('@lousho/build-ai-agent/worker (#289)', () => { expect(Object.keys(result.metafile.inputs).filter((file) => file.includes('ollama-ai-provider'))).toEqual([]); expect(result.outputFiles[0].text).not.toMatch(/(?:from|import\(|require\()\s*["'`]ollama-ai-provider/); expect(Object.keys(result.metafile.inputs).some((file) => file.endsWith('ollama.worker.ts'))).toBe(true); + // GoogleProvider's lazy `@ai-sdk/google` import gets the same shim treatment. + expect(result.outputFiles[0].text).not.toMatch(/(?:from|import\(|require\()\s*["'`]@ai-sdk\/google/); + expect(Object.keys(result.metafile.inputs).some((file) => file.endsWith('google.worker.ts'))).toBe(true); // The same goes for the optional @modelcontextprotocol/sdk peer that // createAgent({ mcpServers }) lazy-imports: bundled it would add ~700 KB // to every Worker, external it would fail the user's build when the peer diff --git a/src/providers/GoogleProvider.test.ts b/src/providers/GoogleProvider.test.ts new file mode 100644 index 000000000..c7918f90c --- /dev/null +++ b/src/providers/GoogleProvider.test.ts @@ -0,0 +1,71 @@ +/** + * Google Provider Tests + * + * `@ai-sdk/google` is an optional peer this repo does not install, so it is + * mocked here, together with the 'ai' SDK's generateText (no live API key). + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +const generateTextMock = vi.fn(); +const createGoogleMock = vi.fn(); + +vi.mock('ai', async () => { + const actual = await vi.importActual('ai'); + return { ...actual, generateText: (...args: unknown[]) => generateTextMock(...args) }; +}); + +vi.mock('@ai-sdk/google', () => ({ + createGoogleGenerativeAI: (options: unknown) => createGoogleMock(options), +})); + +import { GoogleProvider } from './GoogleProvider'; +import { resolveProvider } from './resolveProvider'; + +describe('GoogleProvider', () => { + beforeEach(() => { + generateTextMock.mockReset(); + createGoogleMock.mockReset(); + createGoogleMock.mockImplementation(() => (modelId: string) => ({ fakeModel: modelId })); + generateTextMock.mockResolvedValue({ + text: 'Hello from Gemini', + finishReason: 'stop', + usage: { promptTokens: 4, completionTokens: 3, totalTokens: 7 }, + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('is named google and defaults to gemini-2.5-flash', () => { + const provider = new GoogleProvider({ name: 'google', apiKey: 'k' }); + expect(provider.name).toBe('google'); + expect(provider.defaultModel).toBe('gemini-2.5-flash'); + expect(provider.supportsTools('gemini-2.5-pro')).toBe(true); + expect(provider.supportsStreaming('gemini-2.5-pro')).toBe(true); + }); + + it('builds the @ai-sdk/google model with the configured key, once', async () => { + const provider = new GoogleProvider({ name: 'google', apiKey: 'test-key', defaultModel: 'gemini-2.5-pro' }); + + const result = await provider.generate({ messages: [{ role: 'user', content: 'Hi' }] }); + await provider.generate({ messages: [{ role: 'user', content: 'Hi again' }] }); + + expect(result.text).toBe('Hello from Gemini'); + expect(createGoogleMock).toHaveBeenCalledTimes(1); + expect(createGoogleMock).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'test-key' })); + expect(generateTextMock.mock.calls[0]?.[0]).toMatchObject({ model: { fakeModel: 'gemini-2.5-pro' } }); + }); + + it("is what 'google/' resolves to, with GOOGLE_GENERATIVE_AI_API_KEY as its key", async () => { + vi.stubEnv('GOOGLE_GENERATIVE_AI_API_KEY', 'env-google-key'); + + const provider = resolveProvider('google/gemini-2.5-flash'); + await provider.generate({ messages: [{ role: 'user', content: 'Hi' }] }); + + expect(provider).toBeInstanceOf(GoogleProvider); + expect(provider.defaultModel).toBe('gemini-2.5-flash'); + expect(createGoogleMock).toHaveBeenCalledWith(expect.objectContaining({ apiKey: 'env-google-key' })); + }); +}); diff --git a/src/providers/GoogleProvider.ts b/src/providers/GoogleProvider.ts new file mode 100644 index 000000000..289271e56 --- /dev/null +++ b/src/providers/GoogleProvider.ts @@ -0,0 +1,55 @@ +/** + * Google (Gemini) Provider Implementation + * Uses the 'ai' SDK with `@ai-sdk/google` (shared logic in ./aiSdkProvider) + */ + +import { LanguageModel } from 'ai'; +import { AiSdkProvider, AiSdkProviderConfig } from './aiSdkProvider'; +import { aiMajorOf } from './aiSdkCompat'; +import { lazyValue, loadOptionalPeer } from './optionalPeer'; + +export interface GoogleProviderConfig extends AiSdkProviderConfig { + /** Gemini API key; `@ai-sdk/google` falls back to `GOOGLE_GENERATIVE_AI_API_KEY` when it is unset. */ + apiKey?: string; + baseURL?: string; + defaultModel?: string; +} + +/** + * Google Gemini Provider using the 'ai' SDK + */ +export class GoogleProvider extends AiSdkProvider { + readonly name = 'google'; + protected readonly fallbackModel = 'gemini-2.5-flash'; + /** Loads `@ai-sdk/google` on first use (it is an optional peer). */ + private readonly loadProvider = lazyValue(async () => { + const { createGoogleGenerativeAI } = await loadOptionalPeer('@ai-sdk/google', () => import('@ai-sdk/google'), aiMajorOf(this.ai)); + return createGoogleGenerativeAI(this.clientOptions()); + }); + + /** PDF and plain-text file parts go to the model on ai 6 and 7; older peers have no file parts. */ + protected fileMediaTypes(): readonly string[] { + return aiMajorOf(this.ai) >= 6 ? ['application/pdf', 'text/plain'] : []; + } + + protected async createModel(modelId: string): Promise { + return (await this.loadProvider())(modelId); + } + + /** + * Whether `model` calls tools: every Gemini model. Informational: the + * runtime does not consult it. + */ + supportsTools(model: string): boolean { + return model.startsWith('gemini'); + } + + supportsStreaming(_model: string): boolean { + return true; + } + + /** Current Gemini models (fetching from the API requires additional setup). */ + async getModels(): Promise { + return ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite']; + } +} diff --git a/src/providers/builtinProviders.ts b/src/providers/builtinProviders.ts index 65086c889..d35168253 100644 --- a/src/providers/builtinProviders.ts +++ b/src/providers/builtinProviders.ts @@ -9,7 +9,7 @@ * * Laziness is preserved: importing this module loads only * the provider *classes*. Each provider loads its optional peer SDK - * ('@ai-sdk/openai', '@ai-sdk/anthropic', 'ollama-ai-provider(-v2)') through + * ('@ai-sdk/openai', '@ai-sdk/anthropic', '@ai-sdk/google', 'ollama-ai-provider(-v2)') through * `loadOptionalPeer()` on the first generate()/stream() call, never at * module scope - so nothing here imports (or requires) an optional peer. * @@ -25,6 +25,7 @@ import type { ProviderFactory } from './llm'; import { OpenAIProvider, type OpenAIProviderConfig } from './OpenAIProvider'; import { AnthropicProvider, type AnthropicProviderConfig } from './AnthropicProvider'; import { OpenRouterProvider, type OpenRouterProviderConfig } from './OpenRouterProvider'; +import { GoogleProvider, type GoogleProviderConfig } from './GoogleProvider'; import { OllamaProvider, type OllamaProviderConfig } from './OllamaProvider'; import { PiProvider, type PiProviderConfig } from './pi/PiProvider'; import { MockLLMProvider, type MockProviderConfig } from './mock'; @@ -34,6 +35,7 @@ const BUILTIN_FACTORIES: ReadonlyArray = [ ['openai', (config) => new OpenAIProvider(config as OpenAIProviderConfig)], ['anthropic', (config) => new AnthropicProvider(config as AnthropicProviderConfig)], ['openrouter', (config) => new OpenRouterProvider(config as OpenRouterProviderConfig)], + ['google', (config) => new GoogleProvider(config as GoogleProviderConfig)], ['ollama', (config) => new OllamaProvider(config as OllamaProviderConfig)], // H2: the pi provider loads '@earendil-works/pi-ai' through // loadOptionalPeer() on first use, like every other optional peer. diff --git a/src/providers/importGraph.test.ts b/src/providers/importGraph.test.ts index 5ee05e0e9..7cde3a4fb 100644 --- a/src/providers/importGraph.test.ts +++ b/src/providers/importGraph.test.ts @@ -15,6 +15,7 @@ const REPO_ROOT = path.resolve(__dirname, '..', '..'); const NEVER_LOADED_AT_IMPORT = [ '@ai-sdk/openai', '@ai-sdk/anthropic', + '@ai-sdk/google', 'ollama-ai-provider', 'undici', 'dockerode', diff --git a/src/providers/index.ts b/src/providers/index.ts index 6ef1e9b68..19fb3f989 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -13,6 +13,7 @@ export * from './OpenAIProvider'; export * from './OllamaProvider'; export * from './OpenRouterProvider'; export * from './AnthropicProvider'; +export * from './GoogleProvider'; export { PiProvider, type PiProviderConfig } from './pi/PiProvider'; export * from './resolveProvider'; export * from './resilience'; diff --git a/src/providers/missingPeer.test.ts b/src/providers/missingPeer.test.ts index c021c1e08..75924b758 100644 --- a/src/providers/missingPeer.test.ts +++ b/src/providers/missingPeer.test.ts @@ -15,6 +15,7 @@ function moduleNotFound(packageName: string): Error { afterEach(() => { vi.doUnmock('@ai-sdk/openai'); vi.doUnmock('@ai-sdk/anthropic'); + vi.doUnmock('@ai-sdk/google'); vi.doUnmock('ollama-ai-provider'); vi.doUnmock('ollama-ai-provider-v2'); vi.doUnmock('dockerode'); @@ -53,6 +54,12 @@ const CASES: Case[] = [ installCommand: `npm install @ai-sdk/anthropic@${AI_SDK_RANGE}`, load: async () => new (await import('./AnthropicProvider')).AnthropicProvider({ name: 'anthropic', apiKey: 'k' }), }, + { + label: 'GoogleProvider', + packageName: '@ai-sdk/google', + installCommand: `npm install @ai-sdk/google@${installedAiMajor === 4 ? '^1.2.0' : AI_SDK_RANGE}`, + load: async () => new (await import('./GoogleProvider')).GoogleProvider({ name: 'google', apiKey: 'k' }), + }, { label: 'OllamaProvider', packageName: OLLAMA_PEER.slice(0, OLLAMA_PEER.lastIndexOf('@')), diff --git a/src/providers/providerSpec.ts b/src/providers/providerSpec.ts index 26569aa0a..6cbe2ad08 100644 --- a/src/providers/providerSpec.ts +++ b/src/providers/providerSpec.ts @@ -42,6 +42,13 @@ function aiSdkPeer(name: string): Record { }; } +/** Google: `@ai-sdk/google` 1.x is the `ai` 4 line (0.0.x predates it); 3.x pairs with `ai` 6, 4.x with `ai` 7. */ +const GOOGLE_PEERS: Record = { + 4: { name: '@ai-sdk/google', range: '^1.2.0', accepts: '^1.0.0' }, + 6: { name: '@ai-sdk/google', range: '^3.0.0', accepts: '^3.0.0' }, + 7: { name: '@ai-sdk/google', range: '^4.0.0', accepts: '^4.0.0' }, +}; + const ZOD4_NOTE = 'ollama-ai-provider-v2 needs zod 4 (npm install zod@^4.0.0); with zod 3, use ai@^4.3.19 with ollama-ai-provider@^1.2.0'; @@ -157,6 +164,14 @@ const PROVIDERS: Record = { peers: aiSdkPeer('@ai-sdk/openai'), envDefaultModel: 'openai/gpt-4.1-mini', }, + /** Gemini through `@ai-sdk/google`; the key variable is the one `@ai-sdk/google` itself reads. */ + google: { + envKey: 'GOOGLE_GENERATIVE_AI_API_KEY', + configField: 'apiKey', + envRequired: true, + peers: GOOGLE_PEERS, + envDefaultModel: 'gemini-2.5-flash', + }, ollama: { envKey: 'OLLAMA_BASE_URL', configField: 'baseURL', @@ -195,12 +210,44 @@ const SPEC_PREFIXES = [...PROVIDER_NAMES, MOCK_PROVIDER].join(', '); /** Env var that overrides the automatic provider choice, e.g. `LOUSHO_MODEL=anthropic/claude-sonnet-4-5`. */ const MODEL_ENV_VAR = 'LOUSHO_MODEL'; +/** + * Vendors `@earendil-works/pi-ai` serves that have no prefix of their own here + * (a test keeps each one in pi-ai's provider list), so an unknown prefix + * naming one can be suggested as `pi//`. + */ +export const PI_NESTED_VENDORS: readonly string[] = [ + 'amazon-bedrock', 'azure', 'baseten', 'cerebras', 'cloudflare-workers-ai', 'deepseek', 'fireworks', 'github-copilot', + 'google-vertex', 'groq', 'huggingface', 'meta', 'minimax', 'mistral', 'moonshotai', 'nvidia', 'together', + 'vercel-ai-gateway', 'xai', 'zai', +]; + +/** `amazon-bedrock` -> `amazonBedrock`: the AI SDK factory name a prefix suggests. */ +function factoryName(providerName: string): string { + const camel = providerName.replace(/[^a-z0-9]+([a-z0-9])/gi, (_, letter: string) => letter.toUpperCase()).replace(/[^a-z0-9]/gi, ''); + return /^[a-z]/i.test(camel) ? camel : 'provider'; +} + +/** + * The ways to reach a vendor that has no prefix: OpenRouter (which serves + * most vendors), pi when it knows the vendor, or the vendor's own AI SDK + * package through `fromAiSdk()`. + */ +function otherRoutes(providerName: string, model: string): string { + const lower = providerName.toLowerCase(); + const routes = [`'openrouter/${lower}/${model}' (OpenRouter serves most vendors; needs OPENROUTER_API_KEY)`]; + if (PI_NESTED_VENDORS.includes(lower)) routes.push(`'pi/${lower}/${model}' (through @earendil-works/pi-ai)`); + routes.push(`createAgent({ provider: fromAiSdk(${factoryName(lower)}('${model}')) }) with the vendor's own AI SDK provider package`); + return ` To use '${providerName}' anyway: ${routes.slice(0, -1).join(', ')}, or ${routes[routes.length - 1]}.`; +} + function unknownProviderError(caller: string, providerName: string, spec: string): Error { - const suggestion = closestMatch(providerName, PROVIDER_NAMES); + const model = spec.slice(providerName.length + 1); + // A vendor pi serves is not a typo of a built-in prefix ('xai' is two edits from 'pi'). + const suggestion = PI_NESTED_VENDORS.includes(providerName.toLowerCase()) ? undefined : closestMatch(providerName, PROVIDER_NAMES); return new ConfigurationError( `${caller}: unrecognized provider '${providerName}' in spec '${spec}'. ` + `Supported prefixes: ${SPEC_PREFIXES}.` + - (suggestion ? ` Did you mean '${suggestion}/${spec.slice(providerName.length + 1)}'?` : ''), + (suggestion ? ` Did you mean '${suggestion}/${model}'?` : otherRoutes(providerName, model)), 'model', 'LOUSHO_PROVIDER_UNKNOWN' ); @@ -338,7 +385,7 @@ export function resolveProviderSpec(spec: string, caller: string, registry: Prov /** * Pick a "/" spec from the environment: `LOUSHO_MODEL` if * set, otherwise the first provider (in the order openai, anthropic, - * openrouter, ollama) whose env var is set. Throws, listing every fix, when + * openrouter, google, ollama) whose env var is set. Throws, listing every fix, when * nothing is configured. */ export function modelFromEnv(caller: string, env: Record = process.env): string { diff --git a/src/providers/resolveProvider.test.ts b/src/providers/resolveProvider.test.ts index 188572fe5..c51a49fa4 100644 --- a/src/providers/resolveProvider.test.ts +++ b/src/providers/resolveProvider.test.ts @@ -51,6 +51,21 @@ describe('resolveProvider', () => { }); }); + it('resolves "google/" using GOOGLE_GENERATIVE_AI_API_KEY', () => { + vi.stubEnv('GOOGLE_GENERATIVE_AI_API_KEY', 'test-google-key'); + const createSpy = vi.spyOn(LLMProviderRegistry, 'create').mockReturnValue(fakeProvider('google')); + + resolveProvider('google/gemini-2.5-flash'); + + expect(createSpy).toHaveBeenCalledWith('google', { defaultModel: 'gemini-2.5-flash', apiKey: 'test-google-key' }); + }); + + it('requires GOOGLE_GENERATIVE_AI_API_KEY for "google/"', () => { + vi.stubEnv('GOOGLE_GENERATIVE_AI_API_KEY', ''); + expect(() => resolveProvider('google/gemini-2.5-flash')).toThrow(withCode('LOUSHO_PROVIDER_MISSING_API_KEY')); + expect(() => resolveProvider('google/gemini-2.5-flash')).toThrow('GOOGLE_GENERATIVE_AI_API_KEY is not set'); + }); + it('resolves "ollama/" into baseURL (not apiKey), since Ollama has no API key concept', () => { vi.stubEnv('OLLAMA_BASE_URL', 'http://localhost:11434'); const createSpy = vi @@ -144,12 +159,49 @@ describe('resolveProvider errors', () => { 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, pi, mock. Did you mean 'anthropic/claude-3'?" + "unrecognized provider 'anthopic' in spec 'anthopic/claude-3'. Supported prefixes: openai, anthropic, openrouter, google, ollama, pi, mock. Did you mean 'anthropic/claude-3'?" + ); + expect(() => resolveProvider('anthopic/claude-3')).not.toThrow(/openrouter\/anthopic/); + }); + + it('suggests OpenRouter and fromAiSdk() for a prefix that is not close to a supported one', () => { + expect(() => resolveProvider('zzzzzzzzzz/m')).toThrow( + "Supported prefixes: openai, anthropic, openrouter, google, ollama, pi, mock. To use 'zzzzzzzzzz' anyway: " + + "'openrouter/zzzzzzzzzz/m' (OpenRouter serves most vendors; needs OPENROUTER_API_KEY), " + + "or createAgent({ provider: fromAiSdk(zzzzzzzzzz('m')) }) with the vendor's own AI SDK provider package." + ); + expect(() => resolveProvider('zzzzzzzzzz/m')).not.toThrow(/'pi\//); + }); + + it('also suggests pi for a vendor pi serves, and never mistakes it for a typo of a built-in prefix', () => { + // 'xai' is two edits from 'pi', but it is a vendor of its own. + const error = (() => { + try { + resolveProvider('xai/grok-4'); + return undefined; + } catch (e) { + return e as Error & { code: string }; + } + })(); + expect(error?.code).toBe('LOUSHO_PROVIDER_UNKNOWN'); + expect(error?.message).not.toContain('Did you mean'); + expect(error?.message).toContain( + "To use 'xai' anyway: 'openrouter/xai/grok-4' (OpenRouter serves most vendors; needs OPENROUTER_API_KEY), " + + "'pi/xai/grok-4' (through @earendil-works/pi-ai), or createAgent({ provider: fromAiSdk(xai('grok-4')) })" ); + // The appended help line links the error code's docs section. + expect(error?.message).toMatch(/\[LOUSHO_PROVIDER_UNKNOWN\].*docs\/errors\.md#lousho_provider_unknown/); }); - it('lists supported prefixes without a suggestion when nothing is close', () => { - expect(() => resolveProvider('zzzzzzzzzz/m')).toThrow(/Supported prefixes: openai, anthropic, openrouter, ollama, pi, mock\.$/m); + it('turns a dashed vendor into a valid AI SDK factory name', () => { + expect(() => resolveProvider('amazon-bedrock/claude')).toThrow("fromAiSdk(amazonBedrock('claude'))"); + }); + + it('every vendor it suggests through pi is one pi-ai serves', async () => { + const { PI_NESTED_VENDORS } = await import('./providerSpec'); + const { getProviders } = await import('@earendil-works/pi-ai/compat'); + const known = new Set(getProviders()); + expect(PI_NESTED_VENDORS.filter((vendor) => !known.has(vendor))).toEqual([]); }); it('shows an example for a spec without a provider prefix', () => { @@ -167,6 +219,11 @@ describe('resolveProvider errors', () => { 'OPENROUTER_API_KEY', 'npm install @ai-sdk/openai@^0.0.42 (ai 4); npm install @ai-sdk/openai@^3.0.0 (ai 6); npm install @ai-sdk/openai@^4.0.0 (ai 7)', ], + [ + 'google/gemini-2.5-flash', + 'GOOGLE_GENERATIVE_AI_API_KEY', + 'npm install @ai-sdk/google@^1.2.0 (ai 4); npm install @ai-sdk/google@^3.0.0 (ai 6); npm install @ai-sdk/google@^4.0.0 (ai 7)', + ], [ 'ollama/llama3', 'OLLAMA_BASE_URL', diff --git a/src/spec/specToAgent.ts b/src/spec/specToAgent.ts index e39626778..44f3ae085 100644 --- a/src/spec/specToAgent.ts +++ b/src/spec/specToAgent.ts @@ -16,7 +16,7 @@ import { webFetchTool } from '../tools/built-in/webFetch'; import { ToolDescriptor } from '../types'; import { compilePolicy } from './policy'; -const REAL_PROVIDER_TYPES = new Set(['openai', 'anthropic', 'ollama', 'openrouter']); +const REAL_PROVIDER_TYPES = new Set(['openai', 'anthropic', 'google', 'ollama', 'openrouter']); /** * Resolves a spec's provider config to an LLMProvider. Real provider types diff --git a/src/utils/errorCodes.ts b/src/utils/errorCodes.ts index a3814ae54..c5ccd456e 100644 --- a/src/utils/errorCodes.ts +++ b/src/utils/errorCodes.ts @@ -22,7 +22,8 @@ export const ERROR_CODES = { 'Pass createAgent({ store }) with `checkpoints` (e.g. memoryStore() or a SqliteStore), or drop `sessionId`.', LOUSHO_CONFIG_RESOLVER_FAILED: "Fix the createAgent() option function named in the message; its own error is the `cause`.", LOUSHO_PROVIDER_SPEC_INVALID: "Write the model as '/', e.g. 'openai/gpt-4.1-mini'.", - LOUSHO_PROVIDER_UNKNOWN: 'Use one of the supported provider prefixes listed in the message.', + LOUSHO_PROVIDER_UNKNOWN: + 'Use one of the supported provider prefixes listed in the message; reach any other vendor through openrouter//, pi// or fromAiSdk().', LOUSHO_PROVIDER_MISSING_API_KEY: 'Set the environment variable named in the message, or pass a provider instance.', LOUSHO_PROVIDER_REQUEST_FAILED: 'Check the provider status, your API key and the request; retryable failures are retried by withRetry().', LOUSHO_PROVIDER_RATE_LIMITED: 'Wait and retry (withRetry() honours Retry-After), or lower the request rate.', diff --git a/typings/optional-peers.d.ts b/typings/optional-peers.d.ts index e32b2441b..f3cf9c193 100644 --- a/typings/optional-peers.d.ts +++ b/typings/optional-peers.d.ts @@ -1,12 +1,12 @@ /** * Ambient module declarations for optional peer dependencies. * - * `@ai-sdk/openai` and `ollama-ai-provider` are declared as optional peer + * `@ai-sdk/openai`, `@ai-sdk/google` and `ollama-ai-provider` are declared as optional peer * dependencies (see package.json `peerDependenciesMeta`) and are not * installed in this repo's own node_modules. These minimal ambient * declarations let `tsc`/`tsup`'s declaration (`.d.ts`) build resolve the - * imports in src/providers/OpenAIProvider.ts, src/providers/OpenRouterProvider.ts - * and src/providers/OllamaProvider.ts without requiring consumers of this + * imports in src/providers/OpenAIProvider.ts, src/providers/OpenRouterProvider.ts, + * src/providers/GoogleProvider.ts and src/providers/OllamaProvider.ts without requiring consumers of this * package (or this repo's own CI) to install those optional packages. * * Consumers who actually install the real packages get the real, richer @@ -18,6 +18,12 @@ declare module '@ai-sdk/openai' { export function createOpenAI(options?: Record): any; } +// Not a devDependency: GoogleProvider's tests mock it. +declare module '@ai-sdk/google' { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + export function createGoogleGenerativeAI(options?: Record): any; +} + declare module 'ollama-ai-provider' { // eslint-disable-next-line @typescript-eslint/no-explicit-any export function createOllama(options?: Record): any;