From 375aadc11ec3b9f577c06a3dcd8447b3ea09caa6 Mon Sep 17 00:00:00 2001 From: op-simoneromeo <284916543+op-simoneromeo@users.noreply.github.com> Date: Thu, 21 May 2026 02:48:55 +0800 Subject: [PATCH] Implement Amazon Bedrock AI adapter --- packages/ai/amazon-bedrock/src/index.test.ts | 171 +++++++++++++++ packages/ai/amazon-bedrock/src/index.ts | 216 +++++++++++++++++-- 2 files changed, 374 insertions(+), 13 deletions(-) diff --git a/packages/ai/amazon-bedrock/src/index.test.ts b/packages/ai/amazon-bedrock/src/index.test.ts index f43ad207..c6c2311e 100644 --- a/packages/ai/amazon-bedrock/src/index.test.ts +++ b/packages/ai/amazon-bedrock/src/index.test.ts @@ -1,4 +1,175 @@ import { smokeTest } from '@profullstack/sh1pt-core/testing'; +import { createHash } from 'node:crypto'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import adapter from './index.js'; smokeTest(adapter, { idPrefix: 'ai' }); + +const ctx = ( + secrets: Record = { + AWS_BEDROCK_ACCESS_KEY_ID: 'AKIDEXAMPLE', + AWS_BEDROCK_SECRET_ACCESS_KEY: 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY', + }, + dryRun = false +) => ({ + secret: (key: string) => secrets[key], + log: () => {}, + dryRun, +}); + +describe('Amazon Bedrock Converse generation', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-05-20T18:00:00.000Z')); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + it('short-circuits dry-run before signing or network calls', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + const result = await adapter.generate( + ctx(undefined, true), + 'hello', + {}, + {} + ); + + expect(result).toEqual({ + text: '[dry-run]', + model: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('requires both AWS Bedrock credential parts', async () => { + await expect(adapter.generate( + ctx({ AWS_BEDROCK_ACCESS_KEY_ID: 'AKIDEXAMPLE' }), + 'hello', + {}, + {} + )).rejects.toThrow(/AWS_BEDROCK_SECRET_ACCESS_KEY not in vault/); + }); + + it('posts signed Converse requests and maps text plus usage tokens', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + output: { + message: { + role: 'assistant', + content: [ + { text: 'hello ' }, + { text: 'from bedrock' }, + ], + }, + }, + trace: { promptRouter: { invokedModelId: 'amazon.nova-lite-v1:0' } }, + usage: { inputTokens: 11, outputTokens: 5, totalTokens: 16 }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + + const result = await adapter.generate( + ctx(), + 'hello', + { + model: 'amazon.nova-lite-v1:0', + system: 'be concise', + maxTokens: 128, + temperature: 0.2, + extra: { + additionalModelRequestFields: { top_k: 20 }, + requestMetadata: { source: 'sh1pt-test' }, + }, + }, + { region: 'eu-west-1' } + ); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, request] = fetchMock.mock.calls[0]!; + expect(String(url)).toBe('https://bedrock-runtime.eu-west-1.amazonaws.com/model/amazon.nova-lite-v1%3A0/converse'); + expect(request.method).toBe('POST'); + expect(request.headers['content-type']).toBe('application/json'); + expect(request.headers.host).toBe('bedrock-runtime.eu-west-1.amazonaws.com'); + expect(request.headers['x-amz-date']).toBe('20260520T180000Z'); + expect(request.headers.authorization).toMatch( + /^AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE\/20260520\/eu-west-1\/bedrock\/aws4_request, SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date, Signature=[0-9a-f]{64}$/ + ); + + const body = JSON.parse(request.body); + expect(body).toEqual({ + messages: [ + { + role: 'user', + content: [{ text: 'hello' }], + }, + ], + system: [{ text: 'be concise' }], + additionalModelRequestFields: { top_k: 20 }, + requestMetadata: { source: 'sh1pt-test' }, + inferenceConfig: { + maxTokens: 128, + temperature: 0.2, + }, + }); + expect(request.headers['x-amz-content-sha256']).toBe( + createHash('sha256').update(request.body).digest('hex') + ); + expect(result).toEqual({ + text: 'hello from bedrock', + model: 'amazon.nova-lite-v1:0', + inputTokens: 11, + outputTokens: 5, + }); + }); + + it('supports temporary credentials and custom Bedrock Runtime base URLs', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + output: { message: { content: [{ text: 'ok' }] } }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + + await adapter.generate( + ctx({ + AWS_BEDROCK_ACCESS_KEY_ID: 'AKIDEXAMPLE', + AWS_BEDROCK_SECRET_ACCESS_KEY: 'test-secret', + AWS_BEDROCK_SESSION_TOKEN: 'session-token', + }), + 'ping', + { + extra: { + inferenceConfig: { topP: 0.8 }, + }, + }, + { baseUrl: 'https://bedrock-runtime.test', region: 'us-west-2' } + ); + + const [url, request] = fetchMock.mock.calls[0]!; + expect(String(url)).toBe('https://bedrock-runtime.test/model/anthropic.claude-3-5-sonnet-20241022-v2%3A0/converse'); + expect(request.headers['x-amz-security-token']).toBe('session-token'); + expect(request.headers.authorization).toContain( + 'SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date;x-amz-security-token' + ); + expect(JSON.parse(request.body).inferenceConfig).toEqual({ topP: 0.8 }); + }); + + it('includes status and response body excerpts on errors', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 403, + text: async () => 'AccessDeniedException: denied'.repeat(20), + })); + + await expect(adapter.generate(ctx(), 'hello', {}, {})).rejects.toThrow( + /Amazon Bedrock 403: AccessDeniedException: denied/ + ); + }); +}); diff --git a/packages/ai/amazon-bedrock/src/index.ts b/packages/ai/amazon-bedrock/src/index.ts index 7813231b..6ecd7fe8 100644 --- a/packages/ai/amazon-bedrock/src/index.ts +++ b/packages/ai/amazon-bedrock/src/index.ts @@ -1,30 +1,220 @@ +import { createHash, createHmac } from 'node:crypto'; import { defineAi, tokenSetup } from '@profullstack/sh1pt-core'; interface Config { baseUrl?: string; + region?: string; } +const ACCESS_KEY_SECRET = 'AWS_BEDROCK_ACCESS_KEY_ID'; +const SECRET_KEY_SECRET = 'AWS_BEDROCK_SECRET_ACCESS_KEY'; +const SESSION_TOKEN_SECRET = 'AWS_BEDROCK_SESSION_TOKEN'; +const DEFAULT_REGION = 'us-east-1'; +const DEFAULT_MODEL = 'anthropic.claude-3-5-sonnet-20241022-v2:0'; +const SERVICE = 'bedrock'; + export default defineAi({ id: 'ai-amazon-bedrock', label: 'Amazon Bedrock', - defaultModel: 'anthropic.claude-3-5-sonnet-20241022-v2:0', - models: ['anthropic.claude-3-5-sonnet-20241022-v2:0'], - - async generate(ctx, prompt, _opts, _config) { - const apiKey = ctx.secret('AWS_BEDROCK_ACCESS_KEY_ID'); - if (!apiKey) throw new Error('AWS_BEDROCK_ACCESS_KEY_ID not in vault — run `sh1pt promote ai setup`'); - ctx.log(`[stub] ai-amazon-bedrock · ${prompt.length} chars in — integration pending`); - return { text: '[stub — ai-amazon-bedrock integration not yet implemented]', model: 'anthropic.claude-3-5-sonnet-20241022-v2:0' }; + defaultModel: DEFAULT_MODEL, + models: [ + DEFAULT_MODEL, + 'anthropic.claude-3-haiku-20240307-v1:0', + 'amazon.nova-pro-v1:0', + 'amazon.nova-lite-v1:0', + 'meta.llama3-1-70b-instruct-v1:0', + ], + + async generate(ctx, prompt, opts, config) { + const accessKeyId = ctx.secret(ACCESS_KEY_SECRET); + const secretAccessKey = ctx.secret(SECRET_KEY_SECRET); + if (!accessKeyId) throw new Error(`${ACCESS_KEY_SECRET} not in vault`); + if (!secretAccessKey) throw new Error(`${SECRET_KEY_SECRET} not in vault`); + + const region = config.region ?? ctx.secret('AWS_REGION') ?? ctx.secret('AWS_DEFAULT_REGION') ?? DEFAULT_REGION; + const model = opts.model ?? DEFAULT_MODEL; + ctx.log(`amazon-bedrock · region=${region} · model=${model} · ${prompt.length} chars in`); + if (ctx.dryRun) return { text: '[dry-run]', model }; + + const body = buildConverseBody(prompt, opts); + const bodyText = JSON.stringify(body); + const baseUrl = config.baseUrl ?? `https://bedrock-runtime.${region}.amazonaws.com`; + const url = new URL(`/model/${encodeURIComponent(model)}/converse`, withTrailingSlash(baseUrl)); + const headers = signAwsRequest({ + accessKeyId, + secretAccessKey, + sessionToken: ctx.secret(SESSION_TOKEN_SECRET), + region, + url, + body: bodyText, + now: new Date(), + }); + + const res = await fetch(url, { + method: 'POST', + headers, + body: bodyText, + }); + if (!res.ok) throw new Error(`Amazon Bedrock ${res.status}: ${(await res.text()).slice(0, 200)}`); + + const data = await res.json() as BedrockConverseResponse; + const text = data.output?.message?.content + ?.map((part) => part.text ?? '') + .join('') ?? ''; + return { + text, + model: data.trace?.promptRouter?.invokedModelId ?? model, + inputTokens: data.usage?.inputTokens, + outputTokens: data.usage?.outputTokens, + }; }, setup: tokenSetup({ - secretKey: 'AWS_BEDROCK_ACCESS_KEY_ID', + secretKey: ACCESS_KEY_SECRET, label: 'Amazon Bedrock', - vendorDocUrl: 'https://aws.amazon.com/bedrock', + vendorDocUrl: 'https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_Converse.html', steps: [ - 'Sign in at https://aws.amazon.com/bedrock and create an API key', - 'Copy the key — usually shown once', - 'Paste below; sh1pt encrypts it in the vault', + 'Create an IAM access key with Amazon Bedrock Runtime invoke permissions', + 'Enable access to the Bedrock model you plan to use in the target region', + 'Paste the access key id and secret access key; sh1pt encrypts them in the vault', + ], + fields: [ + { key: SECRET_KEY_SECRET, message: 'AWS secret access key:', secret: true, required: true }, + { key: SESSION_TOKEN_SECRET, message: 'AWS session token (optional):', secret: true }, + { key: 'region', message: 'AWS region (default: us-east-1):' }, + { key: 'baseUrl', message: 'Bedrock Runtime base URL (optional):' }, ], }), }); + +function buildConverseBody(prompt: string, opts: { + system?: string; + maxTokens?: number; + temperature?: number; + extra?: unknown; +}): Record { + const body: Record = { + messages: [ + { + role: 'user', + content: [{ text: prompt }], + }, + ], + ...(opts.system ? { system: [{ text: opts.system }] } : {}), + ...(isRecord(opts.extra) ? opts.extra : {}), + }; + + const inferenceConfig = { + ...(isRecord(body.inferenceConfig) ? body.inferenceConfig : {}), + ...(opts.maxTokens !== undefined ? { maxTokens: opts.maxTokens } : {}), + ...(opts.temperature !== undefined ? { temperature: opts.temperature } : {}), + }; + if (Object.keys(inferenceConfig).length > 0) body.inferenceConfig = inferenceConfig; + + return body; +} + +function signAwsRequest(opts: { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + region: string; + url: URL; + body: string; + now: Date; +}): Record { + const amzDate = toAmzDate(opts.now); + const dateStamp = amzDate.slice(0, 8); + const payloadHash = sha256Hex(opts.body); + const headers: Record = { + 'content-type': 'application/json', + host: opts.url.host, + 'x-amz-content-sha256': payloadHash, + 'x-amz-date': amzDate, + ...(opts.sessionToken ? { 'x-amz-security-token': opts.sessionToken } : {}), + }; + + const signedHeaders = Object.keys(headers).sort().join(';'); + const canonicalHeaders = Object.keys(headers) + .sort() + .map((key) => `${key}:${normalizeHeaderValue(headers[key] ?? '')}\n`) + .join(''); + const canonicalRequest = [ + 'POST', + opts.url.pathname, + opts.url.searchParams.toString(), + canonicalHeaders, + signedHeaders, + payloadHash, + ].join('\n'); + const credentialScope = `${dateStamp}/${opts.region}/${SERVICE}/aws4_request`; + const stringToSign = [ + 'AWS4-HMAC-SHA256', + amzDate, + credentialScope, + sha256Hex(canonicalRequest), + ].join('\n'); + const signature = hmacHex( + getSigningKey(opts.secretAccessKey, dateStamp, opts.region, SERVICE), + stringToSign + ); + + return { + ...headers, + authorization: `AWS4-HMAC-SHA256 Credential=${opts.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`, + }; +} + +function getSigningKey(secretAccessKey: string, dateStamp: string, region: string, service: string): Buffer { + const dateKey = hmac(`AWS4${secretAccessKey}`, dateStamp); + const regionKey = hmac(dateKey, region); + const serviceKey = hmac(regionKey, service); + return hmac(serviceKey, 'aws4_request'); +} + +function hmac(key: string | Buffer, value: string): Buffer { + return createHmac('sha256', key).update(value).digest(); +} + +function hmacHex(key: string | Buffer, value: string): string { + return createHmac('sha256', key).update(value).digest('hex'); +} + +function sha256Hex(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function normalizeHeaderValue(value: string): string { + return value.trim().replace(/\s+/g, ' '); +} + +function toAmzDate(date: Date): string { + return date.toISOString().replace(/[:-]|\.\d{3}/g, ''); +} + +function withTrailingSlash(baseUrl: string): string { + return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +interface BedrockConverseResponse { + output?: { + message?: { + content?: Array<{ + text?: string; + }>; + }; + }; + trace?: { + promptRouter?: { + invokedModelId?: string; + }; + }; + usage?: { + inputTokens?: number; + outputTokens?: number; + }; +}