From 50d8a8f084191173916e425d19ce8ebd6c3e381a Mon Sep 17 00:00:00 2001 From: Muralidhar Challa Date: Sat, 23 May 2026 14:26:58 +0530 Subject: [PATCH 1/3] feat(ai-bedrock): add AWS Bedrock adapter with AG-UI streaming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebased onto latest upstream/main. Rewrote the adapter to emit AG-UI protocol events (RUN_STARTED, TEXT_MESSAGE_*, REASONING_*, TOOL_CALL_*, RUN_FINISHED, RUN_ERROR) instead of the old flat chunk format. Changes from the original branch: - Adapter now uses EventType from @tanstack/ai for all chunk types - Proper AG-UI lifecycle: RUN_STARTED → content/reasoning/tool events → RUN_FINISHED - systemPrompts supports both string and { content, metadata } entries - Added generateIdFor() utility for ID generation - Updated all tests for the AG-UI event format - Fixed standalone tool argument delta emission - Reasoning tags properly emit REASONING_MESSAGE_END before switching to text --- .changeset/bedrock-adapter.md | 5 + docs/adapters/bedrock.md | 239 ++++++ .../ai-bedrock/live-tests/README.md | 36 + .../ai-bedrock/live-tests/package.json | 15 + .../ai-bedrock/live-tests/tool-test-haiku.ts | 86 ++ .../ai-bedrock/live-tests/tool-test-nova.ts | 105 +++ .../ai-bedrock/live-tests/tool-test.ts | 98 +++ packages/typescript/ai-bedrock/package.json | 57 ++ .../ai-bedrock/src/adapters/index.ts | 2 + .../ai-bedrock/src/adapters/text.ts | 740 ++++++++++++++++++ .../typescript/ai-bedrock/src/bedrock-chat.ts | 52 ++ packages/typescript/ai-bedrock/src/index.ts | 31 + .../ai-bedrock/src/message-types.ts | 70 ++ .../typescript/ai-bedrock/src/model-meta.ts | 121 +++ .../src/text/text-provider-options.ts | 60 ++ packages/typescript/ai-bedrock/src/utils.ts | 53 ++ .../ai-bedrock/tests/bedrock-adapter.test.ts | 560 +++++++++++++ .../ai-bedrock/tests/model-meta.test.ts | 62 ++ packages/typescript/ai-bedrock/tsconfig.json | 10 + packages/typescript/ai-bedrock/vite.config.ts | 36 + 20 files changed, 2438 insertions(+) create mode 100644 .changeset/bedrock-adapter.md create mode 100644 docs/adapters/bedrock.md create mode 100644 packages/typescript/ai-bedrock/live-tests/README.md create mode 100644 packages/typescript/ai-bedrock/live-tests/package.json create mode 100644 packages/typescript/ai-bedrock/live-tests/tool-test-haiku.ts create mode 100644 packages/typescript/ai-bedrock/live-tests/tool-test-nova.ts create mode 100644 packages/typescript/ai-bedrock/live-tests/tool-test.ts create mode 100644 packages/typescript/ai-bedrock/package.json create mode 100644 packages/typescript/ai-bedrock/src/adapters/index.ts create mode 100644 packages/typescript/ai-bedrock/src/adapters/text.ts create mode 100644 packages/typescript/ai-bedrock/src/bedrock-chat.ts create mode 100644 packages/typescript/ai-bedrock/src/index.ts create mode 100644 packages/typescript/ai-bedrock/src/message-types.ts create mode 100644 packages/typescript/ai-bedrock/src/model-meta.ts create mode 100644 packages/typescript/ai-bedrock/src/text/text-provider-options.ts create mode 100644 packages/typescript/ai-bedrock/src/utils.ts create mode 100644 packages/typescript/ai-bedrock/tests/bedrock-adapter.test.ts create mode 100644 packages/typescript/ai-bedrock/tests/model-meta.test.ts create mode 100644 packages/typescript/ai-bedrock/tsconfig.json create mode 100644 packages/typescript/ai-bedrock/vite.config.ts diff --git a/.changeset/bedrock-adapter.md b/.changeset/bedrock-adapter.md new file mode 100644 index 000000000..7603b95b8 --- /dev/null +++ b/.changeset/bedrock-adapter.md @@ -0,0 +1,5 @@ +--- +"@tanstack/ai-bedrock": minor +--- + +Add Amazon Bedrock adapter. diff --git a/docs/adapters/bedrock.md b/docs/adapters/bedrock.md new file mode 100644 index 000000000..78f6e9c75 --- /dev/null +++ b/docs/adapters/bedrock.md @@ -0,0 +1,239 @@ +--- +title: AWS Bedrock +id: bedrock-adapter +order: 9 +--- + +The AWS Bedrock adapter provides access to Amazon Bedrock's managed AI models, including Amazon Nova and Anthropic Claude models, via the unified Converse API. + +## Installation + +```bash +npm install @tanstack/ai-bedrock +``` + +## Basic Usage + +```typescript +import { chat } from "@tanstack/ai"; +import { bedrockText } from "@tanstack/ai-bedrock"; + +const stream = chat({ + adapter: bedrockText("amazon.nova-pro-v1:0"), + messages: [{ role: "user", content: "Hello!" }], +}); +``` + +## Basic Usage - Custom Credentials + +```typescript +import { chat } from "@tanstack/ai"; +import { createBedrockChat } from "@tanstack/ai-bedrock"; + +const adapter = createBedrockChat("amazon.nova-pro-v1:0", { + region: "us-east-1", + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + }, +}); + +const stream = chat({ + adapter, + messages: [{ role: "user", content: "Hello!" }], +}); +``` + +## Environment Variables + +The `bedrockText()` factory reads AWS credentials automatically from environment variables: + +```bash +AWS_REGION=us-east-1 +AWS_ACCESS_KEY_ID=AKIA... +AWS_SECRET_ACCESS_KEY=... +AWS_SESSION_TOKEN=... # Optional, for temporary credentials +``` + +## Example: Chat Completion + +```typescript +import { chat, toServerSentEventsResponse } from "@tanstack/ai"; +import { bedrockText } from "@tanstack/ai-bedrock"; + +export async function POST(request: Request) { + const { messages } = await request.json(); + + const stream = chat({ + adapter: bedrockText("amazon.nova-pro-v1:0"), + messages, + }); + + return toServerSentEventsResponse(stream); +} +``` + +## Example: With Tools + +```typescript +import { chat, toolDefinition } from "@tanstack/ai"; +import { bedrockText } from "@tanstack/ai-bedrock"; +import { z } from "zod"; + +const getWeatherDef = toolDefinition({ + name: "get_weather", + description: "Get current weather for a city", + inputSchema: z.object({ + city: z.string(), + }), +}); + +const getWeather = getWeatherDef.server(async ({ city }) => { + return { temperature: 72, conditions: "sunny", city }; +}); + +const stream = chat({ + adapter: bedrockText("anthropic.claude-sonnet-4-5-20250929-v1:0"), + messages: [{ role: "user", content: "What's the weather in Paris?" }], + tools: [getWeather], +}); +``` + +## Thinking / Extended Reasoning + +Models that support thinking (Claude Sonnet 4.5, Claude Haiku 4.5, and Nova models) can be configured to show their reasoning process, streamed as `thinking` chunks: + +```typescript +import { chat } from "@tanstack/ai"; +import { bedrockText } from "@tanstack/ai-bedrock"; + +const stream = chat({ + adapter: bedrockText("anthropic.claude-sonnet-4-5-20250929-v1:0"), + messages: [{ role: "user", content: "Solve this step by step: 17 * 24" }], + modelOptions: { + thinking: { + type: "enabled", + budget_tokens: 2000, + }, + }, +}); + +for await (const chunk of stream) { + if (chunk.type === "thinking") { + process.stdout.write(`[thinking] ${chunk.delta}`); + } else if (chunk.type === "content") { + process.stdout.write(chunk.delta); + } +} +``` + +Nova models use a `reasoningConfig` approach but produce the same `thinking` stream chunks — the adapter normalises both automatically. + +## Multimodal Content + +Nova Pro, Nova Lite, and Claude models support image and document inputs: + +```typescript +import { chat } from "@tanstack/ai"; +import { bedrockText } from "@tanstack/ai-bedrock"; +import { readFileSync } from "fs"; + +const imageBytes = readFileSync("./photo.jpg"); + +const stream = chat({ + adapter: bedrockText("amazon.nova-pro-v1:0"), + messages: [ + { + role: "user", + content: [ + { + type: "image", + source: { type: "base64", value: imageBytes }, + metadata: { mediaType: "image/jpeg" }, + }, + { type: "text", content: "What do you see in this image?" }, + ], + }, + ], +}); +``` + +## Model Options + +```typescript +const stream = chat({ + adapter: bedrockText("amazon.nova-pro-v1:0"), + messages: [{ role: "user", content: "Hello!" }], + modelOptions: { + inferenceConfig: { + maxTokens: 1024, + temperature: 0.7, + topP: 0.9, + }, + stop_sequences: ["END"], + top_k: 50, + }, +}); +``` + +## Supported Models + +### Amazon Nova + +| Model | ID | Context | Inputs | +|-------|----|---------|--------| +| Nova Pro | `amazon.nova-pro-v1:0` | 300K | text, image, video, document | +| Nova Lite | `amazon.nova-lite-v1:0` | 300K | text, image, video, document | +| Nova Micro | `amazon.nova-micro-v1:0` | 128K | text only | + +### Anthropic Claude (via Bedrock) + +| Model | ID | Context | Inputs | +|-------|----|---------|--------| +| Claude Sonnet 4.5 | `anthropic.claude-sonnet-4-5-20250929-v1:0` | 1M | text, image, document | +| Claude Haiku 4.5 | `anthropic.claude-haiku-4-5-20251001-v1:0` | 200K | text, image, document | + +Both Claude models support extended thinking. + +## API Reference + +### `bedrockText(model, config?)` + +Creates a Bedrock text adapter using environment variable credentials. + +**Parameters:** + +- `model` - The Bedrock model ID (e.g., `amazon.nova-pro-v1:0`) +- `config` (optional) - Partial configuration object: + - `region` - AWS region (falls back to `AWS_REGION` / `AWS_DEFAULT_REGION`) + - `credentials.accessKeyId` - AWS access key (falls back to `AWS_ACCESS_KEY_ID`) + - `credentials.secretAccessKey` - AWS secret key (falls back to `AWS_SECRET_ACCESS_KEY`) + +**Returns:** A `BedrockTextAdapter` instance. + +### `createBedrockChat(model, config)` + +Creates a Bedrock text adapter with explicit credentials. + +**Parameters:** + +- `model` - The Bedrock model ID +- `config` - Full configuration object: + - `region` - AWS region (required) + - `credentials.accessKeyId` - AWS access key (required) + - `credentials.secretAccessKey` - AWS secret key (required) + +**Returns:** A `BedrockTextAdapter` instance. + +## Limitations + +- **Structured output**: Not yet supported via the Converse API (planned). +- **Nova Micro**: Text-only; does not support image, video, or document inputs. +- **Thinking for Claude**: Only supported on the first turn of a conversation. + +## Next Steps + +- [Getting Started](../getting-started/quick-start) - Learn the basics +- [Tools Guide](../guides/tools) - Learn about tools +- [Multimodal Content](../guides/multimodal-content) - Using images and documents +- [Other Adapters](./anthropic) - Explore other providers diff --git a/packages/typescript/ai-bedrock/live-tests/README.md b/packages/typescript/ai-bedrock/live-tests/README.md new file mode 100644 index 000000000..5550ee064 --- /dev/null +++ b/packages/typescript/ai-bedrock/live-tests/README.md @@ -0,0 +1,36 @@ +# Bedrock Live Tests + +These tests verify that the Bedrock adapter correctly handles tool calling and multimodal inputs with various models (Nova, Claude). + +## Setup + +1. Create a `.env.local` file in this directory with your AWS credentials: + + ``` + AWS_ACCESS_KEY_ID=... + AWS_SECRET_ACCESS_KEY=... + AWS_REGION=us-east-1 + ``` + +2. Install dependencies: + ```bash + pnpm install + ``` + +## Tests + +### `tool-test.ts` +Tests basic tool calling with Claude 3.5 Sonnet. + +### `tool-test-nova.ts` +Tests Amazon Nova Pro with multimodal inputs (if applicable) and tool calling. + +## Running Tests + +```bash +# Run Claude tool test +pnpm test + +# Run Nova tool test +pnpm test:nova +``` diff --git a/packages/typescript/ai-bedrock/live-tests/package.json b/packages/typescript/ai-bedrock/live-tests/package.json new file mode 100644 index 000000000..d7a5b0973 --- /dev/null +++ b/packages/typescript/ai-bedrock/live-tests/package.json @@ -0,0 +1,15 @@ +{ + "name": "@tanstack/ai-bedrock-live-tests", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "test": "tsx tool-test.ts", + "test:nova": "tsx tool-test-nova.ts" + }, + "devDependencies": { + "tsx": "^4.7.1", + "typescript": "^5.4.2", + "zod": "^4.2.1" + } +} \ No newline at end of file diff --git a/packages/typescript/ai-bedrock/live-tests/tool-test-haiku.ts b/packages/typescript/ai-bedrock/live-tests/tool-test-haiku.ts new file mode 100644 index 000000000..4f87ab6bd --- /dev/null +++ b/packages/typescript/ai-bedrock/live-tests/tool-test-haiku.ts @@ -0,0 +1,86 @@ +// import 'dotenv/config' +import { bedrockText } from '../src/bedrock-chat' +import { z } from 'zod' +import { chat } from '@tanstack/ai' + +function throwMissingEnv(name: string): never { + throw new Error(`Missing required environment variable: ${name}`) +} + +async function main() { + const accessKeyId = process.env.AWS_ACCESS_KEY_ID ?? throwMissingEnv('AWS_ACCESS_KEY_ID') + const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY ?? throwMissingEnv('AWS_SECRET_ACCESS_KEY') + + const modelId = 'anthropic.claude-haiku-4-5-20251001-v1:0' + console.log(`Running tool test for: ${modelId}`) + + const stream = await chat({ + adapter: bedrockText(modelId, { + region: process.env.AWS_REGION || 'us-east-1', + credentials: { + accessKeyId, + secretAccessKey, + }, + }), + modelOptions: { + thinking: { + type: 'enabled', + budget_tokens: 1024 + } + }, + messages: [ + { + role: 'user', + content: 'Use the `get_weather` tool to find the weather in New York and explain it.', + }, + ], + tools: [ + { + name: 'get_weather', + description: 'Get the current weather in a location', + inputSchema: z.object({ + location: z.string().describe('The city and state, e.g. New York, NY'), + }), + execute: async ({ location }) => { + console.log(`\n[TOOL Weather] Fetching weather for ${location}...`) + return { + temperature: 45, + unit: 'F', + condition: 'Cloudy', + } + }, + }, + ], + stream: true + }) + + let finalContent = '' + let hasThinking = false + let toolCallCount = 0 + + console.log('--- Stream Output ---') + for await (const chunk of stream) { + if (chunk.type === 'thinking') { + hasThinking = true + } else if (chunk.type === 'content') { + process.stdout.write(chunk.delta) + finalContent += chunk.delta + } else if (chunk.type === 'tool_call') { + toolCallCount++ + } + } + + console.log('--- Results ---') + console.log('Thinking:', hasThinking) + console.log('Tool calls:', toolCallCount) + console.log('Content length:', finalContent.length) + + if (!finalContent || finalContent.trim().length === 0) { + console.error('Test failed: No final content') + process.exit(1) + } + + console.log('Test passed') +} + +main().catch(console.error) diff --git a/packages/typescript/ai-bedrock/live-tests/tool-test-nova.ts b/packages/typescript/ai-bedrock/live-tests/tool-test-nova.ts new file mode 100644 index 000000000..210f24bce --- /dev/null +++ b/packages/typescript/ai-bedrock/live-tests/tool-test-nova.ts @@ -0,0 +1,105 @@ +import { bedrockText } from '../src/index' +import { z } from 'zod' +import { readFileSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' +import { chat } from '@tanstack/ai' + +// Load environment variables from .env.local manually +const __dirname = dirname(fileURLToPath(import.meta.url)) +try { + const envContent = readFileSync(join(__dirname, '.env.local'), 'utf-8') + envContent.split('\n').forEach((line) => { + const match = line.match(/^([^=]+)=(.*)$/) + if (match) { + process.env[match[1].trim()] = match[2].trim() + } + }) +} catch (e) { + // .env.local not found +} + +function throwMissingEnv(name: string): never { + throw new Error(`Missing required environment variable: ${name}`) +} + +async function testBedrockNovaToolCalling() { + console.log('Testing Bedrock tool calling (Amazon Nova Pro)\n') + + const accessKeyId = process.env.AWS_ACCESS_KEY_ID ?? throwMissingEnv('AWS_ACCESS_KEY_ID') + const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY ?? throwMissingEnv('AWS_SECRET_ACCESS_KEY') + + const stream = await chat({ + adapter: bedrockText('us.amazon.nova-pro-v1:0', { + region: process.env.AWS_REGION || 'us-east-1', + credentials: { + accessKeyId, + secretAccessKey, + } + }), + messages: [ + { + role: 'user', + content: 'Use the `get_temperature` tool to find the temperature in New York and explain why it is the way it is.', + }, + ], + tools: [ + { + name: 'get_temperature', + description: 'Get the current temperature for a specific location', + inputSchema: z.object({ + location: z.string().describe('The city or location'), + unit: z.enum(['celsius', 'fahrenheit']).describe('The temperature unit'), + }), + execute: async ({ location, unit }: { location: string; unit: string }) => { + console.log(`\n[TOOL Temperature] Fetching for ${location}...`) + return { + temperature: 45, + unit: unit, + condition: 'Cloudy', + } + }, + }, + ], + stream: true, + }) + + let finalContent = '' + let hasThinking = false + let toolCallCount = 0 + + console.log('--- Stream Output ---') + for await (const chunk of stream) { + if (chunk.type === 'thinking') { + hasThinking = true + } else if (chunk.type === 'content') { + process.stdout.write(chunk.delta) + finalContent += chunk.delta + } else if (chunk.type === 'tool_call') { + toolCallCount++ + } + } + + console.log('--- Test Results ---') + console.log('Thinking detected:', hasThinking) + console.log('Tool calls:', toolCallCount) + console.log('Final content length:', finalContent.length) + + if (!hasThinking) { + console.warn('Warning: No thinking blocks detected') + } + + if (finalContent.includes('')) { + console.error('Test failed: Thinking tags found in final content') + process.exit(1) + } + + if (!finalContent || finalContent.trim().length === 0) { + console.error('Test failed: No final content - model should explain the temperature') + process.exit(1) + } + + console.log('Test passed') +} + +testBedrockNovaToolCalling().catch(console.error) diff --git a/packages/typescript/ai-bedrock/live-tests/tool-test.ts b/packages/typescript/ai-bedrock/live-tests/tool-test.ts new file mode 100644 index 000000000..292f2154c --- /dev/null +++ b/packages/typescript/ai-bedrock/live-tests/tool-test.ts @@ -0,0 +1,98 @@ +// import 'dotenv/config' +import { bedrockText } from '../src/bedrock-chat' +import { z } from 'zod' +import { chat } from '@tanstack/ai' + +async function main() { + const modelId = 'us.anthropic.claude-sonnet-4-5-20250929-v1:0' + console.log(`Running tool test for: ${modelId}`) + + const stream = await chat({ + adapter: bedrockText(modelId, { + region: process.env.AWS_REGION || 'us-east-1', + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + }, + }), + modelOptions: { + thinking: { + type: 'enabled', + budget_tokens: 2048 + } + }, + messages: [ + { + role: 'user', + content: 'Use the `get_weather` tool to find the weather in San Francisco and then explain why it is the way it is.', + }, + ], + tools: [ + { + name: 'get_weather', + description: 'Get the current weather in a location', + inputSchema: z.object({ + location: z.string().describe('The city and state, e.g. San Francisco, CA'), + }), + execute: async ({ location }) => { + console.log(`\n[TOOL Weather] Fetching weather for ${location}...`) + return { + temperature: 72, + unit: 'F', + condition: 'Sunny', + } + }, + }, + ], + stream: true + }) + + let finalContent = '' + let hasThinking = false + let toolCallCount = 0 + let doneCount = 0 + + console.log('--- Stream Output ---') + for await (const chunk of stream) { + if (chunk.type === 'thinking') { + hasThinking = true + } else if (chunk.type === 'content') { + process.stdout.write(chunk.delta) + finalContent += chunk.delta + } else if (chunk.type === 'tool_call') { + toolCallCount++ + console.log('\nTool call:', chunk.toolCall.function.name) + } else if (chunk.type === 'done') { + doneCount++ + } + } + + console.log('--- Test Results ---') + console.log('Thinking detected:', hasThinking) + console.log('Tool calls:', toolCallCount) + console.log('Done events:', doneCount) + console.log('Final content length:', finalContent.length) + + if (!hasThinking) { + console.error('Test failed: No thinking blocks detected for Claude 4.5') + process.exit(1) + } + + if (toolCallCount === 0) { + console.error('Test failed: No tool calls detected') + process.exit(1) + } + + if (!finalContent || finalContent.trim().length === 0) { + console.error('Test failed: Final content is empty - model should explain weather after getting tool results') + process.exit(1) + } + + if (!finalContent.toLowerCase().includes('72') && !finalContent.toLowerCase().includes('sunny')) { + console.warn('Warning: Final content does not mention the weather data') + } + + console.log('Test passed') +} + +main().catch(console.error) diff --git a/packages/typescript/ai-bedrock/package.json b/packages/typescript/ai-bedrock/package.json new file mode 100644 index 000000000..650640358 --- /dev/null +++ b/packages/typescript/ai-bedrock/package.json @@ -0,0 +1,57 @@ +{ + "name": "@tanstack/ai-bedrock", + "version": "0.0.1", + "description": "AWS Bedrock adapter for TanStack AI", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/typescript/ai-bedrock" + }, + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + }, + "./adapters": { + "types": "./dist/esm/adapters/index.d.ts", + "import": "./dist/esm/adapters/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", + "bedrock", + "aws", + "tanstack", + "adapter" + ], + "dependencies": { + "@aws-sdk/client-bedrock-runtime": "^3.723.0" + }, + "peerDependencies": { + "@tanstack/ai": "workspace:^", + "zod": "^4.0.0" + }, + "devDependencies": { + "@tanstack/ai": "workspace:*", + "@vitest/coverage-v8": "4.0.14", + "vite": "^7.2.7" + } +} \ No newline at end of file diff --git a/packages/typescript/ai-bedrock/src/adapters/index.ts b/packages/typescript/ai-bedrock/src/adapters/index.ts new file mode 100644 index 000000000..8675f95eb --- /dev/null +++ b/packages/typescript/ai-bedrock/src/adapters/index.ts @@ -0,0 +1,2 @@ +export { BedrockTextAdapter } from './text' +export type { BedrockTextConfig } from './text' diff --git a/packages/typescript/ai-bedrock/src/adapters/text.ts b/packages/typescript/ai-bedrock/src/adapters/text.ts new file mode 100644 index 000000000..7be0e19c4 --- /dev/null +++ b/packages/typescript/ai-bedrock/src/adapters/text.ts @@ -0,0 +1,740 @@ +import { + BedrockRuntimeClient, + ConverseStreamCommand, +} from '@aws-sdk/client-bedrock-runtime' +import { + BaseTextAdapter, +} from '@tanstack/ai/adapters' +import { isClaude, isNova } from '../model-meta' +import { generateIdFor } from '../utils' +import type { BedrockModelId } from '../model-meta' +import type { ContentBlock, Message, ToolResultBlock, ToolUseBlock } from '@aws-sdk/client-bedrock-runtime' +import type { + StructuredOutputOptions, + StructuredOutputResult, +} from '@tanstack/ai/adapters' +import { EventType, normalizeSystemPrompts } from '@tanstack/ai' +import type { + DefaultMessageMetadataByModality, + ModelMessage, + StreamChunk, + TextOptions, +} from '@tanstack/ai' +import type { BedrockTextProviderOptions } from '../text/text-provider-options' + +/** + * Configuration for the AWS Bedrock client. + */ +export interface BedrockTextConfig { + /** AWS region where Bedrock is accessed (e.g. `'us-east-1'`). */ + region: string + /** AWS credentials used to authenticate requests. */ + credentials: { + /** AWS access key ID. */ + accessKeyId: string + /** AWS secret access key. */ + secretAccessKey: string + } +} + +/** + * Supported input modalities for Bedrock text adapters. + * Nova Micro only supports `'text'`; other models support the full set. + */ +export type BedrockInputModalities = readonly ['text', 'image', 'video', 'document'] + +/** + * Text adapter for Amazon Bedrock using the unified ConverseStream API. + * + * Supports Amazon Nova and Anthropic Claude models with streaming, tool calling, + * multimodal inputs (text, image, video, document), and extended thinking. + * + * @example + * ```typescript + * import { bedrockText } from '@tanstack/ai-bedrock' + * import { chat } from '@tanstack/ai' + * + * const stream = chat({ + * adapter: bedrockText('amazon.nova-pro-v1:0'), + * messages: [{ role: 'user', content: 'Hello!' }], + * }) + * ``` + */ +export class BedrockTextAdapter< + TModel extends BedrockModelId = BedrockModelId, +> extends BaseTextAdapter< + TModel, + BedrockTextProviderOptions, + BedrockInputModalities, + DefaultMessageMetadataByModality +> { + readonly kind = 'text' as const + readonly name = 'bedrock' as const + + private client: BedrockRuntimeClient + + /** + * @param config - AWS region and credentials for the Bedrock client. + * @param model - The Bedrock model ID to use for requests. + */ + constructor(config: BedrockTextConfig, model: TModel) { + super({}, model) + this.client = new BedrockRuntimeClient({ + region: config.region, + credentials: config.credentials, + }) + } + + /** + * Streams a chat completion from the Bedrock ConverseStream API. + * Yields AG-UI protocol {@link StreamChunk} events. + * + * @param options - Text generation options (messages, tools, modelOptions, etc.) + */ + async *chatStream( + options: TextOptions, + ): AsyncIterable { + const threadId = options.threadId || options.conversationId || this.generateId() + const runId = options.runId || this.generateId() + const parentRunId = options.parentRunId + const timestamp = Date.now() + const model = this.model + + try { + // Convert messages to Converse format (unified across all models) + const messages = options.messages.map(m => this.convertToConverseMessage(m)) + + // Normalise system prompts via the SDK utility + const system = normalizeSystemPrompts(options.systemPrompts).map( + p => ({ text: p.content }), + ) + + const command = new ConverseStreamCommand({ + modelId: model, + messages, + system: system?.length ? system : undefined, + inferenceConfig: { + maxTokens: options.maxTokens, + temperature: options.temperature, + topP: options.topP, + ...options.modelOptions?.inferenceConfig, + }, + toolConfig: options.tools?.length ? { + tools: options.tools.map(t => ({ + toolSpec: { + name: t.name, + description: t.description, + inputSchema: { json: t.inputSchema }, + }, + })), + } : undefined, + // Model-specific extended features via additionalModelRequestFields + additionalModelRequestFields: (() => { + if (isClaude(model) && options.modelOptions?.thinking && options.messages.length === 1) { + // Claude: native thinking support (only first turn) + return { thinking: options.modelOptions.thinking } + } + if (isNova(model) && options.modelOptions?.thinking) { + // Nova: extended thinking via reasoningConfig + // Note: produces tags in text (parsed universally below) + return { + reasoningConfig: { + enabled: true, + maxReasoningEffort: 'medium', + }, + } + } + return undefined + })() as any, // Type assertion for AWS SDK DocumentType + }) + + const response = await this.client.send(command) + + if (!response.stream) { + yield { + type: EventType.RUN_ERROR, + threadId, + runId, + model, + timestamp: Date.now(), + message: 'No stream received from Bedrock', + error: { + message: 'No stream received from Bedrock', + code: 'NO_STREAM', + }, + } + return + } + + yield* this.processConverseStream(response.stream, { + threadId, + runId, + parentRunId, + model, + }) + } catch (error: unknown) { + const err = error as Error & { name?: string } + yield { + type: EventType.RUN_ERROR, + threadId, + runId, + model, + timestamp: Date.now(), + message: err.message || 'Unknown Bedrock error', + error: { + message: err.message || 'Unknown Bedrock error', + code: err.name || 'INTERNAL_ERROR', + }, + } + } + } + + /** + * Structured output is not yet supported for the Bedrock ConverseStream API. + * @throws Always rejects with a not-implemented error. + */ + structuredOutput( + _options: StructuredOutputOptions, + ): Promise> { + // TODO: Migrate to Converse API for structured output + return Promise.reject(new Error('Structured output not yet migrated to ConverseStream API')) + } + + /** + * Convert ModelMessage to Converse API message format (unified across all models) + */ + private convertToConverseMessage(message: ModelMessage): Message { + // Handle tool result messages + if (message.role === 'tool' && message.toolCallId) { + const contentText = typeof message.content === 'string' ? message.content : JSON.stringify(message.content) + let contentBlock: any = { text: contentText } + + // Try to parse as JSON for better structure + try { + const parsed = JSON.parse(contentText) + contentBlock = { json: parsed } + } catch { + // Keep as text + } + + return { + role: 'user', + content: [{ + toolResult: { + toolUseId: message.toolCallId, + content: [contentBlock], + status: ((message as any).status === 'error' || (message as any).error) ? 'failure' : 'success', + } as ToolResultBlock + }] + } + } + + // Handle assistant messages with tool calls + if (message.role === 'assistant' && message.toolCalls?.length) { + const content: Array = [] + + // Add text content if present + if (typeof message.content === 'string' && message.content) { + content.push({ text: message.content }) + } else if (Array.isArray(message.content)) { + for (const part of message.content) { + const block = this.convertPartToConverseBlock(part) + if (block) content.push(block) + } + } + + // Add tool use blocks + for (const tc of message.toolCalls) { + let input = tc.function.arguments + if (typeof input === 'string') { + try { + input = JSON.parse(input) + } catch { + // Keep as string if parsing fails + } + } + + content.push({ + toolUse: { + toolUseId: tc.id, + name: tc.function.name, + input + } as ToolUseBlock + }) + } + + return { role: 'assistant', content } + } + + // Handle regular messages (user or assistant) + const content: Array = [] + + if (typeof message.content === 'string') { + content.push({ text: message.content }) + } else if (Array.isArray(message.content)) { + for (const part of message.content) { + const block = this.convertPartToConverseBlock(part) + if (block) content.push(block) + } + } + + return { + role: message.role === 'user' ? 'user' : 'assistant', + content + } + } + + /** + * Convert message part to Converse content block + */ + private convertPartToConverseBlock(part: any): ContentBlock | null { + if (part.type === 'text') { + return { text: part.content } + } + if (part.type === 'image') { + return { + image: { + format: (part.metadata)?.mediaType?.split('/')[1] || 'jpeg', + source: { bytes: part.source.value } + } + } + } + if (part.type === 'video') { + return { + video: { + format: (part.metadata)?.mediaType?.split('/')[1] || 'mp4', + source: { bytes: part.source.value } + } + } + } + if (part.type === 'document') { + return { + document: { + format: (part.metadata)?.mediaType?.split('/')[1] || 'pdf', + source: { bytes: part.source.value }, + name: (part.metadata)?.name || 'document' + } + } + } + // Skip thinking parts - they're not sent back to the model in Converse API + return null + } + + /** + * Process ConverseStream events and yield AG-UI protocol StreamChunks. + */ + private async *processConverseStream( + stream: AsyncIterable, + ctx: { + threadId: string + runId: string + parentRunId?: string + model: string + }, + ): AsyncIterable { + const { threadId, runId, parentRunId, model } = ctx + const timestamp = Date.now() + + // Track state for proper AG-UI lifecycle events + let hasEmittedRunStarted = false + let hasEmittedTextMessageStart = false + const messageId = generateIdFor('msg', this.name) + let hasClosedText = false + + // Reasoning state + let hasEmittedReasoning = false + const reasoningMessageId = generateIdFor('reasoning', this.name) + let hasClosedReasoning = false + + // Tool state + let toolCallIndex = -1 + const activeToolCalls = new Map() + + // Content parsing state + let accumulatedContent = '' + let isInsideThinking = false + let pendingTagBuffer = '' + let accumulatedThinking = '' + + // Track final metadata + let lastUsage: any | undefined + + for await (const event of stream) { + // Emit RUN_STARTED on first chunk + if (!hasEmittedRunStarted) { + hasEmittedRunStarted = true + yield { + type: EventType.RUN_STARTED, + threadId, + runId, + model, + timestamp, + parentRunId, + } as StreamChunk + } + + // Content block delta (text generation, reasoning, tool input) + if (event.contentBlockDelta) { + const delta = event.contentBlockDelta.delta + + // --- Claude native reasoning (delta.reasoningContent.text) --- + if (delta?.reasoningContent?.text !== undefined) { + if (!hasEmittedReasoning) { + hasEmittedReasoning = true + yield { + type: EventType.REASONING_START, + messageId: reasoningMessageId, + model, + timestamp: Date.now(), + } as StreamChunk + yield { + type: EventType.REASONING_MESSAGE_START, + messageId: reasoningMessageId, + role: 'reasoning' as const, + model, + timestamp: Date.now(), + } as StreamChunk + } + + const reasoningDelta = delta.reasoningContent.text + accumulatedThinking += reasoningDelta + yield { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: reasoningMessageId, + delta: reasoningDelta, + model, + timestamp: Date.now(), + } as StreamChunk + continue + } + + // Signature-only delta — silently ignored + if (delta?.reasoningContent?.signature !== undefined) { + continue + } + + // --- Text content (with tag parsing for Nova) --- + if (delta?.text) { + // Close reasoning before text starts + if (hasEmittedReasoning && !hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: EventType.REASONING_MESSAGE_END, + messageId: reasoningMessageId, + model, + timestamp: Date.now(), + } as StreamChunk + yield { + type: EventType.REASONING_END, + messageId: reasoningMessageId, + model, + timestamp: Date.now(), + } as StreamChunk + } + + // Parse tags embedded in text (Nova models) + let text = pendingTagBuffer + delta.text + pendingTagBuffer = '' + + while (text.length > 0) { + if (!isInsideThinking) { + const startIdx = text.indexOf('') + if (startIdx !== -1) { + // Emit content before the tag + if (startIdx > 0) { + const before = text.substring(0, startIdx) + if (!hasEmittedTextMessageStart) { + hasEmittedTextMessageStart = true + yield { + type: EventType.TEXT_MESSAGE_START, + messageId, + model, + timestamp: Date.now(), + role: 'assistant', + } as StreamChunk + } + accumulatedContent += before + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta: before, + content: accumulatedContent, + model, + timestamp: Date.now(), + } as StreamChunk + } + + // Start thinking + if (!hasEmittedReasoning) { + hasEmittedReasoning = true + yield { + type: EventType.REASONING_START, + messageId: reasoningMessageId, + model, + timestamp: Date.now(), + } as StreamChunk + yield { + type: EventType.REASONING_MESSAGE_START, + messageId: reasoningMessageId, + role: 'reasoning' as const, + model, + timestamp: Date.now(), + } as StreamChunk + } + isInsideThinking = true + text = text.substring(''.length) // 10 chars + } else if (text.includes('<')) { + // Possible partial tag — buffer + const idx = text.lastIndexOf('<') + const before = text.substring(0, idx) + if (before) { + if (!hasEmittedTextMessageStart) { + hasEmittedTextMessageStart = true + yield { + type: EventType.TEXT_MESSAGE_START, + messageId, + model, + timestamp: Date.now(), + role: 'assistant', + } as StreamChunk + } + accumulatedContent += before + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta: before, + content: accumulatedContent, + model, + timestamp: Date.now(), + } as StreamChunk + } + pendingTagBuffer = text.substring(idx) + break + } else { + if (!hasEmittedTextMessageStart) { + hasEmittedTextMessageStart = true + yield { + type: EventType.TEXT_MESSAGE_START, + messageId, + model, + timestamp: Date.now(), + role: 'assistant', + } as StreamChunk + } + accumulatedContent += text + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta: text, + content: accumulatedContent, + model, + timestamp: Date.now(), + } as StreamChunk + break + } + } else { + // Inside tag + const endIdx = text.indexOf('') + if (endIdx !== -1) { + if (endIdx > 0) { + const thinking = text.substring(0, endIdx) + accumulatedThinking += thinking + yield { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: reasoningMessageId, + delta: thinking, + model, + timestamp: Date.now(), + } as StreamChunk + } + isInsideThinking = false + // Close reasoning + if (!hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: EventType.REASONING_MESSAGE_END, + messageId: reasoningMessageId, + model, + timestamp: Date.now(), + } as StreamChunk + yield { + type: EventType.REASONING_END, + messageId: reasoningMessageId, + model, + timestamp: Date.now(), + } as StreamChunk + } + text = text.substring(endIdx + ''.length) // 11 chars + } else if (text.includes('<')) { + const idx = text.lastIndexOf('<') + const thinking = text.substring(0, idx) + if (thinking) { + accumulatedThinking += thinking + yield { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: reasoningMessageId, + delta: thinking, + model, + timestamp: Date.now(), + } as StreamChunk + } + pendingTagBuffer = text.substring(idx) + break + } else { + accumulatedThinking += text + yield { + type: EventType.REASONING_MESSAGE_CONTENT, + messageId: reasoningMessageId, + delta: text, + model, + timestamp: Date.now(), + } as StreamChunk + break + } + } + } + } + + // --- Tool input (arguments) --- + if (delta?.toolUse?.input) { + const inputDelta = delta.toolUse.input + // Find the active tool call (started by contentBlockStart) + const active = [...activeToolCalls.values()].find(t => t.started) + if (active) { + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId: [...activeToolCalls.keys()].find( + k => activeToolCalls.get(k) === active, + )!, + delta: inputDelta, + model, + timestamp: Date.now(), + } as StreamChunk + } + } + } + + // Content block start (for tool use — name + id revealed first) + if (event.contentBlockStart?.start?.toolUse) { + const toolUse = event.contentBlockStart.start.toolUse + toolCallIndex++ + const toolCallId = toolUse.toolUseId + activeToolCalls.set(toolCallId, { + name: toolUse.name, + index: toolCallIndex, + started: true, + }) + + yield { + type: EventType.TOOL_CALL_START, + toolCallId, + toolCallName: toolUse.name, + model, + timestamp: Date.now(), + index: toolCallIndex, + } as StreamChunk + } + + // Content block stop (tool call arguments complete) + if (event.contentBlockStop) { + // Find any tool call whose args just finished + for (const [toolCallId, tc] of activeToolCalls) { + if (tc.started) { + yield { + type: EventType.TOOL_CALL_END, + toolCallId, + model, + timestamp: Date.now(), + } as StreamChunk + tc.started = false + } + } + } + + // Message stop (completion) + if (event.messageStop) { + // Close reasoning if still open + if (hasEmittedReasoning && !hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: EventType.REASONING_MESSAGE_END, + messageId: reasoningMessageId, + model, + timestamp: Date.now(), + } as StreamChunk + yield { + type: EventType.REASONING_END, + messageId: reasoningMessageId, + model, + timestamp: Date.now(), + } as StreamChunk + } + + // End text message if started + if (hasEmittedTextMessageStart && !hasClosedText) { + hasClosedText = true + yield { + type: EventType.TEXT_MESSAGE_END, + messageId, + model, + timestamp: Date.now(), + } as StreamChunk + } + } + + // Metadata (token usage) + if (event.metadata?.usage) { + lastUsage = event.metadata.usage + } + } + + // Ensure text message end is emitted if not done by messageStop + if (hasEmittedTextMessageStart && !hasClosedText) { + hasClosedText = true + yield { + type: EventType.TEXT_MESSAGE_END, + messageId, + model, + timestamp: Date.now(), + } as StreamChunk + } + + // Close reasoning if still open + if (hasEmittedReasoning && !hasClosedReasoning) { + hasClosedReasoning = true + yield { + type: EventType.REASONING_MESSAGE_END, + messageId: reasoningMessageId, + model, + timestamp: Date.now(), + } as StreamChunk + yield { + type: EventType.REASONING_END, + messageId: reasoningMessageId, + model, + timestamp: Date.now(), + } as StreamChunk + } + + // Build usage from metadata + const usage = lastUsage + ? { + promptTokens: lastUsage.inputTokens ?? 0, + completionTokens: lastUsage.outputTokens ?? 0, + totalTokens: + lastUsage.totalTokens ?? + (lastUsage.inputTokens ?? 0) + (lastUsage.outputTokens ?? 0), + } + : undefined + + // Emit RUN_FINISHED + yield { + type: EventType.RUN_FINISHED, + threadId, + runId, + model, + timestamp: Date.now(), + usage, + } as StreamChunk + } +} diff --git a/packages/typescript/ai-bedrock/src/bedrock-chat.ts b/packages/typescript/ai-bedrock/src/bedrock-chat.ts new file mode 100644 index 000000000..eeca61c23 --- /dev/null +++ b/packages/typescript/ai-bedrock/src/bedrock-chat.ts @@ -0,0 +1,52 @@ +import { BedrockTextAdapter } from './adapters/text' +import { getBedrockConfigFromEnv } from './utils' +import type { BedrockTextConfig } from './adapters/text'; +import type { BedrockModelId } from './model-meta' + +/** + * Creates a Bedrock text adapter with an explicit full configuration. + * + * @param model - The Bedrock model ID (e.g. `'amazon.nova-pro-v1:0'`). + * @param config - Full AWS region and credentials configuration. + * @returns A configured {@link BedrockTextAdapter} instance. + */ +export function createBedrockChat< + TModel extends BedrockModelId, +>(model: TModel, config: BedrockTextConfig): BedrockTextAdapter { + return new BedrockTextAdapter(config, model) +} + +/** + * Creates a Bedrock text adapter, reading AWS credentials from environment variables + * (`AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`). + * Any values provided in `config` take precedence over environment variables. + * + * @param model - The Bedrock model ID (e.g. `'amazon.nova-pro-v1:0'`). + * @param config - Optional partial configuration to override environment variable defaults. + * @returns A configured {@link BedrockTextAdapter} instance. + * + * @example + * ```typescript + * import { bedrockText } from '@tanstack/ai-bedrock' + * import { chat } from '@tanstack/ai' + * + * const stream = chat({ + * adapter: bedrockText('amazon.nova-pro-v1:0'), + * messages: [{ role: 'user', content: 'Hello!' }], + * }) + * ``` + */ +export function bedrockText( + model: TModel, + config?: Partial, +): BedrockTextAdapter { + const envConfig = getBedrockConfigFromEnv() + const fullConfig: BedrockTextConfig = { + region: config?.region || envConfig.region || 'us-east-1', + credentials: { + accessKeyId: config?.credentials?.accessKeyId || envConfig.credentials?.accessKeyId || '', + secretAccessKey: config?.credentials?.secretAccessKey || envConfig.credentials?.secretAccessKey || '', + }, + } + return createBedrockChat(model, fullConfig) +} diff --git a/packages/typescript/ai-bedrock/src/index.ts b/packages/typescript/ai-bedrock/src/index.ts new file mode 100644 index 000000000..3f20f3511 --- /dev/null +++ b/packages/typescript/ai-bedrock/src/index.ts @@ -0,0 +1,31 @@ +export { + BedrockTextAdapter, + type BedrockTextConfig, +} from './adapters/text' + +export { + createBedrockChat, + bedrockText, +} from './bedrock-chat' + +export { + BEDROCK_AMAZON_NOVA_PRO_V1, + BEDROCK_AMAZON_NOVA_LITE_V1, + BEDROCK_AMAZON_NOVA_MICRO_V1, + BEDROCK_ANTHROPIC_CLAUDE_SONNET_4_5, + BEDROCK_ANTHROPIC_CLAUDE_HAIKU_4_5, + BEDROCK_CHAT_MODELS, + type BedrockModelMeta, + type BedrockModelId, + type BedrockModelInputModalitiesByName, +} from './model-meta' + +export type { + BedrockMessageMetadataByModality, + BedrockTextMetadata, + BedrockImageMetadata, + BedrockDocumentMetadata, + BedrockAudioMetadata, + BedrockVideoMetadata, + BedrockImageMediaType, +} from './message-types' diff --git a/packages/typescript/ai-bedrock/src/message-types.ts b/packages/typescript/ai-bedrock/src/message-types.ts new file mode 100644 index 000000000..20ea41b3c --- /dev/null +++ b/packages/typescript/ai-bedrock/src/message-types.ts @@ -0,0 +1,70 @@ + +/** + * Bedrock-specific metadata types for multimodal content parts. + * These types extend the base ContentPart metadata with Bedrock-specific options. + */ + +/** + * Supported image media types for Bedrock. + */ +export type BedrockImageMediaType = 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp' + +/** + * Metadata for Bedrock image content parts. + */ +export interface BedrockImageMetadata { + /** + * The MIME type of the image. + */ + mediaType?: BedrockImageMediaType +} + +/** + * Metadata for Bedrock text content parts. + */ +export type BedrockTextMetadata = Record + +/** + * Metadata for Bedrock document content parts. + */ +export interface BedrockDocumentMetadata { + /** + * The MIME type of the document. + */ + mediaType?: string + /** + * The name of the document. + */ + name?: string +} + +/** + * Metadata for Bedrock audio content parts. + */ +export interface BedrockAudioMetadata { + /** + * The MIME type of the audio. + */ + mediaType?: string +} + +/** + * Metadata for Bedrock video content parts. + */ +export interface BedrockVideoMetadata { + /** + * The MIME type of the video. + */ + mediaType?: string +} + +/** + * Map of modality types to their Bedrock-specific metadata types. + */ +export interface BedrockMessageMetadataByModality { + text: BedrockTextMetadata + image: BedrockImageMetadata + audio: BedrockAudioMetadata + video: BedrockVideoMetadata + document: BedrockDocumentMetadata +} diff --git a/packages/typescript/ai-bedrock/src/model-meta.ts b/packages/typescript/ai-bedrock/src/model-meta.ts new file mode 100644 index 000000000..40b225770 --- /dev/null +++ b/packages/typescript/ai-bedrock/src/model-meta.ts @@ -0,0 +1,121 @@ + +/** + * Metadata describing a Bedrock chat model's capabilities. + */ +export interface BedrockModelMeta { + /** Human-readable short name for the model. */ + name: string + /** Full Bedrock model ID used in API requests. */ + id: string + /** Supported capabilities for this model. */ + supports: { + /** Input modalities the model accepts. */ + input: Array<'text' | 'image' | 'audio' | 'video' | 'document'> + /** Whether the model supports extended thinking / reasoning. */ + thinking?: boolean + } + /** Maximum number of tokens in the context window. */ + context_window?: number + /** Maximum number of output tokens the model can generate. */ + max_output_tokens?: number +} + +// =========================== +// Amazon Nova Models (Latest) +// =========================== + +/** Amazon Nova Pro v1 — multimodal model with 300K context. */ +export const BEDROCK_AMAZON_NOVA_PRO_V1 = { + name: 'nova-pro-v1', + id: 'amazon.nova-pro-v1:0', + context_window: 300_000, + max_output_tokens: 5120, + supports: { + input: ['text', 'image', 'video', 'document'], + }, +} as const satisfies BedrockModelMeta + +/** Amazon Nova Lite v1 — cost-effective multimodal model with 300K context. */ +export const BEDROCK_AMAZON_NOVA_LITE_V1 = { + name: 'nova-lite-v1', + id: 'amazon.nova-lite-v1:0', + context_window: 300_000, + max_output_tokens: 5120, + supports: { + input: ['text', 'image', 'video', 'document'], + }, +} as const satisfies BedrockModelMeta + +/** Amazon Nova Micro v1 — text-only model with 128K context, lowest latency. */ +export const BEDROCK_AMAZON_NOVA_MICRO_V1 = { + name: 'nova-micro-v1', + id: 'amazon.nova-micro-v1:0', + context_window: 128_000, + max_output_tokens: 5120, + supports: { + input: ['text'], + }, +} as const satisfies BedrockModelMeta + +// =========================== +// Flagship Anthropic Models (V4) +// =========================== + +/** Anthropic Claude Sonnet 4.5 via Bedrock — 1M context, thinking support. */ +export const BEDROCK_ANTHROPIC_CLAUDE_SONNET_4_5 = { + name: 'claude-4-5-sonnet', + id: 'anthropic.claude-sonnet-4-5-20250929-v1:0', + context_window: 1_000_000, + max_output_tokens: 64_000, + supports: { + input: ['text', 'image', 'document'], + thinking: true, + }, +} as const satisfies BedrockModelMeta + +/** Anthropic Claude Haiku 4.5 via Bedrock — 200K context, fast and cost-effective, thinking support. */ +export const BEDROCK_ANTHROPIC_CLAUDE_HAIKU_4_5 = { + name: 'claude-4-5-haiku', + id: 'anthropic.claude-haiku-4-5-20251001-v1:0', + context_window: 200_000, + max_output_tokens: 64_000, + supports: { + input: ['text', 'image', 'document'], + thinking: true, + }, +} as const satisfies BedrockModelMeta + +/** All supported Bedrock chat model IDs. */ +export const BEDROCK_CHAT_MODELS = [ + BEDROCK_AMAZON_NOVA_PRO_V1.id, + BEDROCK_AMAZON_NOVA_LITE_V1.id, + BEDROCK_AMAZON_NOVA_MICRO_V1.id, + BEDROCK_ANTHROPIC_CLAUDE_SONNET_4_5.id, + BEDROCK_ANTHROPIC_CLAUDE_HAIKU_4_5.id, +] as const + +/** + * Union of known Bedrock model IDs plus an open `string` escape hatch for + * models not yet listed in this package. + */ +export type BedrockModelId = (typeof BEDROCK_CHAT_MODELS)[number] | (string & {}) + +/** + * Type-only map from chat model name to its supported input modalities. + */ +export type BedrockModelInputModalitiesByName = { + [BEDROCK_AMAZON_NOVA_PRO_V1.id]: typeof BEDROCK_AMAZON_NOVA_PRO_V1.supports.input + [BEDROCK_AMAZON_NOVA_LITE_V1.id]: typeof BEDROCK_AMAZON_NOVA_LITE_V1.supports.input + [BEDROCK_AMAZON_NOVA_MICRO_V1.id]: typeof BEDROCK_AMAZON_NOVA_MICRO_V1.supports.input + [BEDROCK_ANTHROPIC_CLAUDE_SONNET_4_5.id]: typeof BEDROCK_ANTHROPIC_CLAUDE_SONNET_4_5.supports.input + [BEDROCK_ANTHROPIC_CLAUDE_HAIKU_4_5.id]: typeof BEDROCK_ANTHROPIC_CLAUDE_HAIKU_4_5.supports.input +} + +// =========================== +// Model Detection Helpers +// =========================== + +/** Returns `true` if the model ID refers to an Anthropic Claude model on Bedrock. */ +export const isClaude = (model: string) => model.includes('anthropic.claude') +/** Returns `true` if the model ID refers to an Amazon Nova model on Bedrock. */ +export const isNova = (model: string) => model.includes('amazon.nova') diff --git a/packages/typescript/ai-bedrock/src/text/text-provider-options.ts b/packages/typescript/ai-bedrock/src/text/text-provider-options.ts new file mode 100644 index 000000000..1b0a5845b --- /dev/null +++ b/packages/typescript/ai-bedrock/src/text/text-provider-options.ts @@ -0,0 +1,60 @@ +/** Provider options controlling stop sequences for Bedrock models. */ +export interface BedrockStopSequencesOptions { + /** + * Custom text sequences that will cause the model to stop generating. + */ + stop_sequences?: Array +} + +/** Provider options for enabling or disabling extended thinking on Claude models. */ +export interface BedrockThinkingOptions { + /** + * Configuration for enabling Claude's extended thinking. + */ + thinking?: + | { + /** + * Determines how many tokens the model can use for its internal reasoning process. + */ + budget_tokens: number + type: 'enabled' + } + | { + type: 'disabled' + } +} + +/** Provider options for token sampling on Bedrock models. */ +export interface BedrockSamplingOptions { + /** + * Only sample from the top K options for each subsequent token. + */ + top_k?: number +} + +/** + * Inference configuration passed directly to the Bedrock ConverseStream API. + * These override the top-level `maxTokens`, `temperature`, and `topP` options. + */ +export interface BedrockInferenceConfig { + /** Maximum number of tokens to generate. */ + maxTokens?: number + /** Sampling temperature (0–1). Higher values produce more varied output. */ + temperature?: number + /** Nucleus sampling probability (0–1). */ + topP?: number + /** Text sequences that cause the model to stop generating. */ + stopSequences?: Array +} + +/** + * All provider-specific options for Bedrock text generation requests. + * Combines stop sequences, thinking, sampling, and low-level inference config. + */ +export type BedrockTextProviderOptions = + BedrockStopSequencesOptions & + BedrockThinkingOptions & + BedrockSamplingOptions & { + /** Additional inference configuration for Bedrock */ + inferenceConfig?: BedrockInferenceConfig + } diff --git a/packages/typescript/ai-bedrock/src/utils.ts b/packages/typescript/ai-bedrock/src/utils.ts new file mode 100644 index 000000000..43d0b5fa4 --- /dev/null +++ b/packages/typescript/ai-bedrock/src/utils.ts @@ -0,0 +1,53 @@ + + +/** + * Resolved AWS configuration used to initialise the Bedrock client. + */ +export interface BedrockClientConfig { + /** AWS region (e.g. `'us-east-1'`). */ + region?: string + /** AWS credentials. */ + credentials?: { + /** AWS access key ID. */ + accessKeyId: string + /** AWS secret access key. */ + secretAccessKey: string + /** Temporary session token (for STS / assumed roles). */ + sessionToken?: string + } +} + +/** + * Gets Bedrock config from environment variables + */ +export function getBedrockConfigFromEnv(): BedrockClientConfig { + const env = + typeof globalThis !== 'undefined' && (globalThis as any).window?.env + ? (globalThis as any).window.env + : typeof process !== 'undefined' + ? process.env + : undefined + + const config: BedrockClientConfig = {} + + if (env?.AWS_REGION || env?.AWS_DEFAULT_REGION) { + config.region = env.AWS_REGION || env.AWS_DEFAULT_REGION + } + + if (env?.AWS_ACCESS_KEY_ID && env?.AWS_SECRET_ACCESS_KEY) { + config.credentials = { + accessKeyId: env.AWS_ACCESS_KEY_ID, + secretAccessKey: env.AWS_SECRET_ACCESS_KEY, + sessionToken: env.AWS_SESSION_TOKEN + } + } + + return config +} + +/** + * Generates a unique ID with a prefix for tracing and debugging. + */ +export function generateIdFor(prefix: string, provider: string): string { + return `${provider}-${prefix}-${Date.now()}-${Math.random().toString(36).substring(7)}` +} diff --git a/packages/typescript/ai-bedrock/tests/bedrock-adapter.test.ts b/packages/typescript/ai-bedrock/tests/bedrock-adapter.test.ts new file mode 100644 index 000000000..ba5671599 --- /dev/null +++ b/packages/typescript/ai-bedrock/tests/bedrock-adapter.test.ts @@ -0,0 +1,560 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + ConverseStreamCommand, +} from '@aws-sdk/client-bedrock-runtime' +import { EventType } from '@tanstack/ai' +import { BedrockTextAdapter } from '../src/adapters/text' + +// Mock the AWS SDK +const { sendMock } = vi.hoisted(() => { + return { sendMock: vi.fn() } +}) + +vi.mock('@aws-sdk/client-bedrock-runtime', () => { + return { + BedrockRuntimeClient: class { + send = sendMock + }, + ConverseStreamCommand: vi.fn(), + } +}) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeStream(events: Array) { + sendMock.mockResolvedValue({ + stream: (async function* () { + for (const event of events) { + await Promise.resolve() + yield event + } + })(), + }) +} + +async function collectChunks(stream: AsyncIterable): Promise { + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + return chunks +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('BedrockTextAdapter', () => { + let adapter: BedrockTextAdapter + + beforeEach(() => { + vi.clearAllMocks() + adapter = new BedrockTextAdapter({ + region: 'us-east-1', + credentials: { accessKeyId: 'test', secretAccessKey: 'test' }, + }, 'anthropic.claude-3-sonnet-20240229-v1:0') + }) + + // ----------------------------------------------------------------------- + // Basic streaming + // ----------------------------------------------------------------------- + + describe('chatStream', () => { + it('should emit RUN_STARTED, TEXT_MESSAGE_*, and RUN_FINISHED', async () => { + makeStream([ + { contentBlockDelta: { delta: { text: 'Hello ' } } }, + { contentBlockDelta: { delta: { text: 'world' } } }, + { messageStop: { stopReason: 'end_turn' } }, + ]) + + const chunks = await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [{ role: 'user', content: 'Hi' }], + })) + + expect(ConverseStreamCommand).toHaveBeenCalled() + + // Should have RUN_STARTED + const runStarted = chunks.find(c => c.type === EventType.RUN_STARTED) + expect(runStarted).toBeDefined() + expect(runStarted).toHaveProperty('threadId') + expect(runStarted).toHaveProperty('runId') + + // Should have TEXT_MESSAGE_START + const msgStart = chunks.find(c => c.type === EventType.TEXT_MESSAGE_START) + expect(msgStart).toBeDefined() + expect(msgStart).toMatchObject({ role: 'assistant' }) + + // Should have TEXT_MESSAGE_CONTENT chunks + const contentChunks = chunks.filter(c => c.type === EventType.TEXT_MESSAGE_CONTENT) + expect(contentChunks.length).toBeGreaterThanOrEqual(2) + expect(contentChunks[0]).toMatchObject({ delta: 'Hello ' }) + expect(contentChunks[1]).toMatchObject({ delta: 'world' }) + + // Should have TEXT_MESSAGE_END + const msgEnd = chunks.find(c => c.type === EventType.TEXT_MESSAGE_END) + expect(msgEnd).toBeDefined() + + // Should have RUN_FINISHED + const runFinished = chunks.find(c => c.type === EventType.RUN_FINISHED) + expect(runFinished).toBeDefined() + }) + + it('should include usage in RUN_FINISHED when metadata is provided', async () => { + makeStream([ + { contentBlockDelta: { delta: { text: 'Hello' } } }, + { messageStop: { stopReason: 'end_turn' } }, + { metadata: { usage: { inputTokens: 10, outputTokens: 25, totalTokens: 35 } } }, + ]) + + const chunks = await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [{ role: 'user', content: 'Hi' }], + })) + + const runFinished = chunks.find(c => c.type === EventType.RUN_FINISHED) as any + expect(runFinished.usage).toEqual({ + promptTokens: 10, + completionTokens: 25, + totalTokens: 35, + }) + }) + + it('should compute totalTokens when SDK omits it', async () => { + makeStream([ + { contentBlockDelta: { delta: { text: 'Hello' } } }, + { messageStop: { stopReason: 'end_turn' } }, + { metadata: { usage: { inputTokens: 10, outputTokens: 25 } } }, + ]) + + const chunks = await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [{ role: 'user', content: 'Hi' }], + })) + + const runFinished = chunks.find(c => c.type === EventType.RUN_FINISHED) as any + expect(runFinished.usage).toEqual({ + promptTokens: 10, + completionTokens: 25, + totalTokens: 35, + }) + }) + + it('should emit TEXT_MESSAGE_END even without messageStop', async () => { + makeStream([ + { contentBlockDelta: { delta: { text: 'Hello' } } }, + ]) + + const chunks = await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [{ role: 'user', content: 'Hi' }], + })) + + const msgEnd = chunks.find(c => c.type === EventType.TEXT_MESSAGE_END) + expect(msgEnd).toBeDefined() + + const runFinished = chunks.find(c => c.type === EventType.RUN_FINISHED) + expect(runFinished).toBeDefined() + }) + }) + + // ----------------------------------------------------------------------- + // Claude native reasoning (reasoningContent blocks) + // ----------------------------------------------------------------------- + + describe('Claude native reasoning (reasoningContent blocks)', () => { + it('emits REASONING events from delta.reasoningContent.text', async () => { + makeStream([ + { contentBlockDelta: { delta: { reasoningContent: { text: 'step one ' } } } }, + { contentBlockDelta: { delta: { reasoningContent: { text: 'step two' } } } }, + // signature delta — should be silently ignored + { contentBlockDelta: { delta: { reasoningContent: { signature: 'sig-abc' } } } }, + { contentBlockDelta: { delta: { text: 'answer' } } }, + { messageStop: { stopReason: 'end_turn' } }, + ]) + + const chunks = await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [{ role: 'user', content: 'Reason through this' }], + })) + + // Should have REASONING_START + REASONING_MESSAGE_START + expect(chunks.find(c => c.type === EventType.REASONING_START)).toBeDefined() + expect(chunks.find(c => c.type === EventType.REASONING_MESSAGE_START)).toBeDefined() + + // Should have 2 REASONING_MESSAGE_CONTENT chunks + const reasoningChunks = chunks.filter(c => c.type === EventType.REASONING_MESSAGE_CONTENT) + expect(reasoningChunks).toHaveLength(2) + expect(reasoningChunks[0]).toMatchObject({ delta: 'step one ' }) + expect(reasoningChunks[1]).toMatchObject({ delta: 'step two' }) + + // signature chunk must NOT produce any output + expect(reasoningChunks).toHaveLength(2) + + // Should have content after reasoning + const contentChunks = chunks.filter(c => c.type === EventType.TEXT_MESSAGE_CONTENT) + expect(contentChunks).toHaveLength(1) + expect(contentChunks[0]).toMatchObject({ delta: 'answer' }) + }) + + it('does not emit reasoning for signature-only deltas', async () => { + makeStream([ + { contentBlockDelta: { delta: { reasoningContent: { signature: 'sig-xyz' } } } }, + { messageStop: { stopReason: 'end_turn' } }, + ]) + + const chunks = await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [{ role: 'user', content: 'Hi' }], + })) + + expect(chunks.filter(c => c.type === EventType.REASONING_MESSAGE_CONTENT)).toHaveLength(0) + }) + }) + + // ----------------------------------------------------------------------- + // Nova thinking tag parsing (text-based) + // ----------------------------------------------------------------------- + + describe('Nova thinking tag parsing (text-based)', () => { + it('emits reasoning and text from a single chunk containing full tags', async () => { + makeStream([ + { contentBlockDelta: { delta: { text: 'some thoughtanswer' } } }, + { messageStop: { stopReason: 'end_turn' } }, + ]) + + const chunks = await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [{ role: 'user', content: 'Hi' }], + })) + + const reasoningChunks = chunks.filter(c => c.type === EventType.REASONING_MESSAGE_CONTENT) + const contentChunks = chunks.filter(c => c.type === EventType.TEXT_MESSAGE_CONTENT) + + expect(reasoningChunks).toHaveLength(1) + expect(reasoningChunks[0]).toMatchObject({ + type: EventType.REASONING_MESSAGE_CONTENT, + delta: 'some thought', + }) + + expect(contentChunks).toHaveLength(1) + expect(contentChunks[0]).toMatchObject({ + type: EventType.TEXT_MESSAGE_CONTENT, + delta: 'answer', + content: 'answer', + }) + }) + + it('emits content before thinking tag then reasoning content', async () => { + makeStream([ + { contentBlockDelta: { delta: { text: 'prefixinside' } } }, + { messageStop: { stopReason: 'end_turn' } }, + ]) + + const chunks = await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [{ role: 'user', content: 'Hi' }], + })) + + const contentChunks = chunks.filter(c => c.type === EventType.TEXT_MESSAGE_CONTENT) + const reasoningChunks = chunks.filter(c => c.type === EventType.REASONING_MESSAGE_CONTENT) + + expect(contentChunks[0]).toMatchObject({ delta: 'prefix' }) + expect(reasoningChunks[0]).toMatchObject({ delta: 'inside' }) + }) + + it('handles opening thinking tag split across two chunks', async () => { + makeStream([ + { contentBlockDelta: { delta: { text: 'thoughttext' } } }, + { messageStop: { stopReason: 'end_turn' } }, + ]) + + const chunks = await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [{ role: 'user', content: 'Hi' }], + })) + + const reasoningChunks = chunks.filter(c => c.type === EventType.REASONING_MESSAGE_CONTENT) + const contentChunks = chunks.filter(c => c.type === EventType.TEXT_MESSAGE_CONTENT) + + expect(reasoningChunks.length).toBeGreaterThan(0) + expect(reasoningChunks.at(-1)?.delta).toBeDefined() + + expect(contentChunks.length).toBeGreaterThan(0) + expect(contentChunks.at(-1)?.content).toBe('text') + }) + + it('handles closing thinking tag split across two chunks', async () => { + makeStream([ + { contentBlockDelta: { delta: { text: 'thoughtafter' } } }, + { messageStop: { stopReason: 'end_turn' } }, + ]) + + const chunks = await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [{ role: 'user', content: 'Hi' }], + })) + + const contentChunks = chunks.filter(c => c.type === EventType.TEXT_MESSAGE_CONTENT) + expect(contentChunks.length).toBeGreaterThan(0) + expect(contentChunks.at(-1)?.content).toBe('after') + }) + + it('accumulates reasoning content across multiple chunks within tags', async () => { + makeStream([ + { contentBlockDelta: { delta: { text: 'part one ' } } }, + { contentBlockDelta: { delta: { text: 'part two' } } }, + { messageStop: { stopReason: 'end_turn' } }, + ]) + + const chunks = await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [{ role: 'user', content: 'Hi' }], + })) + + const reasoningChunks = chunks.filter(c => c.type === EventType.REASONING_MESSAGE_CONTENT) + expect(reasoningChunks.length).toBeGreaterThan(0) + }) + + it('does not emit reasoning chunks when no thinking tags present', async () => { + makeStream([ + { contentBlockDelta: { delta: { text: 'plain text response' } } }, + { messageStop: { stopReason: 'end_turn' } }, + ]) + + const chunks = await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [{ role: 'user', content: 'Hi' }], + })) + + expect(chunks.filter(c => c.type === EventType.REASONING_MESSAGE_CONTENT)).toHaveLength(0) + expect(chunks.filter(c => c.type === EventType.TEXT_MESSAGE_CONTENT)).toHaveLength(1) + }) + }) + + // ----------------------------------------------------------------------- + // Tool call streaming + // ----------------------------------------------------------------------- + + describe('tool call streaming', () => { + it('emits TOOL_CALL_START with name and id', async () => { + makeStream([ + { contentBlockStart: { start: { toolUse: { toolUseId: 'tool-abc', name: 'getWeather' } } } }, + { messageStop: { stopReason: 'tool_use' } }, + ]) + + const chunks = await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [{ role: 'user', content: 'weather?' }], + })) + + const toolStart = chunks.find(c => c.type === EventType.TOOL_CALL_START) + expect(toolStart).toBeDefined() + expect(toolStart).toMatchObject({ + type: EventType.TOOL_CALL_START, + toolCallId: 'tool-abc', + toolCallName: 'getWeather', + index: 0, + }) + }) + + it('emits TOOL_CALL_ARGS for tool input deltas', async () => { + makeStream([ + { contentBlockStart: { start: { toolUse: { toolUseId: 'tool-abc', name: 'getWeather' } } } }, + { contentBlockDelta: { delta: { toolUse: { input: '{"city"' } } } }, + { contentBlockDelta: { delta: { toolUse: { input: ':"Paris"}' } } } }, + { messageStop: { stopReason: 'tool_use' } }, + ]) + + const chunks = await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [{ role: 'user', content: 'weather?' }], + })) + + const argChunks = chunks.filter(c => c.type === EventType.TOOL_CALL_ARGS) + expect(argChunks).toHaveLength(2) + expect(argChunks[0].delta).toBe('{"city"') + expect(argChunks[1].delta).toBe(':"Paris"}') + }) + + it('increments index for each new tool call', async () => { + makeStream([ + { contentBlockStart: { start: { toolUse: { toolUseId: 'tool-1', name: 'toolA' } } } }, + { contentBlockStart: { start: { toolUse: { toolUseId: 'tool-2', name: 'toolB' } } } }, + { messageStop: { stopReason: 'tool_use' } }, + ]) + + const chunks = await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [{ role: 'user', content: 'Hi' }], + })) + + const startChunks = chunks.filter(c => c.type === EventType.TOOL_CALL_START) + expect(startChunks[0]).toMatchObject({ index: 0, toolCallId: 'tool-1' }) + expect(startChunks[1]).toMatchObject({ index: 1, toolCallId: 'tool-2' }) + }) + + it('emits TOOL_CALL_END on contentBlockStop', async () => { + makeStream([ + { contentBlockStart: { start: { toolUse: { toolUseId: 'tool-abc', name: 'getWeather' } } } }, + { contentBlockDelta: { delta: { toolUse: { input: '{}' } } } }, + { contentBlockStop: {} }, + { messageStop: { stopReason: 'tool_use' } }, + ]) + + const chunks = await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [{ role: 'user', content: 'Hi' }], + })) + + const toolEnd = chunks.find(c => c.type === EventType.TOOL_CALL_END) + expect(toolEnd).toBeDefined() + expect(toolEnd).toMatchObject({ toolCallId: 'tool-abc' }) + }) + }) + + // ----------------------------------------------------------------------- + // Error handling + // ----------------------------------------------------------------------- + + describe('error handling', () => { + it('yields RUN_ERROR when no stream is returned', async () => { + sendMock.mockResolvedValue({ stream: undefined }) + + const chunks = await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [{ role: 'user', content: 'Hi' }], + })) + + const errorChunk = chunks.find(c => c.type === EventType.RUN_ERROR) + expect(errorChunk).toBeDefined() + expect(errorChunk).toMatchObject({ + type: EventType.RUN_ERROR, + message: 'No stream received from Bedrock', + error: { message: 'No stream received from Bedrock', code: 'NO_STREAM' }, + }) + }) + + it('yields RUN_ERROR when client.send throws', async () => { + sendMock.mockRejectedValue(new Error('Network failure')) + + const chunks = await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [{ role: 'user', content: 'Hi' }], + })) + + const errorChunk = chunks.find(c => c.type === EventType.RUN_ERROR) + expect(errorChunk).toBeDefined() + expect(errorChunk).toMatchObject({ + type: EventType.RUN_ERROR, + message: 'Network failure', + error: { message: 'Network failure' }, + }) + }) + }) + + // ----------------------------------------------------------------------- + // Message conversion (verified via ConverseStreamCommand call args) + // ----------------------------------------------------------------------- + + describe('message conversion', () => { + it('converts tool result message to Converse toolResult format', async () => { + makeStream([{ messageStop: { stopReason: 'end_turn' } }]) + + await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [ + { role: 'user', content: 'Use tool' }, + { + role: 'assistant', + content: '', + toolCalls: [{ id: 'tu-1', type: 'function', function: { name: 'myTool', arguments: '{}' } }], + }, + { role: 'tool', toolCallId: 'tu-1', content: '{"result":"ok"}' } as any, + ], + })) + + const [command] = (ConverseStreamCommand as any).mock.calls[0] + const toolResultMsg = command.messages.find( + (m: any) => m.content?.[0]?.toolResult !== undefined, + ) + expect(toolResultMsg).toBeDefined() + expect(toolResultMsg.role).toBe('user') + expect(toolResultMsg.content[0].toolResult).toMatchObject({ + toolUseId: 'tu-1', + status: 'success', + }) + }) + + it('marks tool result as failure when message has error status', async () => { + makeStream([{ messageStop: { stopReason: 'end_turn' } }]) + + await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [ + { role: 'user', content: 'Use tool' }, + { + role: 'assistant', + content: '', + toolCalls: [{ id: 'tu-1', type: 'function', function: { name: 'myTool', arguments: '{}' } }], + }, + { role: 'tool', toolCallId: 'tu-1', content: 'failed', status: 'error' } as any, + ], + })) + + const [command] = (ConverseStreamCommand as any).mock.calls[0] + const toolResultMsg = command.messages.find( + (m: any) => m.content?.[0]?.toolResult !== undefined, + ) + expect(toolResultMsg.content[0].toolResult.status).toBe('failure') + }) + + it('converts assistant message with tool calls to Converse content blocks', async () => { + makeStream([{ messageStop: { stopReason: 'end_turn' } }]) + + await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [ + { role: 'user', content: 'Hi' }, + { + role: 'assistant', + content: 'Let me check that', + toolCalls: [{ id: 'tc-1', type: 'function', function: { name: 'lookup', arguments: '{"q":"foo"}' } }], + }, + { role: 'tool', toolCallId: 'tc-1', content: 'result' } as any, + ], + })) + + const [command] = (ConverseStreamCommand as any).mock.calls[0] + const assistantMsg = command.messages.find( + (m: any) => m.role === 'assistant' && m.content?.some((b: any) => b.toolUse), + ) + expect(assistantMsg).toBeDefined() + expect(assistantMsg.content).toEqual( + expect.arrayContaining([ + expect.objectContaining({ text: 'Let me check that' }), + expect.objectContaining({ + toolUse: expect.objectContaining({ name: 'lookup', toolUseId: 'tc-1' }), + }), + ]), + ) + }) + + it('passes system prompts to ConverseStreamCommand', async () => { + makeStream([{ messageStop: { stopReason: 'end_turn' } }]) + + await collectChunks(adapter.chatStream({ + model: 'anthropic.claude-3-sonnet-20240229-v1:0', + messages: [{ role: 'user', content: 'Hi' }], + systemPrompts: ['You are a helpful assistant.'], + })) + + const [command] = (ConverseStreamCommand as any).mock.calls[0] + expect(command.system).toEqual([{ text: 'You are a helpful assistant.' }]) + }) + }) +}) diff --git a/packages/typescript/ai-bedrock/tests/model-meta.test.ts b/packages/typescript/ai-bedrock/tests/model-meta.test.ts new file mode 100644 index 000000000..aeefe9f32 --- /dev/null +++ b/packages/typescript/ai-bedrock/tests/model-meta.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expectTypeOf } from 'vitest' +import type { + BedrockModelInputModalitiesByName, +} from '../src/model-meta' +import type { + AudioPart, + ConstrainedModelMessage, + DocumentPart, + ImagePart, + TextPart, + VideoPart, + InputModalitiesTypes, + DefaultMessageMetadataByModality, +} from '@tanstack/ai' + +/** + * Helper to convert raw modality array to InputModalitiesTypes + */ +type ResolveModalities> = { + inputModalities: T + messageMetadataByModality: DefaultMessageMetadataByModality +} + +/** + * Type assertion tests for Bedrock model input modalities. + */ +describe('Bedrock Model Input Modality Type Assertions', () => { + // Helper type for creating a user message with specific content + type MessageWithContent = { role: 'user'; content: Array } + + describe('Amazon Nova Pro (text + image + video + document)', () => { + type Modalities = ResolveModalities + type Message = ConstrainedModelMessage + + it('should allow TextPart, ImagePart, VideoPart, and DocumentPart', () => { + expectTypeOf>().toExtend() + expectTypeOf>().toExtend() + expectTypeOf>().toExtend() + expectTypeOf>().toExtend() + }) + + it('should NOT allow AudioPart', () => { + expectTypeOf>().not.toExtend() + }) + }) + + describe('Claude 4.5 Sonnet (text + image + document)', () => { + type Modalities = ResolveModalities + type Message = ConstrainedModelMessage + + it('should allow TextPart, ImagePart, and DocumentPart', () => { + expectTypeOf>().toExtend() + expectTypeOf>().toExtend() + expectTypeOf>().toExtend() + }) + + it('should NOT allow AudioPart or VideoPart', () => { + expectTypeOf>().not.toExtend() + expectTypeOf>().not.toExtend() + }) + }) +}) diff --git a/packages/typescript/ai-bedrock/tsconfig.json b/packages/typescript/ai-bedrock/tsconfig.json new file mode 100644 index 000000000..b8bb330eb --- /dev/null +++ b/packages/typescript/ai-bedrock/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist/esm", + "declarationDir": "./dist/esm" + }, + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/packages/typescript/ai-bedrock/vite.config.ts b/packages/typescript/ai-bedrock/vite.config.ts new file mode 100644 index 000000000..c605fd207 --- /dev/null +++ b/packages/typescript/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', './src/adapters/index.ts'], + srcDir: './src', + cjs: false, + }), +) From 45ae9dec8fca91df5a440ddc95786e74646c62ed Mon Sep 17 00:00:00 2001 From: Muralidhar Challa Date: Sat, 23 May 2026 14:32:32 +0530 Subject: [PATCH 2/3] fix(ai-bedrock): fix off-by-one in tag skip position When a tag appears at a non-zero offset in the text buffer, the substring to advance past the tag must use startIdx + tag length, not just the tag length alone. This caused the first character(s) after the opening tag to be silently dropped. --- .../ai-bedrock/src/adapters/text.ts | 2 +- pnpm-lock.yaml | 555 +++++++++++++++--- 2 files changed, 481 insertions(+), 76 deletions(-) diff --git a/packages/typescript/ai-bedrock/src/adapters/text.ts b/packages/typescript/ai-bedrock/src/adapters/text.ts index 7be0e19c4..6f565aba5 100644 --- a/packages/typescript/ai-bedrock/src/adapters/text.ts +++ b/packages/typescript/ai-bedrock/src/adapters/text.ts @@ -482,7 +482,7 @@ export class BedrockTextAdapter< } as StreamChunk } isInsideThinking = true - text = text.substring(''.length) // 10 chars + text = text.substring(startIdx + ''.length) } else if (text.includes('<')) { // Possible partial tag — buffer const idx = text.lastIndexOf('<') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17cf21673..e0e295420 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -992,6 +992,25 @@ importers: specifier: ^4.2.0 version: 4.2.1 + packages/typescript/ai-bedrock: + dependencies: + '@aws-sdk/client-bedrock-runtime': + specifier: ^3.723.0 + version: 3.1052.0 + zod: + specifier: ^4.0.0 + version: 4.3.6 + devDependencies: + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@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.9))(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) + vite: + specifier: ^7.2.7 + 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/typescript/ai-client: dependencies: '@tanstack/ai': @@ -2015,6 +2034,103 @@ 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.1052.0': + resolution: {integrity: sha512-2x5OPQwAovivYvxXi4g5U8s+i6k0/xgC+49KARwEOSm+wD6vOqHVkZ/XYlPlgwUjE5qoFWCCL7btYDy+Y9eNMA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.974.13': + resolution: {integrity: sha512-+Y5/4tHki0uYgyx8eun146DegRVQBpdKGK5RbV0FTKJPpaKTchvqVxrrRFK6Wk0JksO4iAZKw3eqxGEIwtO98w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.39': + resolution: {integrity: sha512-29wX9zpAvEt1vcj0psha+y6ygBHy2V/S72mp6e7q0KARLWXq+pwE/lR6qGkwknQvruh52lXvlqZIga8Hdxkucw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.41': + resolution: {integrity: sha512-IA3CQTjtJkb6u1H4mE4936c8OPBMa9Jggtwe8U2Mqw/vvb/tZ5Ebd0mcZcX0uKWQhOyYo/+qNIwkV5Xh+FeJJA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.43': + resolution: {integrity: sha512-4mzII+3mZEVXXE1xzrLQrCJL7/r62A63bA6SVzZoNL5rqCJghpf+xgGltVrIBBs0n+mOZBKrQl2tRREtvZ5l6A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.43': + resolution: {integrity: sha512-HG7kQCwXtbv3oBV61Ins0oNX8KKyvrMqqRkb6ZiAfQHbMuHaiNaEb2KnpKLPkNpqImSBK82UkVE/kaY6IfWikA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.44': + resolution: {integrity: sha512-sDaBIT0yrNNIPfvlsiTCmANm07zKju+ipWODjEXgZlsjMeIJR3LVp7RDyAOzUoAsTbDfYKDWp+i5WrFiQP6rmQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.39': + resolution: {integrity: sha512-2k/amBifLd75eXNwgvPw/2lKYSQ3NhvHQgkVKVjfUq13/eJ3JRtHmznuFenn74OK3sSfp4SMy1YB2w+UVXoKqA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.43': + resolution: {integrity: sha512-LPc3+Y4vhH1T4x6CMqwCM6hk5+SRf/Lwmgm8INm95wxTtIRHcMwQUVkDzWu4Iw/RSncxYM2BC01OrYbxOPZvyg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.43': + resolution: {integrity: sha512-wQtL34lUD/09VXjwAUo2T+I3aEXRDxMB3DKmTJL/Zj0Gi6sLDTrVhae1XVt01yzkquOWajI/sZW72JGDZ1ciTw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/eventstream-handler-node@3.972.17': + resolution: {integrity: sha512-WFwdNcjchKZr7jKYgGimUZO8sSKQF/le7GGqgeCzz/lHozInE6b0gFJ1YMr8NaIeAoWJwgtrF7RE4/qMgosAdQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-eventstream@3.972.13': + resolution: {integrity: sha512-ECfsw7mf6G/sxNbKbGE3/h1xeIArY/yRI1IjDGYkLgDIankh+aDOtDRSr40LVlIHGL9+jEH1cVuxmbJ8NLL/1A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-websocket@3.972.21': + resolution: {integrity: sha512-yr+5+C7v9R55sAJ89A55Wrm7wIKPVn5cm6J3Hztnd5s/iwEUKxyJqCnIxJu4fVXgG9XBQD1Jc4rsWC1ozahJjA==} + engines: {node: '>= 14.0.0'} + + '@aws-sdk/nested-clients@3.997.11': + resolution: {integrity: sha512-nWXXJ1r/r8N2Gw1pWolRgED38/A9A8DHR2ETWIv220zh4PZHcybbR4hUVWWktmNXTRHzDJwRluapHn0rZxuoqA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.28': + resolution: {integrity: sha512-qs9z5LqXO/CZC2Lg9SGKpoLU8Rhi+m2pFKZqfO9pytX1clc0katqtsDNupJxFy0xT9wsZSPzM2v1y+/H/zfp5Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1052.0': + resolution: {integrity: sha512-QqZNB3so7UIDxZtroc85TQaLVxdZRFm0eWM1CSR2N+b06as9TOrilvrlTZuj3guYlxMs6yLOgGxnklJ5qMYtTw==} + 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.25': + resolution: {integrity: sha512-GH+Kjz4nPKWKHnsiQpnhP1MJdTGIcK4rAka6tzakgjjUkVgNsmPeEbbRAf09SzS1hjGu6duGHCBsxYke0BhHjQ==} + 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'} @@ -3629,6 +3745,9 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@nodable/entities@2.1.0': + resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -5572,6 +5691,42 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} + '@smithy/core@3.24.4': + resolution: {integrity: sha512-3UNRKEyQyAgVgM0LGlerCLm+ChZWZ1GPfde+jBEW6bm6bSBGU1p0EbblaUV3unbhwvidjLA5Zs3sOs7mnZwvAw==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.3.4': + resolution: {integrity: sha512-vKW0MEFRU4Y3MkVZUkpJm+g9qyPGLCXhc0YLggUdSdBB4g7IaSSsCE75P9rBXyWHrXY1UYSQUl8/DwsTR7QciA==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.4.4': + resolution: {integrity: sha512-qM7AUKI4G6d7lNgaZD3lA1tWSolh5r6gcixfTZAPstVURfjIbvreVTPz+994M0yC3HbX4YYhDRgr31Xy3XwWOQ==} + 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.4': + resolution: {integrity: sha512-HIeF+1vrDGzPkkv39Hj2vlHSXHY3p958jd/8ZnePIY6+ZOsQX8coyEUKO5yQu4r0bQIVsbpotVIrXXwyycMStQ==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.4.4': + resolution: {integrity: sha512-e5UtkMvsatzBfbeBZjEOt0k0Z3BEsjTFL/n6fdO5vtBLe67tdy0dX7xw2DU7uZ3acwoHyeCqpU2Fzb7pxwHb6Q==} + 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: @@ -7282,6 +7437,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'} @@ -8300,6 +8458,13 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + 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==} @@ -10149,6 +10314,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'} @@ -11104,6 +11273,9 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strnum@2.3.0: + resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} + style-to-js@1.1.21: resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} @@ -12325,6 +12497,10 @@ packages: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + xmlbuilder2@3.1.1: resolution: {integrity: sha512-WCSfbfZnQDdLQLiMdGUQpMxxckeQ4oZNMNhLVkcekTu7xhD4tuUDyAPoY8CwXvBYE6LwBHd6QW2WZXlOWr1vCw==} engines: {node: '>=12.0'} @@ -12430,6 +12606,222 @@ 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.1052.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.13 + '@aws-sdk/credential-provider-node': 3.972.44 + '@aws-sdk/eventstream-handler-node': 3.972.17 + '@aws-sdk/middleware-eventstream': 3.972.13 + '@aws-sdk/middleware-websocket': 3.972.21 + '@aws-sdk/token-providers': 3.1052.0 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/core@3.974.13': + dependencies: + '@aws-sdk/types': 3.973.9 + '@aws-sdk/xml-builder': 3.972.25 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.24.4 + '@smithy/signature-v4': 5.4.4 + '@smithy/types': 4.14.2 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.39': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.41': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.43': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/credential-provider-env': 3.972.39 + '@aws-sdk/credential-provider-http': 3.972.41 + '@aws-sdk/credential-provider-login': 3.972.43 + '@aws-sdk/credential-provider-process': 3.972.39 + '@aws-sdk/credential-provider-sso': 3.972.43 + '@aws-sdk/credential-provider-web-identity': 3.972.43 + '@aws-sdk/nested-clients': 3.997.11 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/credential-provider-imds': 4.3.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.43': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/nested-clients': 3.997.11 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.44': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.39 + '@aws-sdk/credential-provider-http': 3.972.41 + '@aws-sdk/credential-provider-ini': 3.972.43 + '@aws-sdk/credential-provider-process': 3.972.39 + '@aws-sdk/credential-provider-sso': 3.972.43 + '@aws-sdk/credential-provider-web-identity': 3.972.43 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/credential-provider-imds': 4.3.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.39': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.43': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/nested-clients': 3.997.11 + '@aws-sdk/token-providers': 3.1052.0 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.43': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/nested-clients': 3.997.11 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/eventstream-handler-node@3.972.17': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-eventstream@3.972.13': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/middleware-websocket@3.972.21': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/signature-v4': 5.4.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.11': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.13 + '@aws-sdk/signature-v4-multi-region': 3.996.28 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/fetch-http-handler': 5.4.4 + '@smithy/node-http-handler': 4.7.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.28': + dependencies: + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@smithy/signature-v4': 5.4.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1052.0': + dependencies: + '@aws-sdk/core': 3.974.13 + '@aws-sdk/nested-clients': 3.997.11 + '@aws-sdk/types': 3.973.9 + '@smithy/core': 3.24.4 + '@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.25': + dependencies: + '@nodable/entities': 2.1.0 + '@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 @@ -13853,6 +14245,8 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@nodable/entities@2.1.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -15120,9 +15514,9 @@ snapshots: optionalDependencies: rollup: 4.60.1 - '@rollup/plugin-alias@6.0.0(rollup@4.57.1)': + '@rollup/plugin-alias@6.0.0(rollup@4.60.1)': optionalDependencies: - rollup: 4.57.1 + rollup: 4.60.1 '@rollup/plugin-commonjs@28.0.9(rollup@4.60.1)': dependencies: @@ -15136,9 +15530,9 @@ snapshots: optionalDependencies: rollup: 4.60.1 - '@rollup/plugin-commonjs@29.0.0(rollup@4.57.1)': + '@rollup/plugin-commonjs@29.0.0(rollup@4.60.1)': dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.57.1) + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) commondir: 1.0.1 estree-walker: 2.0.2 fdir: 6.5.0(picomatch@4.0.4) @@ -15146,15 +15540,7 @@ snapshots: magic-string: 0.30.21 picomatch: 4.0.4 optionalDependencies: - rollup: 4.57.1 - - '@rollup/plugin-inject@5.0.5(rollup@4.57.1)': - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.57.1) - estree-walker: 2.0.2 - magic-string: 0.30.21 - optionalDependencies: - rollup: 4.57.1 + rollup: 4.60.1 '@rollup/plugin-inject@5.0.5(rollup@4.60.1)': dependencies: @@ -15164,28 +15550,12 @@ snapshots: optionalDependencies: rollup: 4.60.1 - '@rollup/plugin-json@6.1.0(rollup@4.57.1)': - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.57.1) - optionalDependencies: - rollup: 4.57.1 - '@rollup/plugin-json@6.1.0(rollup@4.60.1)': dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.60.1) optionalDependencies: rollup: 4.60.1 - '@rollup/plugin-node-resolve@16.0.3(rollup@4.57.1)': - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.57.1) - '@types/resolve': 1.20.2 - deepmerge: 4.3.1 - is-module: 1.0.0 - resolve: 1.22.11 - optionalDependencies: - rollup: 4.57.1 - '@rollup/plugin-node-resolve@16.0.3(rollup@4.60.1)': dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.60.1) @@ -15196,13 +15566,6 @@ snapshots: optionalDependencies: rollup: 4.60.1 - '@rollup/plugin-replace@6.0.3(rollup@4.57.1)': - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.57.1) - magic-string: 0.30.21 - optionalDependencies: - rollup: 4.57.1 - '@rollup/plugin-replace@6.0.3(rollup@4.60.1)': dependencies: '@rollup/pluginutils': 5.3.0(rollup@4.60.1) @@ -15210,14 +15573,6 @@ snapshots: optionalDependencies: rollup: 4.60.1 - '@rollup/plugin-terser@0.4.4(rollup@4.57.1)': - dependencies: - serialize-javascript: 6.0.2 - smob: 1.5.0 - terser: 5.44.1 - optionalDependencies: - rollup: 4.57.1 - '@rollup/plugin-terser@0.4.4(rollup@4.60.1)': dependencies: serialize-javascript: 6.0.2 @@ -15226,14 +15581,6 @@ snapshots: optionalDependencies: rollup: 4.60.1 - '@rollup/pluginutils@5.3.0(rollup@4.57.1)': - dependencies: - '@types/estree': 1.0.8 - estree-walker: 2.0.2 - picomatch: 4.0.4 - optionalDependencies: - rollup: 4.57.1 - '@rollup/pluginutils@5.3.0(rollup@4.60.1)': dependencies: '@types/estree': 1.0.8 @@ -15452,6 +15799,54 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} + '@smithy/core@3.24.4': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.3.4': + dependencies: + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.4.4': + dependencies: + '@smithy/core': 3.24.4 + '@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.4': + dependencies: + '@smithy/core': 3.24.4 + '@smithy/types': 4.14.2 + tslib: 2.8.1 + + '@smithy/signature-v4@5.4.4': + dependencies: + '@smithy/core': 3.24.4 + '@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 @@ -17445,10 +17840,10 @@ snapshots: - rollup - supports-color - '@vercel/nft@1.3.0(rollup@4.57.1)': + '@vercel/nft@1.3.0(rollup@4.60.1)': dependencies: '@mapbox/node-pre-gyp': 2.0.3 - '@rollup/pluginutils': 5.3.0(rollup@4.57.1) + '@rollup/pluginutils': 5.3.0(rollup@4.60.1) acorn: 8.15.0 acorn-import-attributes: 1.9.5(acorn@8.15.0) async-sema: 3.1.1 @@ -18111,6 +18506,8 @@ snapshots: boolbase@1.0.0: {} + bowser@2.14.1: {} + boxen@7.1.1: dependencies: ansi-align: 3.0.1 @@ -19285,6 +19682,18 @@ snapshots: fast-levenshtein@2.0.6: {} + 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.0 + 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 @@ -21270,14 +21679,14 @@ snapshots: nitropack@2.13.1(rolldown@1.0.0-rc.17): dependencies: '@cloudflare/kv-asset-handler': 0.4.2 - '@rollup/plugin-alias': 6.0.0(rollup@4.57.1) - '@rollup/plugin-commonjs': 29.0.0(rollup@4.57.1) - '@rollup/plugin-inject': 5.0.5(rollup@4.57.1) - '@rollup/plugin-json': 6.1.0(rollup@4.57.1) - '@rollup/plugin-node-resolve': 16.0.3(rollup@4.57.1) - '@rollup/plugin-replace': 6.0.3(rollup@4.57.1) - '@rollup/plugin-terser': 0.4.4(rollup@4.57.1) - '@vercel/nft': 1.3.0(rollup@4.57.1) + '@rollup/plugin-alias': 6.0.0(rollup@4.60.1) + '@rollup/plugin-commonjs': 29.0.0(rollup@4.60.1) + '@rollup/plugin-inject': 5.0.5(rollup@4.60.1) + '@rollup/plugin-json': 6.1.0(rollup@4.60.1) + '@rollup/plugin-node-resolve': 16.0.3(rollup@4.60.1) + '@rollup/plugin-replace': 6.0.3(rollup@4.60.1) + '@rollup/plugin-terser': 0.4.4(rollup@4.60.1) + '@vercel/nft': 1.3.0(rollup@4.60.1) archiver: 7.0.1 c12: 3.3.3(magicast@0.5.2) chokidar: 5.0.0 @@ -21292,7 +21701,7 @@ snapshots: defu: 6.1.4 destr: 2.0.5 dot-prop: 10.1.0 - esbuild: 0.27.3 + esbuild: 0.27.7 escape-string-regexp: 5.0.0 etag: 1.8.1 exsolve: 1.0.8 @@ -21319,8 +21728,8 @@ snapshots: pkg-types: 2.3.0 pretty-bytes: 7.1.0 radix3: 1.1.2 - rollup: 4.57.1 - rollup-plugin-visualizer: 6.0.5(rolldown@1.0.0-rc.17)(rollup@4.57.1) + rollup: 4.60.1 + rollup-plugin-visualizer: 6.0.5(rolldown@1.0.0-rc.17)(rollup@4.60.1) scule: 1.3.0 semver: 7.7.4 serve-placeholder: 2.0.2 @@ -21745,6 +22154,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.5.0: {} + path-key@3.1.1: {} path-key@4.0.0: {} @@ -22408,16 +22819,6 @@ snapshots: magic-string: 0.30.21 rollup: 4.60.1 - rollup-plugin-visualizer@6.0.5(rolldown@1.0.0-rc.17)(rollup@4.57.1): - dependencies: - open: 8.4.2 - picomatch: 4.0.4 - source-map: 0.7.6 - yargs: 17.7.2 - optionalDependencies: - rolldown: 1.0.0-rc.17 - rollup: 4.57.1 - rollup-plugin-visualizer@6.0.5(rolldown@1.0.0-rc.17)(rollup@4.60.1): dependencies: open: 8.4.2 @@ -22935,6 +23336,8 @@ snapshots: dependencies: js-tokens: 9.0.1 + strnum@2.3.0: {} + style-to-js@1.1.21: dependencies: style-to-object: 1.0.14 @@ -24107,6 +24510,8 @@ snapshots: xml-name-validator@5.0.0: {} + xml-naming@0.1.0: {} + xmlbuilder2@3.1.1: dependencies: '@oozcitak/dom': 1.15.10 From 7a04febdcd83f0d6bd27b4a7ff45a0fc29d8f57f Mon Sep 17 00:00:00 2001 From: Muralidhar Challa Date: Sat, 23 May 2026 14:47:17 +0530 Subject: [PATCH 3/3] feat(ai-bedrock): add pluggable auth with API key bearer token support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auth is now resolved in this order: 1. apiKey (BEDROCK_API_KEY env var or explicit config) → bearer token 2. credentials (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY) → IAM SigV4 3. Default AWS credential chain → IAM roles, SSO, instance profiles Made credentials optional to allow the SDK's default provider chain. Supports AWS_SESSION_TOKEN for STS temporary credentials. Added BEDROCK_API_KEY to env-based configuration. --- .../ai-bedrock/src/adapters/text.ts | 37 ++++++++-- .../typescript/ai-bedrock/src/bedrock-chat.ts | 63 ++++++++++++---- packages/typescript/ai-bedrock/src/utils.ts | 6 ++ .../ai-bedrock/tests/bedrock-adapter.test.ts | 71 +++++++++++++++++++ 4 files changed, 156 insertions(+), 21 deletions(-) diff --git a/packages/typescript/ai-bedrock/src/adapters/text.ts b/packages/typescript/ai-bedrock/src/adapters/text.ts index 6f565aba5..13baff86c 100644 --- a/packages/typescript/ai-bedrock/src/adapters/text.ts +++ b/packages/typescript/ai-bedrock/src/adapters/text.ts @@ -26,15 +26,35 @@ import type { BedrockTextProviderOptions } from '../text/text-provider-options' * Configuration for the AWS Bedrock client. */ export interface BedrockTextConfig { - /** AWS region where Bedrock is accessed (e.g. `'us-east-1'`). */ - region: string - /** AWS credentials used to authenticate requests. */ - credentials: { + /** + * AWS region where Bedrock is accessed (e.g. `'us-east-1'`). + * When omitted, reads from `AWS_REGION` or `AWS_DEFAULT_REGION` env vars. + */ + region?: string + /** + * AWS IAM credentials (access key + secret key). + * + * Use for programmatic access with long-lived or STS temporary credentials. + * Cannot be combined with `apiKey`. + */ + credentials?: { /** AWS access key ID. */ accessKeyId: string /** AWS secret access key. */ secretAccessKey: string + /** Temporary session token (for STS / assumed roles). */ + sessionToken?: string } + /** + * Bedrock API key (bearer token) for authentication. + * + * Generate short-term or long-term keys via the AWS Console or CLI. + * Passed as `Authorization: Bearer `. + * Cannot be combined with `credentials`. + * + * @see https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html + */ + apiKey?: string } /** @@ -80,8 +100,13 @@ export class BedrockTextAdapter< constructor(config: BedrockTextConfig, model: TModel) { super({}, model) this.client = new BedrockRuntimeClient({ - region: config.region, - credentials: config.credentials, + ...(config.region ? { region: config.region } : {}), + // API key (bearer token) — takes precedence over IAM credentials + ...(config.apiKey ? { token: config.apiKey } : {}), + // IAM credentials — only when no API key is set + ...(!config.apiKey && config.credentials + ? { credentials: config.credentials } + : {}), }) } diff --git a/packages/typescript/ai-bedrock/src/bedrock-chat.ts b/packages/typescript/ai-bedrock/src/bedrock-chat.ts index eeca61c23..9a5ccbd95 100644 --- a/packages/typescript/ai-bedrock/src/bedrock-chat.ts +++ b/packages/typescript/ai-bedrock/src/bedrock-chat.ts @@ -17,23 +17,31 @@ export function createBedrockChat< } /** - * Creates a Bedrock text adapter, reading AWS credentials from environment variables - * (`AWS_REGION`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`). - * Any values provided in `config` take precedence over environment variables. + * Creates a Bedrock text adapter with pluggable authentication. + * + * Auth is resolved in this order (first match wins): + * 1. `config.apiKey` or `BEDROCK_API_KEY` env var → bearer token + * 2. `config.credentials` or `AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` env vars → IAM access keys + * 3. AWS SDK default credential chain → IAM roles, SSO, instance profiles, etc. + * + * Region is resolved in this order: + * 1. Explicit `config.region` + * 2. `AWS_REGION` or `AWS_DEFAULT_REGION` env vars + * 3. AWS SDK default region resolution * * @param model - The Bedrock model ID (e.g. `'amazon.nova-pro-v1:0'`). - * @param config - Optional partial configuration to override environment variable defaults. + * @param config - Optional partial configuration to override auto-detected values. * @returns A configured {@link BedrockTextAdapter} instance. * * @example * ```typescript - * import { bedrockText } from '@tanstack/ai-bedrock' - * import { chat } from '@tanstack/ai' - * - * const stream = chat({ - * adapter: bedrockText('amazon.nova-pro-v1:0'), - * messages: [{ role: 'user', content: 'Hello!' }], + * // API key (bearer token) + * const adapter = bedrockText('amazon.nova-pro-v1:0', { + * apiKey: 'bedrock-api-key-...', * }) + * + * // Or from env: BEDROCK_API_KEY=... + * const adapter = bedrockText('amazon.nova-pro-v1:0') * ``` */ export function bedrockText( @@ -41,12 +49,37 @@ export function bedrockText( config?: Partial, ): BedrockTextAdapter { const envConfig = getBedrockConfigFromEnv() + + // Region: explicit > env > undefined (let SDK resolve) + const region = config?.region || envConfig.region || undefined + + // API key (bearer token): explicit > env + const apiKey = config?.apiKey || envConfig.apiKey || undefined + + // Build full config — if apiKey is set, skip credentials const fullConfig: BedrockTextConfig = { - region: config?.region || envConfig.region || 'us-east-1', - credentials: { - accessKeyId: config?.credentials?.accessKeyId || envConfig.credentials?.accessKeyId || '', - secretAccessKey: config?.credentials?.secretAccessKey || envConfig.credentials?.secretAccessKey || '', - }, + ...(region ? { region } : {}), + ...(apiKey ? { apiKey } : {}), } + + // IAM credentials: only when no apiKey and explicitly provided + if (!apiKey) { + const explicitAccessKey = + config?.credentials?.accessKeyId || envConfig.credentials?.accessKeyId || undefined + const explicitSecretKey = + config?.credentials?.secretAccessKey || envConfig.credentials?.secretAccessKey || undefined + + if (explicitAccessKey && explicitSecretKey) { + fullConfig.credentials = { + accessKeyId: explicitAccessKey, + secretAccessKey: explicitSecretKey, + sessionToken: + config?.credentials?.sessionToken || + envConfig.credentials?.sessionToken || + undefined, + } + } + } + return createBedrockChat(model, fullConfig) } diff --git a/packages/typescript/ai-bedrock/src/utils.ts b/packages/typescript/ai-bedrock/src/utils.ts index 43d0b5fa4..dd1eb0026 100644 --- a/packages/typescript/ai-bedrock/src/utils.ts +++ b/packages/typescript/ai-bedrock/src/utils.ts @@ -6,6 +6,8 @@ export interface BedrockClientConfig { /** AWS region (e.g. `'us-east-1'`). */ region?: string + /** Bedrock API key (bearer token). Set via `BEDROCK_API_KEY` env var. */ + apiKey?: string /** AWS credentials. */ credentials?: { /** AWS access key ID. */ @@ -34,6 +36,10 @@ export function getBedrockConfigFromEnv(): BedrockClientConfig { config.region = env.AWS_REGION || env.AWS_DEFAULT_REGION } + if (env?.BEDROCK_API_KEY) { + config.apiKey = env.BEDROCK_API_KEY + } + if (env?.AWS_ACCESS_KEY_ID && env?.AWS_SECRET_ACCESS_KEY) { config.credentials = { accessKeyId: env.AWS_ACCESS_KEY_ID, diff --git a/packages/typescript/ai-bedrock/tests/bedrock-adapter.test.ts b/packages/typescript/ai-bedrock/tests/bedrock-adapter.test.ts index ba5671599..91e99daf5 100644 --- a/packages/typescript/ai-bedrock/tests/bedrock-adapter.test.ts +++ b/packages/typescript/ai-bedrock/tests/bedrock-adapter.test.ts @@ -557,4 +557,75 @@ describe('BedrockTextAdapter', () => { expect(command.system).toEqual([{ text: 'You are a helpful assistant.' }]) }) }) + + // ----------------------------------------------------------------------- + // Credential configuration + // ----------------------------------------------------------------------- + + describe('credential configuration', () => { + it('creates client without credentials (default chain)', async () => { + // We test that the adapter works when no auth config is passed. + // The mock intercepts the client constructor, so we just verify + // the stream works without explicit credentials. + const noCredsAdapter = new BedrockTextAdapter({}, 'amazon.nova-pro-v1:0') + + makeStream([ + { contentBlockDelta: { delta: { text: 'ok' } } }, + { messageStop: { stopReason: 'end_turn' } }, + ]) + + const chunks = await collectChunks(noCredsAdapter.chatStream({ + model: 'amazon.nova-pro-v1:0', + messages: [{ role: 'user', content: 'Hi' }], + })) + + // Should stream successfully + expect(chunks.find(c => c.type === EventType.TEXT_MESSAGE_CONTENT)).toBeDefined() + expect(chunks.find(c => c.type === EventType.RUN_FINISHED)).toBeDefined() + }) + + it('creates adapter with apiKey (bearer token)', async () => { + const apiKeyAdapter = new BedrockTextAdapter( + { apiKey: 'bedrock-key-abc123' }, + 'amazon.nova-pro-v1:0', + ) + + makeStream([ + { contentBlockDelta: { delta: { text: 'ok' } } }, + { messageStop: { stopReason: 'end_turn' } }, + ]) + + const chunks = await collectChunks(apiKeyAdapter.chatStream({ + model: 'amazon.nova-pro-v1:0', + messages: [{ role: 'user', content: 'Hi' }], + })) + + expect(chunks.find(c => c.type === EventType.TEXT_MESSAGE_CONTENT)).toBeDefined() + }) + + it('creates adapter with explicit credentials', async () => { + const credsAdapter = new BedrockTextAdapter( + { + region: 'us-west-2', + credentials: { + accessKeyId: 'my-key', + secretAccessKey: 'my-secret', + }, + }, + 'amazon.nova-pro-v1:0', + ) + + makeStream([ + { contentBlockDelta: { delta: { text: 'ok' } } }, + { messageStop: { stopReason: 'end_turn' } }, + ]) + + const chunks = await collectChunks(credsAdapter.chatStream({ + model: 'amazon.nova-pro-v1:0', + messages: [{ role: 'user', content: 'Hi' }], + })) + + expect(chunks.find(c => c.type === EventType.TEXT_MESSAGE_CONTENT)).toBeDefined() + }) + }) })