diff --git a/.changeset/ai-bedrock-adapter.md b/.changeset/ai-bedrock-adapter.md new file mode 100644 index 000000000..197ba8acd --- /dev/null +++ b/.changeset/ai-bedrock-adapter.md @@ -0,0 +1,5 @@ +--- +'@tanstack/ai-bedrock': minor +--- + +Add `@tanstack/ai-bedrock`: an Amazon Bedrock adapter. The default `bedrockText` path uses Bedrock's **Converse** API (`@aws-sdk/client-bedrock-runtime`), reaching the broad chat catalog including Anthropic Claude, Amazon Nova, and Meta Llama, with streaming, tools, reasoning, and structured output. Opt into Bedrock's OpenAI-compatible endpoints with `api: 'chat'` (Chat Completions) or `api: 'responses'` (gpt-oss Responses). Authentication supports Bedrock API keys or SigV4 via the AWS credential chain. diff --git a/.claude/skills/gap-analysis/SKILL.md b/.claude/skills/gap-analysis/SKILL.md index 0178a00c3..aafb9c3f8 100644 --- a/.claude/skills/gap-analysis/SKILL.md +++ b/.claude/skills/gap-analysis/SKILL.md @@ -73,9 +73,14 @@ markdown report under `.agent/gap-analysis/`. **Do not edit source files.** ## Known providers -`openai`, `anthropic`, `gemini`, `ollama`, `grok`, `groq`, `openrouter`, `fal` -(media-only), `elevenlabs` (TTS-only). The feature matrix tracks the first -seven; `fal` and `elevenlabs` only appear in model/media audits. +`openai`, `anthropic`, `gemini`, `ollama`, `grok`, `groq`, `openrouter`, +`bedrock` (`@tanstack/ai-bedrock`; three-API surface — Converse default +(adapter name `bedrock-converse`), Chat Completions opt-in (`api: 'chat'`, +adapter name `bedrock`), Responses opt-in (`api: 'responses'`, adapter name +`bedrock-responses`)), `fal` (media-only), `elevenlabs` (TTS-only). The +feature matrix tracks `openai`, `anthropic`, `gemini`, `ollama`, `grok`, +`groq`, `openrouter`, `bedrock`, `bedrock-converse`, and `bedrock-responses`; +`fal` and `elevenlabs` only appear in model/media audits. ## Known features (19) diff --git a/.claude/skills/gap-analysis/references/provider-doc-urls.md b/.claude/skills/gap-analysis/references/provider-doc-urls.md index 2dc1818d4..a19c0ae48 100644 --- a/.claude/skills/gap-analysis/references/provider-doc-urls.md +++ b/.claude/skills/gap-analysis/references/provider-doc-urls.md @@ -70,6 +70,16 @@ WebFetch — call `resolve-library-id` with the SDK npm name, then `query-docs`. - Provider routing: https://openrouter.ai/docs/features/provider-routing - (Proxies many providers; uses OpenAI-compatible API.) +## bedrock (Amazon Bedrock) + +- Models / API compatibility: https://docs.aws.amazon.com/bedrock/latest/userguide/models-api-compatibility.html +- Converse API reference (default path): https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html +- OpenAI-compatible Chat Completions: https://docs.aws.amazon.com/bedrock/latest/userguide/inference-chat-completions-mantle.html +- Responses API (mantle): https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-mantle.html +- Cross-region inference profiles: https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html +- API keys: https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html +- (Default path uses Converse API via `@aws-sdk/client-bedrock-runtime` (adapter `bedrock-converse`). Opt-in paths: `api: 'chat'` → OpenAI-compatible Chat Completions (adapter `bedrock`); `api: 'responses'` → Responses API (adapter `bedrock-responses`).) + ## fal (media-only) - Models catalog: https://fal.ai/models diff --git a/docs/adapters/bedrock.md b/docs/adapters/bedrock.md new file mode 100644 index 000000000..b4c99ec97 --- /dev/null +++ b/docs/adapters/bedrock.md @@ -0,0 +1,241 @@ +--- +title: Amazon Bedrock +id: bedrock-adapter +order: 7 +description: "Use Amazon Bedrock with TanStack AI — the Converse API is the default, reaching Claude, Nova, Llama, Mistral, DeepSeek, and more. Opt into OpenAI-compatible Chat Completions or Responses for open-weight and gpt-oss models. Supports streaming, tools, reasoning, and API-key or SigV4 auth." +keywords: + - tanstack ai + - amazon bedrock + - aws + - bedrock + - converse api + - openai compatible + - chat completions + - responses api + - sigv4 + - claude + - nova + - llama + - adapter +--- + +The Bedrock adapter connects TanStack AI to [Amazon Bedrock](https://aws.amazon.com/bedrock/) with three API paths: + +- **Converse** (default) — Bedrock's model-agnostic API built on `@aws-sdk/client-bedrock-runtime`. Reaches the broad chat catalog including Anthropic Claude, Amazon Nova, Meta Llama, Mistral, DeepSeek, Cohere, AI21, and OpenAI gpt-oss models. +- **Chat Completions** (`api: 'chat'`) — Bedrock's OpenAI-compatible Chat Completions endpoint. Reaches open-weight models only (gpt-oss, DeepSeek V3.x, Gemma, Qwen, Mistral open models, GLM, etc.). Does NOT reach Claude, Nova, or Llama. +- **Responses** (`api: 'responses'`) — Bedrock's OpenAI-compatible Responses API, mantle-only. Currently the OpenAI gpt-oss family. + +All paths support streaming, client-side tool calling, and reasoning. + +## Installation + +```bash +pnpm add @tanstack/ai-bedrock +``` + +No additional packages are required. SigV4 authentication is handled by `@aws-sdk/client-bedrock-runtime`, which is a direct dependency. + +## Quick Start (Converse — default) + +The default `bedrockText` call uses the Converse API and reaches the broad model catalog: + +```typescript +import { bedrockText } from '@tanstack/ai-bedrock' +import { chat } from '@tanstack/ai' + +const adapter = bedrockText('us.anthropic.claude-haiku-4-5-20251001-v1:0', { + region: 'us-east-1', +}) + +for await (const chunk of chat({ + adapter, + messages: [{ role: 'user', content: 'What is the capital of France?' }], +})) { + if (chunk.type === 'content') process.stdout.write(chunk.delta) +} +``` + +Equivalent to passing `{ api: 'converse' }` explicitly. Returns a `bedrock-converse` adapter. + +## Authentication + +Bedrock supports two authentication modes. + +### API Key + +Bedrock issues API keys from the AWS Console. See the [Bedrock API keys guide](https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html) for instructions. + +Set one of the following environment variables and the adapter picks it up automatically: + +```bash +BEDROCK_API_KEY=your-bedrock-api-key +# or the legacy name: +AWS_BEARER_TOKEN_BEDROCK=your-bedrock-api-key +``` + +### SigV4 (AWS credential chain) + +For workloads using IAM roles, instance profiles, or `~/.aws/credentials`, set `auth: 'sigv4'` (or leave it as `'auto'` with no API key in the environment). SigV4 works out of the box via `@aws-sdk/client-bedrock-runtime` — no additional packages required. + +```bash +AWS_ACCESS_KEY_ID=... +AWS_SECRET_ACCESS_KEY=... +AWS_SESSION_TOKEN=... # optional, for temporary credentials +``` + +### Auth resolution order (`auth: 'auto'`, the default) + +1. Explicit `apiKey` passed to the factory +2. `BEDROCK_API_KEY` environment variable +3. `AWS_BEARER_TOKEN_BEDROCK` environment variable +4. SigV4 via the standard AWS credential chain + +## Configuration + +`BedrockClientConfig` accepts the following options: + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `api` | `'converse' \| 'chat' \| 'responses'` | `'converse'` | Bedrock API to use | +| `region` | `string` | `'us-east-1'` | AWS region string (e.g. `'us-west-2'`) | +| `auth` | `'apikey' \| 'sigv4' \| 'auto'` | `'auto'` | Authentication mode | +| `apiKey` | `string` | — | Explicit API key (overrides env vars) | +| `baseURL` | `string` | — | Override the computed base URL entirely | +| `endpoint` | `'runtime' \| 'mantle'` | `'runtime'` | Bedrock endpoint to target (Chat Completions path only) | + +The `endpoint` option only applies when `api: 'chat'`. The `runtime` endpoint (`bedrock-runtime`) hosts the broad open-weight catalog; `mantle` is an alternative. The Responses API always targets mantle. + +## Converse API (default) + +`bedrockText(model)` or `bedrockText(model, { api: 'converse' })` returns a `bedrock-converse` adapter backed by `@aws-sdk/client-bedrock-runtime`. This is Bedrock's model-agnostic conversational API and is the recommended path for most use cases. + +**Model scope:** Anthropic Claude, Amazon Nova, Meta Llama, Mistral, DeepSeek, Cohere, AI21, OpenAI gpt-oss, and other models accessible in your account. See [Model availability](#model-availability) below. + +```typescript +import { bedrockText } from '@tanstack/ai-bedrock' +import { chat } from '@tanstack/ai' + +// Claude via Converse +const claudeAdapter = bedrockText('us.anthropic.claude-haiku-4-5-20251001-v1:0', { + region: 'us-east-1', +}) + +// Amazon Nova via Converse +const novaAdapter = bedrockText('us.amazon.nova-pro-v1:0', { + region: 'us-east-1', +}) + +// Meta Llama via Converse +const llamaAdapter = bedrockText('us.meta.llama3-3-70b-instruct-v1:0', { + region: 'us-east-1', +}) +``` + +### Explicit API key (Converse) + +```typescript +import { createBedrockText } from '@tanstack/ai-bedrock' + +const adapter = createBedrockText( + 'us.anthropic.claude-haiku-4-5-20251001-v1:0', + 'your-bedrock-api-key', + { region: 'us-west-2' }, +) +``` + +## Chat Completions API (`api: 'chat'`) + +Set `api: 'chat'` to use Bedrock's OpenAI-compatible Chat Completions endpoint. Returns a `bedrock` adapter. + +**Model scope:** Open-weight models only — gpt-oss, DeepSeek V3.x, Gemma, Qwen, Mistral open models, GLM, and similar. Claude, Nova, and Llama are NOT available on this endpoint. See the [AWS API compatibility matrix](https://docs.aws.amazon.com/bedrock/latest/userguide/models-api-compatibility.html) for the current list. + +```typescript +import { bedrockText } from '@tanstack/ai-bedrock' +import { chat } from '@tanstack/ai' + +const adapter = bedrockText('openai.gpt-oss-mini-1:0', { + region: 'us-east-1', + api: 'chat', +}) + +for await (const chunk of chat({ + adapter, + messages: [{ role: 'user', content: 'What is the capital of France?' }], +})) { + if (chunk.type === 'content') process.stdout.write(chunk.delta) +} +``` + +## Responses API (`api: 'responses'`) + +Set `api: 'responses'` to use Bedrock's OpenAI-compatible Responses API. Returns a `bedrock-responses` adapter. This API is mantle-only. + +**Model scope:** Currently the OpenAI gpt-oss family. The Responses API is stateful — pass `previous_response_id` and `store` through `modelOptions` to continue a conversation server-side. + +```typescript +import { bedrockText } from '@tanstack/ai-bedrock' +import { chat } from '@tanstack/ai' + +const adapter = bedrockText('openai.gpt-oss-120b-1:0', { + region: 'us-east-1', + api: 'responses', +}) + +for await (const chunk of chat({ + adapter, + messages: [{ role: 'user', content: 'Summarize the Bedrock pricing page.' }], +})) { + if (chunk.type === 'content') process.stdout.write(chunk.delta) +} +``` + +## Model Availability + +The adapter ships with a hand-seeded snapshot catalog (`src/model-catalog.generated.ts`) of confirmed model IDs. This catalog can be refreshed by the maintainer script `scripts/fetch-bedrock-models.ts`, which calls `ListFoundationModels` with AWS credentials. + +**Actual model availability depends on your AWS account's model access configuration and the region you are targeting.** Enable model access in the [Amazon Bedrock console](https://console.aws.amazon.com/bedrock/home#/modelaccess) before use. + +For the full list of models and which API endpoints they support, see the [AWS API compatibility matrix](https://docs.aws.amazon.com/bedrock/latest/userguide/models-api-compatibility.html). + +## Supported Capabilities + +- Streaming chat completions +- Client-side tool calling +- Reasoning (extended thinking) +- Multimodal input (text, images, documents — model-dependent) +- JSON schema / structured output + +## API Reference + +### `bedrockText(model, config?)` + +Creates a Bedrock adapter using environment-variable auth. + +- `model` — Model ID (e.g. `'us.anthropic.claude-haiku-4-5-20251001-v1:0'`) +- `config.api` — `'converse'` (default), `'chat'`, or `'responses'` +- `config.region` — AWS region string (default `'us-east-1'`) +- `config.auth` — `'auto'` (default), `'apikey'`, or `'sigv4'` +- `config.apiKey` — Explicit API key (overrides env vars) +- `config.baseURL` — Override base URL +- `config.endpoint` — `'runtime'` (default) or `'mantle'` (Chat Completions path only) + +Returns a chat adapter for use with `chat()` or `generate()`. + +| `api` value | Adapter name | Underlying SDK | +|---|---|---| +| `'converse'` (default) | `bedrock-converse` | `@aws-sdk/client-bedrock-runtime` | +| `'chat'` | `bedrock` | `openai` (OpenAI-compatible) | +| `'responses'` | `bedrock-responses` | `openai` (OpenAI-compatible) | + +### `createBedrockText(model, apiKey, config?)` + +Creates a Bedrock adapter with an explicit API key, bypassing the environment-variable lookup. + +## Next Steps + +- [Amazon Bedrock API keys](https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html) — Create and manage API keys +- [Amazon Bedrock model access](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html) — Enable models in your account +- [AWS API compatibility matrix](https://docs.aws.amazon.com/bedrock/latest/userguide/models-api-compatibility.html) — Which models work with which APIs +- [Converse API reference](https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html) — Native Converse API docs +- [Streaming Guide](../chat/streaming) — Learn about streaming responses +- [Tools Guide](../tools/tools) — Learn about tool calling diff --git a/docs/config.json b/docs/config.json index eb9f7a97c..8e498eedb 100644 --- a/docs/config.json +++ b/docs/config.json @@ -322,6 +322,10 @@ { "label": "OpenRouter Adapter", "to": "adapters/openrouter" + }, + { + "label": "Amazon Bedrock", + "to": "adapters/bedrock" } ] }, diff --git a/docs/getting-started/overview.md b/docs/getting-started/overview.md index bba915f5f..24b67d4eb 100644 --- a/docs/getting-started/overview.md +++ b/docs/getting-started/overview.md @@ -110,6 +110,7 @@ With the help of adapters, TanStack AI can connect to various LLM providers. Ava - **@tanstack/ai-ollama** - Ollama (local models) - **@tanstack/ai-groq** - Groq - **@tanstack/ai-grok** - xAI Grok +- **@tanstack/ai-bedrock** - Amazon Bedrock (Claude, Nova, Llama, and more via AWS) - **@tanstack/ai-fal** - fal (image & video generation) ## Next Steps diff --git a/knip.json b/knip.json index 67ae81303..77dafff1e 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", - "ignoreDependencies": ["@faker-js/faker"], + "ignoreDependencies": ["@faker-js/faker", "@aws-sdk/client-bedrock"], "ignoreWorkspaces": [ "examples/**", "testing/**", @@ -43,6 +43,7 @@ }, "packages/ai-vue-ui": { "ignore": ["src/use-chat-context.ts"] - } + }, + "packages/ai-bedrock": {} } } diff --git a/package.json b/package.json index 53669156c..d6c9e18d4 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ ] }, "devDependencies": { + "@aws-sdk/client-bedrock": "^3.1057.0", "@changesets/changelog-github": "^0.7.0", "@changesets/cli": "^2.30.0", "@faker-js/faker": "^10.1.0", diff --git a/packages/ai-bedrock/README.md b/packages/ai-bedrock/README.md new file mode 100644 index 000000000..c4e2a269d --- /dev/null +++ b/packages/ai-bedrock/README.md @@ -0,0 +1,94 @@ +# @tanstack/ai-bedrock + +Amazon Bedrock adapter for TanStack AI — OpenAI-compatible Chat Completions and Responses APIs with streaming, tool calling, and reasoning. + +## Installation + +```bash +pnpm add @tanstack/ai-bedrock +# or +npm install @tanstack/ai-bedrock +# or +yarn add @tanstack/ai-bedrock +``` + +## Setup + +Get a Bedrock API key from the [Amazon Bedrock console](https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html) and set it as an environment variable: + +```bash +BEDROCK_API_KEY=your-bedrock-api-key +``` + +Alternatively, configure AWS credentials for SigV4 auth (see below). + +## Usage + +```typescript +import { bedrockText } from '@tanstack/ai-bedrock' +import { chat } from '@tanstack/ai' + +const adapter = bedrockText('us.anthropic.claude-3-7-sonnet-20250219-v1:0', { + region: 'us-east-1', +}) + +for await (const chunk of chat({ + adapter, + messages: [{ role: 'user', content: 'Hello from Bedrock!' }], +})) { + if (chunk.type === 'content') process.stdout.write(chunk.delta) +} +``` + +### Responses API + +```typescript +import { bedrockText } from '@tanstack/ai-bedrock' + +const adapter = bedrockText('openai.gpt-oss-120b-1:0', { + region: 'us-east-1', + api: 'responses', +}) +``` + +### With Explicit API Key + +```typescript +import { createBedrockText } from '@tanstack/ai-bedrock' + +const adapter = createBedrockText( + 'us.amazon.nova-pro-v1:0', + 'your-bedrock-api-key', + { region: 'us-west-2' }, +) +``` + +## Authentication + +Auth is resolved in this order: + +1. Explicit `apiKey` passed to the factory +2. `BEDROCK_API_KEY` environment variable +3. `AWS_BEARER_TOKEN_BEDROCK` environment variable +4. SigV4 via the AWS credential chain (requires `pnpm add aws-sigv4-fetch`) + +To use SigV4, install the optional peer dependency and set `auth: 'sigv4'`: + +```bash +pnpm add aws-sigv4-fetch +``` + +```typescript +const adapter = bedrockText('us.anthropic.claude-3-7-sonnet-20250219-v1:0', { + auth: 'sigv4', + region: 'us-east-1', +}) +``` + +## Documentation + +Full documentation: [TanStack AI — Amazon Bedrock adapter](https://tanstack.com/ai/latest/docs/adapters/bedrock) + +## License + +MIT diff --git a/packages/ai-bedrock/package.json b/packages/ai-bedrock/package.json new file mode 100644 index 000000000..0550c6360 --- /dev/null +++ b/packages/ai-bedrock/package.json @@ -0,0 +1,65 @@ +{ + "name": "@tanstack/ai-bedrock", + "version": "0.0.1", + "type": "module", + "description": "Amazon Bedrock adapter for TanStack AI — OpenAI-compatible chat, responses, tools, and reasoning.", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/ai-bedrock" + }, + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + } + }, + "files": [ + "dist", + "src" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "eslint ./src --fix", + "test:build": "publint --strict", + "test:eslint": "eslint ./src", + "test:lib": "vitest run", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "keywords": [ + "ai", + "ai-sdk", + "typescript", + "tanstack", + "bedrock", + "aws", + "adapter", + "llm", + "chat", + "tool-calling" + ], + "devDependencies": { + "@vitest/coverage-v8": "4.0.14", + "vite": "^7.3.3" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:*", + "zod": "^4.0.0" + }, + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-sdk/client-bedrock-runtime": "^3.1057.0", + "@aws-sdk/credential-providers": "^3.1057.0", + "@smithy/signature-v4": "^5.4.5", + "@smithy/types": "^4.14.2", + "@tanstack/ai-utils": "workspace:*", + "@tanstack/openai-base": "workspace:*", + "openai": "^6.9.1" + } +} diff --git a/packages/ai-bedrock/src/adapters/converse-text.ts b/packages/ai-bedrock/src/adapters/converse-text.ts new file mode 100644 index 000000000..fab5b869b --- /dev/null +++ b/packages/ai-bedrock/src/adapters/converse-text.ts @@ -0,0 +1,560 @@ +import { EventType, convertSchemaToJsonSchema } from '@tanstack/ai' +import { BaseTextAdapter } from '@tanstack/ai/adapters' +import { toRunErrorPayload } from '@tanstack/ai/adapter-internals' +import { resolveBedrockAuth } from '../utils/auth' +import { toConverseMessages } from '../converse/message-converter' +import { toToolConfig } from '../converse/tool-converter' +import { processConverseStream } from '../converse/stream-processor' +import { + STRUCTURED_TOOL_NAME, + buildStructuredToolConfig, +} from '../converse/structured-output' +import type { ConverseToolInput } from '../converse/tool-converter' +import type * as BedrockRuntime from '@aws-sdk/client-bedrock-runtime' +import type { + BedrockRuntimeClient, + ContentBlock, + ConverseCommandInput, + ConverseCommandOutput, + ConverseStreamCommandInput, + ConverseStreamOutput, +} from '@aws-sdk/client-bedrock-runtime' +import type { + JSONSchema, + Modality, + StreamChunk, + TextOptions, + Tool, +} from '@tanstack/ai' +import type { + StructuredOutputOptions, + StructuredOutputResult, +} from '@tanstack/ai/adapters' +import type { BedrockClientConfig } from '../utils/client' +import type { BedrockMessageMetadataByModality } from '../message-types' +import type { + BedrockConverseModels, + ResolveInputModalities, + ResolveProviderOptions, +} from '../model-meta' + +/** Config for the Converse adapter — same client config as the chat adapter. */ +export interface BedrockConverseConfig extends BedrockClientConfig {} + +/** + * Bedrock Converse text adapter. Wires the Converse translation modules (message + * converter, tool converter, stream processor, structured-output forced-tool + * builder) onto `@tanstack/ai`'s `BaseTextAdapter` and the + * `@aws-sdk/client-bedrock-runtime` `BedrockRuntimeClient`. + * + * The success-path AG-UI lifecycle (`RUN_STARTED`..`RUN_FINISHED`) is owned by + * `processConverseStream` (per C3); this adapter only owns the catch/`RUN_ERROR` + * path, mirroring openai-base's `chatStream`. + * + * The actual SDK calls live behind two protected seams (`sendStream` / `send`) + * so tests can subclass and inject canned Converse SDK shapes without a real + * AWS request. + */ +export class BedrockConverseTextAdapter< + TModel extends BedrockConverseModels, + // Constraint mirrors the chat adapter (text.ts): the base parameterises + // `TProviderOptions extends Record`, but our default + // `ResolveProviderOptions` resolves to an interface lacking an + // implicit index signature. `Record` is the only constraint + // that accepts that interface AND satisfies the base. Confined to the + // generic constraint — no value `as` cast is introduced. + TProviderOptions extends Record = ResolveProviderOptions, + TInputModalities extends ReadonlyArray = + ResolveInputModalities, +> extends BaseTextAdapter< + TModel, + TProviderOptions, + TInputModalities, + BedrockMessageMetadataByModality +> { + override readonly kind = 'text' as const + override readonly name = 'bedrock-converse' as const + private clientPromise?: Promise + private readonly clientConfig: BedrockConverseConfig + + constructor(config: BedrockConverseConfig, model: TModel) { + super({}, model) + // Defer client construction and auth resolution: the AWS SDK is Node/ + // server-only, so we must not pull it into the static graph here. The + // client (and its dynamic import) is built lazily on first SDK call. + this.clientConfig = config + } + + /** + * Dynamically import `@aws-sdk/client-bedrock-runtime`. The specifier is held + * in a variable (not a string literal) so bundler dep scanners (e.g. Vite/ + * esbuild optimizeDeps) cannot statically discover the AWS SDK and try to + * pre-bundle it for the browser — it would fail on the SDK's Node-only + * `fromTokenFile` export chain. The SDK is Node/server-only and is only + * reached on a real request. `typeof import(...)` is a type-only reference + * (erased at emit) so the imported members keep full typing. + */ + protected importBedrockRuntime(): Promise { + const mod = '@aws-sdk/client-bedrock-runtime' + return import(/* @vite-ignore */ mod) as Promise + } + + /** + * Lazily construct the `BedrockRuntimeClient`. The dynamic import keeps + * `@aws-sdk/client-bedrock-runtime` out of the static/browser graph and + * defers `resolveBedrockAuth` until a real request is made. + */ + protected async getClient(): Promise { + if (!this.clientPromise) { + this.clientPromise = (async () => { + const { BedrockRuntimeClient } = await this.importBedrockRuntime() + const region = this.clientConfig.region ?? 'us-east-1' + const resolved = resolveBedrockAuth( + { + apiKey: this.clientConfig.apiKey, + region, + auth: this.clientConfig.auth, + }, + 'runtime', + ) + const endpoint = this.clientConfig.baseURL + // The installed @aws-sdk/client-bedrock-runtime (v3.1057) exposes a + // first-class `token: TokenIdentity | TokenIdentityProvider` config + // field (HttpAuthSchemeInputConfig) for Bedrock API-key bearer auth — + // no custom requestHandler/middleware needed. SigV4 uses the credential + // provider. + if (resolved.kind === 'bearer') { + return new BedrockRuntimeClient({ + region, + token: { token: resolved.token }, + ...(endpoint ? { endpoint } : {}), + }) + } + return new BedrockRuntimeClient({ + region: resolved.region, + credentials: resolved.credentials, + ...(endpoint ? { endpoint } : {}), + }) + })() + } + return this.clientPromise + } + + // --------------------------------------------------------------------------- + // SDK seams (overridden in tests so no real AWS call happens) + // --------------------------------------------------------------------------- + + protected async sendStream( + input: ConverseStreamCommandInput, + ): Promise> { + const { ConverseStreamCommand } = await this.importBedrockRuntime() + const client = await this.getClient() + const res = await client.send(new ConverseStreamCommand(input)) + if (!res.stream) { + throw new Error('Bedrock Converse: empty stream response') + } + return res.stream + } + + protected async send( + input: ConverseCommandInput, + ): Promise { + const { ConverseCommand } = await this.importBedrockRuntime() + const client = await this.getClient() + return client.send(new ConverseCommand(input)) + } + + // --------------------------------------------------------------------------- + // Public adapter surface + // --------------------------------------------------------------------------- + + async *chatStream( + options: TextOptions, + ): AsyncIterable { + try { + options.logger.request( + `activity=chat provider=${this.name} model=${this.model} messages=${options.messages.length} tools=${options.tools?.length ?? 0} stream=true`, + { provider: this.name, model: this.model }, + ) + const input = this.buildInput(options) + const stream = await this.sendStream(input) + yield* processConverseStream(stream, () => this.generateId()) + } catch (error: unknown) { + const errorPayload = toRunErrorPayload( + error, + `${this.name}.chatStream failed`, + ) + options.logger.errors(`${this.name}.chatStream fatal`, { + error: errorPayload, + source: `${this.name}.chatStream`, + }) + // Conditional `code` spread keeps the wire shape spec-compliant under + // `exactOptionalPropertyTypes` (AG-UI's `RunErrorEvent.code` is optional). + yield { + type: EventType.RUN_ERROR, + model: options.model, + timestamp: Date.now(), + message: errorPayload.message, + ...(errorPayload.code !== undefined && { code: errorPayload.code }), + error: { + message: errorPayload.message, + ...(errorPayload.code !== undefined && { code: errorPayload.code }), + }, + } + } + } + + /** + * Structured output via the forced-tool strategy. Converse has no native + * json_schema response_format, so we force a single tool whose input schema + * is the requested output schema and read the model's `toolUse.input` back as + * the structured result. + */ + async structuredOutput( + options: StructuredOutputOptions, + ): Promise> { + const { chatOptions, outputSchema } = options + try { + chatOptions.logger.request( + `activity=structuredOutput provider=${this.name} model=${this.model} messages=${chatOptions.messages.length}`, + { provider: this.name, model: this.model }, + ) + const input: ConverseCommandInput = { + ...this.buildInput(chatOptions), + toolConfig: buildStructuredToolConfig(outputSchema), + } + const res = await this.send(input) + const structured = extractStructuredToolInput(res) + if (structured === undefined) { + throw new Error( + `${this.name}.structuredOutput: response contained no forced-tool output`, + ) + } + return { + data: structured, + rawText: JSON.stringify(structured), + } + } catch (error: unknown) { + chatOptions.logger.errors(`${this.name}.structuredOutput fatal`, { + error: toRunErrorPayload(error, `${this.name}.structuredOutput failed`), + source: `${this.name}.structuredOutput`, + }) + throw error + } + } + + /** + * Streaming structured output. Same forced-tool strategy as + * `structuredOutput`, but streamed: the forced tool's `toolUse.input` JSON + * fragments are accumulated from the Converse stream and a terminal + * `CUSTOM 'structured-output.complete'` event carries `{ object, raw }`, + * mirroring openai-base's `structuredOutputStream` contract exactly. + */ + async *structuredOutputStream( + options: StructuredOutputOptions, + ): AsyncIterable { + const { chatOptions, outputSchema } = options + const timestamp = Date.now() + const runId = this.generateId() + const threadId = chatOptions.threadId ?? this.generateId() + const messageId = this.generateId() + + let hasEmittedRunStarted = false + let hasEmittedTextMessageStart = false + let accumulatedRaw = '' + let finishReason: 'stop' | 'tool_calls' | 'length' | 'content_filter' = + 'stop' + + try { + chatOptions.logger.request( + `activity=structuredOutputStream provider=${this.name} model=${this.model} messages=${chatOptions.messages.length}`, + { provider: this.name, model: this.model }, + ) + const input: ConverseStreamCommandInput = { + ...this.buildInput(chatOptions), + toolConfig: buildStructuredToolConfig(outputSchema), + } + const stream = await this.sendStream(input) + + // The forced tool streams its `input` as partial-JSON fragments inside + // `contentBlockDelta.delta.toolUse.input`. We surface them as + // TEXT_MESSAGE_CONTENT deltas (raw JSON text), matching openai-base which + // carries the structured JSON as text deltas. + for await (const ev of stream) { + if (!hasEmittedRunStarted) { + hasEmittedRunStarted = true + yield { + type: EventType.RUN_STARTED, + runId, + threadId, + model: chatOptions.model, + timestamp, + parentRunId: chatOptions.parentRunId, + } + } + + if ('contentBlockDelta' in ev) { + const delta = ev.contentBlockDelta?.delta + const fragment = + delta && 'toolUse' in delta ? delta.toolUse?.input : undefined + if (fragment !== undefined) { + if (!hasEmittedTextMessageStart) { + hasEmittedTextMessageStart = true + yield { + type: EventType.TEXT_MESSAGE_START, + messageId, + role: 'assistant', + model: chatOptions.model, + timestamp, + } + } + accumulatedRaw += fragment + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta: fragment, + content: accumulatedRaw, + model: chatOptions.model, + timestamp, + } + } + continue + } + + if ('messageStop' in ev) { + const stopReason = ev.messageStop?.stopReason + finishReason = + stopReason === 'max_tokens' + ? 'length' + : stopReason === 'content_filtered' + ? 'content_filter' + : 'tool_calls' + continue + } + } + + if (!hasEmittedRunStarted) { + hasEmittedRunStarted = true + yield { + type: EventType.RUN_STARTED, + runId, + threadId, + model: chatOptions.model, + timestamp, + parentRunId: chatOptions.parentRunId, + } + } + + if (hasEmittedTextMessageStart) { + yield { + type: EventType.TEXT_MESSAGE_END, + messageId, + model: chatOptions.model, + timestamp, + } + } + + if (accumulatedRaw.length === 0) { + yield { + type: EventType.RUN_ERROR, + runId, + model: chatOptions.model, + timestamp, + message: `${this.name}.structuredOutputStream: response contained no content`, + code: 'empty-response', + error: { + message: `${this.name}.structuredOutputStream: response contained no content`, + code: 'empty-response', + }, + } + return + } + + let parsed: unknown + try { + parsed = JSON.parse(accumulatedRaw) + } catch { + yield { + type: EventType.RUN_ERROR, + runId, + model: chatOptions.model, + timestamp, + message: `Failed to parse structured output as JSON. Content: ${accumulatedRaw.slice(0, 200)}${accumulatedRaw.length > 200 ? '...' : ''}`, + code: 'parse-error', + error: { + message: 'Failed to parse structured output as JSON', + code: 'parse-error', + }, + } + return + } + + yield { + type: EventType.CUSTOM, + name: 'structured-output.complete', + value: { + object: parsed, + raw: accumulatedRaw, + }, + model: chatOptions.model, + timestamp, + } + + yield { + type: EventType.RUN_FINISHED, + runId, + threadId, + model: chatOptions.model, + timestamp, + finishReason, + } + } catch (error: unknown) { + if (!hasEmittedRunStarted) { + hasEmittedRunStarted = true + yield { + type: EventType.RUN_STARTED, + runId, + threadId, + model: chatOptions.model, + timestamp, + parentRunId: chatOptions.parentRunId, + } + } + const errorPayload = toRunErrorPayload( + error, + `${this.name}.structuredOutputStream failed`, + ) + chatOptions.logger.errors(`${this.name}.structuredOutputStream fatal`, { + error: errorPayload, + source: `${this.name}.structuredOutputStream`, + }) + yield { + type: EventType.RUN_ERROR, + runId, + model: chatOptions.model, + timestamp, + message: errorPayload.message, + ...(errorPayload.code !== undefined && { code: errorPayload.code }), + error: { + message: errorPayload.message, + ...(errorPayload.code !== undefined && { code: errorPayload.code }), + }, + } + } + } + + /** + * Converse sends `tools` and a forced structured-output tool via two separate + * mechanisms, never together. Declaring `false` makes the engine run the + * agent loop without `outputSchema` and finalize via `structuredOutput` / + * `structuredOutputStream`. + */ + supportsCombinedToolsAndSchema(): boolean { + return false + } + + // --------------------------------------------------------------------------- + // Request construction + // --------------------------------------------------------------------------- + + /** + * Translate `TextOptions` into a `ConverseCommandInput`. Shared by chatStream, + * structuredOutput, and structuredOutputStream (the latter two override + * `toolConfig` with the forced structured tool afterwards). + */ + protected buildInput( + options: TextOptions, + ): ConverseCommandInput { + const { system, messages } = toConverseMessages( + options.messages, + options.systemPrompts, + ) + + const toolConfig = options.tools + ? toToolConfig(convertTools(options.tools), 'auto') + : undefined + + const inferenceConfig = + options.temperature !== undefined || + options.topP !== undefined || + options.maxTokens !== undefined + ? { + ...(options.temperature !== undefined && { + temperature: options.temperature, + }), + ...(options.topP !== undefined && { topP: options.topP }), + ...(options.maxTokens !== undefined && { + maxTokens: options.maxTokens, + }), + } + : undefined + + return { + modelId: this.model, + messages, + ...(system.length > 0 && { system }), + ...(toolConfig && { toolConfig }), + ...(inferenceConfig && { inferenceConfig }), + } + } +} + +/** + * Convert TanStack `Tool[]` to the Converse tool-converter input shape. Reuses + * the SAME `convertSchemaToJsonSchema` the other adapters use so the Converse + * tool input schemas match what every other provider sends. + */ +function convertTools(tools: Array): Array { + return tools.map((tool) => { + const inputSchema: JSONSchema = convertSchemaToJsonSchema( + tool.inputSchema, + ) ?? { type: 'object', properties: {}, required: [] } + return { + name: tool.name, + description: tool.description, + inputSchema, + } + }) +} + +/** + * Find the forced structured-output tool's `input` in a non-streaming Converse + * response. SDK-boundary narrowing only — `ConverseOutput` is a tagged union + * (`{ message }`) and a tool-use block is `{ toolUse: { input } }`. + */ +function extractStructuredToolInput( + res: ConverseCommandOutput, +): unknown | undefined { + const message = + res.output && 'message' in res.output ? res.output.message : undefined + const content: Array = message?.content ?? [] + for (const block of content) { + if ('toolUse' in block && block.toolUse) { + // Prefer the forced tool by name, but fall back to any toolUse block so a + // provider that omits/renames the tool name still yields its structured + // input rather than failing loud on a name mismatch. + if ( + block.toolUse.name === STRUCTURED_TOOL_NAME || + block.toolUse.name === undefined + ) { + return block.toolUse.input + } + } + } + // Second pass: accept the first toolUse block regardless of name. + for (const block of content) { + if ('toolUse' in block && block.toolUse) { + return block.toolUse.input + } + } + return undefined +} + +/** Converse adapter with an explicit API key (low-level; mirrors createBedrockChat). */ +export function createBedrockConverse( + model: TModel, + apiKey: string, + config?: Omit, +): BedrockConverseTextAdapter { + return new BedrockConverseTextAdapter({ ...config, apiKey }, model) +} diff --git a/packages/ai-bedrock/src/adapters/responses-text.ts b/packages/ai-bedrock/src/adapters/responses-text.ts new file mode 100644 index 000000000..a85be08ed --- /dev/null +++ b/packages/ai-bedrock/src/adapters/responses-text.ts @@ -0,0 +1,74 @@ +import OpenAI from 'openai' +import { OpenAIBaseResponsesTextAdapter } from '@tanstack/openai-base' +import { withBedrockDefaults } from '../utils/client' +import type { Modality } from '@tanstack/ai' +import type { BedrockClientConfig } from '../utils/client' +import type { BedrockMessageMetadataByModality } from '../message-types' +import type { + BedrockChatModelToolCapabilitiesByName, + BedrockResponsesModels, + ResolveInputModalities, +} from '../model-meta' +import type { ExternalResponsesProviderOptions } from '../text/responses-provider-options' + +export interface BedrockResponsesConfig extends BedrockClientConfig {} + +export type { ExternalResponsesProviderOptions as BedrockResponsesProviderOptions } from '../text/responses-provider-options' + +type ResolveToolCapabilities = + TModel extends keyof BedrockChatModelToolCapabilitiesByName + ? NonNullable + : readonly [] + +/** + * Bedrock Responses adapter. Drives mantle's OpenAI-compatible `/responses` + * endpoint via the OpenAI SDK (`client.responses.create`) — the same base + * class ai-openai's `openaiText` uses. Responses is mantle-only, so the + * constructor forces the mantle baseURL. + */ +export class BedrockResponsesTextAdapter< + TModel extends BedrockResponsesModels, + // Constraint mirrors the chat adapter (and ai-groq / ai-openai): the base + // parameterises `TProviderOptions extends Record`, but our + // default `ExternalResponsesProviderOptions` is an interface that (lacking + // an implicit index signature) does not satisfy `Record`. + // `Record` is the only constraint that both accepts that + // interface default AND satisfies the base's constraint. This `any` is + // confined to the generic constraint — no value/shape `as` cast is introduced. + TProviderOptions extends Record = + ExternalResponsesProviderOptions, + TInputModalities extends ReadonlyArray = + ResolveInputModalities, + TToolCapabilities extends ReadonlyArray = + ResolveToolCapabilities, +> extends OpenAIBaseResponsesTextAdapter< + TModel, + TProviderOptions, + TInputModalities, + BedrockMessageMetadataByModality, + TToolCapabilities +> { + override readonly kind = 'text' as const + override readonly name = 'bedrock-responses' as const + + constructor(config: BedrockResponsesConfig, model: TModel) { + // Responses is mantle-only — force the mantle base URL (an explicit + // config.baseURL still wins, e.g. E2E pointing at aimock). + super( + model, + 'bedrock-responses', + new OpenAI(withBedrockDefaults(config, 'mantle')), + ) + } +} + +/** Responses adapter with an explicit API key (low-level; the public branching factory delegates here). */ +export function createBedrockResponsesText< + TModel extends BedrockResponsesModels, +>( + model: TModel, + apiKey: string, + config?: Omit, +): BedrockResponsesTextAdapter { + return new BedrockResponsesTextAdapter({ ...config, apiKey }, model) +} diff --git a/packages/ai-bedrock/src/adapters/text.ts b/packages/ai-bedrock/src/adapters/text.ts new file mode 100644 index 000000000..102b610b7 --- /dev/null +++ b/packages/ai-bedrock/src/adapters/text.ts @@ -0,0 +1,98 @@ +import OpenAI from 'openai' +import { OpenAIBaseChatCompletionsTextAdapter } from '@tanstack/openai-base' +import { withBedrockDefaults } from '../utils/client' +import type { Modality } from '@tanstack/ai' +import type { BedrockClientConfig } from '../utils/client' +import type { BedrockMessageMetadataByModality } from '../message-types' +import type { + BedrockChatModelToolCapabilitiesByName, + BedrockChatModels, + ResolveInputModalities, + ResolveProviderOptions, +} from '../model-meta' + +export interface BedrockTextConfig extends BedrockClientConfig {} + +export type { ExternalTextProviderOptions as BedrockTextProviderOptions } from '../text/text-provider-options' + +type ResolveToolCapabilities = + TModel extends keyof BedrockChatModelToolCapabilitiesByName + ? NonNullable + : readonly [] + +/** + * Bedrock Chat Completions adapter. Drives Bedrock's OpenAI-compatible + * `/chat/completions` endpoint via the OpenAI SDK with a baseURL override + * (same pattern as ai-groq). Tool conversion, streaming, structured output, + * and the agent loop come from the base. + */ +export class BedrockTextAdapter< + TModel extends BedrockChatModels, + // Constraint mirrors ai-groq: the base parameterises `TProviderOptions + // extends Record`, but our default + // `ResolveProviderOptions` resolves to the `BedrockTextProviderOptions` + // interface, which (lacking an implicit index signature) does not satisfy + // `Record`. `Record` is the only constraint that + // both accepts that interface default AND satisfies the base's constraint. + // This `any` is confined to the generic constraint (the established ai-groq + // pattern) — no value/shape `as` cast is introduced. + TProviderOptions extends Record = ResolveProviderOptions, + TInputModalities extends ReadonlyArray = + ResolveInputModalities, + TToolCapabilities extends ReadonlyArray = + ResolveToolCapabilities, +> extends OpenAIBaseChatCompletionsTextAdapter< + TModel, + TProviderOptions, + TInputModalities, + BedrockMessageMetadataByModality, + TToolCapabilities +> { + override readonly kind = 'text' as const + override readonly name = 'bedrock' as const + + constructor(config: BedrockTextConfig, model: TModel) { + // No `forced` -> honors config.endpoint ('runtime' default, 'mantle' allowed). + super(model, 'bedrock', new OpenAI(withBedrockDefaults(config))) + } + + /** + * Surface reasoning deltas (gpt-oss / Claude reasoning) the OpenAI-compatible + * way. Base types the chunk as `unknown`; narrow with runtime guards — no + * `as` casts, no `any`. + */ + protected override extractReasoning( + chunk: unknown, + ): { text: string } | undefined { + return readDeltaReasoning(chunk) + } +} + +/** Cast-free narrowing of a Chat Completions chunk's reasoning delta. */ +function readDeltaReasoning(chunk: unknown): { text: string } | undefined { + if (typeof chunk !== 'object' || chunk === null || !('choices' in chunk)) + return undefined + if (!Array.isArray(chunk.choices)) return undefined + const choice: unknown = chunk.choices[0] + if (typeof choice !== 'object' || choice === null || !('delta' in choice)) + return undefined + const delta = choice.delta + if (typeof delta !== 'object' || delta === null) return undefined + const raw = + 'reasoning' in delta && typeof delta.reasoning === 'string' + ? delta.reasoning + : 'reasoning_content' in delta && + typeof delta.reasoning_content === 'string' + ? delta.reasoning_content + : undefined + return raw && raw.length > 0 ? { text: raw } : undefined +} + +/** Chat adapter with an explicit API key (low-level; the public branching factory delegates here). */ +export function createBedrockChat( + model: TModel, + apiKey: string, + config?: Omit, +): BedrockTextAdapter { + return new BedrockTextAdapter({ ...config, apiKey }, model) +} diff --git a/packages/ai-bedrock/src/converse/message-converter.ts b/packages/ai-bedrock/src/converse/message-converter.ts new file mode 100644 index 000000000..df6f55321 --- /dev/null +++ b/packages/ai-bedrock/src/converse/message-converter.ts @@ -0,0 +1,262 @@ +import { normalizeSystemPrompts } from '@tanstack/ai' +import type { + ContentPart, + ContentPartDataSource, + DocumentPart, + ImagePart, + ModelMessage, + SystemPrompt, + TextPart, +} from '@tanstack/ai' +import type { + ContentBlock, + Message, + SystemContentBlock, + ToolResultContentBlock, +} from '@aws-sdk/client-bedrock-runtime' +import type { DocumentType } from '@smithy/types' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function base64ToBytes(b64: string): Uint8Array { + return new Uint8Array(Buffer.from(b64, 'base64')) +} + +function imageFormat(mime: string): 'png' | 'jpeg' | 'gif' | 'webp' { + switch (mime) { + case 'image/png': + return 'png' + case 'image/jpeg': + case 'image/jpg': + return 'jpeg' + case 'image/gif': + return 'gif' + case 'image/webp': + return 'webp' + default: + throw new Error( + `Bedrock Converse: unsupported image MIME type "${mime}". Supported types: image/png, image/jpeg, image/gif, image/webp.`, + ) + } +} + +function documentFormat( + mime: string, +): 'pdf' | 'csv' | 'doc' | 'docx' | 'xls' | 'xlsx' | 'html' | 'txt' | 'md' { + switch (mime) { + case 'application/pdf': + return 'pdf' + case 'text/csv': + return 'csv' + case 'application/msword': + return 'doc' + case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': + return 'docx' + case 'application/vnd.ms-excel': + return 'xls' + case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': + return 'xlsx' + case 'text/html': + return 'html' + case 'text/plain': + return 'txt' + case 'text/markdown': + case 'text/x-markdown': + return 'md' + default: + throw new Error( + `Bedrock Converse: unsupported document MIME type "${mime}". Supported types: pdf, csv, doc, docx, xls, xlsx, html, txt, md.`, + ) + } +} + +function stringContent(content: string | null | Array): string { + if (content === null) return '' + if (typeof content === 'string') return content + return content + .filter((p): p is TextPart => p.type === 'text') + .map((p) => p.content) + .join('') +} + +function isTextPart(p: ContentPart): p is TextPart { + return p.type === 'text' +} + +function isImagePart(p: ContentPart): p is ImagePart { + return p.type === 'image' +} + +function isDocumentPart(p: ContentPart): p is DocumentPart { + return p.type === 'document' +} + +function isDataSource( + source: ImagePart['source'] | DocumentPart['source'], +): source is ContentPartDataSource { + return source.type === 'data' +} + +function contentPartToBlock(part: ContentPart, docIndex: number): ContentBlock { + if (isTextPart(part)) { + return { text: part.content } + } + + if (isImagePart(part)) { + const { source } = part + if (!isDataSource(source)) { + throw new Error( + 'Bedrock Converse requires inline image bytes; URL image sources are not supported.', + ) + } + return { + image: { + format: imageFormat(source.mimeType), + source: { bytes: base64ToBytes(source.value) }, + }, + } + } + + if (isDocumentPart(part)) { + const { source } = part + if (!isDataSource(source)) { + throw new Error( + 'Bedrock Converse requires inline document bytes; URL document sources are not supported.', + ) + } + return { + document: { + format: documentFormat(source.mimeType), + name: `document-${docIndex}`, + source: { bytes: base64ToBytes(source.value) }, + }, + } + } + + // Fail loud for unsupported part types (audio, video, etc.) + const unsupported = (part as ContentPart).type + throw new Error( + `Bedrock Converse does not support content part type "${String(unsupported)}".`, + ) +} + +function messageToBlocks( + msg: ModelMessage, + docCounter: { value: number }, +): Array { + const blocks: Array = [] + + if (msg.role === 'tool') { + if (!msg.toolCallId) { + throw new Error( + 'Bedrock Converse: tool message is missing toolCallId. Every tool result must reference the tool use ID it is responding to.', + ) + } + const textContent = stringContent(msg.content) + const toolResult: ToolResultContentBlock = { text: textContent } + blocks.push({ + toolResult: { + toolUseId: msg.toolCallId, + content: [toolResult], + status: 'success', + }, + }) + return blocks + } + + // Map content field to text/image/document blocks + if (typeof msg.content === 'string') { + if (msg.content !== '') { + blocks.push({ text: msg.content }) + } + } else if (Array.isArray(msg.content)) { + for (const part of msg.content) { + const docIndex = isDocumentPart(part) ? ++docCounter.value : 0 + blocks.push(contentPartToBlock(part, docIndex)) + } + } + // null → no text blocks + + // Append toolUse blocks for assistant tool calls + if (msg.role === 'assistant' && msg.toolCalls) { + for (const call of msg.toolCalls) { + let input: DocumentType = {} + try { + const parsed = JSON.parse(call.function.arguments || '{}') as unknown + if ( + parsed !== null && + typeof parsed === 'object' && + !Array.isArray(parsed) + ) { + input = parsed as DocumentType + } + } catch { + // Malformed / partial JSON — fall back to empty object so the call + // can still be forwarded rather than crashing the whole request. + input = {} + } + blocks.push({ + toolUse: { + toolUseId: call.id, + name: call.function.name, + input, + }, + }) + } + } + + return blocks +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Convert TanStack AI messages + system prompts into the Converse API format. + * + * - System prompts are lifted into `SystemContentBlock[]`. + * - `tool` role messages are remapped to `user` role `toolResult` blocks. + * - Consecutive messages with the same Converse role are merged (Converse + * requires strict user/assistant alternation). + */ +export function toConverseMessages( + messages: Array, + systemPrompts?: Array, +): { system: Array; messages: Array } { + // Build system blocks (uses normalizeSystemPrompts for runtime validation) + const system: Array = normalizeSystemPrompts( + systemPrompts, + ).map((p) => ({ text: p.content })) + + // Convert each ModelMessage to a Converse Message, merging same-role pairs + const converseMessages: Array = [] + // Global document counter: ensures every document block across all messages + // gets a unique name, preventing Bedrock ValidationException for duplicate names. + const docCounter = { value: 0 } + + for (const msg of messages) { + // Map TanStack roles to Converse roles + const converseRole: 'user' | 'assistant' = + msg.role === 'assistant' ? 'assistant' : 'user' + + const blocks = messageToBlocks(msg, docCounter) + + // Skip messages that produce no content blocks (e.g. assistant with + // null content and no toolCalls). Pushing an empty-content message to + // Converse triggers a ValidationException. + if (blocks.length === 0) continue + + const last = converseMessages[converseMessages.length - 1] + if (last && last.role === converseRole) { + // Merge into the previous message's content array + last.content = [...(last.content ?? []), ...blocks] + } else { + converseMessages.push({ role: converseRole, content: blocks }) + } + } + + return { system, messages: converseMessages } +} diff --git a/packages/ai-bedrock/src/converse/stream-processor.ts b/packages/ai-bedrock/src/converse/stream-processor.ts new file mode 100644 index 000000000..3e69ca2f0 --- /dev/null +++ b/packages/ai-bedrock/src/converse/stream-processor.ts @@ -0,0 +1,267 @@ +import { EventType } from '@tanstack/ai' +import type { RunFinishedEvent, StreamChunk } from '@tanstack/ai' +import type { ConverseStreamOutput } from '@aws-sdk/client-bedrock-runtime' + +/** + * Maps a Bedrock Converse `ConverseStreamOutput` event stream to the TanStack + * AG-UI `StreamChunk` lifecycle. This mirrors, field-for-field, how + * `openai-base`'s `processStreamChunks` constructs each event so the activity + * layer / agent loop behave identically across providers. + * + * Lifecycle ownership matches openai-base: this processor emits the full + * success-path lifecycle itself — `RUN_STARTED` lazily before the first event, + * `TEXT_MESSAGE_*` / `TOOL_CALL_*` / `REASONING_*` for content, and a single + * terminal `RUN_FINISHED` once the iterator is exhausted (so the trailing + * `metadata` usage event is folded into the finish event regardless of arrival + * order). The calling adapter only owns the catch/`RUN_ERROR` path. + * + * Converse streams tool-call arguments as partial-JSON string fragments inside + * `contentBlockDelta.delta.toolUse.input`; each fragment is emitted as a + * `TOOL_CALL_ARGS` `delta`, mirroring OpenAI's `function.arguments` deltas. + * + * @param stream - The Converse event stream from `ConverseStreamCommand`. + * @param newMessageId - Factory for fresh message/tool-call ids (the adapter + * passes `() => this.generateId()`). + */ +export async function* processConverseStream( + stream: AsyncIterable, + newMessageId: () => string, +): AsyncIterable { + const runId = newMessageId() + const threadId = newMessageId() + const messageId = newMessageId() + + let hasEmittedRunStarted = false + + // Text lifecycle + let accumulatedContent = '' + let hasEmittedTextMessageStart = false + + // Reasoning lifecycle + let reasoningMessageId: string | undefined + let hasClosedReasoning = false + + // Tool-call lifecycle, keyed by Converse contentBlockIndex. Converse opens a + // tool-use block with `contentBlockStart`, streams arg fragments via + // `contentBlockDelta`, and closes it with `contentBlockStop`. + const toolCallsByIndex = new Map< + number, + { id: string; name: string; started: boolean } + >() + + // Usage + finish-reason are captured during iteration and folded into the + // single terminal RUN_FINISHED, matching openai-base's deferred-finish + // contract (usage may arrive after the finish signal). + let usage: + | { promptTokens: number; completionTokens: number; totalTokens: number } + | undefined + let finishReason: NonNullable | undefined + + // Lazily emit RUN_STARTED exactly once, before the first content event. + function* ensureRunStarted(): Generator { + if (hasEmittedRunStarted) return + hasEmittedRunStarted = true + yield { + type: EventType.RUN_STARTED, + runId, + threadId, + timestamp: Date.now(), + } + } + + // Close an open reasoning message before text/tool content begins, mirroring + // openai-base which always emits REASONING_MESSAGE_END before TEXT_MESSAGE_START. + function* closeReasoning(): Generator { + if (reasoningMessageId && !hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: EventType.REASONING_MESSAGE_END, + messageId: reasoningMessageId, + timestamp: Date.now(), + } + } + } + + for await (const ev of stream) { + yield* ensureRunStarted() + + // messageStart carries only the role; no AG-UI event maps to it. + if ('messageStart' in ev) continue + + if ('contentBlockStart' in ev) { + const start = ev.contentBlockStart + const toolUse = start?.start?.toolUse + if (start && toolUse) { + yield* closeReasoning() + const id = toolUse.toolUseId ?? newMessageId() + const name = toolUse.name ?? '' + const index = start.contentBlockIndex ?? 0 + toolCallsByIndex.set(index, { + id, + name, + started: true, + }) + yield { + type: EventType.TOOL_CALL_START, + toolCallId: id, + toolCallName: name, + toolName: name, + timestamp: Date.now(), + index, + } + } + continue + } + + if ('contentBlockDelta' in ev) { + const block = ev.contentBlockDelta + const delta = block?.delta + const index = block?.contentBlockIndex ?? 0 + + // Tool-call argument fragments (partial JSON). + if (delta && 'toolUse' in delta && delta.toolUse?.input !== undefined) { + const toolCall = toolCallsByIndex.get(index) + if (toolCall?.started) { + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId: toolCall.id, + timestamp: Date.now(), + delta: delta.toolUse.input, + } + } + continue + } + + // Reasoning content. + if ( + delta && + 'reasoningContent' in delta && + delta.reasoningContent && + 'text' in delta.reasoningContent && + delta.reasoningContent.text !== undefined + ) { + if (!reasoningMessageId) { + reasoningMessageId = newMessageId() + yield { + type: EventType.REASONING_MESSAGE_START, + messageId: reasoningMessageId, + role: 'reasoning', + timestamp: Date.now(), + } + } + yield { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: reasoningMessageId, + delta: delta.reasoningContent.text, + timestamp: Date.now(), + } + continue + } + + // Text content. + if (delta && 'text' in delta && delta.text !== undefined) { + yield* closeReasoning() + if (!hasEmittedTextMessageStart) { + hasEmittedTextMessageStart = true + yield { + type: EventType.TEXT_MESSAGE_START, + messageId, + role: 'assistant', + timestamp: Date.now(), + } + } + accumulatedContent += delta.text + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta: delta.text, + content: accumulatedContent, + timestamp: Date.now(), + } + } + continue + } + + if ('contentBlockStop' in ev) { + const stopIndex = ev.contentBlockStop?.contentBlockIndex ?? 0 + const toolCall = toolCallsByIndex.get(stopIndex) + if (toolCall?.started) { + yield { + type: EventType.TOOL_CALL_END, + toolCallId: toolCall.id, + toolCallName: toolCall.name, + toolName: toolCall.name, + timestamp: Date.now(), + } + toolCallsByIndex.delete(stopIndex) + } + continue + } + + if ('messageStop' in ev) { + const stopReason = ev.messageStop?.stopReason + // Map Converse stopReason to AG-UI's narrower finishReason vocabulary. + finishReason = + stopReason === 'tool_use' + ? 'tool_calls' + : stopReason === 'max_tokens' + ? 'length' + : stopReason === 'content_filtered' + ? 'content_filter' + : 'stop' + continue + } + + if ('metadata' in ev) { + const u = ev.metadata?.usage + if (u) { + usage = { + promptTokens: u.inputTokens ?? 0, + completionTokens: u.outputTokens ?? 0, + totalTokens: u.totalTokens ?? 0, + } + } + continue + } + } + + // Stream ended (possibly without any content) — still emit RUN_STARTED so + // consumers always see a run lifecycle. + yield* ensureRunStarted() + + // Drain any tool call that opened but never received contentBlockStop. + for (const [index, toolCall] of toolCallsByIndex) { + if (!toolCall.started) continue + yield { + type: EventType.TOOL_CALL_END, + toolCallId: toolCall.id, + toolCallName: toolCall.name, + toolName: toolCall.name, + timestamp: Date.now(), + } + toolCallsByIndex.delete(index) + } + + // Close the text message lifecycle if it was opened. + if (hasEmittedTextMessageStart) { + yield { + type: EventType.TEXT_MESSAGE_END, + messageId, + timestamp: Date.now(), + } + } + + // Close any reasoning lifecycle that text never closed. + yield* closeReasoning() + + // Single terminal RUN_FINISHED. Conditional `usage` spread keeps the wire + // shape spec-compliant (AG-UI's `usage` is optional with no `| undefined`). + yield { + type: EventType.RUN_FINISHED, + runId, + threadId, + timestamp: Date.now(), + finishReason: finishReason ?? 'stop', + ...(usage && { usage }), + } +} diff --git a/packages/ai-bedrock/src/converse/structured-output.ts b/packages/ai-bedrock/src/converse/structured-output.ts new file mode 100644 index 000000000..c818b22aa --- /dev/null +++ b/packages/ai-bedrock/src/converse/structured-output.ts @@ -0,0 +1,24 @@ +import type { ToolConfiguration } from '@aws-sdk/client-bedrock-runtime' +import type { DocumentType } from '@smithy/types' + +export const STRUCTURED_TOOL_NAME = 'structured_output' + +/** + * Converse has no native json_schema response_format. Structured output is + * achieved by forcing a single tool whose input schema is the requested output + * schema; the model's tool-use `input` is the structured result. + */ +export function buildStructuredToolConfig(schema: unknown): ToolConfiguration { + return { + tools: [ + { + toolSpec: { + name: STRUCTURED_TOOL_NAME, + description: 'Return the final answer as structured JSON.', + inputSchema: { json: schema as DocumentType }, + }, + }, + ], + toolChoice: { tool: { name: STRUCTURED_TOOL_NAME } }, + } +} diff --git a/packages/ai-bedrock/src/converse/tool-converter.ts b/packages/ai-bedrock/src/converse/tool-converter.ts new file mode 100644 index 000000000..2f41ff308 --- /dev/null +++ b/packages/ai-bedrock/src/converse/tool-converter.ts @@ -0,0 +1,44 @@ +import type { + ToolChoice, + ToolConfiguration, +} from '@aws-sdk/client-bedrock-runtime' +import type { DocumentType } from '@smithy/types' + +export interface ConverseToolInput { + name: string + description?: string + inputSchema: unknown +} + +export type ToolChoiceInput = + | 'auto' + | 'required' + | 'none' + | { type: 'tool'; name: string } + +export function toToolConfig( + tools: Array, + choice: ToolChoiceInput | undefined, +): ToolConfiguration | undefined { + if (!tools.length) return undefined + const toolChoice = mapChoice(choice) + return { + tools: tools.map((t) => ({ + toolSpec: { + name: t.name, + ...(t.description ? { description: t.description } : {}), + inputSchema: { json: t.inputSchema as DocumentType }, + }, + })), + ...(toolChoice ? { toolChoice } : {}), + } +} + +function mapChoice( + choice: ToolChoiceInput | undefined, +): ToolChoice | undefined { + if (!choice || choice === 'auto') return { auto: {} } + if (choice === 'required') return { any: {} } + if (choice === 'none') return undefined + return { tool: { name: choice.name } } +} diff --git a/packages/ai-bedrock/src/index.ts b/packages/ai-bedrock/src/index.ts new file mode 100644 index 000000000..73bc89006 --- /dev/null +++ b/packages/ai-bedrock/src/index.ts @@ -0,0 +1,178 @@ +/** + * @module @tanstack/ai-bedrock + * + * Amazon Bedrock adapter for TanStack AI via Bedrock's OpenAI-compatible APIs + * and the native Converse API. The public `bedrockText` / `createBedrockText` + * factory branches between the Converse adapter (DEFAULT), the Chat Completions + * adapter (`api: 'chat'`), and the Responses adapter (`api: 'responses'`). + */ +import { BedrockTextAdapter } from './adapters/text' +import { BedrockResponsesTextAdapter } from './adapters/responses-text' +import { BedrockConverseTextAdapter } from './adapters/converse-text' +import { BEDROCK_RESPONSES_MODELS } from './model-meta' +import type { BedrockTextConfig } from './adapters/text' +import type { BedrockResponsesConfig } from './adapters/responses-text' +import type { BedrockConverseConfig } from './adapters/converse-text' +import type { BedrockClientConfig } from './utils' +import type { + BedrockChatModels, + BedrockConverseModels, + BedrockResponsesModels, +} from './model-meta' + +/** Config for the branching factory's converse mode (default, or api: 'converse'). */ +export type BedrockConverseApiConfig = BedrockConverseConfig & { + api?: 'converse' +} +/** Config for the branching factory's chat mode (api: 'chat' required). */ +export type BedrockChatApiConfig = BedrockTextConfig & { + api: 'chat' +} +/** Config for the branching factory's responses mode (api: 'responses' required). */ +export type BedrockResponsesApiConfig = BedrockResponsesConfig & { + api: 'responses' +} + +type AnyBedrockAdapter = + | BedrockConverseTextAdapter + | BedrockTextAdapter + | BedrockResponsesTextAdapter + +/** Cast-free runtime guard: is this model in the Responses-capable subset? */ +function isResponsesModel(model: string): model is BedrockResponsesModels { + return BEDROCK_RESPONSES_MODELS.some((m) => m === model) +} + +/** Strip the `api` discriminator from a config without an unused-var lint error. */ +function stripApi(config: T): Omit { + const { api, ...rest } = config + void api + return rest +} + +/** + * Shared branching used by both public factories. Constructs the adapter + * classes directly so their constructors run the full auth cascade lazily + * (config.apiKey → BEDROCK_API_KEY → AWS_BEARER_TOKEN_BEDROCK → SigV4). No + * eager env-key fetch here, so `auth: 'sigv4'` never throws for a missing key. + * + * Default path → Converse adapter; opt-in via `api: 'chat'` or `api: 'responses'`. + */ +function build( + model: BedrockConverseModels, + config?: BedrockClientConfig & { api?: 'converse' | 'chat' | 'responses' }, +): AnyBedrockAdapter { + if (config?.api === 'responses') { + const rest = stripApi(config) + if (!isResponsesModel(model)) { + throw new Error( + `Model "${model}" is not available on the Bedrock Responses API. ` + + `Responses-capable models: ${BEDROCK_RESPONSES_MODELS.join(', ')}.`, + ) + } + return new BedrockResponsesTextAdapter(rest, model) + } + if (config?.api === 'chat') { + return new BedrockTextAdapter(stripApi(config), model as BedrockChatModels) + } + // Default + explicit 'converse' + return new BedrockConverseTextAdapter(config ? stripApi(config) : {}, model) +} + +// --- createBedrockText: explicit key, overloaded on `api` --- +export function createBedrockText( + model: TModel, + apiKey: string, + config?: BedrockConverseApiConfig, +): BedrockConverseTextAdapter +export function createBedrockText( + model: TModel, + apiKey: string, + config: BedrockChatApiConfig, +): BedrockTextAdapter +export function createBedrockText( + model: TModel, + apiKey: string, + config: BedrockResponsesApiConfig, +): BedrockResponsesTextAdapter +export function createBedrockText( + model: BedrockConverseModels, + apiKey: string, + config?: + | BedrockConverseApiConfig + | BedrockChatApiConfig + | BedrockResponsesApiConfig, +): AnyBedrockAdapter { + // Explicit apiKey is authoritative — spread config first so it can't override. + return build(model, { ...config, apiKey }) +} + +// --- bedrockText: env-key counterpart, same overloads --- +export function bedrockText( + model: TModel, + config?: BedrockConverseApiConfig, +): BedrockConverseTextAdapter +export function bedrockText( + model: TModel, + config: BedrockChatApiConfig, +): BedrockTextAdapter +export function bedrockText( + model: TModel, + config: BedrockResponsesApiConfig, +): BedrockResponsesTextAdapter +export function bedrockText( + model: BedrockConverseModels, + config?: + | BedrockConverseApiConfig + | BedrockChatApiConfig + | BedrockResponsesApiConfig, +): AnyBedrockAdapter { + // No eager env-key fetch: the adapter constructor resolves auth lazily so + // SigV4 (and the env-key fallback) work without a forced API key here. + return build(model, config) +} + +// --- Re-exports --- +export { + BedrockTextAdapter, + createBedrockChat, + type BedrockTextConfig, + type BedrockTextProviderOptions, +} from './adapters/text' +export { + BedrockResponsesTextAdapter, + createBedrockResponsesText, + type BedrockResponsesConfig, + type BedrockResponsesProviderOptions, +} from './adapters/responses-text' +export { + BedrockConverseTextAdapter, + createBedrockConverse, + type BedrockConverseConfig, +} from './adapters/converse-text' +export { + resolveBedrockAuth, + withBedrockDefaults, + type BedrockClientConfig, + type BedrockEndpoint, + type ResolvedBedrockAuth, +} from './utils' +export { + BEDROCK_CHAT_MODELS, + BEDROCK_RESPONSES_MODELS, + BEDROCK_CONVERSE_MODELS, + type BedrockChatModels, + type BedrockResponsesModels, + type BedrockConverseModels, + type BedrockChatModelProviderOptionsByName, + type BedrockChatModelToolCapabilitiesByName, + type BedrockModelInputModalitiesByName, +} from './model-meta' +export type { + BedrockMessageMetadataByModality, + BedrockTextMetadata, + BedrockImageMetadata, + BedrockAudioMetadata, + BedrockVideoMetadata, + BedrockDocumentMetadata, +} from './message-types' diff --git a/packages/ai-bedrock/src/message-types.ts b/packages/ai-bedrock/src/message-types.ts new file mode 100644 index 000000000..8ca1846ee --- /dev/null +++ b/packages/ai-bedrock/src/message-types.ts @@ -0,0 +1,24 @@ +/** + * Bedrock content-part metadata by modality, used for type inference when + * constructing multimodal messages. Bedrock's OpenAI-compatible Chat + * Completions accepts the standard OpenAI image-detail hint; other modalities + * carry no extra metadata today. + */ +export interface BedrockTextMetadata {} + +export interface BedrockImageMetadata { + /** Image processing detail: 'auto' (default), 'low', or 'high'. */ + detail?: 'auto' | 'low' | 'high' +} + +export interface BedrockAudioMetadata {} +export interface BedrockVideoMetadata {} +export interface BedrockDocumentMetadata {} + +export interface BedrockMessageMetadataByModality { + text: BedrockTextMetadata + image: BedrockImageMetadata + audio: BedrockAudioMetadata + video: BedrockVideoMetadata + document: BedrockDocumentMetadata +} diff --git a/packages/ai-bedrock/src/model-catalog.generated.ts b/packages/ai-bedrock/src/model-catalog.generated.ts new file mode 100644 index 000000000..f2d3dafda --- /dev/null +++ b/packages/ai-bedrock/src/model-catalog.generated.ts @@ -0,0 +1,88 @@ +// GENERATED by scripts/fetch-bedrock-models.ts — do not edit by hand. +// Refresh: AWS_REGION=us-east-1 pnpm tsx scripts/fetch-bedrock-models.ts +export const GENERATED_BEDROCK_MODELS = [ + { + id: 'openai.gpt-oss-120b-1:0', + input: ['text'], + output: ['text'], + apis: { converse: true, chat: true, responses: true }, + }, + { + id: 'openai.gpt-oss-20b-1:0', + input: ['text'], + output: ['text'], + apis: { converse: true, chat: true, responses: true }, + }, + { + id: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + input: ['text', 'image', 'document'], + output: ['text'], + apis: { converse: true, chat: false, responses: false }, + }, + { + id: 'us.anthropic.claude-haiku-4-5-20251001-v1:0', + input: ['text', 'image', 'document'], + output: ['text'], + apis: { converse: true, chat: false, responses: false }, + }, + { + id: 'us.anthropic.claude-3-7-sonnet-20250219-v1:0', + input: ['text', 'image', 'document'], + output: ['text'], + apis: { converse: true, chat: false, responses: false }, + }, + { + id: 'us.anthropic.claude-3-5-sonnet-20241022-v2:0', + input: ['text', 'image', 'document'], + output: ['text'], + apis: { converse: true, chat: false, responses: false }, + }, + { + id: 'us.anthropic.claude-3-5-haiku-20241022-v1:0', + input: ['text'], + output: ['text'], + apis: { converse: true, chat: false, responses: false }, + }, + { + id: 'us.amazon.nova-pro-v1:0', + input: ['text', 'image', 'document'], + output: ['text'], + apis: { converse: true, chat: false, responses: false }, + }, + { + id: 'us.amazon.nova-lite-v1:0', + input: ['text', 'image', 'document'], + output: ['text'], + apis: { converse: true, chat: false, responses: false }, + }, + { + id: 'us.amazon.nova-micro-v1:0', + input: ['text'], + output: ['text'], + apis: { converse: true, chat: false, responses: false }, + }, + { + id: 'us.meta.llama3-3-70b-instruct-v1:0', + input: ['text'], + output: ['text'], + apis: { converse: true, chat: false, responses: false }, + }, + { + id: 'us.meta.llama4-maverick-17b-instruct-v1:0', + input: ['text', 'image'], + output: ['text'], + apis: { converse: true, chat: false, responses: false }, + }, + { + id: 'us.mistral.pixtral-large-2502-v1:0', + input: ['text', 'image'], + output: ['text'], + apis: { converse: true, chat: false, responses: false }, + }, + { + id: 'us.deepseek.r1-v1:0', + input: ['text'], + output: ['text'], + apis: { converse: true, chat: false, responses: false }, + }, +] as const diff --git a/packages/ai-bedrock/src/model-meta.ts b/packages/ai-bedrock/src/model-meta.ts new file mode 100644 index 000000000..46da36c97 --- /dev/null +++ b/packages/ai-bedrock/src/model-meta.ts @@ -0,0 +1,58 @@ +import { GENERATED_BEDROCK_MODELS } from './model-catalog.generated' +import type { BedrockTextProviderOptions } from './text/text-provider-options' + +type Entry = (typeof GENERATED_BEDROCK_MODELS)[number] + +/** + * Type-level per-API filter over the generated catalog. Because the catalog is + * `as const`, `Extract` preserves literal `id` unions (no widening to `string`). + */ +type IdsWhere = Extract< + Entry, + { apis: Record } +>['id'] + +export type BedrockConverseModels = IdsWhere<'converse'> +export type BedrockChatModels = IdsWhere<'chat'> +export type BedrockResponsesModels = IdsWhere<'responses'> + +/** Runtime catalogs. Cast-free narrowing via a type predicate (the ai-bedrock pattern). */ +// Every catalog entry advertises `converse: true` (Converse is the universal +// Bedrock surface), so the id list is the full catalog — no runtime filter needed. +export const BEDROCK_CONVERSE_MODELS: ReadonlyArray = + GENERATED_BEDROCK_MODELS.map((m) => m.id) + +export const BEDROCK_CHAT_MODELS: ReadonlyArray = + GENERATED_BEDROCK_MODELS.filter( + (m): m is Extract => m.apis.chat, + ).map((m) => m.id) + +export const BEDROCK_RESPONSES_MODELS: ReadonlyArray = + GENERATED_BEDROCK_MODELS.filter( + (m): m is Extract => m.apis.responses, + ).map((m) => m.id) + +/** Per-model input modalities (drives type-safe multimodal content). Covers ALL models. */ +export type BedrockModelInputModalitiesByName = { + [E in Entry as E['id']]: E['input'] +} + +/** Provider options per model. Same options for every model; keyed over the full catalog. */ +export type BedrockChatModelProviderOptionsByName = { + [E in Entry as E['id']]: BedrockTextProviderOptions +} + +/** No provider-specific tools — empty tuple makes cross-provider ProviderTool a compile error. */ +export type BedrockChatModelToolCapabilitiesByName = { + [E in Entry as E['id']]: readonly [] +} + +export type ResolveProviderOptions = + TModel extends keyof BedrockChatModelProviderOptionsByName + ? BedrockChatModelProviderOptionsByName[TModel] + : BedrockTextProviderOptions + +export type ResolveInputModalities = + TModel extends keyof BedrockModelInputModalitiesByName + ? BedrockModelInputModalitiesByName[TModel] + : readonly ['text'] diff --git a/packages/ai-bedrock/src/text/responses-provider-options.ts b/packages/ai-bedrock/src/text/responses-provider-options.ts new file mode 100644 index 000000000..a2f0c28a7 --- /dev/null +++ b/packages/ai-bedrock/src/text/responses-provider-options.ts @@ -0,0 +1,28 @@ +/** + * Bedrock Responses API provider options. Mantle's Responses endpoint adds + * stateful conversation management on top of the OpenAI Responses fields. + * + * @see https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-mantle.html + */ +export interface BedrockResponsesProviderOptions { + /** Continue a stored conversation from a prior response. */ + previous_response_id?: string | null + /** Whether Bedrock retains the response for 30 days (default true). Set false to opt out. */ + store?: boolean | null + metadata?: { [key: string]: string } | null + max_output_tokens?: number | null + temperature?: number | null + top_p?: number | null + parallel_tool_calls?: boolean | null + tool_choice?: + | 'none' + | 'auto' + | 'required' + | { type: 'function'; name: string } + | null + /** Reasoning controls for reasoning-capable models. */ + reasoning?: { effort?: 'low' | 'medium' | 'high' } | null + user?: string | null +} + +export type ExternalResponsesProviderOptions = BedrockResponsesProviderOptions diff --git a/packages/ai-bedrock/src/text/text-provider-options.ts b/packages/ai-bedrock/src/text/text-provider-options.ts new file mode 100644 index 000000000..ef7edcdf3 --- /dev/null +++ b/packages/ai-bedrock/src/text/text-provider-options.ts @@ -0,0 +1,33 @@ +/** + * Bedrock Chat Completions provider options. Bedrock accepts the standard + * OpenAI Chat Completions request fields; we surface the commonly-used ones + * plus `reasoning_effort` (supported by gpt-oss and reasoning models). + * + * @see https://docs.aws.amazon.com/bedrock/latest/userguide/model-parameters-openai.html + */ +export interface BedrockTextProviderOptions { + frequency_penalty?: number | null + presence_penalty?: number | null + logit_bias?: { [token: string]: number } | null + logprobs?: boolean | null + top_logprobs?: number | null + max_completion_tokens?: number | null + metadata?: { [key: string]: string } | null + n?: number | null + parallel_tool_calls?: boolean | null + /** gpt-oss / reasoning models: 'low' | 'medium' (default) | 'high'. */ + reasoning_effort?: 'low' | 'medium' | 'high' | null + seed?: number | null + stop?: string | Array | null + temperature?: number | null + tool_choice?: + | 'none' + | 'auto' + | 'required' + | { type: 'function'; function: { name: string } } + | null + top_p?: number | null + user?: string | null +} + +export type ExternalTextProviderOptions = BedrockTextProviderOptions diff --git a/packages/ai-bedrock/src/utils/auth.ts b/packages/ai-bedrock/src/utils/auth.ts new file mode 100644 index 000000000..75cc19321 --- /dev/null +++ b/packages/ai-bedrock/src/utils/auth.ts @@ -0,0 +1,79 @@ +import { getApiKeyFromEnv } from '@tanstack/ai-utils' +import type { AwsCredentialIdentityProvider } from '@smithy/types' +import type * as CredentialProviders from '@aws-sdk/credential-providers' + +export type BedrockEndpoint = 'runtime' | 'mantle' + +/** SigV4 service name differs per endpoint. */ +export function sigv4Service(endpoint: BedrockEndpoint): string { + return endpoint === 'mantle' ? 'bedrock-mantle' : 'bedrock' +} + +export type ResolvedBedrockAuth = + | { kind: 'bearer'; token: string } + | { + kind: 'sigv4' + region: string + service: string + credentials: AwsCredentialIdentityProvider + } + +const DEFAULT_REGION = 'us-east-1' + +function readApiKeyFromEnv(): string | undefined { + try { + return getApiKeyFromEnv('BEDROCK_API_KEY') + } catch { + try { + return getApiKeyFromEnv('AWS_BEARER_TOKEN_BEDROCK') + } catch { + return undefined + } + } +} + +export interface BedrockAuthConfig { + apiKey?: string + region?: string + auth?: 'apikey' | 'sigv4' | 'auto' +} + +/** apiKey -> BEDROCK_API_KEY -> AWS_BEARER_TOKEN_BEDROCK -> SigV4 (credential chain). */ +export function resolveBedrockAuth( + config: BedrockAuthConfig, + endpoint: BedrockEndpoint, +): ResolvedBedrockAuth { + const mode = config.auth ?? 'auto' + const region = config.region ?? DEFAULT_REGION + + if (mode !== 'sigv4') { + const token = config.apiKey ?? readApiKeyFromEnv() + if (token) return { kind: 'bearer', token } + if (mode === 'apikey') { + throw new Error( + 'No Bedrock API key found. Set BEDROCK_API_KEY (or ' + + 'AWS_BEARER_TOKEN_BEDROCK), pass `apiKey`, or use auth: "sigv4".', + ) + } + } + + return { + kind: 'sigv4', + region, + service: sigv4Service(endpoint), + // Lazy credential provider: the AWS SDK is Node/server-only, so we defer the + // dynamic import until SigV4 actually needs to resolve credentials. The + // specifier is held in a variable (not a string literal) so bundler dep + // scanners (e.g. Vite/esbuild optimizeDeps) cannot statically discover the + // AWS SDK and try to pre-bundle it for the browser — it would fail on the + // SDK's Node-only `fromTokenFile` export chain. `typeof import(...)` is a + // type-only reference (erased at emit) so we keep full typing. + credentials: async (...args) => { + const mod = '@aws-sdk/credential-providers' + const { fromNodeProviderChain } = (await import( + /* @vite-ignore */ mod + )) as typeof CredentialProviders + return fromNodeProviderChain()(...args) + }, + } +} diff --git a/packages/ai-bedrock/src/utils/client.ts b/packages/ai-bedrock/src/utils/client.ts new file mode 100644 index 000000000..75fee20ef --- /dev/null +++ b/packages/ai-bedrock/src/utils/client.ts @@ -0,0 +1,62 @@ +import { resolveBedrockAuth } from './auth' +import { createSigV4Fetch } from './openai-sigv4-fetch' +import type { ClientOptions } from 'openai' +import type { BedrockEndpoint } from './auth' + +export type { BedrockEndpoint } from './auth' +export { resolveBedrockAuth } from './auth' +export type { ResolvedBedrockAuth } from './auth' + +export interface BedrockClientConfig extends Omit< + ClientOptions, + 'apiKey' | 'baseURL' +> { + /** Bedrock API key (bearer). Optional — falls back to env, then SigV4. */ + apiKey?: string + /** Full AWS region (e.g. 'us-east-1'). Default 'us-east-1'. */ + region?: string + /** Chat adapter only; the responses adapter forces 'mantle'. Default 'runtime'. */ + endpoint?: BedrockEndpoint + /** Auth strategy. Default 'auto' (apiKey → env → SigV4). */ + auth?: 'apikey' | 'sigv4' | 'auto' + /** Explicit override; wins over the computed endpoint URL (used by E2E → aimock). */ + baseURL?: string +} + +const DEFAULT_REGION = 'us-east-1' +/** OpenAI SDK requires a non-empty apiKey even when a signed fetch overrides Authorization. */ +const SIGV4_PLACEHOLDER_KEY = 'bedrock-sigv4' + +function buildBaseURL(region: string, endpoint: BedrockEndpoint): string { + return endpoint === 'mantle' + ? `https://bedrock-mantle.${region}.api.aws/v1` + : `https://bedrock-runtime.${region}.amazonaws.com/openai/v1` +} + +/** Builds OpenAI ClientOptions for the requested endpoint. `forced` pins the endpoint (responses → 'mantle'). */ +export function withBedrockDefaults( + config: BedrockClientConfig, + forced?: BedrockEndpoint, +): ClientOptions { + const { region, endpoint, auth, apiKey, baseURL, fetch, ...rest } = config + const resolvedRegion = region ?? DEFAULT_REGION + const resolvedEndpoint = forced ?? endpoint ?? 'runtime' + const resolved = resolveBedrockAuth( + { apiKey, region: resolvedRegion, auth }, + resolvedEndpoint, + ) + if (resolved.kind === 'bearer') { + return { + ...rest, + baseURL: baseURL ?? buildBaseURL(resolvedRegion, resolvedEndpoint), + apiKey: resolved.token, + ...(fetch ? { fetch } : {}), + } + } + return { + ...rest, + baseURL: baseURL ?? buildBaseURL(resolvedRegion, resolvedEndpoint), + apiKey: SIGV4_PLACEHOLDER_KEY, + fetch: fetch ?? createSigV4Fetch(resolved), + } +} diff --git a/packages/ai-bedrock/src/utils/index.ts b/packages/ai-bedrock/src/utils/index.ts new file mode 100644 index 000000000..aa73872fb --- /dev/null +++ b/packages/ai-bedrock/src/utils/index.ts @@ -0,0 +1,7 @@ +export { + resolveBedrockAuth, + withBedrockDefaults, + type BedrockClientConfig, + type BedrockEndpoint, + type ResolvedBedrockAuth, +} from './client' diff --git a/packages/ai-bedrock/src/utils/openai-sigv4-fetch.ts b/packages/ai-bedrock/src/utils/openai-sigv4-fetch.ts new file mode 100644 index 000000000..b0fcb01ef --- /dev/null +++ b/packages/ai-bedrock/src/utils/openai-sigv4-fetch.ts @@ -0,0 +1,55 @@ +import { SignatureV4 } from '@smithy/signature-v4' +import { Sha256 } from '@aws-crypto/sha256-js' +import type { HttpRequest } from '@smithy/types' +import type { ResolvedBedrockAuth } from './auth' + +type FetchLike = typeof fetch + +/** + * Wraps a fetch so each request is SigV4-signed via the AWS signer that ships + * with `@aws-sdk/client-bedrock-runtime`. Replaces the old aws-sigv4-fetch peer. + */ +export function createSigV4Fetch( + auth: Extract, + baseFetch: FetchLike = fetch, +): FetchLike { + const signer = new SignatureV4({ + service: auth.service, + region: auth.region, + credentials: auth.credentials, + sha256: Sha256, + }) + + return async (input, init) => { + // Request.toString() returns '[object Request]', not the URL — use .url instead. + const href = + typeof input === 'string' + ? input + : input instanceof Request + ? input.url + : input.toString() + const url = new URL(href) + const headers: Record = {} + new Headers(init?.headers).forEach((v, k) => (headers[k] = v)) + headers['host'] = url.host + + const body = init?.body ?? undefined + + // Construct a plain object satisfying the @smithy/types HttpRequest interface — + // no @smithy/protocol-http needed. + const request: HttpRequest = { + method: init?.method ?? 'GET', + protocol: url.protocol, + hostname: url.hostname, + path: url.pathname + url.search, + headers, + body, + } + + const signed = await signer.sign(request) + return baseFetch(url.toString(), { + ...init, + headers: signed.headers as Record, + }) + } +} diff --git a/packages/ai-bedrock/tests/adapter.test.ts b/packages/ai-bedrock/tests/adapter.test.ts new file mode 100644 index 000000000..a9bc3a7aa --- /dev/null +++ b/packages/ai-bedrock/tests/adapter.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { BedrockTextAdapter, createBedrockChat } from '../src/adapters/text' +import { + BedrockResponsesTextAdapter, + createBedrockResponsesText, +} from '../src/adapters/responses-text' +import { bedrockText, createBedrockText } from '../src/index' +import { BedrockTextAdapter as ChatAdapter } from '../src/adapters/text' +import { BedrockResponsesTextAdapter as RespAdapter } from '../src/adapters/responses-text' +import { BedrockConverseTextAdapter as ConverseAdapter } from '../src/adapters/converse-text' + +afterEach(() => vi.unstubAllEnvs()) + +describe('BedrockTextAdapter', () => { + it('constructs with name "bedrock" and kind "text"', () => { + const a = createBedrockChat('openai.gpt-oss-120b-1:0', 'test-key', { + region: 'us-east-1', + }) + expect(a).toBeInstanceOf(BedrockTextAdapter) + expect(a.name).toBe('bedrock') + expect(a.kind).toBe('text') + expect(a.model).toBe('openai.gpt-oss-120b-1:0') + }) + + describe('extractReasoning (cast-free)', () => { + // Access the protected hook through a tiny typed subclass — no `as` casts. + class Probe extends BedrockTextAdapter<'openai.gpt-oss-120b-1:0'> { + read(chunk: unknown) { + return this.extractReasoning(chunk) + } + } + const probe = new Probe({ apiKey: 'k' }, 'openai.gpt-oss-120b-1:0') + + it('reads delta.reasoning', () => { + expect( + probe.read({ choices: [{ delta: { reasoning: 'thinking' } }] }), + ).toEqual({ text: 'thinking' }) + }) + it('reads delta.reasoning_content', () => { + expect( + probe.read({ choices: [{ delta: { reasoning_content: 'rc' } }] }), + ).toEqual({ text: 'rc' }) + }) + it('returns undefined for unrelated chunks', () => { + expect( + probe.read({ choices: [{ delta: { content: 'hi' } }] }), + ).toBeUndefined() + expect(probe.read({})).toBeUndefined() + expect(probe.read(null)).toBeUndefined() + }) + it('returns undefined for empty-string reasoning', () => { + expect( + probe.read({ choices: [{ delta: { reasoning: '' } }] }), + ).toBeUndefined() + }) + it('returns undefined for non-array choices', () => { + expect(probe.read({ choices: 'not-an-array' })).toBeUndefined() + }) + }) +}) + +describe('BedrockResponsesTextAdapter', () => { + it('constructs with name "bedrock-responses", forces mantle baseURL', () => { + const a = createBedrockResponsesText( + 'openai.gpt-oss-120b-1:0', + 'test-key', + { + region: 'us-east-1', + }, + ) + expect(a).toBeInstanceOf(BedrockResponsesTextAdapter) + expect(a.name).toBe('bedrock-responses') + expect(a.kind).toBe('text') + }) +}) + +describe('createBedrockText (branching factory)', () => { + it('defaults to the Converse adapter', () => { + const a = createBedrockText('us.amazon.nova-pro-v1:0', 'k', { + region: 'us-east-1', + }) + expect(a).toBeInstanceOf(ConverseAdapter) + expect(a.name).toBe('bedrock-converse') + }) + + it("explicit api: 'converse' returns the Converse adapter", () => { + const a = createBedrockText('us.amazon.nova-pro-v1:0', 'k', { + region: 'us-east-1', + api: 'converse', + }) + expect(a).toBeInstanceOf(ConverseAdapter) + expect(a.name).toBe('bedrock-converse') + }) + + it("returns the responses adapter when api: 'responses'", () => { + const a = createBedrockText('openai.gpt-oss-120b-1:0', 'k', { + region: 'us-east-1', + api: 'responses', + }) + expect(a).toBeInstanceOf(RespAdapter) + expect(a.name).toBe('bedrock-responses') + }) + + it("explicit api: 'chat' returns the chat adapter", () => { + const a = createBedrockText('openai.gpt-oss-120b-1:0', 'k', { api: 'chat' }) + expect(a).toBeInstanceOf(ChatAdapter) + }) + + it('rejects a chat-only model with api:responses (compile-time) and throws at runtime', () => { + expect(() => { + // @ts-expect-error — a chat-only model is not assignable to the api:'responses' overload + // (BedrockResponsesModels). This line also locks the compile-time contract: if the + // overloads ever stop rejecting it, the @ts-expect-error becomes unused and tsc fails. + createBedrockText('us.anthropic.claude-3-5-haiku-20241022-v1:0', 'k', { + api: 'responses', + }) + }).toThrowError(/Responses-capable models:/) + }) +}) + +describe('bedrockText (env-key branching factory)', () => { + it('reads the key from BEDROCK_API_KEY and defaults to Converse', () => { + vi.stubEnv('BEDROCK_API_KEY', 'env-key') + expect( + bedrockText('us.amazon.nova-pro-v1:0', { region: 'us-east-1' }), + ).toBeInstanceOf(ConverseAdapter) + expect( + bedrockText('openai.gpt-oss-120b-1:0', { + region: 'us-east-1', + api: 'responses', + }), + ).toBeInstanceOf(RespAdapter) + expect( + bedrockText('openai.gpt-oss-120b-1:0', { + region: 'us-east-1', + api: 'chat', + }), + ).toBeInstanceOf(ChatAdapter) + }) + + it('does not require an API key when auth is sigv4', () => { + vi.stubEnv('BEDROCK_API_KEY', '') + vi.stubEnv('AWS_BEARER_TOKEN_BEDROCK', '') + // Must NOT throw — SigV4 path resolves lazily. + expect(() => + bedrockText('openai.gpt-oss-120b-1:0', { + region: 'us-east-1', + auth: 'sigv4', + }), + ).not.toThrow() + }) +}) diff --git a/packages/ai-bedrock/tests/auth.test.ts b/packages/ai-bedrock/tests/auth.test.ts new file mode 100644 index 000000000..0ac7e0867 --- /dev/null +++ b/packages/ai-bedrock/tests/auth.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { resolveBedrockAuth } from '../src/utils/auth' + +describe('resolveBedrockAuth', () => { + it('returns bearer when an explicit apiKey is given', () => { + const r = resolveBedrockAuth( + { apiKey: 'k', region: 'us-east-1' }, + 'runtime', + ) + expect(r).toEqual({ kind: 'bearer', token: 'k' }) + }) + + it('returns bearer from BEDROCK_API_KEY env', () => { + process.env.BEDROCK_API_KEY = 'envkey' + try { + const r = resolveBedrockAuth({ region: 'us-east-1' }, 'runtime') + expect(r).toEqual({ kind: 'bearer', token: 'envkey' }) + } finally { + delete process.env.BEDROCK_API_KEY + } + }) + + it('returns sigv4 with service+region when auth forced sigv4', () => { + const r = resolveBedrockAuth( + { auth: 'sigv4', region: 'us-west-2' }, + 'mantle', + ) + expect(r.kind).toBe('sigv4') + if (r.kind === 'sigv4') { + expect(r.region).toBe('us-west-2') + expect(r.service).toBe('bedrock-mantle') + } + }) + + it('throws in apikey mode with no key available', () => { + delete process.env.BEDROCK_API_KEY + delete process.env.AWS_BEARER_TOKEN_BEDROCK + expect(() => resolveBedrockAuth({ auth: 'apikey' }, 'runtime')).toThrow( + /No Bedrock API key/, + ) + }) +}) diff --git a/packages/ai-bedrock/tests/client.test.ts b/packages/ai-bedrock/tests/client.test.ts new file mode 100644 index 000000000..ac17e5e15 --- /dev/null +++ b/packages/ai-bedrock/tests/client.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { resolveBedrockAuth, withBedrockDefaults } from '../src/utils/client' + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('withBedrockDefaults', () => { + it('builds the runtime URL by default', () => { + const out = withBedrockDefaults({ apiKey: 'k', region: 'us-east-1' }) + expect(out.baseURL).toBe( + 'https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1', + ) + }) + + it('defaults region to us-east-1', () => { + const out = withBedrockDefaults({ apiKey: 'k' }) + expect(out.baseURL).toBe( + 'https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1', + ) + }) + + it('builds the mantle URL when endpoint is mantle', () => { + const out = withBedrockDefaults({ + apiKey: 'k', + region: 'eu-west-1', + endpoint: 'mantle', + }) + expect(out.baseURL).toBe('https://bedrock-mantle.eu-west-1.api.aws/v1') + }) + + it('forces mantle when the `forced` arg is mantle, ignoring config.endpoint', () => { + const out = withBedrockDefaults( + { apiKey: 'k', region: 'us-west-2', endpoint: 'runtime' }, + 'mantle', + ) + expect(out.baseURL).toBe('https://bedrock-mantle.us-west-2.api.aws/v1') + }) + + it('honors an explicit baseURL override', () => { + const out = withBedrockDefaults({ + apiKey: 'k', + baseURL: 'http://127.0.0.1:4010/v1', + }) + expect(out.baseURL).toBe('http://127.0.0.1:4010/v1') + }) + + it('does not leak region/endpoint/auth into the OpenAI ClientOptions', () => { + const out = withBedrockDefaults({ + apiKey: 'k', + region: 'us-east-1', + endpoint: 'runtime', + auth: 'apikey', + }) + expect('region' in out).toBe(false) + expect('endpoint' in out).toBe(false) + expect('auth' in out).toBe(false) + }) + + it('explicit baseURL survives the SigV4 path and signer is attached', () => { + const out = withBedrockDefaults({ + baseURL: 'http://127.0.0.1:4010/v1', + auth: 'sigv4', + region: 'us-east-1', + }) + expect(out.baseURL).toBe('http://127.0.0.1:4010/v1') + expect(typeof out.fetch).toBe('function') + }) + + it('user-supplied fetch wins over the SigV4 signer', () => { + const userFetch: NonNullable< + import('openai').ClientOptions['fetch'] + > = async () => new Response() + const out = withBedrockDefaults({ + auth: 'sigv4', + region: 'us-east-1', + fetch: userFetch, + }) + expect(out.fetch).toBe(userFetch) + }) +}) + +describe('resolveBedrockAuth', () => { + it('uses an explicit apiKey — returns bearer', () => { + const r = resolveBedrockAuth({ apiKey: 'explicit' }, 'runtime') + expect(r).toEqual({ kind: 'bearer', token: 'explicit' }) + }) + + it('falls back to BEDROCK_API_KEY — returns bearer', () => { + vi.stubEnv('BEDROCK_API_KEY', 'from-bedrock-env') + const r = resolveBedrockAuth({}, 'runtime') + expect(r).toEqual({ kind: 'bearer', token: 'from-bedrock-env' }) + }) + + it('falls back to AWS_BEARER_TOKEN_BEDROCK — returns bearer', () => { + vi.stubEnv('AWS_BEARER_TOKEN_BEDROCK', 'from-aws-env') + const r = resolveBedrockAuth({}, 'runtime') + expect(r).toEqual({ kind: 'bearer', token: 'from-aws-env' }) + }) + + it("auth: 'apikey' with no key throws an actionable error", () => { + vi.stubEnv('BEDROCK_API_KEY', '') + vi.stubEnv('AWS_BEARER_TOKEN_BEDROCK', '') + expect(() => + resolveBedrockAuth({ auth: 'apikey' }, 'runtime'), + ).toThrowError(/No Bedrock API key/) + }) + + it("auth: 'sigv4' returns kind:'sigv4' with region and service", () => { + const r = resolveBedrockAuth( + { auth: 'sigv4', region: 'us-east-1' }, + 'runtime', + ) + expect(r.kind).toBe('sigv4') + if (r.kind === 'sigv4') { + expect(r.region).toBe('us-east-1') + expect(r.service).toBe('bedrock') + } + }) + + it("'auto' with no key falls through to SigV4 — returns kind:'sigv4'", () => { + vi.stubEnv('BEDROCK_API_KEY', '') + vi.stubEnv('AWS_BEARER_TOKEN_BEDROCK', '') + const r = resolveBedrockAuth({ region: 'us-east-1' }, 'runtime') + expect(r.kind).toBe('sigv4') + }) +}) diff --git a/packages/ai-bedrock/tests/converse/adapter.test.ts b/packages/ai-bedrock/tests/converse/adapter.test.ts new file mode 100644 index 000000000..eba7ed3d1 --- /dev/null +++ b/packages/ai-bedrock/tests/converse/adapter.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest' +import { EventType } from '@tanstack/ai' +import { resolveDebugOption } from '@tanstack/ai/adapter-internals' +import { BedrockConverseTextAdapter } from '../../src/adapters/converse-text' +import type { + ConverseCommandOutput, + ConverseStreamOutput, +} from '@aws-sdk/client-bedrock-runtime' +import type { StreamChunk, TextOptions } from '@tanstack/ai' + +/** + * Subclass that overrides the protected SDK seams so no real AWS call happens. + * The adapter's translation logic (buildInput, lifecycle wiring) is exercised + * end-to-end against canned Converse SDK shapes. + */ +class StubAdapter extends BedrockConverseTextAdapter<'us.amazon.nova-pro-v1:0'> { + streamEvents: Array = [] + nonStreamOutput: ConverseCommandOutput = + {} as unknown as ConverseCommandOutput + + protected override async sendStream(): Promise< + AsyncIterable + > { + const evs = this.streamEvents + return (async function* () { + for (const e of evs) yield e + })() + } + + protected override async send(): Promise { + return this.nonStreamOutput + } +} + +const testLogger = resolveDebugOption(false) + +/** Minimal TextOptions for the stub. */ +function textOptions(overrides: Partial = {}): TextOptions { + return { + model: 'us.amazon.nova-pro-v1:0', + messages: [{ role: 'user', content: 'hi' }], + logger: testLogger, + ...overrides, + } +} + +describe('BedrockConverseTextAdapter', () => { + it('exposes name "bedrock-converse" and kind "text"', () => { + const a = new BedrockConverseTextAdapter( + { apiKey: 'k' }, + 'us.amazon.nova-pro-v1:0', + ) + expect(a.name).toBe('bedrock-converse') + expect(a.kind).toBe('text') + }) + + it('streams text through chatStream', async () => { + const a = new StubAdapter({ apiKey: 'k' }, 'us.amazon.nova-pro-v1:0') + a.streamEvents = [ + { messageStart: { role: 'assistant' } }, + { contentBlockDelta: { delta: { text: 'hi' }, contentBlockIndex: 0 } }, + { messageStop: { stopReason: 'end_turn' } }, + // SDK boundary: the metadata event requires `metrics` too — narrow the + // canned shape through `unknown` rather than spell out every field. + { + metadata: { + usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, + }, + } as unknown as ConverseStreamOutput, + ] + const types: Array = [] + for await (const c of a.chatStream(textOptions())) { + types.push(c.type) + } + expect(types).toContain(EventType.TEXT_MESSAGE_CONTENT) + expect(types).toContain(EventType.RUN_FINISHED) + }) + + it('emits RUN_ERROR when the stream seam throws', async () => { + class ThrowingAdapter extends BedrockConverseTextAdapter<'us.amazon.nova-pro-v1:0'> { + protected override async sendStream(): Promise< + AsyncIterable + > { + throw new Error('boom') + } + protected override async send(): Promise { + return {} as unknown as ConverseCommandOutput + } + } + const a = new ThrowingAdapter({ apiKey: 'k' }, 'us.amazon.nova-pro-v1:0') + const types: Array = [] + for await (const c of a.chatStream(textOptions())) { + types.push(c.type) + } + expect(types).toContain(EventType.RUN_ERROR) + }) + + it('returns parsed object from structuredOutput (forced tool)', async () => { + const a = new StubAdapter({ apiKey: 'k' }, 'us.amazon.nova-pro-v1:0') + a.nonStreamOutput = { + output: { + message: { + role: 'assistant', + content: [ + { + toolUse: { + toolUseId: 's', + name: 'structured_output', + input: { n: 5 }, + }, + }, + ], + }, + }, + // SDK boundary: a real ConverseCommandOutput also carries stopReason / + // usage / metrics / $metadata — narrow through `unknown` for the fixture. + } as unknown as ConverseCommandOutput + const res = await a.structuredOutput({ + chatOptions: textOptions({ messages: [{ role: 'user', content: 'go' }] }), + outputSchema: { type: 'object', properties: { n: { type: 'number' } } }, + }) + expect(res.data).toEqual({ n: 5 }) + expect(JSON.parse(res.rawText)).toEqual({ n: 5 }) + }) + + it('streams structured output through structuredOutputStream', async () => { + const a = new StubAdapter({ apiKey: 'k' }, 'us.amazon.nova-pro-v1:0') + a.streamEvents = [ + { messageStart: { role: 'assistant' } }, + { + contentBlockStart: { + start: { toolUse: { toolUseId: 's', name: 'structured_output' } }, + contentBlockIndex: 0, + }, + }, + { + contentBlockDelta: { + delta: { toolUse: { input: '{"n":5}' } }, + contentBlockIndex: 0, + }, + }, + { contentBlockStop: { contentBlockIndex: 0 } }, + { messageStop: { stopReason: 'tool_use' } }, + ] + const events: Array = [] + for await (const c of a.structuredOutputStream({ + chatOptions: textOptions({ messages: [{ role: 'user', content: 'go' }] }), + outputSchema: { type: 'object', properties: { n: { type: 'number' } } }, + })) { + events.push(c) + } + const complete = events.find( + (e): e is Extract => + e.type === EventType.CUSTOM && + 'name' in e && + e.name === 'structured-output.complete', + ) + expect(complete).toBeDefined() + expect((complete?.value as { object: unknown }).object).toEqual({ n: 5 }) + }) + + it('declares it does not support combined tools and schema', () => { + const a = new BedrockConverseTextAdapter( + { apiKey: 'k' }, + 'us.amazon.nova-pro-v1:0', + ) + expect(a.supportsCombinedToolsAndSchema()).toBe(false) + }) +}) diff --git a/packages/ai-bedrock/tests/converse/message-converter.test.ts b/packages/ai-bedrock/tests/converse/message-converter.test.ts new file mode 100644 index 000000000..4f05bb1f3 --- /dev/null +++ b/packages/ai-bedrock/tests/converse/message-converter.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, it } from 'vitest' +import { toConverseMessages } from '../../src/converse/message-converter' +import type { ModelMessage } from '@tanstack/ai' + +describe('toConverseMessages', () => { + it('lifts system prompts into the Converse system field', () => { + const { system, messages } = toConverseMessages( + [{ role: 'user', content: 'hi' }], + ['be terse'], + ) + expect(system).toEqual([{ text: 'be terse' }]) + expect(messages).toEqual([{ role: 'user', content: [{ text: 'hi' }] }]) + }) + + it('normalizes object system prompts and joins multiple', () => { + const { system } = toConverseMessages( + [{ role: 'user', content: 'hi' }], + ['a', { content: 'b' }], + ) + expect(system).toEqual([{ text: 'a' }, { text: 'b' }]) + }) + + it('merges consecutive same-role messages (Converse requires alternation)', () => { + const { messages } = toConverseMessages([ + { role: 'user', content: 'a' }, + { role: 'user', content: 'b' }, + ]) + expect(messages).toEqual([ + { role: 'user', content: [{ text: 'a' }, { text: 'b' }] }, + ]) + }) + + it('maps assistant tool calls to toolUse and tool results to a user toolResult', () => { + const msgs: ModelMessage[] = [ + { + role: 'assistant', + content: '', + toolCalls: [ + { + id: 't1', + type: 'function', + function: { name: 'getX', arguments: '{"a":1}' }, + }, + ], + }, + { role: 'tool', content: '{"ok":true}', toolCallId: 't1' }, + ] + const { messages } = toConverseMessages(msgs) + expect(messages[0]).toEqual({ + role: 'assistant', + content: [ + { toolUse: { toolUseId: 't1', name: 'getX', input: { a: 1 } } }, + ], + }) + expect(messages[1]).toEqual({ + role: 'user', + content: [ + { + toolResult: { + toolUseId: 't1', + content: [{ text: '{"ok":true}' }], + status: 'success', + }, + }, + ], + }) + }) + + it('maps a data-source image part to a Converse image block', () => { + const { messages } = toConverseMessages([ + { + role: 'user', + content: [ + { type: 'text', content: 'look' }, + { + type: 'image', + source: { type: 'data', value: btoa('xy'), mimeType: 'image/png' }, + }, + ], + }, + ]) + const content = messages[0]!.content! + const textBlock = content[0]! + const imageBlock = content[1]! + expect(textBlock).toEqual({ text: 'look' }) + expect(imageBlock).toMatchObject({ image: { format: 'png' } }) + // bytes decoded from base64 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((imageBlock as any).image.source.bytes).toEqual( + new Uint8Array([120, 121]), + ) + }) + + it('throws on a URL image source (Converse needs inline bytes)', () => { + expect(() => + toConverseMessages([ + { + role: 'user', + content: [ + { + type: 'image', + source: { type: 'url', value: 'https://x/y.png' }, + }, + ], + }, + ]), + ).toThrow(/inline|bytes|URL/i) + }) + + it('gives distinct names to multiple document parts in one message', () => { + const { messages } = toConverseMessages([ + { + role: 'user', + content: [ + { + type: 'document', + source: { + type: 'data', + value: btoa('doc1'), + mimeType: 'application/pdf', + }, + }, + { + type: 'document', + source: { + type: 'data', + value: btoa('doc2'), + mimeType: 'text/plain', + }, + }, + ], + }, + ]) + const content = messages[0]!.content! + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const name0 = (content[0] as any).document.name as string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const name1 = (content[1] as any).document.name as string + expect(name0).not.toBe(name1) + expect(name0).toMatch(/document-\d+/) + expect(name1).toMatch(/document-\d+/) + }) + + it('gives distinct names to document parts across multiple messages', () => { + const { messages } = toConverseMessages([ + { + role: 'user', + content: [ + { + type: 'document', + source: { + type: 'data', + value: btoa('doc1'), + mimeType: 'application/pdf', + }, + }, + ], + }, + { + role: 'assistant', + content: 'noted', + }, + { + role: 'user', + content: [ + { + type: 'document', + source: { + type: 'data', + value: btoa('doc2'), + mimeType: 'text/plain', + }, + }, + ], + }, + ]) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const name0 = (messages[0]!.content![0] as any).document.name as string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const name1 = (messages[2]!.content![0] as any).document.name as string + expect(name0).not.toBe(name1) + }) +}) diff --git a/packages/ai-bedrock/tests/converse/stream-processor.test.ts b/packages/ai-bedrock/tests/converse/stream-processor.test.ts new file mode 100644 index 000000000..5c310ce5c --- /dev/null +++ b/packages/ai-bedrock/tests/converse/stream-processor.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { EventType } from '@tanstack/ai' +import { processConverseStream } from '../../src/converse/stream-processor' +import type { ConverseStreamOutput } from '@aws-sdk/client-bedrock-runtime' + +// Test fixtures use the minimal field subset the processor reads. Cast at the +// generator boundary to the SDK union type — the SDK marks every field as +// `T | undefined` and requires sibling fields (e.g. `metrics` on metadata) the +// processor never touches, so supplying full Smithy shapes would only add noise. +type ConverseStreamFixture = { + [K in keyof ConverseStreamOutput]?: unknown +} + +async function* gen(...e: Array) { + for (const x of e) yield x as ConverseStreamOutput +} + +describe('processConverseStream', () => { + it('emits the text lifecycle and finishes', async () => { + const types: Array = [] + for await (const c of processConverseStream( + gen( + { messageStart: { role: 'assistant' } }, + { contentBlockDelta: { delta: { text: 'Hel' }, contentBlockIndex: 0 } }, + { contentBlockDelta: { delta: { text: 'lo' }, contentBlockIndex: 0 } }, + { contentBlockStop: { contentBlockIndex: 0 } }, + { messageStop: { stopReason: 'end_turn' } }, + { + metadata: { + usage: { inputTokens: 3, outputTokens: 2, totalTokens: 5 }, + }, + }, + ), + () => 'msg-1', + )) { + types.push(c.type) + } + expect(types).toContain(EventType.RUN_STARTED) + expect(types).toContain(EventType.TEXT_MESSAGE_START) + expect(types).toContain(EventType.TEXT_MESSAGE_CONTENT) + expect(types).toContain(EventType.TEXT_MESSAGE_END) + expect(types).toContain(EventType.RUN_FINISHED) + }) + + it('accumulates text content across deltas', async () => { + const contents: Array = [] + for await (const c of processConverseStream( + gen( + { messageStart: { role: 'assistant' } }, + { contentBlockDelta: { delta: { text: 'Hel' }, contentBlockIndex: 0 } }, + { contentBlockDelta: { delta: { text: 'lo' }, contentBlockIndex: 0 } }, + { messageStop: { stopReason: 'end_turn' } }, + ), + () => 'msg-1', + )) { + if (c.type === EventType.TEXT_MESSAGE_CONTENT) + contents.push((c as { delta: string }).delta) + } + expect(contents).toEqual(['Hel', 'lo']) + }) + + it('emits TOOL_CALL_* for a toolUse block with streamed args', async () => { + const types: Array = [] + const argDeltas: Array = [] + for await (const c of processConverseStream( + gen( + { messageStart: { role: 'assistant' } }, + { + contentBlockStart: { + start: { toolUse: { toolUseId: 't1', name: 'getX' } }, + contentBlockIndex: 0, + }, + }, + { + contentBlockDelta: { + delta: { toolUse: { input: '{"a":' } }, + contentBlockIndex: 0, + }, + }, + { + contentBlockDelta: { + delta: { toolUse: { input: '1}' } }, + contentBlockIndex: 0, + }, + }, + { contentBlockStop: { contentBlockIndex: 0 } }, + { messageStop: { stopReason: 'tool_use' } }, + ), + () => 'msg-2', + )) { + types.push(c.type) + if (c.type === EventType.TOOL_CALL_ARGS) + argDeltas.push((c as { delta: string }).delta) + } + expect(types).toContain(EventType.TOOL_CALL_START) + expect(types).toContain(EventType.TOOL_CALL_ARGS) + expect(types).toContain(EventType.TOOL_CALL_END) + expect(argDeltas.join('')).toBe('{"a":1}') + }) + + it('emits reasoning content', async () => { + const types: Array = [] + for await (const c of processConverseStream( + gen( + { messageStart: { role: 'assistant' } }, + { + contentBlockDelta: { + delta: { reasoningContent: { text: 'thinking' } }, + contentBlockIndex: 0, + }, + }, + { messageStop: { stopReason: 'end_turn' } }, + ), + () => 'msg-3', + )) { + types.push(c.type) + } + expect(types).toContain(EventType.REASONING_MESSAGE_CONTENT) + }) +}) diff --git a/packages/ai-bedrock/tests/converse/structured-output.test.ts b/packages/ai-bedrock/tests/converse/structured-output.test.ts new file mode 100644 index 000000000..673d92afc --- /dev/null +++ b/packages/ai-bedrock/tests/converse/structured-output.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest' +import { + STRUCTURED_TOOL_NAME, + buildStructuredToolConfig, +} from '../../src/converse/structured-output' + +describe('buildStructuredToolConfig', () => { + it('wraps the output schema as a single forced tool', () => { + const schema = { type: 'object', properties: { n: { type: 'number' } } } + const cfg = buildStructuredToolConfig(schema) + expect(cfg.tools?.[0]).toEqual({ + toolSpec: { + name: STRUCTURED_TOOL_NAME, + description: 'Return the final answer as structured JSON.', + inputSchema: { json: schema }, + }, + }) + expect(cfg.toolChoice).toEqual({ tool: { name: STRUCTURED_TOOL_NAME } }) + }) +}) diff --git a/packages/ai-bedrock/tests/converse/tool-converter.test.ts b/packages/ai-bedrock/tests/converse/tool-converter.test.ts new file mode 100644 index 000000000..b2b3c9626 --- /dev/null +++ b/packages/ai-bedrock/tests/converse/tool-converter.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { toToolConfig } from '../../src/converse/tool-converter' + +describe('toToolConfig', () => { + it('maps JSON-schema tools to Converse toolSpec', () => { + const cfg = toToolConfig( + [ + { + name: 'getX', + description: 'd', + inputSchema: { type: 'object', properties: {} }, + }, + ], + 'auto', + ) + expect(cfg?.tools?.[0]).toEqual({ + toolSpec: { + name: 'getX', + description: 'd', + inputSchema: { json: { type: 'object', properties: {} } }, + }, + }) + expect(cfg?.toolChoice).toEqual({ auto: {} }) + }) + + it('maps required -> any and a named tool -> tool', () => { + expect( + toToolConfig([{ name: 'a', inputSchema: {} }], 'required')?.toolChoice, + ).toEqual({ any: {} }) + expect( + toToolConfig([{ name: 'a', inputSchema: {} }], { + type: 'tool', + name: 'a', + })?.toolChoice, + ).toEqual({ tool: { name: 'a' } }) + }) + + it('omits description when not provided', () => { + const cfg = toToolConfig([{ name: 'a', inputSchema: {} }], 'auto') + expect(cfg?.tools?.[0]).toEqual({ + toolSpec: { name: 'a', inputSchema: { json: {} } }, + }) + }) + + it('returns undefined when there are no tools', () => { + expect(toToolConfig([], 'auto')).toBeUndefined() + }) + + it('returns undefined toolChoice for "none" (caller omits tools instead)', () => { + const cfg = toToolConfig([{ name: 'a', inputSchema: {} }], 'none') + expect(cfg?.toolChoice).toBeUndefined() + }) +}) diff --git a/packages/ai-bedrock/tests/factory.test.ts b/packages/ai-bedrock/tests/factory.test.ts new file mode 100644 index 000000000..c4ae697a0 --- /dev/null +++ b/packages/ai-bedrock/tests/factory.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { bedrockText, createBedrockText } from '../src/index' + +describe('bedrockText branching', () => { + it('defaults to the Converse adapter', () => { + const a = bedrockText('us.anthropic.claude-haiku-4-5-20251001-v1:0', { + apiKey: 'k', + }) + expect(a.name).toBe('bedrock-converse') + }) + it('api "converse" is explicit Converse', () => { + const a = bedrockText('us.amazon.nova-pro-v1:0', { + apiKey: 'k', + api: 'converse', + }) + expect(a.name).toBe('bedrock-converse') + }) + it('api "chat" returns the Chat Completions adapter', () => { + const a = bedrockText('openai.gpt-oss-120b-1:0', { + apiKey: 'k', + api: 'chat', + }) + expect(a.name).toBe('bedrock') + }) + it('api "responses" returns the Responses adapter', () => { + const a = bedrockText('openai.gpt-oss-120b-1:0', { + apiKey: 'k', + api: 'responses', + }) + expect(a.name).toBe('bedrock-responses') + }) + it('createBedrockText defaults to Converse with an explicit key', () => { + const a = createBedrockText('us.amazon.nova-lite-v1:0', 'k') + expect(a.name).toBe('bedrock-converse') + }) +}) diff --git a/packages/ai-bedrock/tests/model-meta.test.ts b/packages/ai-bedrock/tests/model-meta.test.ts new file mode 100644 index 000000000..c1b867adc --- /dev/null +++ b/packages/ai-bedrock/tests/model-meta.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { + BEDROCK_CHAT_MODELS, + BEDROCK_RESPONSES_MODELS, +} from '../src/model-meta' + +describe('bedrock model-meta', () => { + it('chat catalog is non-empty and unique', () => { + expect(BEDROCK_CHAT_MODELS.length).toBeGreaterThan(0) + expect(new Set(BEDROCK_CHAT_MODELS).size).toBe(BEDROCK_CHAT_MODELS.length) + }) + + it('responses catalog is non-empty and unique', () => { + expect(BEDROCK_RESPONSES_MODELS.length).toBeGreaterThan(0) + expect(new Set(BEDROCK_RESPONSES_MODELS).size).toBe( + BEDROCK_RESPONSES_MODELS.length, + ) + }) + + it('every responses model is also a chat model (Responses subset of Chat reach)', () => { + const chat = new Set(BEDROCK_CHAT_MODELS) + for (const m of BEDROCK_RESPONSES_MODELS) expect(chat.has(m)).toBe(true) + }) + + it('includes the confirmed gpt-oss ids', () => { + expect(BEDROCK_CHAT_MODELS).toContain('openai.gpt-oss-120b-1:0') + expect(BEDROCK_RESPONSES_MODELS).toContain('openai.gpt-oss-120b-1:0') + }) +}) diff --git a/packages/ai-bedrock/tests/openai-sigv4-fetch.test.ts b/packages/ai-bedrock/tests/openai-sigv4-fetch.test.ts new file mode 100644 index 000000000..43e73d854 --- /dev/null +++ b/packages/ai-bedrock/tests/openai-sigv4-fetch.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { createSigV4Fetch } from '../src/utils/openai-sigv4-fetch' + +describe('createSigV4Fetch', () => { + it('signs the request and adds an Authorization header', async () => { + let seen: Headers | undefined + const fakeFetch: typeof fetch = async (_url, init) => { + seen = new Headers(init?.headers) + return new Response('{}', { status: 200 }) + } + const signed = createSigV4Fetch( + { + kind: 'sigv4', + region: 'us-east-1', + service: 'bedrock', + credentials: async () => ({ + accessKeyId: 'AKIA', + secretAccessKey: 'secret', + }), + }, + fakeFetch, + ) + await signed( + 'https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1/chat/completions', + { + method: 'POST', + body: '{}', + headers: { 'content-type': 'application/json' }, + }, + ) + expect(seen?.get('authorization')).toMatch(/AWS4-HMAC-SHA256/) + }) +}) diff --git a/packages/ai-bedrock/tsconfig.json b/packages/ai-bedrock/tsconfig.json new file mode 100644 index 000000000..c38689f4e --- /dev/null +++ b/packages/ai-bedrock/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/ai-bedrock/vite.config.ts b/packages/ai-bedrock/vite.config.ts new file mode 100644 index 000000000..77bcc2e60 --- /dev/null +++ b/packages/ai-bedrock/vite.config.ts @@ -0,0 +1,36 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + '**/types.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/packages/ai/skills/ai-core/adapter-configuration/SKILL.md b/packages/ai/skills/ai-core/adapter-configuration/SKILL.md index 621bd1c2c..a3c20a96d 100644 --- a/packages/ai/skills/ai-core/adapter-configuration/SKILL.md +++ b/packages/ai/skills/ai-core/adapter-configuration/SKILL.md @@ -2,11 +2,12 @@ name: ai-core/adapter-configuration description: > Provider adapter selection and configuration: openaiText, anthropicText, - geminiText, ollamaText, grokText, groqText, openRouterText. Per-model - type safety with modelOptions, reasoning/thinking configuration, + geminiText, ollamaText, grokText, groqText, openRouterText, bedrockText. + Per-model type safety with modelOptions, reasoning/thinking configuration, runtime adapter switching, extendAdapter() for custom models, createModel(). API key env vars: OPENAI_API_KEY, ANTHROPIC_API_KEY, GOOGLE_API_KEY/GEMINI_API_KEY, - XAI_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY, OLLAMA_HOST. + XAI_API_KEY, GROQ_API_KEY, OPENROUTER_API_KEY, OLLAMA_HOST, + BEDROCK_API_KEY (or AWS_BEARER_TOKEN_BEDROCK). type: sub-skill library: tanstack-ai library_version: '0.10.0' @@ -69,6 +70,7 @@ The text adapter is the primary one for chat/completions: | Groq | `@tanstack/ai-groq` | `groqText` | `GROQ_API_KEY` | | OpenRouter | `@tanstack/ai-openrouter` | `openRouterText` | `OPENROUTER_API_KEY` | | Ollama | `@tanstack/ai-ollama` | `ollamaText` | `OLLAMA_HOST` (default: `http://localhost:11434`) | +| Bedrock | `@tanstack/ai-bedrock` | `bedrockText` | `BEDROCK_API_KEY` or `AWS_BEARER_TOKEN_BEDROCK` | ```typescript // Each factory takes model as first arg, optional config as second @@ -79,6 +81,7 @@ import { grokText } from '@tanstack/ai-grok' import { groqText } from '@tanstack/ai-groq' import { openRouterText } from '@tanstack/ai-openrouter' import { ollamaText } from '@tanstack/ai-ollama' +import { bedrockText } from '@tanstack/ai-bedrock' // Model string is passed to the factory, NOT to chat() const adapter = openaiText('gpt-5.2') @@ -88,6 +91,7 @@ const adapter4 = grokText('grok-4') const adapter5 = groqText('llama-3.3-70b-versatile') const adapter6 = openRouterText('anthropic/claude-sonnet-4') const adapter7 = ollamaText('llama3.3') +const adapter8 = bedrockText('us.anthropic.claude-3-7-sonnet-20250219-v1:0') // Optional: pass explicit API key const adapterWithKey = openaiText('gpt-5.2', { @@ -95,6 +99,14 @@ const adapterWithKey = openaiText('gpt-5.2', { }) ``` +`@tanstack/ai-bedrock` (Amazon Bedrock) branches on `config.api`: + +- `bedrockText(model)` or `bedrockText(model, { api: 'converse' })` (the default) — Bedrock's native Converse API via `@aws-sdk/client-bedrock-runtime` (adapter name `bedrock-converse`). Reaches the broad catalog: Claude, Nova, Llama, Mistral, DeepSeek, and more. +- `bedrockText(model, { api: 'chat' })` — OpenAI-compatible Chat Completions endpoint (adapter name `bedrock`). Open-weight models only (gpt-oss, DeepSeek V3.x, Gemma, Qwen, etc.). Does NOT reach Claude, Nova, or Llama. +- `bedrockText(model, { api: 'responses' })` — OpenAI-compatible Responses API, mantle-only (adapter name `bedrock-responses`). Currently gpt-oss family. + +Use `createBedrockText(model, apiKey, config?)` to pass the key explicitly. Auth resolves from `BEDROCK_API_KEY` / `AWS_BEARER_TOKEN_BEDROCK`, or SigV4 via the standard AWS credential chain (no extra packages needed — handled by `@aws-sdk/client-bedrock-runtime`). + ### 2. Runtime Adapter Switching Use an adapter factory map to switch providers dynamically based on user @@ -276,15 +288,16 @@ Source: docs/migration/migration.md Each provider uses a specific env var name. Using the wrong one causes a runtime error: -| Provider | Correct Env Var | Common Mistake | -| ---------- | ------------------------------------ | ------------------------------------------------------------------------ | -| OpenAI | `OPENAI_API_KEY` | | -| Anthropic | `ANTHROPIC_API_KEY` | | -| Gemini | `GOOGLE_API_KEY` or `GEMINI_API_KEY` | `GOOGLE_GENAI_API_KEY` (does not work) | -| Grok (xAI) | `XAI_API_KEY` | `GROK_API_KEY` (does not work) | -| Groq | `GROQ_API_KEY` | | -| OpenRouter | `OPENROUTER_API_KEY` | | -| Ollama | `OLLAMA_HOST` | No API key needed, just the host URL (default: `http://localhost:11434`) | +| Provider | Correct Env Var | Common Mistake | +| ---------- | ---------------------------------------------- | ------------------------------------------------------------------------ | +| OpenAI | `OPENAI_API_KEY` | | +| Anthropic | `ANTHROPIC_API_KEY` | | +| Gemini | `GOOGLE_API_KEY` or `GEMINI_API_KEY` | `GOOGLE_GENAI_API_KEY` (does not work) | +| Grok (xAI) | `XAI_API_KEY` | `GROK_API_KEY` (does not work) | +| Groq | `GROQ_API_KEY` | | +| OpenRouter | `OPENROUTER_API_KEY` | | +| Ollama | `OLLAMA_HOST` | No API key needed, just the host URL (default: `http://localhost:11434`) | +| Bedrock | `BEDROCK_API_KEY` / `AWS_BEARER_TOKEN_BEDROCK` | Falls back to SigV4 credentials when no API key is set | Source: adapter source code (`utils/client.ts` in each adapter package). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbdb8c90d..ffe16d271 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: .: devDependencies: + '@aws-sdk/client-bedrock': + specifier: ^3.1057.0 + version: 3.1057.0 '@changesets/changelog-github': specifier: ^0.7.0 version: 0.7.0 @@ -1026,6 +1029,46 @@ importers: specifier: ^4.2.0 version: 4.2.1 + packages/ai-bedrock: + dependencies: + '@aws-crypto/sha256-js': + specifier: ^5.2.0 + version: 5.2.0 + '@aws-sdk/client-bedrock-runtime': + specifier: ^3.1057.0 + version: 3.1057.0 + '@aws-sdk/credential-providers': + specifier: ^3.1057.0 + version: 3.1057.0 + '@smithy/signature-v4': + specifier: ^5.4.5 + version: 5.4.5 + '@smithy/types': + specifier: ^4.14.2 + version: 4.14.2 + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@tanstack/ai-utils': + specifier: workspace:* + version: link:../ai-utils + '@tanstack/openai-base': + specifier: workspace:* + version: link:../openai-base + openai: + specifier: ^6.9.1 + version: 6.10.0(ws@8.19.0)(zod@4.3.6) + zod: + specifier: ^4.0.0 + version: 4.3.6 + devDependencies: + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(vitest@4.0.14(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(happy-dom@20.0.11)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.15))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + vite: + specifier: ^7.3.3 + version: 7.3.3(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + packages/ai-client: dependencies: '@tanstack/ai': @@ -1816,6 +1859,9 @@ importers: '@tanstack/ai-anthropic': specifier: workspace:* version: link:../../packages/ai-anthropic + '@tanstack/ai-bedrock': + specifier: workspace:* + version: link:../../packages/ai-bedrock '@tanstack/ai-client': specifier: workspace:* version: link:../../packages/ai-client @@ -2089,6 +2135,123 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-bedrock-runtime@3.1057.0': + resolution: {integrity: sha512-TqnYAhAEk45+w3JmS5uHc05AAxfQ7NDyfuARzBv/Y5WuDftRPJMm6FBHCEH7dqcDCcAHmI+XyCYaBI7g7EgweQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-bedrock@3.1057.0': + resolution: {integrity: sha512-3Vn/bXD6Ohcx8HO8awru4IKxKITEFR3/xRDmkX5StdU4wT1ZTQGLpDjzisJbPy1UmcGwo/Zp9EzeGzZu4xLDkw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-cognito-identity@3.1057.0': + resolution: {integrity: sha512-5MliYkp2u0+2arTp5fZIaxl+xmm90LEKv/VeSxhfNQW4t0fvWJrNO429/jchWQenNoDRrOGE59VfbuZUfwFujg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.15': + resolution: {integrity: sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-cognito-identity@3.972.38': + resolution: {integrity: sha512-OHkK6xOx/IHkSbQdDWxnVCLU+j28EFl8wyWgBILQDFAPY8n240C/O4gjmFx+zFU12lL8njgJQ5GWAIWq88CnSQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.41': + resolution: {integrity: sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.43': + resolution: {integrity: sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.46': + resolution: {integrity: sha512-hvcgcwOiS0nb2XFb5Op1Pz/vYaWz5K8kKullziGpdNRuG0NwzRXseuPt2CoBqknHGaSPVesu1aOn2OcctEYdCA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.45': + resolution: {integrity: sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.47': + resolution: {integrity: sha512-HrId+C0DWA5qDIyLG64/kjUB2RNtPypxmABnIctK+TA1P1kHlOYoE/Wf5T5tKOMKgb08P7k/zNyhvfJ3lh5Oag==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.41': + resolution: {integrity: sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.45': + resolution: {integrity: sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.45': + resolution: {integrity: sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-providers@3.1057.0': + resolution: {integrity: sha512-rbrEHtz11g0kxsSkYr3fx2HABNNblp4AhB2MgPvJHgYOWfJ2eBviU7Mvoaef0PW8QH6lbZDfJcnM7eKvtvz3sw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/eventstream-handler-node@3.972.18': + resolution: {integrity: sha512-QPQhwY/fstR8fMZFWrsJRNoTP6D1RjRPHGRX7u9/VkF3opCsvD0oXPz6qzkX94SchzvuS5vyFZbJbPcMEs2Jeg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-eventstream@3.972.14': + resolution: {integrity: sha512-DoZ4djVj/74XQ6M/IwxuKh543tTvLCL7u1Dx+VDHMgW9yGNrFSJJ1l0LrUQRaekic5CB12wUiiOoHL0VI6H0gg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-websocket@3.972.23': + resolution: {integrity: sha512-F0d4A9pJFiwljyKgSwU1Z5n+CXSv8bp+V5SthbS2rftB8wBN9z1K2Yyv3xbeK0AM2T0g4q6Ptf0shFF+oQZyiA==} + engines: {node: '>= 14.0.0'} + + '@aws-sdk/nested-clients@3.997.13': + resolution: {integrity: sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.30': + resolution: {integrity: sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1056.0': + resolution: {integrity: sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1057.0': + resolution: {integrity: sha512-nIypx3Pvn9l7XoCi1a1ruY/FdUyfQW0LXk/2BdazRzs7rOAZeoSdZx9E1A6bmXIDedrG+09hFb8QlxhEk40jfA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.9': + resolution: {integrity: sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.5': + resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.26': + resolution: {integrity: sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.4': + resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -4195,6 +4358,9 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@nodable/entities@2.1.1': + resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -6201,6 +6367,42 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@smithy/core@3.24.5': + resolution: {integrity: sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.3.6': + resolution: {integrity: sha512-tHhdiWZfG1ZIh2YcRfPJmY2gHcBmqbAzqm3ER4TIDFYsSEqTD5tICT7cgQ/kI8LRakxp12myOYyK68XPn7MnHw==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.4.5': + resolution: {integrity: sha512-SK3VMeH0fibgdTg2QeB+O4p7Yy/2E5HBOHJeC58FshkDdeuX8lOgO7PfjYfLyPLP1ch55j91cQqKBzDS0mRjSQ==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/node-http-handler@4.7.5': + resolution: {integrity: sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.4.5': + resolution: {integrity: sha512-QBJKWGqIknH0dc9LWpfH1mkdokAx6iXYN3UcQ3eY6uIEyScuoQAhfl94ge7ozUy9WgFUdE8xsvwBjaYBbWmPNA==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.14.2': + resolution: {integrity: sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + '@solid-devtools/debugger@0.28.1': resolution: {integrity: sha512-6qIUI6VYkXoRnL8oF5bvh2KgH71qlJ18hNw/mwSyY6v48eb80ZR48/5PDXufUa3q+MBSuYa1uqTMwLewpay9eg==} peerDependencies: @@ -8007,6 +8209,9 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + boxen@7.1.1: resolution: {integrity: sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==} engines: {node: '>=14.16'} @@ -9197,6 +9402,13 @@ packages: fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-xml-builder@1.2.0: + resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} + + fast-xml-parser@5.7.3: + resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} + hasBin: true + fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -11295,6 +11507,10 @@ packages: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} + path-expression-matcher@1.5.0: + resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} + engines: {node: '>=14.0.0'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -12374,6 +12590,9 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strnum@2.3.0: + resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} + structured-headers@0.4.1: resolution: {integrity: sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==} @@ -13681,6 +13900,10 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + xml2js@0.6.0: resolution: {integrity: sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==} engines: {node: '>=4.0.0'} @@ -13799,6 +14022,284 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.9 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.9 + '@aws-sdk/util-locate-window': 3.965.5 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.9 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-bedrock-runtime@3.1057.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.15 + '@aws-sdk/credential-provider-node': 3.972.47 + '@aws-sdk/eventstream-handler-node': 3.972.18 + '@aws-sdk/middleware-eventstream': 3.972.14 + '@aws-sdk/middleware-websocket': 3.972.23 + '@aws-sdk/token-providers': 3.1057.0 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.5 + '@smithy/fetch-http-handler': 5.4.5 + '@smithy/node-http-handler': 4.7.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/client-bedrock@3.1057.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.15 + '@aws-sdk/credential-provider-node': 3.972.47 + '@aws-sdk/token-providers': 3.1057.0 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.5 + '@smithy/fetch-http-handler': 5.4.5 + '@smithy/node-http-handler': 4.7.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/client-cognito-identity@3.1057.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.15 + '@aws-sdk/credential-provider-node': 3.972.47 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.5 + '@smithy/fetch-http-handler': 5.4.5 + '@smithy/node-http-handler': 4.7.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/core@3.974.15': + dependencies: + '@aws-sdk/types': 3.973.9 + '@aws-sdk/xml-builder': 3.972.26 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.24.5 + '@smithy/signature-v4': 5.4.5 + '@smithy/types': 4.14.2 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-cognito-identity@3.972.38': + dependencies: + '@aws-sdk/nested-clients': 3.997.13 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.41': + dependencies: + '@aws-sdk/core': 3.974.15 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.43': + dependencies: + '@aws-sdk/core': 3.974.15 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.5 + '@smithy/fetch-http-handler': 5.4.5 + '@smithy/node-http-handler': 4.7.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.46': + dependencies: + '@aws-sdk/core': 3.974.15 + '@aws-sdk/credential-provider-env': 3.972.41 + '@aws-sdk/credential-provider-http': 3.972.43 + '@aws-sdk/credential-provider-login': 3.972.45 + '@aws-sdk/credential-provider-process': 3.972.41 + '@aws-sdk/credential-provider-sso': 3.972.45 + '@aws-sdk/credential-provider-web-identity': 3.972.45 + '@aws-sdk/nested-clients': 3.997.13 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.5 + '@smithy/credential-provider-imds': 4.3.6 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.45': + dependencies: + '@aws-sdk/core': 3.974.15 + '@aws-sdk/nested-clients': 3.997.13 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.47': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.41 + '@aws-sdk/credential-provider-http': 3.972.43 + '@aws-sdk/credential-provider-ini': 3.972.46 + '@aws-sdk/credential-provider-process': 3.972.41 + '@aws-sdk/credential-provider-sso': 3.972.45 + '@aws-sdk/credential-provider-web-identity': 3.972.45 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.5 + '@smithy/credential-provider-imds': 4.3.6 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.41': + dependencies: + '@aws-sdk/core': 3.974.15 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.45': + dependencies: + '@aws-sdk/core': 3.974.15 + '@aws-sdk/nested-clients': 3.997.13 + '@aws-sdk/token-providers': 3.1056.0 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.45': + dependencies: + '@aws-sdk/core': 3.974.15 + '@aws-sdk/nested-clients': 3.997.13 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-providers@3.1057.0': + dependencies: + '@aws-sdk/client-cognito-identity': 3.1057.0 + '@aws-sdk/core': 3.974.15 + '@aws-sdk/credential-provider-cognito-identity': 3.972.38 + '@aws-sdk/credential-provider-env': 3.972.41 + '@aws-sdk/credential-provider-http': 3.972.43 + '@aws-sdk/credential-provider-ini': 3.972.46 + '@aws-sdk/credential-provider-login': 3.972.45 + '@aws-sdk/credential-provider-node': 3.972.47 + '@aws-sdk/credential-provider-process': 3.972.41 + '@aws-sdk/credential-provider-sso': 3.972.45 + '@aws-sdk/credential-provider-web-identity': 3.972.45 + '@aws-sdk/nested-clients': 3.997.13 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.5 + '@smithy/credential-provider-imds': 4.3.6 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/eventstream-handler-node@3.972.18': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.972.14': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-websocket@3.972.23': + dependencies: + '@aws-sdk/core': 3.974.15 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.5 + '@smithy/fetch-http-handler': 5.4.5 + '@smithy/signature-v4': 5.4.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.13': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.15 + '@aws-sdk/signature-v4-multi-region': 3.996.30 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.5 + '@smithy/fetch-http-handler': 5.4.5 + '@smithy/node-http-handler': 4.7.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.30': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/signature-v4': 5.4.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1056.0': + dependencies: + '@aws-sdk/core': 3.974.15 + '@aws-sdk/nested-clients': 3.997.13 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1057.0': + dependencies: + '@aws-sdk/core': 3.974.15 + '@aws-sdk/nested-clients': 3.997.13 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/types@3.973.9': + dependencies: + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.5': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.26': + dependencies: + '@smithy/types': 4.14.2 + fast-xml-parser: 5.7.3 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} + '@babel/code-frame@7.26.2': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -15970,6 +16471,8 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@nodable/entities@2.1.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -17649,6 +18152,54 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@smithy/core@3.24.5': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.3.6': + dependencies: + '@smithy/core': 3.24.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.4.5': + dependencies: + '@smithy/core': 3.24.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.5': + dependencies: + '@smithy/core': 3.24.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/signature-v4@5.4.5': + dependencies: + '@smithy/core': 3.24.5 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/types@4.14.2': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + '@solid-devtools/debugger@0.28.1(solid-js@1.9.10)': dependencies: '@nothing-but/utils': 0.17.0 @@ -20440,6 +20991,8 @@ snapshots: boolbase@1.0.0: {} + bowser@2.14.1: {} + boxen@7.1.1: dependencies: ansi-align: 3.0.1 @@ -21820,6 +22373,18 @@ snapshots: fast-sha256@1.3.0: {} + fast-xml-builder@1.2.0: + dependencies: + path-expression-matcher: 1.5.0 + xml-naming: 0.1.0 + + fast-xml-parser@5.7.3: + dependencies: + '@nodable/entities': 2.1.1 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.3.0 + fastq@1.19.1: dependencies: reusify: 1.1.0 @@ -23158,8 +23723,8 @@ snapshots: magicast@0.5.2: dependencies: - '@babel/parser': 7.29.0 - '@babel/types': 7.29.0 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 source-map-js: 1.2.1 make-dir@2.1.0: @@ -24646,6 +25211,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.5.0: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -25991,6 +26558,8 @@ snapshots: dependencies: js-tokens: 9.0.1 + strnum@2.3.0: {} + structured-headers@0.4.1: {} style-to-js@1.1.21: @@ -27221,6 +27790,8 @@ snapshots: xml-name-validator@5.0.0: {} + xml-naming@0.1.0: {} + xml2js@0.6.0: dependencies: sax: 1.6.0 diff --git a/scripts/bedrock-api-compatibility.json b/scripts/bedrock-api-compatibility.json new file mode 100644 index 000000000..a85990bde --- /dev/null +++ b/scripts/bedrock-api-compatibility.json @@ -0,0 +1,59 @@ +[ + { + "match": "openai.gpt-oss", + "converse": true, + "chat": true, + "responses": true + }, + { + "match": "anthropic.claude", + "converse": true, + "chat": false, + "responses": false + }, + { + "match": "amazon.nova", + "converse": true, + "chat": false, + "responses": false + }, + { + "match": "meta.llama", + "converse": true, + "chat": false, + "responses": false + }, + { + "match": "ai21.jamba", + "converse": true, + "chat": false, + "responses": false + }, + { + "match": "cohere.command", + "converse": true, + "chat": false, + "responses": false + }, + { + "match": "deepseek.r1", + "converse": true, + "chat": false, + "responses": false + }, + { "match": "deepseek", "converse": true, "chat": true, "responses": false }, + { + "match": "mistral.pixtral", + "converse": true, + "chat": false, + "responses": false + }, + { "match": "mistral", "converse": true, "chat": true, "responses": false }, + { "match": "qwen", "converse": true, "chat": true, "responses": false }, + { + "match": "google.gemma", + "converse": true, + "chat": true, + "responses": false + } +] diff --git a/scripts/fetch-bedrock-models.ts b/scripts/fetch-bedrock-models.ts new file mode 100644 index 000000000..a4df76e82 --- /dev/null +++ b/scripts/fetch-bedrock-models.ts @@ -0,0 +1,190 @@ +/** + * Fetches the Bedrock foundation-model + inference-profile catalog and WRITES + * packages/ai-bedrock/src/model-catalog.generated.ts so the committed file + * stays fresh without manual editing. + * + * MAINTAINER-ONLY. Not run in CI. Requires AWS credentials (standard provider + * chain) with bedrock:List* permissions, and the AWS SDK: + * pnpm add -Dw @aws-sdk/client-bedrock # if not already installed + * AWS_REGION=us-east-1 pnpm tsx scripts/fetch-bedrock-models.ts + * + * Per-API flags (converse / chat / responses) come from the static seed file + * scripts/bedrock-api-compatibility.json, transcribed from: + * https://docs.aws.amazon.com/bedrock/latest/userguide/models-api-compatibility.html + * Update the JSON seed to add new providers/models before re-running the script. + * + * Why manual: ListFoundationModels carries modalities + inference types but no + * pricing, and per-account/region availability varies. The committed model-catalog + * is the long-term source of truth; this script regenerates it automatically. + */ +import { + BedrockClient, + ListFoundationModelsCommand, + ListInferenceProfilesCommand, +} from '@aws-sdk/client-bedrock' +import { readFileSync, writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { join, dirname } from 'node:path' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ROOT = join(__dirname, '..') + +interface CompatibilityRule { + match: string + converse: boolean + chat: boolean + responses: boolean +} + +function loadCompatibilitySeed(): CompatibilityRule[] { + const seedPath = join(__dirname, 'bedrock-api-compatibility.json') + return JSON.parse(readFileSync(seedPath, 'utf-8')) as CompatibilityRule[] +} + +function lookupApis( + id: string, + rules: CompatibilityRule[], +): { converse: boolean; chat: boolean; responses: boolean } { + for (const rule of rules) { + if (id.includes(rule.match)) { + return { + converse: rule.converse, + chat: rule.chat, + responses: rule.responses, + } + } + } + // Default: Converse is supported by virtually all text models; chat/responses are opt-in. + return { converse: true, chat: false, responses: false } +} + +function emitCatalog( + entries: Array<{ + id: string + profileId?: string + input: string[] + output: string[] + apis: { converse: boolean; chat: boolean; responses: boolean } + }>, +): string { + const lines: string[] = [ + `// GENERATED by scripts/fetch-bedrock-models.ts — do not edit by hand.`, + `// Refresh: AWS_REGION=us-east-1 pnpm tsx scripts/fetch-bedrock-models.ts`, + `export const GENERATED_BEDROCK_MODELS = [`, + ] + + for (const entry of entries) { + const inputLiteral = entry.input.map((m) => `'${m}'`).join(', ') + const outputLiteral = entry.output.map((m) => `'${m}'`).join(', ') + const apis = entry.apis + + const profilePart = + entry.profileId !== undefined ? `profileId: '${entry.profileId}', ` : '' + + lines.push( + ` { id: '${entry.id}', ${profilePart}input: [${inputLiteral}], output: [${outputLiteral}], apis: { converse: ${apis.converse}, chat: ${apis.chat}, responses: ${apis.responses} } },`, + ) + } + + lines.push(`] as const`, ``) + return lines.join('\n') +} + +async function main() { + const region = process.env['AWS_REGION'] ?? 'us-east-1' + const client = new BedrockClient({ region }) + const compatRules = loadCompatibilitySeed() + + // ListFoundationModels typically returns no nextToken, but loop on it anyway + // so we stay correct if it ever paginates. + const modelSummaries = [] + let modelsToken: string | undefined + do { + const page = await client.send( + new ListFoundationModelsCommand({ + byOutputModality: 'TEXT', + ...(modelsToken ? { nextToken: modelsToken } : {}), + }), + ) + modelSummaries.push(...(page.modelSummaries ?? [])) + modelsToken = page.nextToken + } while (modelsToken) + + // ListInferenceProfiles is paginated — collect every page via nextToken. + const inferenceProfileSummaries = [] + let profilesToken: string | undefined + do { + const page = await client.send( + new ListInferenceProfilesCommand( + profilesToken ? { nextToken: profilesToken } : {}, + ), + ) + inferenceProfileSummaries.push(...(page.inferenceProfileSummaries ?? [])) + profilesToken = page.nextToken + } while (profilesToken) + + // Build a lookup: base model id → preferred cross-region inference profile id. + // A cross-region profile id typically looks like "us.". + const profileByBaseId = new Map() + for (const profile of inferenceProfileSummaries) { + const profileId = profile.inferenceProfileId + if (!profileId) continue + // Strip the leading region prefix (e.g. "us.", "eu.", "ap.") to get the base id. + const baseId = profileId.replace(/^(us|eu|ap)\./, '') + if (!profileByBaseId.has(baseId)) { + profileByBaseId.set(baseId, profileId) + } + } + + const textModels = modelSummaries + .filter((m) => (m.outputModalities ?? []).includes('TEXT')) + .map((m) => { + const id = m.modelId ?? '' + return { + id, + input: (m.inputModalities ?? []).map((x) => x.toLowerCase()), + } + }) + .filter((m) => m.id.length > 0) + + const entries = textModels.map((m) => { + const profileId = profileByBaseId.get(m.id) + const resolvedId = profileId ?? m.id + const apis = lookupApis(resolvedId, compatRules) + + const entry: { + id: string + profileId?: string + input: string[] + output: string[] + apis: { converse: boolean; chat: boolean; responses: boolean } + } = { + id: resolvedId, + input: m.input, + output: ['text'], + apis, + } + + if (profileId !== undefined) { + entry.profileId = profileId + } + + return entry + }) + + const outPath = join( + ROOT, + 'packages', + 'ai-bedrock', + 'src', + 'model-catalog.generated.ts', + ) + const content = emitCatalog(entries) + writeFileSync(outPath, content, 'utf-8') + console.log(`Wrote ${entries.length} models to ${outPath}`) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/testing/e2e/README.md b/testing/e2e/README.md index ab0f13479..09fec08cc 100644 --- a/testing/e2e/README.md +++ b/testing/e2e/README.md @@ -184,6 +184,16 @@ await waitForAssistantText(page, 'Fender Stratocaster') 3. **Add to `tests/test-matrix.ts`** — mirror the support matrix 4. **No fixture changes needed** — aimock translates to correct wire format +### Bedrock Converse coverage gap + +The `bedrock` and `bedrock-responses` providers in this matrix use `createBedrockText` with a `baseURL` pointing at aimock — they speak Bedrock's **OpenAI-compatible** endpoint, which aimock's OpenAI replay handles fine. + +The default `bedrock-converse` adapter (introduced later) uses `@aws-sdk/client-bedrock-runtime` and speaks AWS's **binary event-stream (`vnd.amazon.eventstream`) Converse protocol**, which `@copilotkit/aimock` does not currently mock. Adding `bedrock-converse` to the live matrix would fail without a Converse-capable aimock provider. + +**Coverage today:** the Converse translation layer (message converter, tool converter, stream processor, structured output, adapter) is covered by unit tests in `packages/ai-bedrock/tests/converse/` (64 tests). The OpenAI-compatible `bedrock` and `bedrock-responses` entries remain in the E2E matrix as-is. + +**Follow-up:** a Bedrock/Converse provider will be added to aimock to close this gap and enable full E2E coverage of the Converse path. + **SDK baseURL notes:** - OpenAI, Grok: `LLMOCK_OPENAI` (with `/v1`) + `defaultHeaders` diff --git a/testing/e2e/package.json b/testing/e2e/package.json index 54b5fcef5..b67aee849 100644 --- a/testing/e2e/package.json +++ b/testing/e2e/package.json @@ -17,6 +17,7 @@ "@tailwindcss/vite": "^4.1.18", "@tanstack/ai": "workspace:*", "@tanstack/ai-anthropic": "workspace:*", + "@tanstack/ai-bedrock": "workspace:*", "@tanstack/ai-client": "workspace:*", "@tanstack/ai-elevenlabs": "workspace:*", "@tanstack/ai-gemini": "workspace:*", diff --git a/testing/e2e/src/lib/feature-support.ts b/testing/e2e/src/lib/feature-support.ts index 49aa74708..3f8a45990 100644 --- a/testing/e2e/src/lib/feature-support.ts +++ b/testing/e2e/src/lib/feature-support.ts @@ -15,6 +15,8 @@ export const matrix: Record> = { 'ollama', 'groq', 'grok', + 'bedrock', + 'bedrock-responses', 'openrouter', ]), 'one-shot-text': new Set([ @@ -24,6 +26,8 @@ export const matrix: Record> = { 'ollama', 'groq', 'grok', + 'bedrock', + 'bedrock-responses', 'openrouter', ]), reasoning: new Set(['openai', 'anthropic', 'gemini']), @@ -34,6 +38,8 @@ export const matrix: Record> = { 'ollama', 'groq', 'grok', + 'bedrock', + 'bedrock-responses', 'openrouter', ]), 'tool-calling': new Set([ @@ -43,6 +49,8 @@ export const matrix: Record> = { 'ollama', 'groq', 'grok', + 'bedrock', + 'bedrock-responses', 'openrouter', ]), 'parallel-tool-calls': new Set([ @@ -51,6 +59,8 @@ export const matrix: Record> = { 'gemini', 'groq', 'grok', + 'bedrock', + 'bedrock-responses', 'openrouter', ]), // Gemini excluded: approval flow timing issues with Gemini's streaming format @@ -60,6 +70,8 @@ export const matrix: Record> = { 'ollama', 'groq', 'grok', + 'bedrock', + 'bedrock-responses', 'openrouter', ]), // Ollama excluded: aimock doesn't support content+toolCalls for /api/chat format @@ -69,6 +81,8 @@ export const matrix: Record> = { 'gemini', 'groq', 'grok', + 'bedrock', + 'bedrock-responses', 'openrouter', ]), 'structured-output': new Set([ @@ -78,13 +92,22 @@ export const matrix: Record> = { 'ollama', 'groq', 'grok', + 'bedrock', + 'bedrock-responses', 'openrouter', ]), // Streaming structured output: only providers with native streaming JSON // schema support are listed here. Other providers fall back to the // activity-layer `fallbackStructuredOutputStream` (which wraps the // non-streaming `structuredOutput`) but aren't exercised by E2E yet. - 'structured-output-stream': new Set(['openai', 'groq', 'grok', 'openrouter']), + 'structured-output-stream': new Set([ + 'openai', + 'groq', + 'grok', + 'bedrock', + 'bedrock-responses', + 'openrouter', + ]), // Multi-turn structured output: every turn produces its own typed // `structured-output` part on the assistant message, and historical // turns stay renderable. Works for every provider that supports both @@ -109,6 +132,8 @@ export const matrix: Record> = { 'ollama', 'groq', 'grok', + 'bedrock', + 'bedrock-responses', 'openrouter', ]), 'agentic-structured': new Set([ @@ -118,6 +143,8 @@ export const matrix: Record> = { 'ollama', 'groq', 'grok', + 'bedrock', + 'bedrock-responses', 'openrouter', ]), // Native-combined-mode adapters only. Each provider's default test model @@ -130,6 +157,9 @@ export const matrix: Record> = { 'gemini', 'grok', ]), + // Bedrock excluded: the default e2e model (openai.gpt-oss-120b) is text-only + // (input: ['text'], no vision) — image input isn't supported, so the + // multimodal request never carries the image and the description comes back empty. 'multimodal-image': new Set([ 'openai', 'anthropic', @@ -137,6 +167,7 @@ export const matrix: Record> = { 'grok', 'openrouter', ]), + // Bedrock excluded: same text-only default e2e model as multimodal-image above. 'multimodal-structured': new Set([ 'openai', 'anthropic', @@ -150,6 +181,8 @@ export const matrix: Record> = { 'gemini', 'ollama', 'grok', + 'bedrock', + 'bedrock-responses', 'openrouter', ]), 'summarize-stream': new Set([ @@ -158,6 +191,8 @@ export const matrix: Record> = { 'gemini', 'ollama', 'grok', + 'bedrock', + 'bedrock-responses', 'openrouter', ]), // Gemini excluded: aimock doesn't mock Gemini's Imagen predict endpoint format diff --git a/testing/e2e/src/lib/providers.ts b/testing/e2e/src/lib/providers.ts index fe80ed5e4..adee1b350 100644 --- a/testing/e2e/src/lib/providers.ts +++ b/testing/e2e/src/lib/providers.ts @@ -7,6 +7,7 @@ import { createGeminiTextInteractions } from '@tanstack/ai-gemini/experimental' import { createOllamaChat } from '@tanstack/ai-ollama' import { createGroqText } from '@tanstack/ai-groq' import { createGrokText } from '@tanstack/ai-grok' +import { createBedrockText } from '@tanstack/ai-bedrock' import { createOpenRouterResponsesText, createOpenRouterText, @@ -24,6 +25,8 @@ const defaultModels: Record = { ollama: 'mistral', groq: 'llama-3.3-70b-versatile', grok: 'grok-3', + bedrock: 'openai.gpt-oss-120b-1:0', + 'bedrock-responses': 'openai.gpt-oss-120b-1:0', openrouter: 'openai/gpt-4o', 'openrouter-responses': 'openai/gpt-4o', // ElevenLabs has no chat/text model — the support matrix already filters @@ -112,6 +115,37 @@ export function createTextAdapter( defaultHeaders: testHeaders, }), }), + // NOTE: Only the OpenAI-compatible Bedrock paths are E2E-covered here. + // The default `bedrock-converse` adapter uses the AWS binary event-stream + // (vnd.amazon.eventstream) Converse protocol, which aimock cannot replay — + // that path is covered by unit tests in packages/ai-bedrock/tests/converse/ + // instead. See testing/e2e/README.md § "Bedrock Converse coverage gap". + bedrock: () => + createChatOptions({ + adapter: createBedrockText( + model as 'openai.gpt-oss-120b-1:0', + DUMMY_KEY, + { + baseURL: openaiUrl, + defaultHeaders: testHeaders, + // Converse is now the default; this matrix entry exercises the + // OpenAI-compatible Chat Completions path, so pin api: 'chat'. + api: 'chat', + }, + ), + }), + 'bedrock-responses': () => + createChatOptions({ + adapter: createBedrockText( + model as 'openai.gpt-oss-120b-1:0', + DUMMY_KEY, + { + baseURL: openaiUrl, + defaultHeaders: testHeaders, + api: 'responses', + }, + ), + }), openrouter: () => { // OpenRouter SDK exposes an HTTPClient with beforeRequest hooks. Use // that to inject X-Test-Id, since `defaultHeaders` isn't supported and diff --git a/testing/e2e/src/lib/types.ts b/testing/e2e/src/lib/types.ts index a8dbd0cf1..3a161acea 100644 --- a/testing/e2e/src/lib/types.ts +++ b/testing/e2e/src/lib/types.ts @@ -7,6 +7,8 @@ export type Provider = | 'ollama' | 'grok' | 'groq' + | 'bedrock' + | 'bedrock-responses' | 'openrouter' | 'openrouter-responses' | 'elevenlabs' @@ -44,6 +46,8 @@ export const ALL_PROVIDERS: Provider[] = [ 'ollama', 'grok', 'groq', + 'bedrock', + 'bedrock-responses', 'openrouter', 'openrouter-responses', 'elevenlabs', diff --git a/testing/e2e/tests/test-matrix.ts b/testing/e2e/tests/test-matrix.ts index f48dcebc0..a1473f5b0 100644 --- a/testing/e2e/tests/test-matrix.ts +++ b/testing/e2e/tests/test-matrix.ts @@ -20,6 +20,8 @@ export const providers: Provider[] = [ 'ollama', 'groq', 'grok', + 'bedrock', + 'bedrock-responses', 'openrouter', 'openrouter-responses', 'elevenlabs',