diff --git a/.release-please-manifest.json b/.release-please-manifest.json index fb1561b5ca..80aa6f36a3 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "6.44.0" + ".": "6.45.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f182ad8c9..4fad015b92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,53 @@ # Changelog +## 6.45.0 (2026-06-24) + +Full Changelog: [v6.44.0...v6.45.0](https://github.com/openai/openai-node/compare/v6.44.0...v6.45.0) + +### Features + +* add afterCompletion hook to runTools ([#1064](https://github.com/openai/openai-node/issues/1064)) ([4976c22](https://github.com/openai/openai-node/commit/4976c22bf608e4d5f70d6386cfe9c428cf8ff2c7)) +* **realtime:** support sideband call_id connections ([#1795](https://github.com/openai/openai-node/issues/1795)) ([4fd59e1](https://github.com/openai/openai-node/commit/4fd59e1be4ad478373cdcb4f185109af29067da8)) +* reject emitted promise on error in EventEmitter ([#1596](https://github.com/openai/openai-node/issues/1596)) ([e0b09da](https://github.com/openai/openai-node/commit/e0b09da782f0d6ce69a383fd3f3d02a0c7608d6d)) +* **responses:** add response input items helper ([#1794](https://github.com/openai/openai-node/issues/1794)) ([7e5a86a](https://github.com/openai/openai-node/commit/7e5a86a6162d76a7248fa983cb16a16c185a097a)) +* **responses:** add response stream accumulator helper ([#1793](https://github.com/openai/openai-node/issues/1793)) ([32e3e39](https://github.com/openai/openai-node/commit/32e3e39e1736659196aadd7d0bb8d6ca90f69168)) + + +### Bug Fixes + +* avoid serializing null optional bodies ([e4ddff6](https://github.com/openai/openai-node/commit/e4ddff6f2085b1c69eaa790e48d1a22d2dd9d7fb)) +* clean up stream abort listeners ([#1884](https://github.com/openai/openai-node/issues/1884)) ([be55b8f](https://github.com/openai/openai-node/commit/be55b8fb352be80d9d30dd263451615f4342d69f)) +* Leading whitespace chunks break partial parser (structured outputs) ([#1213](https://github.com/openai/openai-node/issues/1213)) ([88d806e](https://github.com/openai/openai-node/commit/88d806eb91f2b41ba4cac3ee69eb73656cab1849)) +* **responses:** avoid parsing incomplete structured output ([#1852](https://github.com/openai/openai-node/issues/1852)) ([4c39681](https://github.com/openai/openai-node/commit/4c396817cbc9586654fa7a376d55a29d8793995d)) +* **responses:** restore output_text on streamed final responses ([#1681](https://github.com/openai/openai-node/issues/1681)) ([bbde4d9](https://github.com/openai/openai-node/commit/bbde4d9e3bbceef5e14bcb71b3a7c36388dbab2b)) +* validate workload identity access tokens ([#1885](https://github.com/openai/openai-node/issues/1885)) ([80febe4](https://github.com/openai/openai-node/commit/80febe441c2dc2d110b412be022fe044bbae2cd9)) +* **zod:** fully resolve extracted wrapper definitions ([#1766](https://github.com/openai/openai-node/issues/1766)) ([5d46ed2](https://github.com/openai/openai-node/commit/5d46ed2012f73b18d6594c62b9fb6351bef76d3d)) +* **zod:** sanitize extracted definition refs ([#1903](https://github.com/openai/openai-node/issues/1903)) ([6ef2111](https://github.com/openai/openai-node/commit/6ef21116801bdaa8e9f25686a8bd365a270401bf)) + + +### Chores + +* **internal:** codegen related update ([81fcf96](https://github.com/openai/openai-node/commit/81fcf96fccca29dbcbf33bcb7578b98e06d7a6b0)) + + +### Documentation + +* add vision example using image URL ([#1571](https://github.com/openai/openai-node/issues/1571)) ([70966c9](https://github.com/openai/openai-node/commit/70966c922d0f65eb4711552cf5acb85c7842c2a9)) +* **azure:** add Responses API example ([#1675](https://github.com/openai/openai-node/issues/1675)) ([46453b0](https://github.com/openai/openai-node/commit/46453b06aa8682e5d37be810296bd2f96fe11096)) +* **azure:** use standard client for v1 chat ([#1692](https://github.com/openai/openai-node/issues/1692)) ([fba57e5](https://github.com/openai/openai-node/commit/fba57e577c484c579fbc12741aba6f7df2875eca)) +* clarify abort-on-function-call example ([#1843](https://github.com/openai/openai-node/issues/1843)) ([f21f248](https://github.com/openai/openai-node/commit/f21f24842827f514262bf3c0b5ba4c634585ce31)) +* clarify assistant stream event names ([#1860](https://github.com/openai/openai-node/issues/1860)) ([95fc6cd](https://github.com/openai/openai-node/commit/95fc6cd2d0790797eb62b62a0da45dd13de62c0e)) +* clarify supported zod schema features ([#1880](https://github.com/openai/openai-node/issues/1880)) ([ea93760](https://github.com/openai/openai-node/commit/ea93760643871960ad28cbb8654432f091902241)) +* fix helper example links ([#1890](https://github.com/openai/openai-node/issues/1890)) ([9c58f39](https://github.com/openai/openai-node/commit/9c58f399bd71201d4ff08d5192ca84608aba082b)) +* fix typos in tool calls stream example ([#1817](https://github.com/openai/openai-node/issues/1817)) ([8915ef0](https://github.com/openai/openai-node/commit/8915ef0d46ab47b03c561d87e4271b79bc569793)) +* **readme:** fix two typos and remove duplicate section ([#1733](https://github.com/openai/openai-node/issues/1733)) ([e2f8051](https://github.com/openai/openai-node/commit/e2f80517c01b25432371f7814c20221b3aed67d4)) +* **runner:** remove "the the" duplicate-word typo in JSDoc ([#1892](https://github.com/openai/openai-node/issues/1892)) ([52f075f](https://github.com/openai/openai-node/commit/52f075ff39c14a6114c74b73e9dfbc230445f681)) + + +### Build System + +* vendor tsc-multi build tool ([#1948](https://github.com/openai/openai-node/issues/1948)) ([7c35bcd](https://github.com/openai/openai-node/commit/7c35bcd186140f47a13f9e60e19e40a1994cbf5e)) + ## 6.44.0 (2026-06-17) Full Changelog: [v6.43.0...v6.44.0](https://github.com/openai/openai-node/compare/v6.43.0...v6.44.0) diff --git a/README.md b/README.md index da8c9f2396..d0a570706a 100644 --- a/README.md +++ b/README.md @@ -521,13 +521,15 @@ For more information on support for the Azure API, see [azure.md](azure.md). ## Amazon Bedrock -To use this library with [Amazon Bedrock's OpenAI-compatible API](https://docs.aws.amazon.com/bedrock/latest/userguide/models-api-compatibility.html), use the `BedrockOpenAI` class instead of the `OpenAI` class. +To use this library with [Amazon Bedrock's OpenAI-compatible API](https://docs.aws.amazon.com/bedrock/latest/userguide/models-api-compatibility.html), configure the standard `OpenAI` client with the Bedrock provider: ```ts -import { BedrockOpenAI } from 'openai'; +import OpenAI from 'openai'; +import { bedrock } from 'openai/providers/bedrock/aws'; -// gets the bearer token from AWS_BEARER_TOKEN_BEDROCK and the region from AWS_REGION/AWS_DEFAULT_REGION -const client = new BedrockOpenAI(); +const client = new OpenAI({ + provider: bedrock({ region: 'us-west-2' }), +}); const response = await client.responses.create({ model: 'openai.gpt-5.4', @@ -537,19 +539,24 @@ const response = await client.responses.create({ console.log(response.output_text); ``` -`BedrockOpenAI` configures AWS bearer auth and the Bedrock Mantle endpoint, then uses the normal SDK resources. AWS controls which endpoints and features are supported; unsupported calls surface the provider's normal HTTP errors through the SDK. +Use a model that [supports the Responses API](https://docs.aws.amazon.com/bedrock/latest/userguide/models-api-compatibility.html). A model returned by the Models API may support a different Bedrock inference API instead. -Pass `baseURL` or set `AWS_BEDROCK_BASE_URL` to override the derived `https://bedrock-mantle..api.aws/openai/v1` endpoint. For long-running apps, pass `bedrockTokenProvider` to refresh the Bedrock bearer token before each request. +This uses the regional `https://bedrock-mantle..api.aws/openai/v1` endpoint. The region can also come from `AWS_REGION` or `AWS_DEFAULT_REGION`, and `AWS_BEDROCK_BASE_URL` can override the endpoint. -Set `AWS_BEARER_TOKEN_BEDROCK` to an [Amazon Bedrock API key](https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html). To refresh tokens yourself, pass a provider instead of `apiKey`: +The AWS entrypoint uses the standard AWS credential chain by default. It also accepts a named profile, static credentials, or a custom credential provider. Install its peer dependencies before importing it: -```ts -const client = new BedrockOpenAI({ - awsRegion: 'us-west-2', - bedrockTokenProvider: async () => refreshBedrockToken(), -}); +```bash +npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/signature-v4 ``` +The AWS entrypoint uses normal static imports so bundlers and serverless packagers can trace these dependencies. If one is missing, importing `openai/providers/bedrock/aws` fails immediately with the runtime's normal module-not-found error, for example: + +```text +Cannot find module '@aws-sdk/credential-provider-node' +``` + +For Bedrock API key authentication, import `bedrock` from `openai/providers/bedrock` instead. That entrypoint has no AWS dependencies and works in browser-compatible runtimes when `dangerouslyAllowBrowser` is enabled. SigV4 authentication is supported in Node.js and compatible server runtimes and requires replayable request bodies. The legacy, bearer-only `BedrockOpenAI` class remains available for compatibility. + For more information on support for Amazon Bedrock, see [bedrock.md](bedrock.md). ## Advanced Usage diff --git a/bedrock.md b/bedrock.md index 63f19cdb15..230da459b5 100644 --- a/bedrock.md +++ b/bedrock.md @@ -1,13 +1,14 @@ # Amazon Bedrock -To use this library with [Amazon Bedrock's OpenAI-compatible API](https://docs.aws.amazon.com/bedrock/latest/userguide/models-api-compatibility.html), use the `BedrockOpenAI` -class instead of the `OpenAI` class. +To use this library with [Amazon Bedrock's OpenAI-compatible API](https://docs.aws.amazon.com/bedrock/latest/userguide/models-api-compatibility.html), configure the standard `OpenAI` client with the Bedrock provider: ```ts -import { BedrockOpenAI } from 'openai'; +import OpenAI from 'openai'; +import { bedrock } from 'openai/providers/bedrock/aws'; -// gets the bearer token from AWS_BEARER_TOKEN_BEDROCK and the region from AWS_REGION/AWS_DEFAULT_REGION -const client = new BedrockOpenAI(); +const client = new OpenAI({ + provider: bedrock({ region: 'us-west-2' }), +}); const response = await client.responses.create({ model: 'openai.gpt-5.4', @@ -17,15 +18,142 @@ const response = await client.responses.create({ console.log(response.output_text); ``` -`BedrockOpenAI` configures AWS bearer auth and the Bedrock Mantle endpoint, then uses the normal SDK resources. AWS controls which endpoints and features are supported; unsupported calls surface the provider's normal HTTP errors through the SDK. +Use a model that [supports the Responses API](https://docs.aws.amazon.com/bedrock/latest/userguide/models-api-compatibility.html). A model returned by the Models API may support a different Bedrock inference API instead. + +The provider uses the regional `https://bedrock-mantle..api.aws/openai/v1` endpoint and the normal SDK resources. AWS controls which endpoints and features are supported; unsupported calls surface the provider's normal HTTP errors through the SDK. + +The region defaults to `AWS_REGION` or `AWS_DEFAULT_REGION`. Pass `baseURL` or set `AWS_BEDROCK_BASE_URL` to override the derived endpoint: + +```ts +const client = new OpenAI({ + provider: bedrock({ + region: 'us-west-2', + baseURL: 'https://bedrock.example.com/openai/v1', + }), +}); +``` + +## Authentication + +The AWS entrypoint selects authentication in this order: + +1. One explicit mode passed to `bedrock(...)`: `apiKey` or `tokenProvider`, static AWS credentials, `profile`, or `credentialProvider`. +2. The [Amazon Bedrock API key](https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html) in `AWS_BEARER_TOKEN_BEDROCK`. +3. The default AWS credential chain. + +Explicit bearer and AWS credential modes are mutually exclusive. Similarly, configure only one AWS credential mode at a time. + +### Bearer authentication + +Pass a Bedrock API key directly, set `AWS_BEARER_TOKEN_BEDROCK`, or use `tokenProvider` to resolve a fresh token before every request attempt: + +```ts +const client = new OpenAI({ + provider: bedrock({ + region: 'us-west-2', + apiKey: process.env['BEDROCK_API_KEY'], + }), +}); +``` + +For a refreshable bearer credential: -Pass `baseURL` or set `AWS_BEDROCK_BASE_URL` to override the derived `https://bedrock-mantle..api.aws/openai/v1` endpoint. For long-running apps, pass `bedrockTokenProvider` to refresh the Bedrock bearer token before each request. +```ts +const client = new OpenAI({ + provider: bedrock({ + region: 'us-west-2', + tokenProvider: async () => refreshBedrockToken(), + }), +}); +``` -Set `AWS_BEARER_TOKEN_BEDROCK` to an [Amazon Bedrock API key](https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html). To refresh tokens yourself, pass a provider instead of `apiKey`: +Bearer authentication does not require any additional dependencies when imported from the dependency-free entrypoint: ```ts +import { bedrock } from 'openai/providers/bedrock'; + +const client = new OpenAI({ + provider: bedrock({ + region: 'us-west-2', + apiKey: process.env['AWS_BEARER_TOKEN_BEDROCK'], + }), +}); +``` + +The dependency-free entrypoint supports only `apiKey`, `tokenProvider`, and `AWS_BEARER_TOKEN_BEDROCK`. Use the AWS entrypoint for SigV4 authentication. + +### AWS credentials and SigV4 + +Install the AWS entrypoint's peer dependencies to sign requests with SigV4: + +```sh +npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/signature-v4 +``` + +The AWS entrypoint uses normal static imports so Vite, Webpack, and serverless packagers can include these dependencies. If one is missing, importing `openai/providers/bedrock/aws` fails immediately with the runtime's normal module-not-found error, for example: + +```text +Cannot find module '@aws-sdk/credential-provider-node' +``` + +Import the AWS entrypoint, then omit explicit authentication to use the default AWS credential chain or select a shared-config profile: + +```ts +import { bedrock } from 'openai/providers/bedrock/aws'; + +const client = new OpenAI({ + provider: bedrock({ + region: 'us-west-2', + profile: 'my-profile', + }), +}); +``` + +Pass temporary AWS credentials directly, including the session token: + +```ts +const client = new OpenAI({ + provider: bedrock({ + region: 'us-west-2', + accessKeyId: process.env['AWS_ACCESS_KEY_ID'], + secretAccessKey: process.env['AWS_SECRET_ACCESS_KEY'], + sessionToken: process.env['AWS_SESSION_TOKEN'], + }), +}); +``` + +For credentials that can change, pass a provider. It is called before every request attempt, including retries: + +```ts +const client = new OpenAI({ + provider: bedrock({ + region: 'us-west-2', + credentialProvider: async () => ({ + accessKeyId: process.env['AWS_ACCESS_KEY_ID']!, + secretAccessKey: process.env['AWS_SECRET_ACCESS_KEY']!, + sessionToken: process.env['AWS_SESSION_TOKEN'], + }), + }), +}); +``` + +SigV4 authentication is supported in Node.js and compatible server runtimes. Bearer authentication can be used in other runtimes without loading the AWS packages by importing from `openai/providers/bedrock`. + +The SDK's current SigV4 mode requires a replayable, buffered body such as a string, `ArrayBuffer`, or typed-array view. The standard JSON API methods already meet this requirement. Custom `FormData`, readable streams, and other non-replayable request bodies are rejected before sending; response streaming is unaffected. Signed requests also do not automatically follow redirects, because the redirect target would require a new signature. + +Bedrock Mantle also supports `UNSIGNED-PAYLOAD` and AWS-chunked request signing, but this SDK does not enable those modes. Mantle waits for the complete request body before authentication and authorization, so streaming a request body does not reduce request latency. + +## Legacy `BedrockOpenAI` class + +The `BedrockOpenAI` class remains available for existing bearer-authenticated applications. It accepts the `awsRegion` and `bedrockTokenProvider` option names and uses the same `/openai/v1` endpoint as the provider: + +```ts +import { BedrockOpenAI } from 'openai'; + const client = new BedrockOpenAI({ awsRegion: 'us-west-2', - bedrockTokenProvider: async () => refreshBedrockToken(), + apiKey: process.env['AWS_BEARER_TOKEN_BEDROCK'], }); ``` + +New applications using AWS credentials should prefer `new OpenAI({ provider: bedrock(...) })` with the `openai/providers/bedrock/aws` entrypoint. diff --git a/ecosystem-tests/ts-browser-webpack/src/index.ts b/ecosystem-tests/ts-browser-webpack/src/index.ts index 201876e8ad..beef1deede 100644 --- a/ecosystem-tests/ts-browser-webpack/src/index.ts +++ b/ecosystem-tests/ts-browser-webpack/src/index.ts @@ -1,5 +1,6 @@ import OpenAI, { toFile } from 'openai'; import { distance } from 'fastest-levenshtein'; +import { bedrock } from 'openai/providers/bedrock'; import { ChatCompletion } from 'openai/resources/chat/completions'; type TestCase = { @@ -96,6 +97,22 @@ const params = new URLSearchParams(location.search); const client = new OpenAI({ apiKey: params.get('apiKey') ?? undefined, dangerouslyAllowBrowser: true }); +it('supports Bedrock bearer authentication without AWS dependencies', async function () { + let authorization: string | null = null; + const bedrockClient = new OpenAI({ + provider: bedrock({ region: 'us-east-1', apiKey: 'bedrock-token' }), + dangerouslyAllowBrowser: true, + fetch: async (_url, init) => { + authorization = new Headers(init?.headers).get('authorization'); + return new Response('{}', { headers: { 'Content-Type': 'application/json' } }); + }, + }); + + await bedrockClient.request({ method: 'get', path: '/models' }); + + expect(authorization).toEqual('Bearer bedrock-token'); +}); + async function typeTests() { // @ts-expect-error this should error if the `Uploadable` type was resolved correctly await client.audio.transcriptions.create({ file: { foo: true }, model: 'whisper-1' }); diff --git a/examples/bedrock/responses.ts b/examples/bedrock/responses.ts index f8a3f60e55..ef39ff127f 100644 --- a/examples/bedrock/responses.ts +++ b/examples/bedrock/responses.ts @@ -1,11 +1,16 @@ #!/usr/bin/env -S npm run tsn -- -T -import { BedrockOpenAI } from 'openai'; +import OpenAI from 'openai'; +import { bedrock } from 'openai/providers/bedrock/aws'; -const client = new BedrockOpenAI(); +const client = new OpenAI({ + provider: bedrock({ region: 'us-west-2' }), +}); -// For refreshed Bedrock bearer tokens: -// const client = new BedrockOpenAI({ awsRegion: 'us-west-2', bedrockTokenProvider: getBedrockToken }); +// For refreshed Bedrock bearer tokens, import from 'openai/providers/bedrock': +// const client = new OpenAI({ +// provider: bedrock({ region: 'us-west-2', tokenProvider: getBedrockToken }), +// }); async function main() { const response = await client.responses.create({ diff --git a/jest.config.ts b/jest.config.ts index 5d98b45f9a..1c2d17173f 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -17,7 +17,7 @@ const config: JestConfigWithTsJest = { '/deno_tests/', '/packages/', ], - testPathIgnorePatterns: ['scripts'], + testPathIgnorePatterns: ['scripts', '/tests/live/'], // prettierPath: require.resolve('prettier-2'), }; diff --git a/jest.live.config.ts b/jest.live.config.ts new file mode 100644 index 0000000000..a14ac9ffa1 --- /dev/null +++ b/jest.live.config.ts @@ -0,0 +1,11 @@ +import type { JestConfigWithTsJest } from 'ts-jest'; + +import baseConfig from './jest.config'; + +const config: JestConfigWithTsJest = { + ...baseConfig, + testMatch: ['/tests/live/**/*.live.test.ts'], + testPathIgnorePatterns: [], +}; + +export default config; diff --git a/jsr.json b/jsr.json index 61cb2e5cab..7355246b05 100644 --- a/jsr.json +++ b/jsr.json @@ -1,12 +1,17 @@ { "name": "@openai/openai", - "version": "6.44.0", + "version": "6.45.0", "exports": { ".": "./index.ts", + "./providers/bedrock": "./providers/bedrock.ts", + "./providers/bedrock/aws": "./providers/bedrock/aws.ts", "./helpers/zod": "./helpers/zod.ts", "./beta/realtime/websocket": "./beta/realtime/websocket.ts" }, "imports": { + "@aws-sdk/credential-provider-node": "npm:@aws-sdk/credential-provider-node@^3.972.47", + "@smithy/hash-node": "npm:@smithy/hash-node@^4.3.5", + "@smithy/signature-v4": "npm:@smithy/signature-v4@^5.4.5", "zod": "npm:zod@3" }, "publish": { diff --git a/package.json b/package.json index d5668d130a..e251ea5bca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openai", - "version": "6.44.0", + "version": "6.45.0", "description": "The official TypeScript library for the OpenAI API", "author": "OpenAI ", "types": "dist/index.d.ts", @@ -18,6 +18,7 @@ }, "scripts": { "test": "./scripts/test", + "test:live:bedrock": "jest --config jest.live.config.ts --runInBand tests/live/bedrock.live.test.ts", "build": "./scripts/build", "prepublishOnly": "echo 'to publish, run pnpm build && (cd dist; npm publish)' && exit 1", "format": "./scripts/format", @@ -28,6 +29,10 @@ }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.3", + "@aws-sdk/credential-provider-node": "^3.972.47", + "@smithy/hash-node": "^4.3.5", + "@smithy/protocol-http": "^5.4.5", + "@smithy/signature-v4": "^5.4.5", "@swc/core": "^1.3.102", "@swc/jest": "^0.2.29", "@types/jest": "^29.4.0", @@ -80,10 +85,22 @@ } }, "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, "ws": { "optional": true }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 23bcaa3792..d1cfbd724d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,18 @@ importers: '@arethetypeswrong/cli': specifier: ^0.18.3 version: 0.18.3 + '@aws-sdk/credential-provider-node': + specifier: ^3.972.47 + version: 3.972.47 + '@smithy/hash-node': + specifier: ^4.3.5 + version: 4.3.5 + '@smithy/protocol-http': + specifier: ^5.4.5 + version: 5.4.5 + '@smithy/signature-v4': + specifier: ^5.4.5 + version: 5.4.5 '@swc/core': specifier: ^1.3.102 version: 1.15.40 @@ -125,7 +137,7 @@ importers: version: 14.2.35(@babel/core@7.29.7)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) openai: specifier: file:.. - version: file:(ws@8.21.0)(zod@4.4.3) + version: file:(@aws-sdk/credential-provider-node@3.972.47)(@smithy/hash-node@4.3.5)(@smithy/signature-v4@5.4.6)(ws@8.21.0)(zod@4.4.3) zod-to-json-schema: specifier: ^3.21.4 version: 3.25.2(zod@4.4.3) @@ -154,6 +166,87 @@ packages: resolution: {integrity: sha512-sWBB/tdIktaT5xMq0Dz6CJyqcf6oMNdmiKiuPU1lWoJLTL6gjRSsksBuSgqot21hylkklBQY1wiSu+PkZhW7sw==} engines: {node: '>=20'} + '@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/core@3.974.18': + resolution: {integrity: sha512-JDYCPI0j7zGrzXTDFsLB346cxss7J/AxH7+O0MzWlqppJBEyB9Qe6TQXRL6iwLUo/xZkNv9KFmBL2hqElmwW0g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.44': + resolution: {integrity: sha512-3hKJVrZ7bqXzDAXCQp+OaQ1ASN+vWstaNuEH418wQVl//cRZhqhfR9Bjk1qIWmgUGe8/D3gdO73PgidRj378EQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.46': + resolution: {integrity: sha512-VhwC9pGAZHhiQ2xSViyOPDFqvr9aRxGCAXZtADsUhU3R65nad7y//CwynE6mQnWNR+suRlqE79W36IVayL+m1g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.50': + resolution: {integrity: sha512-09Xi6ovxiK42+De/qBGF71sT5F2bWgYM+1fFyDwSOpy1xpsQ5R/naIu7MVDpH6Dic36QNc8dAv4KADtMGK2JYg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.49': + resolution: {integrity: sha512-EfJF/1Fh9mI4pZyoheU2RY9xUhTcugIZNkD63+orXMkYj/QXacJNbKVDUK90Yv5hE+aX+rt9J/EZ9Qr3vKOa7g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.47': + resolution: {integrity: sha512-HrId+C0DWA5qDIyLG64/kjUB2RNtPypxmABnIctK+TA1P1kHlOYoE/Wf5T5tKOMKgb08P7k/zNyhvfJ3lh5Oag==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.44': + resolution: {integrity: sha512-V+UUhZpRP7QDRhi+qgBDisM9tUBnYmMje8Bk77A6MZsfeGeGdMsQXmaHP1CDYFcept0o/Rz5g2Y0TMeVlG9dzg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.49': + resolution: {integrity: sha512-9QqOYGuh5tZ76OzaT68kwI78AH+5lS/uZGGvkfxb3fc8FzRrIz2jOufNTliEBEeSAwmgK2rWLNsK+IB3zbtNPA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.49': + resolution: {integrity: sha512-IYx1lN38MnnPXv+NBLpuATu0cZakbZ321TAfjW+aVkw7HIJF38YnEwdeEO55MSl3pl7hIX1IvvnD6EmnAzmAJw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.997.17': + resolution: {integrity: sha512-lDRgraoTfKRawUyc176Ow93mrNrOho/x+EoK4C+lKU+vKkHWhNhzvSMVAx0WEJUJoeQxxDN5ZdKMfiGEyNejig==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.996.32': + resolution: {integrity: sha512-llvApLcsWtmRFhG2wT3WIp1CmDeRaIYutqty1ZZXoMzK7TiJ6MOLOimk9eXUS8PwgG4ew4pa4QAbt0lfhn++1w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.1063.0': + resolution: {integrity: sha512-nYDaWWdzjKiDP5xj8k4oUgcYd4WPgzfAOgdU5vJsaqH/07Dfvm7ffisHCFJ+NEl7kUC9JEIUxh0kznvenbo3NQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.11': + resolution: {integrity: sha512-YjS0qFuECClRh4qhEyW8XagW0fwEPBeZ1cfsW/gU73Kh/ExFILxbzxOfPCmzF/2DwEvhvsHYt0b0qnvStwKYrg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.6': + resolution: {integrity: sha512-ZfHjfwSzeXj+Lg9AK5ZNmeDkXev6V+w2tn1t4kgDdRtUaRCthepTQiFwbD06EF9oNGH4LaLg+Mb6U16Ypv5bSw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/xml-builder@3.972.28': + resolution: {integrity: sha512-lI/l3c/vPvsxmspzV63NfS3x9q4CkMmdhJy4QiM+NThAufVkDvi/PZZQ6xETnICL0UD7jI808pY83gllf86RFg==} + 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'} + '@azure/abort-controller@2.1.2': resolution: {integrity: sha512-nBrLsEWm4J2u5LpAPjxADTlq3trDgVZZXHNKabeXZtpq3d3AbN/KGO82R87rdDz5/lYB024rtEf10/q0urNgsA==} engines: {node: '>=18.0.0'} @@ -605,6 +698,9 @@ packages: cpu: [x64] os: [win32] + '@nodable/entities@2.1.1': + resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -633,6 +729,54 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@smithy/core@3.24.6': + resolution: {integrity: sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.3.8': + resolution: {integrity: sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.4.6': + resolution: {integrity: sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.3.5': + resolution: {integrity: sha512-/tUIDaB36qjLq/CIhMRIiFXCT7rVGBGAhFmMA9PbC/iW2u3QPNATZuFSdK0JBO3qeSPoHBeudFMmsbFq2Mf5EQ==} + 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.7': + resolution: {integrity: sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.4.5': + resolution: {integrity: sha512-jOD+4WNWQLntiLJn3r82C7BLheEbRCKTbU5U5bskZmT7nwRiGkh0IghuHwHRZ1ZEFXpHltQxxp9/koOPsdluJg==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.4.5': + resolution: {integrity: sha512-QBJKWGqIknH0dc9LWpfH1mkdokAx6iXYN3UcQ3eY6uIEyScuoQAhfl94ge7ozUy9WgFUdE8xsvwBjaYBbWmPNA==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.4.6': + resolution: {integrity: sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.14.3': + resolution: {integrity: sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==} + 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'} + '@swc/core-darwin-arm64@1.15.40': resolution: {integrity: sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ==} engines: {node: '>=10'} @@ -991,6 +1135,9 @@ packages: resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + bowser@2.14.1: + resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} + brace-expansion@2.1.1: resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} @@ -1366,6 +1513,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.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -2049,9 +2203,18 @@ packages: 'openai@file:': resolution: {directory: '', type: directory} peerDependencies: + '@aws-sdk/credential-provider-node': '>=3.972.0 <4' + '@smithy/hash-node': '>=4.3.0 <5' + '@smithy/signature-v4': '>=5.4.0 <6' ws: ^8.18.0 zod: ^3.25 || ^4.0 peerDependenciesMeta: + '@aws-sdk/credential-provider-node': + optional: true + '@smithy/hash-node': + optional: true + '@smithy/signature-v4': + optional: true ws: optional: true zod: @@ -2114,6 +2277,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-is-absolute@1.0.1: resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} engines: {node: '>=0.10.0'} @@ -2368,6 +2535,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strnum@2.3.0: + resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} + styled-jsx@5.1.1: resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} engines: {node: '>= 12.0.0'} @@ -2600,6 +2770,10 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + xml-naming@0.1.0: + resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} + engines: {node: '>=16.0.0'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -2664,6 +2838,179 @@ snapshots: typescript: 5.6.1-rc validate-npm-package-name: 5.0.1 + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.11 + 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.11 + '@aws-sdk/util-locate-window': 3.965.6 + '@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.11 + 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.11 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/core@3.974.18': + dependencies: + '@aws-sdk/types': 3.973.11 + '@aws-sdk/xml-builder': 3.972.28 + '@aws/lambda-invoke-store': 0.2.4 + '@smithy/core': 3.24.6 + '@smithy/signature-v4': 5.4.6 + '@smithy/types': 4.14.3 + bowser: 2.14.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.44': + dependencies: + '@aws-sdk/core': 3.974.18 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.46': + dependencies: + '@aws-sdk/core': 3.974.18 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/fetch-http-handler': 5.4.6 + '@smithy/node-http-handler': 4.7.7 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.50': + dependencies: + '@aws-sdk/core': 3.974.18 + '@aws-sdk/credential-provider-env': 3.972.44 + '@aws-sdk/credential-provider-http': 3.972.46 + '@aws-sdk/credential-provider-login': 3.972.49 + '@aws-sdk/credential-provider-process': 3.972.44 + '@aws-sdk/credential-provider-sso': 3.972.49 + '@aws-sdk/credential-provider-web-identity': 3.972.49 + '@aws-sdk/nested-clients': 3.997.17 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/credential-provider-imds': 4.3.8 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.49': + dependencies: + '@aws-sdk/core': 3.974.18 + '@aws-sdk/nested-clients': 3.997.17 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-node@3.972.47': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.44 + '@aws-sdk/credential-provider-http': 3.972.46 + '@aws-sdk/credential-provider-ini': 3.972.50 + '@aws-sdk/credential-provider-process': 3.972.44 + '@aws-sdk/credential-provider-sso': 3.972.49 + '@aws-sdk/credential-provider-web-identity': 3.972.49 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/credential-provider-imds': 4.3.8 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-process@3.972.44': + dependencies: + '@aws-sdk/core': 3.974.18 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.49': + dependencies: + '@aws-sdk/core': 3.974.18 + '@aws-sdk/nested-clients': 3.997.17 + '@aws-sdk/token-providers': 3.1063.0 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-web-identity@3.972.49': + dependencies: + '@aws-sdk/core': 3.974.18 + '@aws-sdk/nested-clients': 3.997.17 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.17': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.974.18 + '@aws-sdk/signature-v4-multi-region': 3.996.32 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/fetch-http-handler': 5.4.6 + '@smithy/node-http-handler': 4.7.7 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.996.32': + dependencies: + '@aws-sdk/types': 3.973.11 + '@smithy/signature-v4': 5.4.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1063.0': + dependencies: + '@aws-sdk/core': 3.974.18 + '@aws-sdk/nested-clients': 3.997.17 + '@aws-sdk/types': 3.973.11 + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/types@3.973.11': + dependencies: + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.6': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.28': + dependencies: + '@smithy/types': 4.14.3 + fast-xml-parser: 5.7.3 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.4': {} + '@azure/abort-controller@2.1.2': dependencies: tslib: 2.8.1 @@ -3258,6 +3605,8 @@ snapshots: '@next/swc-win32-x64-msvc@14.2.33': optional: true + '@nodable/entities@2.1.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3284,6 +3633,70 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@smithy/core@3.24.6': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.3.8': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.4.6': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/hash-node@4.3.5': + dependencies: + '@smithy/core': 3.24.6 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/node-http-handler@4.7.7': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/protocol-http@5.4.5': + dependencies: + '@smithy/core': 3.24.6 + tslib: 2.8.1 + + '@smithy/signature-v4@5.4.5': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/signature-v4@5.4.6': + dependencies: + '@smithy/core': 3.24.6 + '@smithy/types': 4.14.3 + tslib: 2.8.1 + + '@smithy/types@4.14.3': + 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 + '@swc/core-darwin-arm64@1.15.40': optional: true @@ -3697,6 +4110,8 @@ snapshots: transitivePeerDependencies: - supports-color + bowser@2.14.1: {} + brace-expansion@2.1.1: dependencies: balanced-match: 1.0.2 @@ -4077,6 +4492,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.1 + fast-xml-builder: 1.2.0 + path-expression-matcher: 1.5.0 + strnum: 2.3.0 + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -4914,8 +5341,11 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 - openai@file:(ws@8.21.0)(zod@4.4.3): + openai@file:(@aws-sdk/credential-provider-node@3.972.47)(@smithy/hash-node@4.3.5)(@smithy/signature-v4@5.4.6)(ws@8.21.0)(zod@4.4.3): optionalDependencies: + '@aws-sdk/credential-provider-node': 3.972.47 + '@smithy/hash-node': 4.3.5 + '@smithy/signature-v4': 5.4.6 ws: 8.21.0 zod: 4.4.3 @@ -4977,6 +5407,8 @@ snapshots: path-exists@4.0.0: {} + path-expression-matcher@1.5.0: {} + path-is-absolute@1.0.1: {} path-key@3.1.1: {} @@ -5216,6 +5648,8 @@ snapshots: strip-json-comments@3.1.1: {} + strnum@2.3.0: {} + styled-jsx@5.1.1(@babel/core@7.29.7)(react@18.3.1): dependencies: client-only: 0.0.1 @@ -5407,6 +5841,8 @@ snapshots: dependencies: is-wsl: 3.1.1 + xml-naming@0.1.0: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/scripts/build b/scripts/build index cc678f266e..938543d3d0 100755 --- a/scripts/build +++ b/scripts/build @@ -36,6 +36,8 @@ node scripts/utils/postprocess-files.cjs # import the output ESM (cd dist && node -e 'require("openai")') (cd dist && node -e 'import("openai")' --input-type=module) +(cd dist && node -e 'require("openai/providers/bedrock")') +(cd dist && node -e 'import("openai/providers/bedrock")' --input-type=module) if [ "${OPENAI_DISABLE_DENO_BUILD:-0}" != "1" ] && [ -e ./scripts/build-deno ] then diff --git a/src/azure.ts b/src/azure.ts index 3dff14b7a8..a9130f4bc0 100644 --- a/src/azure.ts +++ b/src/azure.ts @@ -8,7 +8,10 @@ import { OpenAI } from './client'; import type { ClientOptions } from './client'; /** API Client for interfacing with the Azure OpenAI API. */ -export interface AzureClientOptions extends ClientOptions { +export interface AzureClientOptions extends Omit { + /** AzureOpenAI does not support third-party provider configuration. */ + provider?: never; + /** * Defaults to process.env['OPENAI_API_VERSION']. */ diff --git a/src/client.ts b/src/client.ts index ac082a5e13..c7df965cde 100644 --- a/src/client.ts +++ b/src/client.ts @@ -233,6 +233,7 @@ import { import { type Fetch } from './internal/builtin-types'; import { isRunningInBrowser } from './internal/detect-platform'; import { HeadersLike, NullableHeaders, buildHeaders } from './internal/headers'; +import { configureProvider, type Provider, type ProviderRuntime } from './internal/provider'; import { FinalRequestOptions, RequestOptions } from './internal/request-options'; import { readEnv } from './internal/utils/env'; import { @@ -363,6 +364,12 @@ export interface ClientOptions { * Mutually exclusive with `apiKey`. */ workloadIdentity?: WorkloadIdentity | undefined; + + /** + * Configure this client to use a third-party API provider. + * Mutually exclusive with top-level authentication and `baseURL` options. + */ + provider?: Provider | undefined; } /** @@ -386,6 +393,7 @@ export class OpenAI { #encoder: Opts.RequestEncoder; protected idempotencyHeader?: string; protected _options: ClientOptions; + private _provider: ProviderRuntime | undefined; private _workloadIdentityAuth?: WorkloadIdentityAuth; /** @@ -397,6 +405,7 @@ export class OpenAI { * @param {string | null | undefined} [opts.project=process.env['OPENAI_PROJECT_ID'] ?? null] * @param {string | null | undefined} [opts.webhookSecret=process.env['OPENAI_WEBHOOK_SECRET'] ?? null] * @param {string} [opts.baseURL=process.env['OPENAI_BASE_URL'] ?? https://api.openai.com/v1] - Override the default base URL for the API. + * @param {Provider} [opts.provider] - Configure a third-party API provider. Mutually exclusive with top-level authentication and base URL options. * @param {number} [opts.timeout=10 minutes] - The maximum amount of time (in milliseconds) the client will wait for a response before timing out. * @param {MergedRequestInit} [opts.fetchOptions] - Additional `RequestInit` options to be passed to `fetch` calls. * @param {Fetch} [opts.fetch] - Specify a custom `fetch` function implementation. @@ -405,16 +414,32 @@ export class OpenAI { * @param {Record} opts.defaultQuery - Default query parameters to include with every request to the API. * @param {boolean} [opts.dangerouslyAllowBrowser=false] - By default, client-side use of this library is not allowed, as it risks exposing your secret API credentials to attackers. */ - constructor({ - baseURL = readEnv('OPENAI_BASE_URL'), - apiKey = readEnv('OPENAI_API_KEY') ?? null, - adminAPIKey = readEnv('OPENAI_ADMIN_KEY') ?? null, - organization = readEnv('OPENAI_ORG_ID') ?? null, - project = readEnv('OPENAI_PROJECT_ID') ?? null, - webhookSecret = readEnv('OPENAI_WEBHOOK_SECRET') ?? null, - workloadIdentity, - ...opts - }: ClientOptions = {}) { + constructor(clientOptions: ClientOptions = {}) { + const provider = clientOptions.provider; + if (provider) { + const conflictingOptions = (['apiKey', 'adminAPIKey', 'workloadIdentity', 'baseURL'] as const).filter( + (key) => clientOptions[key] != null, + ); + if (conflictingOptions.length) { + throw new Errors.OpenAIError( + `The \`provider\` option cannot be used with ${conflictingOptions + .map((key) => `\`${key}\``) + .join(', ')}. Configure authentication and the base URL through the provider instead.`, + ); + } + } + + const { + baseURL = provider ? null : readEnv('OPENAI_BASE_URL'), + apiKey = provider ? null : readEnv('OPENAI_API_KEY') ?? null, + adminAPIKey = provider ? null : readEnv('OPENAI_ADMIN_KEY') ?? null, + organization = provider ? null : readEnv('OPENAI_ORG_ID') ?? null, + project = provider ? null : readEnv('OPENAI_PROJECT_ID') ?? null, + webhookSecret = readEnv('OPENAI_WEBHOOK_SECRET') ?? null, + workloadIdentity, + ...opts + } = clientOptions; + const providerRuntime = provider ? configureProvider(provider) : undefined; const options: ClientOptions = { apiKey, adminAPIKey, @@ -422,15 +447,16 @@ export class OpenAI { project, webhookSecret, workloadIdentity, + provider, ...opts, - baseURL: baseURL || `https://api.openai.com/v1`, + baseURL: providerRuntime?.baseURL ?? (baseURL || `https://api.openai.com/v1`), }; if (apiKey && workloadIdentity) { throw new Errors.OpenAIError('The `apiKey` and `workloadIdentity` options are mutually exclusive'); } - if (!apiKey && !adminAPIKey && !workloadIdentity) { + if (!providerRuntime && !apiKey && !adminAPIKey && !workloadIdentity) { throw new Errors.OpenAIError( 'Missing credentials. Please pass an `apiKey`, `workloadIdentity`, `adminAPIKey`, or set the `OPENAI_API_KEY` or `OPENAI_ADMIN_KEY` environment variable.', ); @@ -457,7 +483,7 @@ export class OpenAI { this.fetch = options.fetch ?? Shims.getDefaultFetch(); this.#encoder = Opts.FallbackEncoder; - const customHeadersEnv = readEnv('OPENAI_CUSTOM_HEADERS'); + const customHeadersEnv = provider ? undefined : readEnv('OPENAI_CUSTOM_HEADERS'); if (customHeadersEnv) { const parsed: Record = {}; for (const line of customHeadersEnv.split('\n')) { @@ -470,6 +496,7 @@ export class OpenAI { } this._options = options; + this._provider = providerRuntime; if (workloadIdentity) { this._workloadIdentityAuth = new WorkloadIdentityAuth(workloadIdentity, this.fetch); @@ -486,7 +513,9 @@ export class OpenAI { * Create a new client instance re-using the same options given to the current client with optional overriding. */ withOptions(options: Partial): this { - const client = new (this.constructor as any as new (props: ClientOptions) => typeof this)({ + const inheritedProvider = this._options.provider; + const provider = options.provider ?? inheritedProvider; + const inheritedOptions: ClientOptions = { ...this._options, baseURL: this.baseURL, maxRetries: this.maxRetries, @@ -501,7 +530,23 @@ export class OpenAI { organization: this.organization, project: this.project, webhookSecret: this.webhookSecret, + }; + if (provider) { + delete inheritedOptions.apiKey; + delete inheritedOptions.adminAPIKey; + delete inheritedOptions.workloadIdentity; + delete inheritedOptions.baseURL; + if (provider !== inheritedProvider) { + delete inheritedOptions.organization; + delete inheritedOptions.project; + delete inheritedOptions.defaultHeaders; + } + } + + const client = new (this.constructor as any as new (props: ClientOptions) => typeof this)({ + ...inheritedOptions, ...options, + provider, }); return client; } @@ -510,7 +555,7 @@ export class OpenAI { * Check whether the base URL is set to its default. */ #baseURLOverridden(): boolean { - return this.baseURL !== 'https://api.openai.com/v1'; + return this._provider !== undefined || this.baseURL !== 'https://api.openai.com/v1'; } protected defaultQuery(): Record | undefined { @@ -592,6 +637,8 @@ export class OpenAI { } async _callApiKey(): Promise { + if (this._provider) return false; + const apiKey = this._options.apiKey; if (typeof apiKey !== 'function') return false; @@ -644,6 +691,8 @@ export class OpenAI { * Used as a callback for mutating the given `FinalRequestOptions` object. */ protected async prepareOptions(options: FinalRequestOptions): Promise { + if (this._provider) return; + const security = options.__security ?? { bearerAuth: true }; if (security.bearerAuth) { await this._callApiKey(); @@ -718,6 +767,7 @@ export class OpenAI { }); await this.prepareRequest(req, { url, options }); + await this._provider?.prepareRequest?.(req, { url, options }); /** Not an API request ID, just for correlating local log entries. */ const requestLogID = 'log_' + ((Math.random() * (1 << 24)) | 0).toString(16).padStart(6, '0'); @@ -1115,13 +1165,17 @@ export class OpenAI { 'OpenAI-Organization': this.organization, 'OpenAI-Project': this.project, }, - await this.authHeaders(options, options.__security ?? { bearerAuth: true }), + this._provider ? undefined : ( + await this.authHeaders(options, options.__security ?? { bearerAuth: true }) + ), this._options.defaultHeaders, bodyHeaders, options.headers, ]); - this.validateHeaders(headers, options.__security ?? { bearerAuth: true }); + if (!this._provider) { + this.validateHeaders(headers, options.__security ?? { bearerAuth: true }); + } return headers.values; } @@ -1132,12 +1186,20 @@ export class OpenAI { return () => controller.abort(); } - private buildBody({ options: { body, headers: rawHeaders } }: { options: FinalRequestOptions }): { + private buildBody({ options }: { options: FinalRequestOptions }): { bodyHeaders: HeadersLike; body: BodyInit | undefined; isStreamingBody: boolean; } { + const { body, headers: rawHeaders } = options; if (!body) { + // A resource method always passes a `body` key when its operation defines a + // request body, even if the caller omitted an optional body param. Keep the + // content-type for those, and only elide it for operations with no body at + // all (e.g. GET/DELETE). + if (body === undefined && 'body' in options) { + return { ...this.#encoder({ body, headers: buildHeaders([rawHeaders]) }), isStreamingBody: false }; + } return { bodyHeaders: undefined, body: undefined, isStreamingBody: false }; } const headers = buildHeaders([rawHeaders]); diff --git a/src/internal/bedrock.ts b/src/internal/bedrock.ts new file mode 100644 index 0000000000..5b78c3a6cc --- /dev/null +++ b/src/internal/bedrock.ts @@ -0,0 +1,153 @@ +import * as Errors from '../error'; +import type { ApiKeySetter } from '../client'; +import type { FinalizedRequestInit } from './types'; +import type { ProviderRequestContext } from './provider'; +import { readEnv } from './utils'; + +export interface BedrockEndpointOptions { + /** AWS region used to derive the default Mantle endpoint. */ + region?: string | undefined; + + /** Bedrock API root. Defaults to AWS_BEDROCK_BASE_URL or the regional Mantle endpoint. */ + baseURL?: string | null | undefined; +} + +export interface BedrockBearerOptions { + /** Explicit Bedrock bearer credential. Set to null to skip the environment bearer fallback. */ + apiKey?: string | null | undefined; + + /** A function that resolves a Bedrock bearer credential before every request attempt. */ + tokenProvider?: ApiKeySetter | undefined; +} + +export interface BedrockRequestAuth { + prepareRequest(request: FinalizedRequestInit, context: ProviderRequestContext): void | Promise; +} + +export type BedrockAuthFactory = () => BedrockRequestAuth; + +export function errorWithCause(message: string, cause: unknown): Errors.OpenAIError { + const error = new Errors.OpenAIError(message) as Errors.OpenAIError & { cause?: unknown }; + error.cause = cause; + return error; +} + +export function normalizeOptionalString(value: string | null | undefined): string | undefined { + const normalized = typeof value === 'string' ? value.trim() : undefined; + return normalized ? normalized : undefined; +} + +function normalizeBaseURL(baseURL: string): string { + const url = new URL(baseURL); + const responsesMatch = url.pathname.match(/\/responses(?:\/.*)?$/); + if (responsesMatch?.index !== undefined) { + url.pathname = url.pathname.slice(0, responsesMatch.index) || '/'; + } + return url.toString().replace(/\/$/, ''); +} + +export function resolveBedrockEndpoint(options: BedrockEndpointOptions): { + region: string | undefined; + baseURL: string; +} { + if (options.region !== undefined && !normalizeOptionalString(options.region)) { + throw new Errors.OpenAIError('The Bedrock AWS `region` must not be empty.'); + } + if ( + options.baseURL !== undefined && + options.baseURL !== null && + !normalizeOptionalString(options.baseURL) + ) { + throw new Errors.OpenAIError('The Bedrock `baseURL` must not be empty.'); + } + + const region = + normalizeOptionalString(options.region) ?? + normalizeOptionalString(readEnv('AWS_REGION')) ?? + normalizeOptionalString(readEnv('AWS_DEFAULT_REGION')); + const configuredBaseURL = + options.baseURL === undefined ? normalizeOptionalString(readEnv('AWS_BEDROCK_BASE_URL')) + : options.baseURL === null ? undefined + : normalizeOptionalString(options.baseURL); + + if (configuredBaseURL) return { region, baseURL: normalizeBaseURL(configuredBaseURL) }; + if (!region) { + throw new Errors.OpenAIError( + 'Bedrock requires an AWS region. Pass `region` to `bedrock(...)`, or set `AWS_REGION` or `AWS_DEFAULT_REGION`.', + ); + } + return { region, baseURL: `https://bedrock-mantle.${region}.api.aws/openai/v1` }; +} + +export function assertProviderOwnsAuthorization(headers: Headers): void { + if (headers.has('authorization')) { + throw new Errors.OpenAIError( + 'Bedrock provider authentication cannot be combined with a custom `Authorization` header.', + ); + } +} + +class BedrockBearerAuth implements BedrockRequestAuth { + constructor(private readonly tokenProvider: ApiKeySetter) {} + + async prepareRequest(request: FinalizedRequestInit, _context: ProviderRequestContext): Promise { + const headers = new Headers(request.headers); + assertProviderOwnsAuthorization(headers); + + let token: unknown; + try { + token = await this.tokenProvider(); + } catch (cause) { + throw errorWithCause('Failed to resolve a bearer credential for Bedrock.', cause); + } + if (typeof token !== 'string' || !token.trim()) { + throw new Errors.OpenAIError('The Bedrock bearer credential provider must return a non-empty string.'); + } + headers.set('authorization', `Bearer ${token}`); + request.headers = headers; + } +} + +export function resolveBedrockBearerAuth( + options: BedrockBearerOptions, + { allowEnvironment = true }: { allowEnvironment?: boolean } = {}, +): { factory: BedrockAuthFactory | undefined; explicit: boolean } { + if ( + options.apiKey !== undefined && + options.apiKey !== null && + (typeof options.apiKey !== 'string' || !options.apiKey.trim()) + ) { + throw new Errors.OpenAIError('The Bedrock bearer credential must not be empty.'); + } + if (options.apiKey != null && options.tokenProvider) { + throw new Errors.OpenAIError( + 'The `apiKey` and `tokenProvider` options are mutually exclusive. Configure only one.', + ); + } + + if (options.tokenProvider) { + const tokenProvider = options.tokenProvider; + return { factory: () => new BedrockBearerAuth(tokenProvider), explicit: true }; + } + if (options.apiKey != null) { + const apiKey = options.apiKey; + return { factory: () => new BedrockBearerAuth(async () => apiKey), explicit: true }; + } + if (allowEnvironment && options.apiKey !== null && readEnv('AWS_BEARER_TOKEN_BEDROCK')) { + return { + explicit: false, + factory: () => + new BedrockBearerAuth(async () => { + const token = readEnv('AWS_BEARER_TOKEN_BEDROCK'); + if (!token) { + throw new Errors.OpenAIError( + 'Could not find credentials for Bedrock. Set `AWS_BEARER_TOKEN_BEDROCK` or configure AWS credential authentication.', + ); + } + return token; + }), + }; + } + + return { factory: undefined, explicit: false }; +} diff --git a/src/internal/provider.ts b/src/internal/provider.ts new file mode 100644 index 0000000000..23af41ce3c --- /dev/null +++ b/src/internal/provider.ts @@ -0,0 +1,60 @@ +import type { FinalRequestOptions } from './request-options'; +import type { FinalizedRequestInit } from './types'; + +declare const providerBrand: unique symbol; + +/** An opaque provider configuration created by {@link createProvider}. */ +export interface Provider { + readonly [providerBrand]: true; +} + +export interface ProviderRequestContext { + url: string; + options: FinalRequestOptions; +} + +export interface ProviderRuntime { + name: string; + baseURL: string; + prepareRequest?(request: FinalizedRequestInit, context: ProviderRequestContext): void | Promise; +} + +export interface ProviderDefinition { + configure(): ProviderRuntime; +} + +/** + * A provider factory such as `bedrock(options)` captures configuration in a + * definition, while every OpenAI client receives a fresh runtime from + * `definition.configure()`. Keeping definitions out of the provider object + * makes providers opaque and prevents arbitrary objects from imitating one. + * It also leaves provider-specific dependencies outside the core SDK. + * + * The registry lives on `globalThis` under a global symbol so a provider made + * by one copy of the package still works with another copy, including mixed + * CommonJS and ESM installations. The WeakMap avoids retaining discarded + * provider configurations. + */ +const providerDefinitionsKey = Symbol.for('openai.node.providerDefinitions.v1'); +const providerGlobal = globalThis as any; +const existingProviderDefinitions = providerGlobal[providerDefinitionsKey] as + | WeakMap + | undefined; +const providerDefinitions = existingProviderDefinitions ?? new WeakMap(); +if (!existingProviderDefinitions) { + Object.defineProperty(providerGlobal, providerDefinitionsKey, { value: providerDefinitions }); +} + +export function createProvider(definition: ProviderDefinition): Provider { + const provider = Object.freeze({}) as Provider; + providerDefinitions.set(provider, definition); + return provider; +} + +export function configureProvider(provider: Provider): ProviderRuntime { + const definition = providerDefinitions.get(provider); + if (!definition) { + throw new Error('Invalid provider. Providers must be created with createProvider().'); + } + return definition.configure(); +} diff --git a/src/internal/utils/log.ts b/src/internal/utils/log.ts index 94de74b5b7..9e39b0f386 100644 --- a/src/internal/utils/log.ts +++ b/src/internal/utils/log.ts @@ -109,6 +109,7 @@ export const formatRequestDetails = (details: { name.toLowerCase() === 'authorization' || name.toLowerCase() === 'api-key' || name.toLowerCase() === 'x-api-key' || + name.toLowerCase() === 'x-amz-security-token' || name.toLowerCase() === 'cookie' || name.toLowerCase() === 'set-cookie' ) ? diff --git a/src/providers/bedrock.ts b/src/providers/bedrock.ts new file mode 100644 index 0000000000..f29b97eb3a --- /dev/null +++ b/src/providers/bedrock.ts @@ -0,0 +1,33 @@ +import * as Errors from '../error'; +import type { Provider } from '../internal/provider'; +import { createProvider } from '../internal/provider'; +import { + resolveBedrockBearerAuth, + resolveBedrockEndpoint, + type BedrockBearerOptions, + type BedrockEndpointOptions, +} from '../internal/bedrock'; + +export interface BedrockProviderOptions extends BedrockEndpointOptions, BedrockBearerOptions {} + +/** Configure the standard OpenAI client for Amazon Bedrock using bearer authentication. */ +export function bedrock(options: BedrockProviderOptions = {}): Provider { + const { baseURL } = resolveBedrockEndpoint(options); + const { factory } = resolveBedrockBearerAuth(options); + if (!factory) { + throw new Errors.OpenAIError( + 'Bedrock bearer authentication requires an `apiKey`, `tokenProvider`, or `AWS_BEARER_TOKEN_BEDROCK`. For AWS credential authentication, import `bedrock` from `openai/providers/bedrock/aws`.', + ); + } + + return createProvider({ + configure() { + const auth = factory(); + return { + name: 'bedrock', + baseURL, + prepareRequest: auth.prepareRequest.bind(auth), + }; + }, + }); +} diff --git a/src/providers/bedrock/aws.ts b/src/providers/bedrock/aws.ts new file mode 100644 index 0000000000..9fa659bb2e --- /dev/null +++ b/src/providers/bedrock/aws.ts @@ -0,0 +1,255 @@ +import { defaultProvider } from '@aws-sdk/credential-provider-node'; +import { Hash } from '@smithy/hash-node'; +import { SignatureV4 } from '@smithy/signature-v4'; + +import * as Errors from '../../error'; +import type { BodyInit } from '../../internal/builtin-types'; +import { + assertProviderOwnsAuthorization, + errorWithCause, + normalizeOptionalString, + resolveBedrockBearerAuth, + resolveBedrockEndpoint, + type BedrockBearerOptions, + type BedrockEndpointOptions, + type BedrockRequestAuth, +} from '../../internal/bedrock'; +import { createProvider, type Provider, type ProviderRequestContext } from '../../internal/provider'; +import type { FinalizedRequestInit } from '../../internal/types'; + +const BEDROCK_SERVICE = 'bedrock-mantle'; + +export interface AwsCredentialIdentity { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + expiration?: Date; +} + +export type AwsCredentialsProvider = () => AwsCredentialIdentity | Promise; + +export interface BedrockProviderOptions extends BedrockEndpointOptions, BedrockBearerOptions { + /** Explicit AWS access key ID. Must be paired with secretAccessKey. */ + accessKeyId?: string | undefined; + + /** Explicit AWS secret access key. Must be paired with accessKeyId. */ + secretAccessKey?: string | undefined; + + /** Optional session token for explicit temporary AWS credentials. */ + sessionToken?: string | undefined; + + /** Explicit AWS shared-config profile. */ + profile?: string | undefined; + + /** A refreshable provider returning AWS credentials. */ + credentialProvider?: AwsCredentialsProvider | undefined; +} + +function validateStaticCredentials(options: BedrockProviderOptions): AwsCredentialIdentity | undefined { + const hasAccessKey = options.accessKeyId !== undefined; + const hasSecretKey = options.secretAccessKey !== undefined; + if (hasAccessKey !== hasSecretKey || (options.sessionToken !== undefined && !hasAccessKey)) { + throw new Errors.OpenAIError( + 'The `accessKeyId` and `secretAccessKey` options must be provided together. A `sessionToken` may only be used with both.', + ); + } + if (!hasAccessKey) return undefined; + + if ( + typeof options.accessKeyId !== 'string' || + !options.accessKeyId.trim() || + typeof options.secretAccessKey !== 'string' || + !options.secretAccessKey.trim() + ) { + throw new Errors.OpenAIError( + 'Static AWS credentials require non-empty `accessKeyId` and `secretAccessKey` values.', + ); + } + if ( + options.sessionToken !== undefined && + (typeof options.sessionToken !== 'string' || !options.sessionToken.trim()) + ) { + throw new Errors.OpenAIError('A static AWS `sessionToken` must not be empty when provided.'); + } + + return { + accessKeyId: options.accessKeyId, + secretAccessKey: options.secretAccessKey, + ...(options.sessionToken ? { sessionToken: options.sessionToken } : {}), + }; +} + +function requestTarget(parsedURL: URL): { path: string; query: Record } { + const query: Record = {}; + for (const [name, value] of parsedURL.searchParams) { + const existing = query[name]; + query[name] = + existing === undefined ? value + : typeof existing === 'string' ? [existing, value] + : [...existing, value]; + } + return { path: parsedURL.pathname, query }; +} + +function signableBody(body: BodyInit | null | undefined): string | ArrayBuffer | ArrayBufferView | undefined { + if (body === undefined || body === null) return undefined; + if (typeof body === 'string' || body instanceof ArrayBuffer || ArrayBuffer.isView(body)) return body; + throw new Errors.OpenAIError( + "The SDK's Bedrock SigV4 mode requires a replayable request body. Buffer the body before sending or use bearer authentication.", + ); +} + +function validateCredentialIdentity(identity: AwsCredentialIdentity): AwsCredentialIdentity { + if ( + typeof identity?.accessKeyId !== 'string' || + !identity.accessKeyId.trim() || + typeof identity.secretAccessKey !== 'string' || + !identity.secretAccessKey.trim() || + (identity.sessionToken !== undefined && + (typeof identity.sessionToken !== 'string' || !identity.sessionToken.trim())) + ) { + throw new Errors.OpenAIError( + 'Failed to resolve AWS credentials for Bedrock. Verify your AWS profile, environment variables, or runtime identity configuration and try again.', + ); + } + return identity; +} + +class BedrockSigV4Auth implements BedrockRequestAuth { + private signer: SignatureV4 | undefined; + private resolvedCredentialsProvider: (() => Promise) | undefined; + + constructor( + private readonly options: { + region: string; + staticCredentials?: AwsCredentialIdentity | undefined; + profile?: string | undefined; + credentialProvider?: AwsCredentialsProvider | undefined; + usesDefaultChain: boolean; + }, + ) {} + + private credentialsProvider(): () => Promise { + if (this.resolvedCredentialsProvider) return this.resolvedCredentialsProvider; + + if (this.options.staticCredentials) { + const credentials = this.options.staticCredentials; + return (this.resolvedCredentialsProvider = async () => credentials); + } + if (this.options.credentialProvider) { + const provider = this.options.credentialProvider; + return (this.resolvedCredentialsProvider = async () => validateCredentialIdentity(await provider())); + } + + const provider = defaultProvider(this.options.profile ? { profile: this.options.profile } : {}); + return (this.resolvedCredentialsProvider = async () => validateCredentialIdentity(await provider())); + } + + private signatureV4(): SignatureV4 { + return (this.signer ??= new SignatureV4({ + credentials: this.credentialsProvider(), + region: this.options.region, + service: BEDROCK_SERVICE, + sha256: Hash.bind(null, 'sha256'), + })); + } + + async prepareRequest(request: FinalizedRequestInit, { url }: ProviderRequestContext): Promise { + if (Object.prototype.toString.call((globalThis as any).process) !== '[object process]') { + throw new Errors.OpenAIError( + 'Bedrock AWS credential authentication is only supported in Node.js and compatible server runtimes. Use bearer authentication in this runtime.', + ); + } + + const parsedURL = new URL(url); + const canonicalRegion = /^bedrock-mantle\.([a-z0-9-]+)\.api\.aws$/i.exec(parsedURL.hostname)?.[1]; + if (canonicalRegion && canonicalRegion !== this.options.region) { + throw new Errors.OpenAIError( + `The Bedrock endpoint region \`${canonicalRegion}\` does not match the SigV4 region \`${this.options.region}\`.`, + ); + } + + const headers = new Headers(request.headers); + assertProviderOwnsAuthorization(headers); + headers.delete('x-amz-date'); + headers.delete('x-amz-security-token'); + headers.delete('x-amz-content-sha256'); + headers.set('host', parsedURL.host); + + const method = (request.method ?? 'GET').toUpperCase(); + const body = signableBody(request.body); + + let signed: { headers: Record }; + try { + signed = await this.signatureV4().sign({ + protocol: parsedURL.protocol, + hostname: parsedURL.hostname, + ...(parsedURL.port ? { port: Number(parsedURL.port) } : {}), + method, + ...requestTarget(parsedURL), + headers: Object.fromEntries(headers.entries()), + ...(body !== undefined ? { body } : {}), + }); + } catch (cause) { + const message = + this.options.usesDefaultChain ? + 'Could not find credentials for Bedrock. Pass AWS credentials to `bedrock(...)` or configure the default AWS credential chain.' + : 'Failed to resolve AWS credentials for Bedrock. Verify your AWS profile, environment variables, or runtime identity configuration and try again.'; + throw errorWithCause(message, cause); + } + + request.method = method; + request.redirect = 'manual'; + request.headers = new Headers(signed.headers); + } +} + +/** Configure the standard OpenAI client for Amazon Bedrock using bearer or AWS authentication. */ +export function bedrock(options: BedrockProviderOptions = {}): Provider { + const staticCredentials = validateStaticCredentials(options); + const profile = normalizeOptionalString(options.profile); + if (options.profile !== undefined && !profile) { + throw new Errors.OpenAIError('The Bedrock AWS `profile` must not be empty.'); + } + + const awsModes = [!!staticCredentials, !!profile, !!options.credentialProvider].filter(Boolean).length; + if (awsModes > 1) { + throw new Errors.OpenAIError( + 'Bedrock authentication is ambiguous. Configure exactly one explicit AWS mode: static credentials, profile, or credential provider.', + ); + } + const explicitAwsAuth = awsModes === 1; + const bearerAuth = resolveBedrockBearerAuth(options, { allowEnvironment: !explicitAwsAuth }); + if (bearerAuth.explicit && explicitAwsAuth) { + throw new Errors.OpenAIError( + 'Bearer and AWS credential authentication are mutually exclusive. Configure exactly one explicit mode: bearer credential, static AWS credentials, profile, or credential provider.', + ); + } + + const { region, baseURL } = resolveBedrockEndpoint(options); + if (!bearerAuth.factory && !region) { + throw new Errors.OpenAIError( + 'Bedrock requires an AWS region. Pass `region` to `bedrock(...)`, or set `AWS_REGION` or `AWS_DEFAULT_REGION`.', + ); + } + const credentialProvider = options.credentialProvider; + + return createProvider({ + configure() { + const auth = + bearerAuth.factory?.() ?? + new BedrockSigV4Auth({ + region: region!, + staticCredentials, + profile, + credentialProvider, + usesDefaultChain: !explicitAwsAuth, + }); + return { + name: 'bedrock', + baseURL, + prepareRequest: auth.prepareRequest.bind(auth), + }; + }, + }); +} diff --git a/src/version.ts b/src/version.ts index c2785ea2ac..e6d46dcb4c 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = '6.44.0'; // x-release-please-version +export const VERSION = '6.45.0'; // x-release-please-version diff --git a/tests/fixtures/bedrock/v1/sigv4.json b/tests/fixtures/bedrock/v1/sigv4.json new file mode 100644 index 0000000000..441c634999 --- /dev/null +++ b/tests/fixtures/bedrock/v1/sigv4.json @@ -0,0 +1,23 @@ +{ + "$comment": "Uses AWS SigV4 documentation test credentials. These are public examples, not real AWS credentials.", + "signingDate": "2025-01-02T03:04:05.000Z", + "region": "us-east-1", + "service": "bedrock-mantle", + "credentials": { + "accessKeyId": "AKIDEXAMPLE", + "secretAccessKey": "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + "sessionToken": "fixture-session-token" + }, + "request": { + "method": "POST", + "url": "https://bedrock-mantle.us-east-1.api.aws/openai/v1/responses", + "body": "{\"model\":\"gpt-4o\",\"input\":\"hello\"}", + "contentType": "application/json" + }, + "expected": { + "date": "20250102T030405Z", + "payloadHash": "50329e51ad520f21b77bad0b01999930ff556cd1bf18434701251ba6c9f877bc", + "canonicalRequestHash": "1b69b17ef7548a7bf16a6ee749acfd44b7793e04216345dddd6cfaf4c01bfde5", + "authorization": "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20250102/us-east-1/bedrock-mantle/aws4_request, SignedHeaders=content-type;host;x-amz-content-sha256;x-amz-date;x-amz-security-token, Signature=dc20c8fbe516daf0ccae7e5b7a78fc2936870413a9f1af6e1b2d44b970ce411f" + } +} diff --git a/tests/index.test.ts b/tests/index.test.ts index 028eccb17a..e19e33b98b 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -614,6 +614,30 @@ describe('default encoder', () => { }); } + test('keeps content-type for omitted optional request bodies', async () => { + const { req } = await client.buildRequest({ + path: '/foo', + method: 'post', + body: undefined, + }); + + expect(req.headers).toBeInstanceOf(Headers); + expect(req.headers.get('content-type')).toEqual('application/json'); + expect(req.body).toBe(undefined); + }); + + test('does not serialize null optional request bodies', async () => { + const { req } = await client.buildRequest({ + path: '/foo', + method: 'post', + body: null, + }); + + expect(req.headers).toBeInstanceOf(Headers); + expect(req.headers.get('content-type')).toEqual(null); + expect(req.body).toBe(undefined); + }); + const encoder = new TextEncoder(); const asyncIterable = (async function* () { yield encoder.encode('a\n'); diff --git a/tests/lib/bedrock-provider-dependencies.test.ts b/tests/lib/bedrock-provider-dependencies.test.ts new file mode 100644 index 0000000000..929f8c82ea --- /dev/null +++ b/tests/lib/bedrock-provider-dependencies.test.ts @@ -0,0 +1,211 @@ +import type { RequestInfo, RequestInit } from 'openai/internal/builtin-types'; + +const originalEnv = process.env; +const optionalDependencies = [ + '@aws-sdk/credential-provider-node', + '@smithy/hash-node', + '@smithy/signature-v4', +] as const; + +beforeEach(() => { + jest.resetModules(); + for (const dependency of optionalDependencies) jest.dontMock(dependency); + + process.env = { ...originalEnv }; + delete process.env['AWS_BEARER_TOKEN_BEDROCK']; + delete process.env['AWS_ACCESS_KEY_ID']; + delete process.env['AWS_SECRET_ACCESS_KEY']; + delete process.env['AWS_SESSION_TOKEN']; + delete process.env['AWS_BEDROCK_BASE_URL']; + delete process.env['AWS_REGION']; + delete process.env['AWS_DEFAULT_REGION']; + delete process.env['AWS_PROFILE']; + delete process.env['AWS_SHARED_CREDENTIALS_FILE']; + delete process.env['AWS_CONFIG_FILE']; +}); + +afterEach(() => { + process.env = originalEnv; +}); + +function jsonResponse(body: unknown = {}): Response { + return new Response(JSON.stringify(body), { + headers: { 'Content-Type': 'application/json' }, + }); +} + +async function loadBedrockModules(): Promise<{ + OpenAI: typeof import('openai').default; + bedrock: typeof import('openai/providers/bedrock/aws').bedrock; +}> { + const openai = require('openai') as typeof import('openai'); + const bedrockProvider = + require('openai/providers/bedrock/aws') as typeof import('openai/providers/bedrock/aws'); + return { OpenAI: openai.default, bedrock: bedrockProvider.bedrock }; +} + +describe('Bedrock provider optional dependencies', () => { + test('keeps the root and bearer entrypoints independent from AWS packages', async () => { + for (const dependency of optionalDependencies) { + jest.doMock(dependency, () => { + throw new Error(`unexpected AWS import: ${dependency}`); + }); + } + + await jest.isolateModulesAsync(async () => { + const openai = require('openai') as typeof import('openai'); + const bearerProvider = require('openai/providers/bedrock') as typeof import('openai/providers/bedrock'); + const fetch = jest.fn(async () => jsonResponse()); + const client = new openai.default({ + provider: bearerProvider.bedrock({ region: 'us-east-1', apiKey: 'bearer-token' }), + fetch, + }); + + await client.request({ method: 'get', path: '/models' }); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + }); + + test('resolves temporary environment credentials through the real AWS default chain', async () => { + process.env['AWS_ACCESS_KEY_ID'] = 'environment-access-key'; + process.env['AWS_SECRET_ACCESS_KEY'] = 'environment-secret-key'; + process.env['AWS_SESSION_TOKEN'] = 'environment-session-token'; + + await jest.isolateModulesAsync(async () => { + const { OpenAI, bedrock } = await loadBedrockModules(); + let requestedInit: RequestInit | undefined; + const client = new OpenAI({ + provider: bedrock({ region: 'us-west-2', apiKey: null }), + maxRetries: 0, + fetch: async (_url: RequestInfo, init?: RequestInit) => { + requestedInit = init; + return jsonResponse(); + }, + }); + + await client.request({ method: 'get', path: '/models' }); + + const headers = new Headers(requestedInit?.headers); + expect(headers.get('authorization')).toContain('Credential=environment-access-key/'); + expect(headers.get('x-amz-security-token')).toBe('environment-session-token'); + }); + }); + + test('forwards a named profile to the AWS default provider and signs the request', async () => { + const credentialsProvider = jest.fn(async () => ({ + accessKeyId: 'profile-access-key', + secretAccessKey: 'profile-secret-key', + sessionToken: 'profile-session-token', + })); + const defaultProvider = jest.fn(() => credentialsProvider); + jest.doMock('@aws-sdk/credential-provider-node', () => ({ defaultProvider })); + + await jest.isolateModulesAsync(async () => { + const { OpenAI, bedrock } = await loadBedrockModules(); + let requestedInit: RequestInit | undefined; + const client = new OpenAI({ + provider: bedrock({ region: 'us-west-2', profile: 'engineering' }), + maxRetries: 0, + fetch: async (_url: RequestInfo, init?: RequestInit) => { + requestedInit = init; + return jsonResponse(); + }, + }); + + await client.request({ method: 'get', path: '/models' }); + + expect(defaultProvider).toHaveBeenCalledTimes(1); + expect(defaultProvider).toHaveBeenCalledWith({ profile: 'engineering' }); + expect(credentialsProvider).toHaveBeenCalled(); + const headers = new Headers(requestedInit?.headers); + expect(headers.get('authorization')).toContain('Credential=profile-access-key/'); + expect(headers.get('authorization')).toContain('/us-west-2/bedrock-mantle/aws4_request'); + expect(headers.get('x-amz-security-token')).toBe('profile-session-token'); + }); + }); + + test('initializes the default AWS credential chain with empty options', async () => { + const credentialsProvider = jest.fn(async () => ({ + accessKeyId: 'default-access-key', + secretAccessKey: 'default-secret-key', + })); + const defaultProvider = jest.fn(() => credentialsProvider); + jest.doMock('@aws-sdk/credential-provider-node', () => ({ defaultProvider })); + + await jest.isolateModulesAsync(async () => { + const { OpenAI, bedrock } = await loadBedrockModules(); + const fetch = jest.fn(async () => jsonResponse()); + const client = new OpenAI({ + provider: bedrock({ region: 'us-east-1', apiKey: null }), + maxRetries: 0, + fetch, + }); + + await client.request({ method: 'get', path: '/models' }); + + expect(defaultProvider).toHaveBeenCalledTimes(1); + expect(defaultProvider).toHaveBeenCalledWith({}); + expect(credentialsProvider).toHaveBeenCalled(); + expect(fetch).toHaveBeenCalledTimes(1); + }); + }); + + test('rejects an invalid identity resolved by the default AWS provider', async () => { + const identity = { secretAccessKey: 'secret-key' }; + const credentialsProvider = jest.fn(async () => identity); + const defaultProvider = jest.fn(() => credentialsProvider); + jest.doMock('@aws-sdk/credential-provider-node', () => ({ defaultProvider })); + + await jest.isolateModulesAsync(async () => { + const { OpenAI, bedrock } = await loadBedrockModules(); + const fetch = jest.fn(async () => jsonResponse()); + const client = new OpenAI({ + provider: bedrock({ region: 'us-east-1', profile: 'invalid-profile' }), + maxRetries: 0, + fetch, + }); + + await expect(client.request({ method: 'get', path: '/models' })).rejects.toThrow( + 'Failed to resolve AWS credentials for Bedrock', + ); + expect(fetch).not.toHaveBeenCalled(); + }); + }); + + test('surfaces the runtime module error when an AWS dependency is missing', async () => { + const missingDependency = new Error('Cannot find module @aws-sdk/credential-provider-node'); + jest.doMock('@aws-sdk/credential-provider-node', () => { + throw missingDependency; + }); + + await jest.isolateModulesAsync(async () => { + await expect(loadBedrockModules()).rejects.toBe(missingDependency); + }); + }); + + test('preserves the default credential chain failure and its cause', async () => { + const cause = new Error('no AWS credentials available'); + const defaultProvider = jest.fn(() => async () => { + throw cause; + }); + jest.doMock('@aws-sdk/credential-provider-node', () => ({ defaultProvider })); + + await jest.isolateModulesAsync(async () => { + const { OpenAI, bedrock } = await loadBedrockModules(); + const fetch = jest.fn(async () => jsonResponse()); + const client = new OpenAI({ + provider: bedrock({ region: 'us-east-1', apiKey: null }), + maxRetries: 0, + fetch, + }); + + await expect(client.request({ method: 'get', path: '/models' })).rejects.toMatchObject({ + message: expect.stringContaining('Could not find credentials for Bedrock'), + cause, + }); + expect(defaultProvider).toHaveBeenCalledWith({}); + expect(fetch).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/lib/bedrock-provider.test.ts b/tests/lib/bedrock-provider.test.ts new file mode 100644 index 0000000000..34b03a83d1 --- /dev/null +++ b/tests/lib/bedrock-provider.test.ts @@ -0,0 +1,463 @@ +import OpenAI from 'openai'; +import type { RequestInfo, RequestInit } from 'openai/internal/builtin-types'; +import { configureProvider } from 'openai/internal/provider'; +import { bedrock as bearerBedrock } from 'openai/providers/bedrock'; +import { bedrock, type BedrockProviderOptions } from 'openai/providers/bedrock/aws'; +import { SignatureV4 } from '@smithy/signature-v4'; + +import sigV4Fixture from '../fixtures/bedrock/v1/sigv4.json'; + +const originalEnv = process.env; +const BEDROCK_ENVIRONMENT_VARIABLES = [ + 'AWS_BEARER_TOKEN_BEDROCK', + 'AWS_BEDROCK_BASE_URL', + 'AWS_REGION', + 'AWS_DEFAULT_REGION', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SESSION_TOKEN', + 'AWS_EC2_METADATA_DISABLED', +] as const; + +beforeEach(() => { + process.env = { ...originalEnv }; + for (const name of BEDROCK_ENVIRONMENT_VARIABLES) delete process.env[name]; +}); + +afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + process.env = originalEnv; +}); + +function jsonResponse(body: unknown = {}): Response { + return new Response(JSON.stringify(body), { + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('bedrock provider', () => { + test('owns the Mantle endpoint and bearer authentication', async () => { + let requestedURL: RequestInfo | undefined; + let requestedInit: RequestInit | undefined; + const client = new OpenAI({ + provider: bearerBedrock({ region: 'us-east-1', apiKey: 'bedrock-token' }), + fetch: async (url, init) => { + requestedURL = url; + requestedInit = init; + return jsonResponse(); + }, + }); + + await client.request({ method: 'get', path: '/models' }); + + expect(client.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/openai/v1'); + expect(String(requestedURL)).toBe('https://bedrock-mantle.us-east-1.api.aws/openai/v1/models'); + expect(new Headers(requestedInit?.headers).get('authorization')).toBe('Bearer bedrock-token'); + }); + + test('keeps the environment bearer mode across withOptions and refreshes its value', async () => { + process.env['AWS_BEARER_TOKEN_BEDROCK'] = 'first-token'; + const authorizationHeaders: string[] = []; + const fetch = async (_url: RequestInfo, init?: RequestInit): Promise => { + authorizationHeaders.push(new Headers(init?.headers).get('authorization') ?? ''); + return jsonResponse(); + }; + const client = new OpenAI({ provider: bearerBedrock({ region: 'us-east-1' }), fetch }); + + await client.request({ method: 'get', path: '/models' }); + delete process.env['AWS_BEARER_TOKEN_BEDROCK']; + const copiedClient = client.withOptions({ timeout: 1_000 }); + process.env['AWS_BEARER_TOKEN_BEDROCK'] = 'refreshed-token'; + await copiedClient.request({ method: 'get', path: '/models' }); + + expect(authorizationHeaders).toEqual(['Bearer first-token', 'Bearer refreshed-token']); + }); + + test('apiKey: null skips the environment bearer fallback', async () => { + process.env['AWS_BEARER_TOKEN_BEDROCK'] = 'environment-token'; + process.env['AWS_EC2_METADATA_DISABLED'] = 'true'; + const fetch = jest.fn(async () => jsonResponse()); + const client = new OpenAI({ + provider: bedrock({ region: 'us-east-1', apiKey: null }), + fetch, + }); + + await expect(client.request({ method: 'get', path: '/models' })).rejects.toThrow( + 'Could not find credentials for Bedrock', + ); + + expect(fetch).not.toHaveBeenCalled(); + }); + + test('the dependency-free entrypoint points AWS credential users to the AWS entrypoint', () => { + expect(() => bearerBedrock({ region: 'us-east-1', apiKey: null })).toThrow( + 'openai/providers/bedrock/aws', + ); + }); + + test('baseURL: null skips the environment endpoint fallback', () => { + process.env['AWS_BEDROCK_BASE_URL'] = 'https://environment.example/v1'; + + const client = new OpenAI({ + provider: bearerBedrock({ region: 'us-east-1', baseURL: null, apiKey: 'bedrock-token' }), + }); + + expect(client.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/openai/v1'); + }); + + test('requires a region only when deriving the default endpoint', () => { + expect(() => bearerBedrock({ apiKey: 'bedrock-token' })).toThrow('Bedrock requires an AWS region'); + expect(() => + bearerBedrock({ baseURL: 'https://bedrock.example.com/openai/v1', apiKey: 'bedrock-token' }), + ).not.toThrow(); + }); + + test('normalizes a Responses URL back to its API root', () => { + const client = new OpenAI({ + provider: bearerBedrock({ + baseURL: 'https://bedrock.example.com/responses/response-id', + apiKey: 'bedrock-token', + }), + }); + + expect(client.baseURL).toBe('https://bedrock.example.com'); + }); + + test('matches the canonical SigV4 fixture and disables automatic redirects', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date(sigV4Fixture.signingDate)); + let requestedURL: RequestInfo | undefined; + let requestedInit: RequestInit | undefined; + const client = new OpenAI({ + provider: bedrock({ + region: sigV4Fixture.region, + accessKeyId: sigV4Fixture.credentials.accessKeyId, + secretAccessKey: sigV4Fixture.credentials.secretAccessKey, + sessionToken: sigV4Fixture.credentials.sessionToken, + }), + organization: null, + project: null, + defaultHeaders: { + accept: null, + 'user-agent': null, + 'x-stainless-retry-count': null, + 'x-stainless-lang': null, + 'x-stainless-package-version': null, + 'x-stainless-os': null, + 'x-stainless-arch': null, + 'x-stainless-runtime': null, + 'x-stainless-runtime-version': null, + }, + fetch: async (url, init) => { + requestedURL = url; + requestedInit = init; + return jsonResponse(); + }, + }); + + await client.request({ + method: 'post', + path: '/responses', + body: sigV4Fixture.request.body, + headers: { 'content-type': sigV4Fixture.request.contentType }, + }); + + const headers = new Headers(requestedInit?.headers); + expect(String(requestedURL)).toBe(sigV4Fixture.request.url); + expect(requestedInit?.method).toBe(sigV4Fixture.request.method); + expect(requestedInit?.redirect).toBe('manual'); + expect(requestedInit?.body).toBe(sigV4Fixture.request.body); + expect(headers.get('x-amz-date')).toBe(sigV4Fixture.expected.date); + expect(headers.get('x-amz-content-sha256')).toBe(sigV4Fixture.expected.payloadHash); + expect(headers.get('x-amz-security-token')).toBe(sigV4Fixture.credentials.sessionToken); + expect(headers.get('authorization')).toBe(sigV4Fixture.expected.authorization); + }); + + test('rejects a custom Authorization header before fetch', async () => { + const fetch = jest.fn(async () => jsonResponse()); + const client = new OpenAI({ + provider: bearerBedrock({ region: 'us-east-1', apiKey: 'bedrock-token' }), + fetch, + }); + + await expect( + client.request({ + method: 'get', + path: '/models', + headers: { authorization: 'Bearer custom-token' }, + }), + ).rejects.toThrow('cannot be combined with a custom `Authorization` header'); + expect(fetch).not.toHaveBeenCalled(); + }); + + test('rejects non-replayable SigV4 bodies before fetch', async () => { + const fetch = jest.fn(async () => jsonResponse()); + const body = new FormData(); + body.append('input', 'hello'); + const client = new OpenAI({ + provider: bedrock({ + region: 'us-east-1', + accessKeyId: 'access-key', + secretAccessKey: 'secret-key', + }), + fetch, + }); + + await expect(client.request({ method: 'post', path: '/responses', body })).rejects.toThrow( + 'requires a replayable request body', + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + test('surfaces bearer credential provider failures with their cause', async () => { + const cause = new Error('token service unavailable'); + const fetch = jest.fn(async () => jsonResponse()); + const client = new OpenAI({ + provider: bedrock({ + region: 'us-east-1', + tokenProvider: async () => { + throw cause; + }, + }), + fetch, + }); + + await expect(client.request({ method: 'get', path: '/models' })).rejects.toMatchObject({ + message: 'Failed to resolve a bearer credential for Bedrock.', + cause, + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + test.each([[''], [' '], [undefined as unknown as string]])( + 'rejects an invalid value returned by a bearer credential provider', + async (token) => { + const fetch = jest.fn(async () => jsonResponse()); + const client = new OpenAI({ + provider: bearerBedrock({ region: 'us-east-1', tokenProvider: async () => token }), + fetch, + }); + + await expect(client.request({ method: 'get', path: '/models' })).rejects.toThrow( + 'must return a non-empty string', + ); + expect(fetch).not.toHaveBeenCalled(); + }, + ); + + test('fails if an ambient bearer credential disappears before the request', async () => { + process.env['AWS_BEARER_TOKEN_BEDROCK'] = 'temporary-token'; + const fetch = jest.fn(async () => jsonResponse()); + const client = new OpenAI({ provider: bearerBedrock({ region: 'us-east-1' }), fetch }); + delete process.env['AWS_BEARER_TOKEN_BEDROCK']; + + await expect(client.request({ method: 'get', path: '/models' })).rejects.toMatchObject({ + message: 'Failed to resolve a bearer credential for Bedrock.', + cause: expect.objectContaining({ + message: expect.stringContaining('Could not find credentials for Bedrock'), + }), + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + test.each([ + undefined, + { accessKeyId: '', secretAccessKey: 'secret-key' }, + { accessKeyId: 'access-key', secretAccessKey: '' }, + { accessKeyId: 'access-key', secretAccessKey: 'secret-key', sessionToken: '' }, + ])('rejects an invalid identity returned by a credential provider', async (credentials) => { + const fetch = jest.fn(async () => jsonResponse()); + const client = new OpenAI({ + provider: bedrock({ + region: 'us-east-1', + credentialProvider: async () => credentials as any, + }), + fetch, + }); + + await expect(client.request({ method: 'get', path: '/models' })).rejects.toThrow( + 'Failed to resolve AWS credentials for Bedrock', + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + test('surfaces credential provider failures with their cause', async () => { + const cause = new Error('credential service unavailable'); + const fetch = jest.fn(async () => jsonResponse()); + const client = new OpenAI({ + provider: bedrock({ + region: 'us-east-1', + credentialProvider: async () => { + throw cause; + }, + }), + fetch, + }); + + await expect(client.request({ method: 'get', path: '/models' })).rejects.toMatchObject({ + message: + 'Failed to resolve AWS credentials for Bedrock. Verify your AWS profile, environment variables, or runtime identity configuration and try again.', + cause, + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + test('rejects SigV4 authentication outside Node-compatible runtimes', async () => { + const runtime = configureProvider( + bedrock({ region: 'us-east-1', accessKeyId: 'access-key', secretAccessKey: 'secret-key' }), + ); + const processDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'process'); + Object.defineProperty(globalThis, 'process', { configurable: true, value: undefined }); + + let thrown: unknown; + try { + await runtime.prepareRequest!({ headers: new Headers(), method: 'GET' } as any, { + url: 'https://bedrock-mantle.us-east-1.api.aws/openai/v1/models', + options: {} as any, + }); + } catch (error) { + thrown = error; + } finally { + if (processDescriptor) Object.defineProperty(globalThis, 'process', processDescriptor); + } + + expect(thrown).toMatchObject({ + message: expect.stringContaining('only supported in Node.js and compatible server runtimes'), + }); + }); + + test('signs buffered body variants and replaces stale signing headers', async () => { + const sign = jest.spyOn(SignatureV4.prototype, 'sign'); + const runtime = configureProvider( + bedrock({ + region: 'us-east-1', + baseURL: 'https://localhost:8443/openai/v1', + accessKeyId: 'access-key', + secretAccessKey: 'secret-key', + }), + ); + const firstRequest = { + headers: new Headers({ + 'x-amz-date': 'stale-date', + 'x-amz-security-token': 'stale-token', + 'x-amz-content-sha256': 'stale-hash', + }), + method: 'post', + body: new ArrayBuffer(2), + } as any; + + await runtime.prepareRequest!(firstRequest, { + url: 'https://localhost:8443/openai/v1/models?tag=one&tag=two&tag=three', + options: {} as any, + }); + + expect(firstRequest.method).toBe('POST'); + expect(firstRequest.redirect).toBe('manual'); + expect(firstRequest.headers.get('host')).toBe('localhost:8443'); + expect(firstRequest.headers.get('authorization')).toContain('AWS4-HMAC-SHA256'); + expect(firstRequest.headers.get('x-amz-date')).not.toBe('stale-date'); + expect(firstRequest.headers.get('x-amz-security-token')).toBeNull(); + expect(firstRequest.headers.get('x-amz-content-sha256')).not.toBe('stale-hash'); + expect(sign.mock.calls[0]?.[0]).toMatchObject({ + method: 'POST', + port: 8443, + path: '/openai/v1/models', + query: { tag: ['one', 'two', 'three'] }, + body: firstRequest.body, + }); + + const secondRequest = { headers: new Headers(), method: 'post', body: new Uint8Array([1]) } as any; + await runtime.prepareRequest!(secondRequest, { + url: 'https://localhost:8443/openai/v1/responses', + options: {} as any, + }); + expect(secondRequest.method).toBe('POST'); + expect(secondRequest.headers.get('authorization')).toContain('AWS4-HMAC-SHA256'); + expect(sign.mock.calls[1]?.[0]).toMatchObject({ method: 'POST', body: secondRequest.body }); + + const thirdRequest = { headers: new Headers() } as any; + await runtime.prepareRequest!(thirdRequest, { + url: 'https://localhost:8443/openai/v1/models', + options: {} as any, + }); + expect(thirdRequest.method).toBe('GET'); + expect(sign.mock.calls[2]?.[0]).toMatchObject({ method: 'GET' }); + }); + + test('signs with a valid custom credential provider', async () => { + const credentialProvider = jest.fn(async () => ({ + accessKeyId: 'provider-access-key', + secretAccessKey: 'provider-secret-key', + sessionToken: 'provider-session-token', + })); + let requestedHeaders: Headers | undefined; + const client = new OpenAI({ + provider: bedrock({ region: 'us-east-1', credentialProvider }), + fetch: async (_url, init) => { + requestedHeaders = new Headers(init?.headers); + return jsonResponse(); + }, + }); + + await client.request({ method: 'get', path: '/models' }); + + expect(credentialProvider).toHaveBeenCalledTimes(1); + expect(requestedHeaders?.get('authorization')).toContain('Credential=provider-access-key/'); + expect(requestedHeaders?.get('x-amz-security-token')).toBe('provider-session-token'); + }); + + test('requires a signing region when a custom endpoint uses the default AWS credential chain', () => { + expect( + () => + new OpenAI({ + provider: bedrock({ baseURL: 'https://bedrock.example.com/openai/v1' }), + }), + ).toThrow('Bedrock requires an AWS region'); + }); + + test('rejects a canonical endpoint whose region does not match the signing region', async () => { + const fetch = jest.fn(async () => jsonResponse()); + const client = new OpenAI({ + provider: bedrock({ + region: 'us-east-1', + baseURL: 'https://bedrock-mantle.us-west-2.api.aws/openai/v1', + accessKeyId: 'access-key', + secretAccessKey: 'secret-key', + }), + fetch, + }); + + await expect(client.request({ method: 'get', path: '/models' })).rejects.toThrow( + 'endpoint region `us-west-2` does not match the SigV4 region `us-east-1`', + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + test.each<[string, BedrockProviderOptions]>([ + ['empty access key ID', { accessKeyId: '', secretAccessKey: 'secret-key' }], + ['empty secret access key', { accessKeyId: 'access-key', secretAccessKey: '' }], + ['empty session token', { accessKeyId: 'access-key', secretAccessKey: 'secret-key', sessionToken: '' }], + ['empty profile', { profile: ' ' }], + ['empty bearer credential', { apiKey: ' ' }], + ['empty region', { region: ' ' }], + ['empty base URL', { baseURL: ' ' }], + ])('rejects an explicit %s instead of falling back to ambient credentials', (_name, options) => { + expect(() => bedrock({ region: 'us-east-1', ...options })).toThrow(/must not be empty|non-empty/); + }); + + test.each<[string, BedrockProviderOptions]>([ + ['session token without static credentials', { sessionToken: 'session-token' }], + [ + 'multiple AWS credential modes', + { accessKeyId: 'access-key', secretAccessKey: 'secret-key', profile: 'profile' }, + ], + ['profile and credential provider', { profile: 'profile', credentialProvider: async () => ({}) as any }], + ['bearer and AWS credentials', { apiKey: 'token', profile: 'profile' }], + ['static bearer and token provider', { apiKey: 'token', tokenProvider: async () => 'token' }], + ])('rejects %s', (_name, options) => { + expect(() => bedrock({ region: 'us-east-1', ...options })).toThrow( + /must be provided together|ambiguous|mutually exclusive/, + ); + }); +}); diff --git a/tests/lib/provider.test.ts b/tests/lib/provider.test.ts new file mode 100644 index 0000000000..c9a262c7d7 --- /dev/null +++ b/tests/lib/provider.test.ts @@ -0,0 +1,279 @@ +import OpenAI from 'openai'; +import { createProvider, type ProviderRuntime } from 'openai/internal/provider'; +import { formatRequestDetails } from 'openai/internal/utils/log'; + +const originalEnv = process.env; + +beforeEach(() => { + process.env = { ...originalEnv }; +}); + +afterEach(() => { + process.env = originalEnv; +}); + +function provider(runtime: Omit & Partial = {}) { + return createProvider({ + configure: () => ({ + name: 'test-provider', + baseURL: 'https://provider.example/v1', + ...runtime, + }), + }); +} + +describe('provider', () => { + test('owns the base URL and authentication instead of using OpenAI environment variables', async () => { + process.env['OPENAI_API_KEY'] = 'openai-api-key'; + process.env['OPENAI_ADMIN_KEY'] = 'openai-admin-key'; + process.env['OPENAI_BASE_URL'] = 'https://openai.example/v1'; + process.env['OPENAI_ORG_ID'] = 'openai-org'; + process.env['OPENAI_PROJECT_ID'] = 'openai-project'; + process.env['OPENAI_CUSTOM_HEADERS'] = 'X-OpenAI-Ambient: leaked'; + + let requestedURL: string | URL | Request | undefined; + let requestedInit: RequestInit | undefined; + const client = new OpenAI({ + provider: provider({ + prepareRequest(request, { url }) { + expect(url).toBe('https://provider.example/v1/models'); + expect(request.headers.has('authorization')).toBe(false); + expect(request.headers.has('openai-organization')).toBe(false); + expect(request.headers.has('openai-project')).toBe(false); + expect(request.headers.has('x-openai-ambient')).toBe(false); + request.headers.set('authorization', 'Provider token'); + }, + }), + fetch: async (url, init) => { + requestedURL = url; + requestedInit = init; + return new Response('{}', { headers: { 'Content-Type': 'application/json' } }); + }, + }); + + const callApiKey = jest.spyOn(client, '_callApiKey'); + const authHeaders = jest.spyOn(client as any, 'authHeaders'); + const validateHeaders = jest.spyOn(client as any, 'validateHeaders'); + + await client.request({ method: 'get', path: '/models' }); + + expect(client.baseURL).toBe('https://provider.example/v1'); + expect(client.buildURL('/models', null, 'https://route-default.example/v1')).toBe( + 'https://provider.example/v1/models', + ); + expect(requestedURL).toBe('https://provider.example/v1/models'); + expect((requestedInit?.headers as Headers).get('authorization')).toBe('Provider token'); + expect(callApiKey).not.toHaveBeenCalled(); + expect(authHeaders).not.toHaveBeenCalled(); + expect(validateHeaders).not.toHaveBeenCalled(); + }); + + test.each([ + ['apiKey', 'openai-api-key'], + ['adminAPIKey', 'openai-admin-key'], + ['workloadIdentity', {}], + ['baseURL', 'https://override.example/v1'], + ])('rejects an explicit %s option', (key, value) => { + expect( + () => + new OpenAI({ + provider: provider(), + [key]: value, + }), + ).toThrow(`\`${key}\``); + }); + + test('reports every conflicting top-level option together', () => { + expect( + () => + new OpenAI({ + provider: provider(), + apiKey: 'openai-api-key', + adminAPIKey: 'openai-admin-key', + workloadIdentity: { + identityProviderId: 'identity-provider', + serviceAccountId: 'service-account', + provider: { tokenType: 'jwt', getToken: async () => 'subject-token' }, + }, + baseURL: 'https://override.example/v1', + }), + ).toThrow('`apiKey`, `adminAPIKey`, `workloadIdentity`, `baseURL`'); + }); + + test('allows null top-level options', () => { + expect( + () => + new OpenAI({ + provider: provider(), + apiKey: null, + adminAPIKey: null, + workloadIdentity: null, + baseURL: null, + } as any), + ).not.toThrow(); + }); + + test('configures one runtime per client and preserves the provider in withOptions', () => { + process.env['OPENAI_API_KEY'] = 'openai-api-key'; + process.env['OPENAI_ADMIN_KEY'] = 'openai-admin-key'; + process.env['OPENAI_BASE_URL'] = 'https://openai.example/v1'; + + const configure = jest.fn(() => ({ + name: 'test-provider', + baseURL: 'https://provider.example/v1', + })); + const configuredProvider = createProvider({ configure }); + const client = new OpenAI({ provider: configuredProvider }); + const cloned = client.withOptions({ timeout: 1 }); + + expect(configure).toHaveBeenCalledTimes(2); + expect(cloned).not.toBe(client); + expect(cloned.baseURL).toBe('https://provider.example/v1'); + expect(cloned.timeout).toBe(1); + }); + + test('preserves provider headers when cloning withOptions', async () => { + let requestedHeaders: Headers | undefined; + const client = new OpenAI({ + provider: provider(), + defaultHeaders: { 'x-provider-custom': 'preserve-me' }, + fetch: async (_url, init) => { + requestedHeaders = new Headers(init?.headers); + return new Response('{}', { headers: { 'Content-Type': 'application/json' } }); + }, + }); + + await client.withOptions({ timeout: 1 }).request({ method: 'get', path: '/models' }); + + expect(requestedHeaders?.get('x-provider-custom')).toBe('preserve-me'); + }); + + test('does not let a request-level default base URL replace the provider base URL', () => { + const client = new OpenAI({ + provider: provider({ baseURL: 'https://api.openai.com/v1' }), + }); + + expect(client.buildURL('/models', null, 'https://route-default.example/v1')).toBe( + 'https://api.openai.com/v1/models', + ); + }); + + test('runs after subclass preparation on every request attempt', async () => { + const order: string[] = []; + let attempt = 0; + + class TestClient extends OpenAI { + protected override async prepareRequest(request: RequestInit): Promise { + order.push('subclass'); + (request.headers as Headers).set('x-prepared-by', 'subclass'); + } + } + + const client = new TestClient({ + provider: provider({ + prepareRequest(request) { + order.push('provider'); + expect(request.headers.get('x-prepared-by')).toBe('subclass'); + request.headers.set('x-attempt', String(++attempt)); + }, + }), + maxRetries: 1, + fetch: async (_url, init) => { + if ((init?.headers as Headers).get('x-attempt') === '1') { + return new Response(undefined, { + status: 429, + headers: { 'Retry-After-Ms': '1' }, + }); + } + return new Response('{}', { headers: { 'Content-Type': 'application/json' } }); + }, + }); + + await client.request({ method: 'get', path: '/models' }); + + expect(order).toEqual(['subclass', 'provider', 'subclass', 'provider']); + expect(attempt).toBe(2); + }); + + test('rejects provider objects that were not created by createProvider', () => { + expect(() => new OpenAI({ provider: {} as any })).toThrow( + 'Invalid provider. Providers must be created with createProvider().', + ); + }); + + test('shares provider definitions across duplicate module instances', () => { + const configuredProvider = provider({ baseURL: 'https://shared.example/v1' }); + + jest.isolateModules(() => { + const duplicate = require('openai/internal/provider') as typeof import('openai/internal/provider'); + expect(duplicate.configureProvider(configuredProvider).baseURL).toBe('https://shared.example/v1'); + }); + }); + + test('preserves standard OpenAI authentication when no provider is configured', async () => { + let requestedHeaders: Headers | undefined; + const client = new OpenAI({ + apiKey: 'openai-api-key', + fetch: async (_url, init) => { + requestedHeaders = new Headers(init?.headers); + return new Response('{}', { headers: { 'Content-Type': 'application/json' } }); + }, + }); + + await client.request({ method: 'get', path: '/models' }); + + expect(requestedHeaders?.get('authorization')).toBe('Bearer openai-api-key'); + }); + + test('can replace standard OpenAI routing with a provider in withOptions', async () => { + let requestedURL: string | URL | Request | undefined; + let requestedHeaders: Headers | undefined; + const client = new OpenAI({ + apiKey: 'openai-api-key', + fetch: async (url, init) => { + requestedURL = url; + requestedHeaders = new Headers(init?.headers); + return new Response('{}', { headers: { 'Content-Type': 'application/json' } }); + }, + }); + const routedClient = client.withOptions({ provider: provider() }); + + await routedClient.request({ method: 'get', path: '/models' }); + + expect(client.baseURL).toBe('https://api.openai.com/v1'); + expect(routedClient.baseURL).toBe('https://provider.example/v1'); + expect(requestedURL).toBe('https://provider.example/v1/models'); + expect(requestedHeaders?.has('authorization')).toBe(false); + }); + + test('drops inherited OpenAI headers when switching to a provider in withOptions', async () => { + process.env['OPENAI_CUSTOM_HEADERS'] = 'X-OpenAI-Ambient: leaked'; + process.env['OPENAI_ORG_ID'] = 'openai-org'; + process.env['OPENAI_PROJECT_ID'] = 'openai-project'; + + let requestedHeaders: Headers | undefined; + const client = new OpenAI({ + apiKey: 'openai-api-key', + fetch: async (_url, init) => { + requestedHeaders = new Headers(init?.headers); + return new Response('{}', { headers: { 'Content-Type': 'application/json' } }); + }, + }); + const routedClient = client.withOptions({ provider: provider() }); + + await routedClient.request({ method: 'get', path: '/models' }); + + expect(requestedHeaders?.has('authorization')).toBe(false); + expect(requestedHeaders?.has('openai-organization')).toBe(false); + expect(requestedHeaders?.has('openai-project')).toBe(false); + expect(requestedHeaders?.has('x-openai-ambient')).toBe(false); + }); +}); + +test('request logging redacts AWS session tokens', () => { + const details = formatRequestDetails({ + headers: new Headers({ 'x-amz-security-token': 'session-token' }), + }); + + expect(details.headers).toEqual({ 'x-amz-security-token': '***' }); +}); diff --git a/tests/live/bedrock.live.test.ts b/tests/live/bedrock.live.test.ts new file mode 100644 index 0000000000..b1ff35a40d --- /dev/null +++ b/tests/live/bedrock.live.test.ts @@ -0,0 +1,142 @@ +import OpenAI from 'openai'; +import type { Provider } from 'openai/internal/provider'; +import { bedrock as bearerBedrock } from 'openai/providers/bedrock'; +import { bedrock as awsBedrock } from 'openai/providers/bedrock/aws'; + +/** + * Example: + * BEDROCK_LIVE_TEST=1 BEDROCK_LIVE_AUTH=profile AWS_PROFILE=my-profile \ + * AWS_REGION=us-west-2 BEDROCK_MODEL=openai.gpt-oss-120b pnpm test:live:bedrock + * + * BEDROCK_MODEL must support the Responses API. A model returned by the Models + * API may support a different Bedrock inference API instead. + * + * Set BEDROCK_LIVE_STREAM=1 to include a second, streaming inference request. + */ +const LIVE_TEST_FLAG = 'BEDROCK_LIVE_TEST'; +const AUTH_MODE_ENV = 'BEDROCK_LIVE_AUTH'; +const MODEL_ENV = 'BEDROCK_MODEL'; +const STREAM_ENV = 'BEDROCK_LIVE_STREAM'; +const LIVE_TEST_TIMEOUT = 180_000; + +const authModes = [ + 'bearer', + 'environment-bearer', + 'default-chain', + 'profile', + 'static', + 'custom-provider', +] as const; +type AuthMode = (typeof authModes)[number]; + +function requiredEnv(name: string): string { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`Set ${name} before running the Bedrock live test.`); + return value; +} + +function readAuthMode(): AuthMode { + const value = requiredEnv(AUTH_MODE_ENV); + if ((authModes as readonly string[]).includes(value)) return value as AuthMode; + throw new Error(`${AUTH_MODE_ENV} must be one of: ${authModes.join(', ')}.`); +} + +async function providerForAuth( + mode: AuthMode, + endpoint: { region: string; baseURL?: string | undefined }, +): Promise { + switch (mode) { + case 'bearer': + return bearerBedrock({ ...endpoint, apiKey: requiredEnv('AWS_BEARER_TOKEN_BEDROCK') }); + case 'environment-bearer': + requiredEnv('AWS_BEARER_TOKEN_BEDROCK'); + return bearerBedrock(endpoint); + case 'default-chain': + return awsBedrock({ ...endpoint, apiKey: null }); + case 'profile': + return awsBedrock({ ...endpoint, apiKey: null, profile: requiredEnv('AWS_PROFILE') }); + case 'static': { + const sessionToken = process.env['AWS_SESSION_TOKEN']?.trim(); + return awsBedrock({ + ...endpoint, + apiKey: null, + accessKeyId: requiredEnv('AWS_ACCESS_KEY_ID'), + secretAccessKey: requiredEnv('AWS_SECRET_ACCESS_KEY'), + ...(sessionToken ? { sessionToken } : {}), + }); + } + case 'custom-provider': { + const { defaultProvider } = await import('@aws-sdk/credential-provider-node'); + const profile = process.env['AWS_PROFILE']?.trim(); + return awsBedrock({ + ...endpoint, + apiKey: null, + credentialProvider: defaultProvider(profile ? { profile } : {}), + }); + } + } +} + +if (process.env[LIVE_TEST_FLAG] !== '1') { + throw new Error( + `Refusing to make live AWS requests. Set ${LIVE_TEST_FLAG}=1 and use \`pnpm test:live:bedrock\`.`, + ); +} + +const region = process.env['AWS_REGION']?.trim() || process.env['AWS_DEFAULT_REGION']?.trim(); +if (!region) throw new Error('Set AWS_REGION or AWS_DEFAULT_REGION before running the Bedrock live test.'); + +const model = requiredEnv(MODEL_ENV); +const authMode = readAuthMode(); +const baseURL = process.env['AWS_BEDROCK_BASE_URL']?.trim(); +const runStreamingTest = process.env[STREAM_ENV] === '1'; + +jest.setTimeout(LIVE_TEST_TIMEOUT); + +describe(`Amazon Bedrock live (${authMode})`, () => { + let client: OpenAI; + + beforeAll(async () => { + client = new OpenAI({ + provider: await providerForAuth(authMode, { + region, + ...(baseURL ? { baseURL } : {}), + }), + maxRetries: 0, + timeout: 120_000, + }); + }); + + test('lists the configured model and creates a response', async () => { + const models = await client.models.list(); + expect(models.data.map((candidate) => candidate.id)).toContain(model); + + const response = await client.responses.create({ + model, + input: 'Reply with exactly: bedrock live test passed', + store: false, + }); + + expect(response.id).toEqual(expect.any(String)); + expect(response.output_text.trim().length).toBeGreaterThan(0); + }); + + (runStreamingTest ? test : test.skip)('streams a response', async () => { + const stream = await client.responses.create({ + model, + input: 'Reply with exactly: bedrock streaming test passed', + store: false, + stream: true, + }); + let eventCount = 0; + let completed = false; + + for await (const event of stream) { + eventCount += 1; + completed ||= event.type === 'response.completed'; + } + + expect(eventCount).toBeGreaterThan(0); + expect(completed).toBe(true); + }); +});