From 5f1f07debff922338f87d9c496748a25c3a5fd32 Mon Sep 17 00:00:00 2001 From: Jim Blomo Date: Wed, 10 Jun 2026 12:42:35 -0700 Subject: [PATCH 01/11] [bedrock_sigv4_auth] Add AWS-native Bedrock authentication --- README.md | 17 +- bedrock.md | 17 +- package.json | 35 ++++ src/bedrock.ts | 371 +++++++++++++++++++++++++++++++++++- tests/lib/bedrock.test.ts | 273 ++++++++++++++++++++++++++- yarn.lock | 384 +++++++++++++++++++++++++++++++++++++- 6 files changed, 1083 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 7625da1e2..24c65751e 100644 --- a/README.md +++ b/README.md @@ -419,7 +419,7 @@ 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. +`BedrockOpenAI` configures AWS authentication 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. 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. @@ -432,6 +432,21 @@ const client = new BedrockOpenAI({ }); ``` +To use the standard AWS credential chain and SigV4 authentication, install the optional AWS signing dependencies and omit bearer-token configuration: + +```sh +npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/protocol-http @smithy/signature-v4 +``` + +```ts +const client = new BedrockOpenAI({ + awsRegion: 'us-west-2', + awsProfile: 'my-profile', // optional; otherwise uses the default AWS credential chain +}); +``` + +You can also pass explicit temporary credentials or an `awsCredentialsProvider`. Explicit bearer and AWS credential options are mutually exclusive. Without explicit authentication, `AWS_BEARER_TOKEN_BEDROCK` takes precedence over the default AWS credential chain. + For more information on support for Amazon Bedrock, see [bedrock.md](bedrock.md). ### Retries diff --git a/bedrock.md b/bedrock.md index 63f19cdb1..3b7fe4b64 100644 --- a/bedrock.md +++ b/bedrock.md @@ -17,7 +17,7 @@ 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. +`BedrockOpenAI` configures AWS authentication 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. 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. @@ -29,3 +29,18 @@ const client = new BedrockOpenAI({ bedrockTokenProvider: async () => refreshBedrockToken(), }); ``` + +To use the standard AWS credential chain and SigV4 authentication, install the optional AWS signing dependencies and omit bearer-token configuration: + +```sh +npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/protocol-http @smithy/signature-v4 +``` + +```ts +const client = new BedrockOpenAI({ + awsRegion: 'us-west-2', + awsProfile: 'my-profile', // optional; otherwise uses the default AWS credential chain +}); +``` + +You can also pass explicit temporary credentials or an `awsCredentialsProvider`. Explicit bearer and AWS credential options are mutually exclusive. Without explicit authentication, `AWS_BEARER_TOKEN_BEDROCK` takes precedence over the default AWS credential chain. diff --git a/package.json b/package.json index b2a61b90a..cb57345a1 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,13 @@ "dependencies": {}, "devDependencies": { "@arethetypeswrong/cli": "^0.17.0", + "@aws-sdk/core": "3.974.15", + "@aws-sdk/credential-provider-node": "3.972.47", + "@aws-sdk/token-providers": "3.1057.0", + "@smithy/core": "3.24.5", + "@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", @@ -86,10 +93,38 @@ } }, "peerDependencies": { + "@aws-sdk/core": ">=3.974.0 <4", + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@aws-sdk/token-providers": ">=3.1057.0 <4", + "@smithy/core": ">=3.24.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/protocol-http": ">=5.4.0 <6", + "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { + "@aws-sdk/core": { + "optional": true + }, + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@aws-sdk/token-providers": { + "optional": true + }, + "@smithy/core": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/protocol-http": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, "ws": { "optional": true }, diff --git a/src/bedrock.ts b/src/bedrock.ts index cdb9151d4..f030b730d 100644 --- a/src/bedrock.ts +++ b/src/bedrock.ts @@ -10,6 +10,227 @@ import type { ResponseStreamParams } from './lib/responses/ResponseStream'; import * as API from './resources/index'; import type * as ResponsesAPI from './resources/responses/responses'; +export interface AwsCredentialIdentity { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + expiration?: Date; +} + +export type AwsCredentialsProvider = () => AwsCredentialIdentity | Promise; +type AsyncAwsCredentialsProvider = () => Promise; + +type BedrockBearerDependencies = { + authSchemePreference: typeof import('@aws-sdk/core/httpAuthSchemes').NODE_AUTH_SCHEME_PREFERENCE_OPTIONS; + fromEnvSigningName: typeof import('@aws-sdk/token-providers').fromEnvSigningName; + HttpBearerAuthSigner: typeof import('@smithy/core').HttpBearerAuthSigner; + HttpRequest: typeof import('@smithy/protocol-http').HttpRequest; +}; + +type BedrockSigV4Dependencies = { + defaultProvider: typeof import('@aws-sdk/credential-provider-node').defaultProvider; + Hash: typeof import('@smithy/hash-node').Hash; + HttpRequest: typeof import('@smithy/protocol-http').HttpRequest; + SignatureV4: typeof import('@smithy/signature-v4').SignatureV4; +}; + +let bedrockBearerDependencies: Promise | undefined; +let bedrockSigV4Dependencies: Promise | undefined; + +function loadBedrockBearerDependencies(): Promise { + return (bedrockBearerDependencies ??= Promise.all([ + import('@aws-sdk/core/httpAuthSchemes'), + import('@aws-sdk/token-providers'), + import('@smithy/core'), + import('@smithy/protocol-http'), + ]) + .then(([awsCore, tokenProviders, smithyCore, protocolHttp]) => ({ + authSchemePreference: awsCore.NODE_AUTH_SCHEME_PREFERENCE_OPTIONS, + fromEnvSigningName: tokenProviders.fromEnvSigningName, + HttpBearerAuthSigner: smithyCore.HttpBearerAuthSigner, + HttpRequest: protocolHttp.HttpRequest, + })) + .catch(() => undefined)); +} + +function loadBedrockSigV4Dependencies(): Promise { + return (bedrockSigV4Dependencies ??= Promise.all([ + import('@aws-sdk/credential-provider-node'), + import('@smithy/hash-node'), + import('@smithy/protocol-http'), + import('@smithy/signature-v4'), + ]).then(([credentialProvider, hashNode, protocolHttp, signatureV4]) => ({ + defaultProvider: credentialProvider.defaultProvider, + Hash: hashNode.Hash, + HttpRequest: protocolHttp.HttpRequest, + SignatureV4: signatureV4.SignatureV4, + }))); +} + +function bedrockAwsRequestTarget(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 }; +} + +class BedrockAwsBearerAuth { + constructor(private readonly fromEnvironment: boolean) {} + + async sign(url: string, request: RequestInit, token: string | null): Promise { + const dependencies = await loadBedrockBearerDependencies(); + if (!dependencies) return; + + let identity = token ? { token } : undefined; + if (this.fromEnvironment) { + dependencies.authSchemePreference.environmentVariableSelector(process.env, { + signingName: 'bedrock', + }); + identity = await dependencies.fromEnvSigningName({ signingName: 'bedrock' })(); + } + if (!identity) return; + + const parsedURL = new URL(url); + const target = bedrockAwsRequestTarget(parsedURL); + const headers = Object.fromEntries(new Headers(request.headers).entries()); + delete headers['authorization']; + const signed = await new dependencies.HttpBearerAuthSigner().sign( + new dependencies.HttpRequest({ + protocol: parsedURL.protocol, + hostname: parsedURL.hostname, + ...(parsedURL.port ? { port: Number(parsedURL.port) } : {}), + method: (request.method ?? 'GET').toUpperCase(), + ...target, + headers, + ...(request.body !== undefined && request.body !== null ? { body: request.body } : {}), + }), + identity, + {}, + ); + request.headers = new Headers(signed.headers); + } +} + +class BedrockAwsAuth { + private readonly region: string; + private readonly profile: string | undefined; + private readonly credentials: AwsCredentialIdentity | AwsCredentialsProvider | undefined; + private defaultCredentialsProvider: AsyncAwsCredentialsProvider | undefined; + private signer: InstanceType | undefined; + + constructor({ + region, + profile, + credentials, + }: { + region: string; + profile?: string | undefined; + credentials?: AwsCredentialIdentity | AwsCredentialsProvider | undefined; + }) { + this.region = region; + this.profile = profile; + this.credentials = credentials; + } + + async sign(url: string, request: RequestInit): Promise { + let dependencies: BedrockSigV4Dependencies; + try { + dependencies = await loadBedrockSigV4Dependencies(); + } catch (error) { + throw new Errors.OpenAIError( + `AWS credential authentication requires the optional AWS SDK dependencies. Install the Bedrock peer dependencies listed by the openai package. ${String( + error, + )}`, + ); + } + + const configuredCredentials = this.credentials; + const credentials = + typeof configuredCredentials === 'function' ? + async () => configuredCredentials() + : configuredCredentials ?? + (this.defaultCredentialsProvider ??= dependencies.defaultProvider( + this.profile ? { profile: this.profile } : {}, + )); + const signer = (this.signer ??= new dependencies.SignatureV4({ + credentials, + region: this.region, + service: 'bedrock-mantle', + sha256: dependencies.Hash.bind(null, 'sha256'), + })); + const parsedURL = new URL(url); + const target = bedrockAwsRequestTarget(parsedURL); + const headers = Object.fromEntries(new Headers(request.headers).entries()); + delete headers['authorization']; + headers['host'] = parsedURL.host; + const signed = await signer.sign( + new dependencies.HttpRequest({ + protocol: parsedURL.protocol, + hostname: parsedURL.hostname, + ...(parsedURL.port ? { port: Number(parsedURL.port) } : {}), + method: (request.method ?? 'GET').toUpperCase(), + ...target, + headers, + ...(request.body !== undefined && request.body !== null ? { body: request.body } : {}), + }), + ); + request.headers = new Headers(signed.headers); + } +} + +function hasExplicitAwsAuth(options: { + awsProfile?: string | undefined; + awsAccessKeyId?: string | undefined; + awsSecretAccessKey?: string | undefined; + awsSessionToken?: string | undefined; + awsCredentialsProvider?: AwsCredentialsProvider | undefined; +}): boolean { + return [ + options.awsProfile, + options.awsAccessKeyId, + options.awsSecretAccessKey, + options.awsSessionToken, + options.awsCredentialsProvider, + ].some((value) => value !== undefined); +} + +function validateExplicitAwsAuth(options: { + awsProfile?: string | undefined; + awsAccessKeyId?: string | undefined; + awsSecretAccessKey?: string | undefined; + awsSessionToken?: string | undefined; + awsCredentialsProvider?: AwsCredentialsProvider | undefined; +}): void { + if ((options.awsAccessKeyId === undefined) !== (options.awsSecretAccessKey === undefined)) { + throw new Errors.OpenAIError( + 'The `awsAccessKeyId` and `awsSecretAccessKey` arguments must be provided together.', + ); + } + const sources = [ + options.awsProfile !== undefined, + options.awsAccessKeyId !== undefined, + options.awsCredentialsProvider !== undefined, + ].filter(Boolean).length; + if (sources > 1) { + throw new Errors.OpenAIError( + 'The `awsProfile`, explicit AWS credentials, and `awsCredentialsProvider` arguments are mutually exclusive.', + ); + } + if (options.awsSessionToken !== undefined && options.awsAccessKeyId === undefined) { + throw new Errors.OpenAIError( + 'The `awsSessionToken` argument requires explicit AWS access key credentials.', + ); + } +} + export interface BedrockClientOptions extends Omit { /** @@ -29,12 +250,12 @@ export interface BedrockClientOptions baseURL?: string | null | undefined; /** - * BedrockOpenAI only supports Bedrock bearer token authentication. + * BedrockOpenAI only supports Bedrock bearer token or AWS credential authentication. */ adminAPIKey?: never; /** - * BedrockOpenAI only supports Bedrock bearer token authentication. + * BedrockOpenAI only supports Bedrock bearer token or AWS credential authentication. */ workloadIdentity?: never; @@ -45,6 +266,21 @@ export interface BedrockClientOptions */ awsRegion?: string | undefined; + /** AWS shared-config profile used by the standard credential chain. */ + awsProfile?: string | undefined; + + /** Explicit AWS access key ID. Must be provided with `awsSecretAccessKey`. */ + awsAccessKeyId?: string | undefined; + + /** Explicit AWS secret access key. Must be provided with `awsAccessKeyId`. */ + awsSecretAccessKey?: string | undefined; + + /** Optional session token for explicit temporary AWS credentials. */ + awsSessionToken?: string | undefined; + + /** Provider returning AWS SDK-compatible credentials. */ + awsCredentialsProvider?: AwsCredentialsProvider | undefined; + /** * A function that returns a Bedrock bearer token and is invoked before each request. */ @@ -101,6 +337,15 @@ function restoreBedrockStreamOutputText(responses: API.Responses): API.Responses /** API Client for interfacing with Amazon Bedrock's OpenAI-compatible endpoint. */ export class BedrockOpenAI extends OpenAI { private readonly bedrockTokenProvider: ApiKeySetter | undefined; + private readonly bedrockAwsBearerAuth: BedrockAwsBearerAuth | undefined; + private readonly bedrockAwsAuth: BedrockAwsAuth | undefined; + private readonly awsRegion: string | undefined; + private readonly awsProfile: string | undefined; + private readonly awsAccessKeyId: string | undefined; + private readonly awsSecretAccessKey: string | undefined; + private readonly awsSessionToken: string | undefined; + private readonly awsCredentialsProvider: AwsCredentialsProvider | undefined; + private readonly usesRegionDerivedBaseURL: boolean; /** * API Client for interfacing with Amazon Bedrock's OpenAI-compatible endpoint. @@ -114,16 +359,45 @@ export class BedrockOpenAI extends OpenAI { baseURL = readEnv('AWS_BEDROCK_BASE_URL'), apiKey, awsRegion = readEnv('AWS_REGION') ?? readEnv('AWS_DEFAULT_REGION'), + awsProfile, + awsAccessKeyId, + awsSecretAccessKey, + awsSessionToken, + awsCredentialsProvider, bedrockTokenProvider, adminAPIKey, workloadIdentity, ...opts }: BedrockClientOptions = {}) { if (adminAPIKey || workloadIdentity) { - throw new Errors.OpenAIError('BedrockOpenAI only supports Bedrock bearer token authentication.'); + throw new Errors.OpenAIError( + 'BedrockOpenAI only supports Bedrock bearer token or AWS credential authentication.', + ); } - if (apiKey === undefined && !bedrockTokenProvider) { + const explicitBearerAuth = apiKey != null || bedrockTokenProvider !== undefined; + const explicitAwsAuth = hasExplicitAwsAuth({ + awsProfile, + awsAccessKeyId, + awsSecretAccessKey, + awsSessionToken, + awsCredentialsProvider, + }); + if (explicitBearerAuth && explicitAwsAuth) { + throw new Errors.OpenAIError( + 'Bearer token and AWS credential authentication arguments are mutually exclusive.', + ); + } + validateExplicitAwsAuth({ + awsProfile, + awsAccessKeyId, + awsSecretAccessKey, + awsSessionToken, + awsCredentialsProvider, + }); + + const ambientBearerAuth = !explicitBearerAuth && !explicitAwsAuth; + if (ambientBearerAuth) { apiKey = readEnv('AWS_BEARER_TOKEN_BEDROCK') ?? null; } @@ -133,28 +407,60 @@ export class BedrockOpenAI extends OpenAI { ); } + if (apiKey === '') { + throw new Errors.OpenAIError('The `apiKey` argument must not be empty.'); + } + if (apiKey && bedrockTokenProvider) { throw new Errors.OpenAIError( 'The `apiKey` and `bedrockTokenProvider` arguments are mutually exclusive; only one can be passed at a time.', ); } - if (!apiKey && !bedrockTokenProvider) { + const useAwsAuth = !apiKey && !bedrockTokenProvider; + if (useAwsAuth && !awsRegion?.trim()) { throw new Errors.OpenAIError( - 'Missing credentials. Please pass an `apiKey` or `bedrockTokenProvider`, or set the `AWS_BEARER_TOKEN_BEDROCK` environment variable.', + 'AWS credential authentication requires `awsRegion`, `AWS_REGION`, or `AWS_DEFAULT_REGION`.', ); } - const configuredBaseURL = baseURL?.trim() ? baseURL : deriveBedrockBaseURL(awsRegion); + const explicitBaseURL = baseURL?.trim() ? baseURL : undefined; + const usesRegionDerivedBaseURL = explicitBaseURL === undefined; + const configuredBaseURL = explicitBaseURL ?? deriveBedrockBaseURL(awsRegion); super({ - apiKey: bedrockTokenProvider ?? apiKey, + apiKey: useAwsAuth ? 'bedrock-aws-auth' : bedrockTokenProvider ?? apiKey, adminAPIKey: null, baseURL: normalizeBedrockBaseURL(configuredBaseURL), ...opts, }); this.bedrockTokenProvider = bedrockTokenProvider; + this.bedrockAwsBearerAuth = useAwsAuth ? undefined : new BedrockAwsBearerAuth(ambientBearerAuth); + this.bedrockAwsAuth = + useAwsAuth ? + new BedrockAwsAuth({ + region: awsRegion!, + profile: awsProfile, + credentials: + awsCredentialsProvider ?? + (awsAccessKeyId && awsSecretAccessKey ? + { + accessKeyId: awsAccessKeyId, + secretAccessKey: awsSecretAccessKey, + ...(awsSessionToken ? { sessionToken: awsSessionToken } : {}), + } + : undefined), + }) + : undefined; + if (useAwsAuth) this.apiKey = null; + this.awsRegion = awsRegion; + this.awsProfile = awsProfile; + this.awsAccessKeyId = awsAccessKeyId; + this.awsSecretAccessKey = awsSecretAccessKey; + this.awsSessionToken = awsSessionToken; + this.awsCredentialsProvider = awsCredentialsProvider; + this.usesRegionDerivedBaseURL = usesRegionDerivedBaseURL; this.responses = restoreBedrockStreamOutputText(new API.Responses(this)); } @@ -171,6 +477,8 @@ export class BedrockOpenAI extends OpenAI { opts: FinalRequestOptions, schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, ): Promise { + if (this.bedrockAwsAuth) return undefined; + const security = schemes ?? { bearerAuth: true, adminAPIKeyAuth: true }; if ((security.bearerAuth || security.adminAPIKeyAuth) && this.apiKey !== null) { return buildHeaders([{ Authorization: `Bearer ${this.apiKey}` }]); @@ -179,13 +487,56 @@ export class BedrockOpenAI extends OpenAI { return super.authHeaders(opts, security); } + protected override validateHeaders( + headers: NullableHeaders, + schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, + ): void { + if (this.bedrockAwsAuth) return; + super.validateHeaders(headers, schemes); + } + + protected override async prepareRequest( + request: RequestInit, + context: { url: string; options: FinalRequestOptions }, + ): Promise { + await super.prepareRequest(request, context); + if (this.bedrockAwsAuth) { + await this.bedrockAwsAuth.sign(context.url, request); + } else { + await this.bedrockAwsBearerAuth?.sign(context.url, request, this.apiKey); + } + } + override withOptions(options: Partial): this { + const awsAuthOverride = hasExplicitAwsAuth(options); const bedrockTokenProvider = - options.apiKey !== undefined ? undefined : options.bedrockTokenProvider ?? this.bedrockTokenProvider; + options.apiKey !== undefined || awsAuthOverride ? + undefined + : options.bedrockTokenProvider ?? this.bedrockTokenProvider; + const preserveAwsAuth = + this.bedrockAwsAuth !== undefined && + !awsAuthOverride && + options.apiKey === undefined && + !bedrockTokenProvider; + const baseURL = + options.baseURL !== undefined ? options.baseURL + : options.awsRegion !== undefined && this.usesRegionDerivedBaseURL ? undefined + : this.baseURL; return super.withOptions({ ...options, - ...(bedrockTokenProvider ? { apiKey: undefined, bedrockTokenProvider } : {}), + awsRegion: options.awsRegion ?? this.awsRegion, + awsProfile: options.awsProfile ?? (preserveAwsAuth ? this.awsProfile : undefined), + awsAccessKeyId: options.awsAccessKeyId ?? (preserveAwsAuth ? this.awsAccessKeyId : undefined), + awsSecretAccessKey: + options.awsSecretAccessKey ?? (preserveAwsAuth ? this.awsSecretAccessKey : undefined), + awsSessionToken: options.awsSessionToken ?? (preserveAwsAuth ? this.awsSessionToken : undefined), + awsCredentialsProvider: + options.awsCredentialsProvider ?? (preserveAwsAuth ? this.awsCredentialsProvider : undefined), + baseURL, + ...(bedrockTokenProvider || preserveAwsAuth || awsAuthOverride ? + { apiKey: undefined, bedrockTokenProvider } + : {}), } as Partial); } } diff --git a/tests/lib/bedrock.test.ts b/tests/lib/bedrock.test.ts index 75cefadff..01714558c 100644 --- a/tests/lib/bedrock.test.ts +++ b/tests/lib/bedrock.test.ts @@ -1,5 +1,8 @@ import { BedrockOpenAI, NotFoundError, type BedrockClientOptions } from 'openai'; import { type RequestInfo, type RequestInit } from 'openai/internal/builtin-types'; +import { Hash } from '@smithy/hash-node'; +import { HttpRequest } from '@smithy/protocol-http'; +import { SignatureV4 } from '@smithy/signature-v4'; const RESPONSE_BODY = { id: 'resp_123', @@ -142,7 +145,8 @@ describe('instantiate bedrock client', () => { test('does not use OPENAI_API_KEY', () => { process.env['OPENAI_API_KEY'] = 'openai token'; process.env['AWS_REGION'] = 'us-west-2'; - expect(() => new BedrockOpenAI()).toThrow(/AWS_BEARER_TOKEN_BEDROCK/); + const client = new BedrockOpenAI(); + expect(client.apiKey).toBeNull(); }); test('requires endpoint configuration', () => { @@ -160,6 +164,39 @@ describe('instantiate bedrock client', () => { ).toThrow(/mutually exclusive/); }); + test('rejects an empty explicit bearer token', () => { + expect( + () => + new BedrockOpenAI({ + baseURL: 'https://example.com/openai/v1', + apiKey: '', + }), + ).toThrow(/must not be empty/); + }); + + test('rejects bearer and AWS credentials together', () => { + expect( + () => + new BedrockOpenAI({ + baseURL: 'https://example.com/openai/v1', + apiKey: 'token', + awsAccessKeyId: 'access key', + awsSecretAccessKey: 'secret key', + }), + ).toThrow(/mutually exclusive/); + }); + + test('rejects partial explicit AWS credentials', () => { + expect( + () => + new BedrockOpenAI({ + baseURL: 'https://example.com/openai/v1', + awsRegion: 'us-east-1', + awsAccessKeyId: 'access key', + }), + ).toThrow(/must be provided together/); + }); + test('requires refreshable tokens to use provider option', () => { expect( () => @@ -196,6 +233,182 @@ describe('instantiate bedrock client', () => { expect(authorizationHeaders).toEqual(['Bearer first', 'Bearer second']); }); + test('uses AWS token discovery and bearer signer for ambient bearer', async () => { + process.env['AWS_BEARER_TOKEN_BEDROCK'] = 'ambient token'; + process.env['AWS_REGION'] = 'us-east-1'; + const client = new BedrockOpenAI({ + fetch: async (_url, init) => { + expect(new Headers(init?.headers).get('authorization')).toBe('Bearer ambient token'); + return new globalThis.Response(JSON.stringify(RESPONSE_BODY), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }, + }); + client.apiKey = 'mutated cached token'; + + await client.responses.create({ model: 'gpt-4o', input: 'hello' }); + }); + + test('explicit AWS credentials override ambient bearer', async () => { + process.env['AWS_BEARER_TOKEN_BEDROCK'] = 'ambient token'; + const requests: Headers[] = []; + const client = new BedrockOpenAI({ + baseURL: 'https://example.com/openai/v1', + awsRegion: 'us-east-1', + awsAccessKeyId: 'access key', + awsSecretAccessKey: 'secret key', + awsSessionToken: 'session token', + fetch: async (_url, init) => { + requests.push(new Headers(init?.headers)); + return new globalThis.Response(JSON.stringify(RESPONSE_BODY), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }, + }); + + await client.responses.create({ model: 'gpt-4o', input: 'hello' }); + + expect(requests[0]!.get('authorization')).toContain('AWS4-HMAC-SHA256 Credential=access key/'); + expect(requests[0]!.get('authorization')).toContain('SignedHeaders='); + expect(requests[0]!.get('authorization')).toMatch(/SignedHeaders=[^,]*\bhost\b/); + expect(requests[0]!.get('host')).toBe('example.com'); + expect(requests[0]!.get('x-amz-security-token')).toBe('session token'); + }); + + test('signs the uppercase HTTP method transmitted by fetch', async () => { + let signatureMatches = false; + const client = new BedrockOpenAI({ + baseURL: 'https://example.com/openai/v1', + awsRegion: 'us-east-1', + awsAccessKeyId: 'access key', + awsSecretAccessKey: 'secret key', + fetch: async (url, init) => { + const parsedURL = new URL(String(url)); + const headers = Object.fromEntries(new Headers(init?.headers).entries()); + const actualAuthorization = headers['authorization']; + delete headers['authorization']; + const amzDate = headers['x-amz-date']!; + const signingDate = new Date( + amzDate.replace(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/, '$1-$2-$3T$4:$5:$6Z'), + ); + const expected = await new SignatureV4({ + credentials: { accessKeyId: 'access key', secretAccessKey: 'secret key' }, + region: 'us-east-1', + service: 'bedrock-mantle', + sha256: Hash.bind(null, 'sha256'), + }).sign( + new HttpRequest({ + protocol: parsedURL.protocol, + hostname: parsedURL.hostname, + method: init?.method?.toUpperCase() ?? 'GET', + path: parsedURL.pathname, + headers, + ...(init?.body ? { body: init.body } : {}), + }), + { signingDate }, + ); + signatureMatches = actualAuthorization === expected.headers['authorization']; + return new globalThis.Response(JSON.stringify(RESPONSE_BODY), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }, + }); + + await client.responses.create({ model: 'gpt-4o', input: 'hello' }); + + expect(signatureMatches).toBe(true); + }); + + test('signs query parameters as a Smithy query bag', async () => { + let signatureMatches = false; + const client = new BedrockOpenAI({ + baseURL: 'https://example.com/openai/v1', + awsRegion: 'us-east-1', + awsAccessKeyId: 'access key', + awsSecretAccessKey: 'secret key', + fetch: async (url, init) => { + const parsedURL = new URL(String(url)); + const headers = Object.fromEntries(new Headers(init?.headers).entries()); + const actualAuthorization = headers['authorization']; + delete headers['authorization']; + 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]; + } + const amzDate = headers['x-amz-date']!; + const signingDate = new Date( + amzDate.replace(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/, '$1-$2-$3T$4:$5:$6Z'), + ); + const expected = await new SignatureV4({ + credentials: { accessKeyId: 'access key', secretAccessKey: 'secret key' }, + region: 'us-east-1', + service: 'bedrock-mantle', + sha256: Hash.bind(null, 'sha256'), + }).sign( + new HttpRequest({ + protocol: parsedURL.protocol, + hostname: parsedURL.hostname, + method: init?.method?.toUpperCase() ?? 'GET', + path: parsedURL.pathname, + query, + headers, + }), + { signingDate }, + ); + signatureMatches = actualAuthorization === expected.headers['authorization']; + return new globalThis.Response(responseStreamSSE(), { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }); + }, + }); + + for await (const _event of await client.responses.retrieve('resp_123', { + starting_after: 1, + stream: true, + })) { + // Consume the stream so the request completes. + } + + expect(signatureMatches).toBe(true); + }); + + test('refreshes AWS credentials before retries', async () => { + const requests: Headers[] = []; + const credentials = [ + { accessKeyId: 'first access key', secretAccessKey: 'first secret', sessionToken: 'first token' }, + { accessKeyId: 'second access key', secretAccessKey: 'second secret', sessionToken: 'second token' }, + ]; + const client = new BedrockOpenAI({ + baseURL: 'https://example.com/openai/v1', + awsRegion: 'us-east-1', + awsCredentialsProvider: async () => credentials.shift()!, + fetch: async (_url, init) => { + requests.push(new Headers(init?.headers)); + const status = requests.length === 1 ? 500 : 200; + return new globalThis.Response( + JSON.stringify(status === 500 ? { error: 'server error' } : RESPONSE_BODY), + { status, headers: { 'Content-Type': 'application/json' } }, + ); + }, + maxRetries: 1, + }); + + await client.responses.create({ model: 'gpt-4o', input: 'hello' }); + + expect(requests[0]!.get('authorization')).toContain('Credential=first access key/'); + expect(requests[0]!.get('x-amz-security-token')).toBe('first token'); + expect(requests[1]!.get('authorization')).toContain('Credential=second access key/'); + expect(requests[1]!.get('x-amz-security-token')).toBe('second token'); + }); + test('preserves token provider across withOptions', async () => { const authorizationHeaders: string[] = []; const fetch = async (_url: RequestInfo, init?: RequestInit): Promise => { @@ -216,6 +429,64 @@ describe('instantiate bedrock client', () => { expect(authorizationHeaders).toEqual(['Bearer provider token']); }); + test('preserves AWS credentials across withOptions', async () => { + const requests: Headers[] = []; + const client = new BedrockOpenAI({ + baseURL: 'https://example.com/openai/v1', + awsRegion: 'us-east-1', + awsAccessKeyId: 'access key', + awsSecretAccessKey: 'secret key', + fetch: async (_url, init) => { + requests.push(new Headers(init?.headers)); + return new globalThis.Response(JSON.stringify(RESPONSE_BODY), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }, + }); + + await client.withOptions({ timeout: 1 }).responses.create({ model: 'gpt-4o', input: 'hello' }); + + expect(requests[0]!.get('authorization')).toContain('Credential=access key/'); + }); + + test('replaces the AWS credential source in withOptions', () => { + const explicitCredentialsClient = new BedrockOpenAI({ + baseURL: 'https://example.com/openai/v1', + awsRegion: 'us-east-1', + awsAccessKeyId: 'access key', + awsSecretAccessKey: 'secret key', + }); + + const profileClient = explicitCredentialsClient.withOptions({ awsProfile: 'other-profile' }); + expect(() => + profileClient.withOptions({ + awsAccessKeyId: 'replacement access key', + awsSecretAccessKey: 'replacement secret key', + }), + ).not.toThrow(); + }); + + test('recomputes a region-derived base URL in withOptions', () => { + const client = new BedrockOpenAI({ awsRegion: 'us-east-1', apiKey: 'token' }); + + const copiedClient = client.withOptions({ awsRegion: 'eu-west-1' }); + + expect(copiedClient.baseURL).toBe('https://bedrock-mantle.eu-west-1.api.aws/openai/v1'); + }); + + test('keeps an explicit base URL when the region changes in withOptions', () => { + const client = new BedrockOpenAI({ + baseURL: 'https://example.com/openai/v1', + awsRegion: 'us-east-1', + apiKey: 'token', + }); + + const copiedClient = client.withOptions({ awsRegion: 'eu-west-1' }); + + expect(copiedClient.baseURL).toBe('https://example.com/openai/v1'); + }); + test('passes non-Responses resources through', async () => { const requests: string[] = []; const fetch = async (url: RequestInfo): Promise => { diff --git a/yarn.lock b/yarn.lock index 24d0d19f9..1358eb4a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -38,6 +38,254 @@ typescript "5.6.1-rc" validate-npm-package-name "^5.0.0" +"@aws-crypto/crc32@5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/crc32/-/crc32-5.2.0.tgz#cfcc22570949c98c6689cfcbd2d693d36cdae2e1" + integrity sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg== + dependencies: + "@aws-crypto/util" "^5.2.0" + "@aws-sdk/types" "^3.222.0" + tslib "^2.6.2" + +"@aws-crypto/sha256-browser@5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz#153895ef1dba6f9fce38af550e0ef58988eb649e" + integrity sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw== + 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.222.0" + "@aws-sdk/util-locate-window" "^3.0.0" + "@smithy/util-utf8" "^2.0.0" + tslib "^2.6.2" + +"@aws-crypto/sha256-js@5.2.0", "@aws-crypto/sha256-js@^5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz#c4fdb773fdbed9a664fc1a95724e206cf3860042" + integrity sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA== + dependencies: + "@aws-crypto/util" "^5.2.0" + "@aws-sdk/types" "^3.222.0" + tslib "^2.6.2" + +"@aws-crypto/supports-web-crypto@^5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz#a1e399af29269be08e695109aa15da0a07b5b5fb" + integrity sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg== + dependencies: + tslib "^2.6.2" + +"@aws-crypto/util@^5.2.0": + version "5.2.0" + resolved "https://registry.yarnpkg.com/@aws-crypto/util/-/util-5.2.0.tgz#71284c9cffe7927ddadac793c14f14886d3876da" + integrity sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ== + dependencies: + "@aws-sdk/types" "^3.222.0" + "@smithy/util-utf8" "^2.0.0" + tslib "^2.6.2" + +"@aws-sdk/core@3.974.15", "@aws-sdk/core@^3.974.15": + version "3.974.15" + resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.974.15.tgz#841395d805ed33b8e4f30b1b86749922a0c6a058" + integrity sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw== + dependencies: + "@aws-sdk/types" "^3.973.9" + "@aws-sdk/xml-builder" "^3.972.26" + "@aws/lambda-invoke-store" "^0.2.2" + "@smithy/core" "^3.24.5" + "@smithy/signature-v4" "^5.4.5" + "@smithy/types" "^4.14.2" + bowser "^2.11.0" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-env@^3.972.41": + version "3.972.41" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.41.tgz#753b584626798be5310cf87976f3c72d410f709a" + integrity sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg== + dependencies: + "@aws-sdk/core" "^3.974.15" + "@aws-sdk/types" "^3.973.9" + "@smithy/core" "^3.24.5" + "@smithy/types" "^4.14.2" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-http@^3.972.43": + version "3.972.43" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.43.tgz#bc9af93cdba81df5ce487a3e46f232d677092c97" + integrity sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA== + dependencies: + "@aws-sdk/core" "^3.974.15" + "@aws-sdk/types" "^3.973.9" + "@smithy/core" "^3.24.5" + "@smithy/fetch-http-handler" "^5.4.5" + "@smithy/node-http-handler" "^4.7.5" + "@smithy/types" "^4.14.2" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-ini@^3.972.46": + version "3.972.46" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.46.tgz#3fea86be2fb9593ac7d339cb4c6ff78e9f69df30" + integrity sha512-hvcgcwOiS0nb2XFb5Op1Pz/vYaWz5K8kKullziGpdNRuG0NwzRXseuPt2CoBqknHGaSPVesu1aOn2OcctEYdCA== + dependencies: + "@aws-sdk/core" "^3.974.15" + "@aws-sdk/credential-provider-env" "^3.972.41" + "@aws-sdk/credential-provider-http" "^3.972.43" + "@aws-sdk/credential-provider-login" "^3.972.45" + "@aws-sdk/credential-provider-process" "^3.972.41" + "@aws-sdk/credential-provider-sso" "^3.972.45" + "@aws-sdk/credential-provider-web-identity" "^3.972.45" + "@aws-sdk/nested-clients" "^3.997.13" + "@aws-sdk/types" "^3.973.9" + "@smithy/core" "^3.24.5" + "@smithy/credential-provider-imds" "^4.3.6" + "@smithy/types" "^4.14.2" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-login@^3.972.45": + version "3.972.45" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.45.tgz#e11744272965d423ace09d3aa2b389248d665699" + integrity sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA== + dependencies: + "@aws-sdk/core" "^3.974.15" + "@aws-sdk/nested-clients" "^3.997.13" + "@aws-sdk/types" "^3.973.9" + "@smithy/core" "^3.24.5" + "@smithy/types" "^4.14.2" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-node@3.972.47": + version "3.972.47" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.47.tgz#f25a77bce8924688bb581e87f8d7ef1477eab9a0" + integrity sha512-HrId+C0DWA5qDIyLG64/kjUB2RNtPypxmABnIctK+TA1P1kHlOYoE/Wf5T5tKOMKgb08P7k/zNyhvfJ3lh5Oag== + dependencies: + "@aws-sdk/credential-provider-env" "^3.972.41" + "@aws-sdk/credential-provider-http" "^3.972.43" + "@aws-sdk/credential-provider-ini" "^3.972.46" + "@aws-sdk/credential-provider-process" "^3.972.41" + "@aws-sdk/credential-provider-sso" "^3.972.45" + "@aws-sdk/credential-provider-web-identity" "^3.972.45" + "@aws-sdk/types" "^3.973.9" + "@smithy/core" "^3.24.5" + "@smithy/credential-provider-imds" "^4.3.6" + "@smithy/types" "^4.14.2" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-process@^3.972.41": + version "3.972.41" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.41.tgz#68d2a4f46ec15e8cd0da1d1d3e56b8889df9b926" + integrity sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ== + dependencies: + "@aws-sdk/core" "^3.974.15" + "@aws-sdk/types" "^3.973.9" + "@smithy/core" "^3.24.5" + "@smithy/types" "^4.14.2" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-sso@^3.972.45": + version "3.972.45" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.45.tgz#6c934ed4905f1d0966f6ada39d07432b166b201a" + integrity sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA== + dependencies: + "@aws-sdk/core" "^3.974.15" + "@aws-sdk/nested-clients" "^3.997.13" + "@aws-sdk/token-providers" "3.1056.0" + "@aws-sdk/types" "^3.973.9" + "@smithy/core" "^3.24.5" + "@smithy/types" "^4.14.2" + tslib "^2.6.2" + +"@aws-sdk/credential-provider-web-identity@^3.972.45": + version "3.972.45" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.45.tgz#a8d189321695bf90a69344165814ef0274959221" + integrity sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ== + dependencies: + "@aws-sdk/core" "^3.974.15" + "@aws-sdk/nested-clients" "^3.997.13" + "@aws-sdk/types" "^3.973.9" + "@smithy/core" "^3.24.5" + "@smithy/types" "^4.14.2" + tslib "^2.6.2" + +"@aws-sdk/nested-clients@^3.997.13": + version "3.997.13" + resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.997.13.tgz#5566425fadaac7e9a31141eb12710c2c1cf0a184" + integrity sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg== + dependencies: + "@aws-crypto/sha256-browser" "5.2.0" + "@aws-crypto/sha256-js" "5.2.0" + "@aws-sdk/core" "^3.974.15" + "@aws-sdk/signature-v4-multi-region" "^3.996.30" + "@aws-sdk/types" "^3.973.9" + "@smithy/core" "^3.24.5" + "@smithy/fetch-http-handler" "^5.4.5" + "@smithy/node-http-handler" "^4.7.5" + "@smithy/types" "^4.14.2" + tslib "^2.6.2" + +"@aws-sdk/signature-v4-multi-region@^3.996.30": + version "3.996.30" + resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.30.tgz#78e324094413c0c1e2e4b8c77d6981d1a2393c9f" + integrity sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw== + dependencies: + "@aws-sdk/types" "^3.973.9" + "@smithy/signature-v4" "^5.4.5" + "@smithy/types" "^4.14.2" + tslib "^2.6.2" + +"@aws-sdk/token-providers@3.1056.0": + version "3.1056.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1056.0.tgz#a61372715ebc6527c4849c671539461fee4ca050" + integrity sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA== + dependencies: + "@aws-sdk/core" "^3.974.15" + "@aws-sdk/nested-clients" "^3.997.13" + "@aws-sdk/types" "^3.973.9" + "@smithy/core" "^3.24.5" + "@smithy/types" "^4.14.2" + tslib "^2.6.2" + +"@aws-sdk/token-providers@3.1057.0": + version "3.1057.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1057.0.tgz#066287fdf6152f1156699fc5cf0acf6b346ecec5" + integrity sha512-nIypx3Pvn9l7XoCi1a1ruY/FdUyfQW0LXk/2BdazRzs7rOAZeoSdZx9E1A6bmXIDedrG+09hFb8QlxhEk40jfA== + dependencies: + "@aws-sdk/core" "^3.974.15" + "@aws-sdk/nested-clients" "^3.997.13" + "@aws-sdk/types" "^3.973.9" + "@smithy/core" "^3.24.5" + "@smithy/types" "^4.14.2" + tslib "^2.6.2" + +"@aws-sdk/types@^3.222.0", "@aws-sdk/types@^3.973.9": + version "3.973.9" + resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.973.9.tgz#7d1c08cc6e82ec2ac2f2da102a7dd55806592f7f" + integrity sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg== + dependencies: + "@smithy/types" "^4.14.2" + tslib "^2.6.2" + +"@aws-sdk/util-locate-window@^3.0.0": + version "3.965.5" + resolved "https://registry.yarnpkg.com/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz#e30e6ff2aff6436209ed42c765dec2d2a48df7c0" + integrity sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ== + dependencies: + tslib "^2.6.2" + +"@aws-sdk/xml-builder@^3.972.26": + version "3.972.26" + resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.972.26.tgz#949366fe7c195f676f0ab9e002dd95b70942410c" + integrity sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g== + dependencies: + "@smithy/types" "^4.14.2" + fast-xml-parser "5.7.3" + tslib "^2.6.2" + +"@aws/lambda-invoke-store@^0.2.2": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz#802f6a50f6b6589063ef63ba8acdee86fcb9f395" + integrity sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ== + "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.28.6": version "7.28.6" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.28.6.tgz#72499312ec58b1e2245ba4a4f550c132be4982f7" @@ -688,6 +936,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@nodable/entities@^2.1.0": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@nodable/entities/-/entities-2.1.1.tgz#ce41931e9b72606d7f0598d665e46e889285d78a" + integrity sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -733,6 +986,97 @@ dependencies: "@sinonjs/commons" "^3.0.0" +"@smithy/core@3.24.5", "@smithy/core@^3.24.5": + version "3.24.5" + resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.24.5.tgz#396ca5662afc6d83a8f41b7e492e427c48a0924e" + integrity sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA== + dependencies: + "@aws-crypto/crc32" "5.2.0" + "@smithy/types" "^4.14.2" + tslib "^2.6.2" + +"@smithy/credential-provider-imds@^4.3.6": + version "4.3.6" + resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.6.tgz#dc1d06447b3208987489133923bcb6bbdd114cc6" + integrity sha512-tHhdiWZfG1ZIh2YcRfPJmY2gHcBmqbAzqm3ER4TIDFYsSEqTD5tICT7cgQ/kI8LRakxp12myOYyK68XPn7MnHw== + dependencies: + "@smithy/core" "^3.24.5" + "@smithy/types" "^4.14.2" + tslib "^2.6.2" + +"@smithy/fetch-http-handler@^5.4.5": + version "5.4.5" + resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.5.tgz#5087612b4cb22671c13c2b5abe96dfb6518ec9a1" + integrity sha512-SK3VMeH0fibgdTg2QeB+O4p7Yy/2E5HBOHJeC58FshkDdeuX8lOgO7PfjYfLyPLP1ch55j91cQqKBzDS0mRjSQ== + dependencies: + "@smithy/core" "^3.24.5" + "@smithy/types" "^4.14.2" + tslib "^2.6.2" + +"@smithy/hash-node@4.3.5": + version "4.3.5" + resolved "https://registry.yarnpkg.com/@smithy/hash-node/-/hash-node-4.3.5.tgz#cc57f732a3114426e08082ad35a4f91787fd79b6" + integrity sha512-/tUIDaB36qjLq/CIhMRIiFXCT7rVGBGAhFmMA9PbC/iW2u3QPNATZuFSdK0JBO3qeSPoHBeudFMmsbFq2Mf5EQ== + dependencies: + "@smithy/core" "^3.24.5" + tslib "^2.6.2" + +"@smithy/is-array-buffer@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz#f84f0d9f9a36601a9ca9381688bd1b726fd39111" + integrity sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA== + dependencies: + tslib "^2.6.2" + +"@smithy/node-http-handler@^4.7.5": + version "4.7.5" + resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.7.5.tgz#1fdb6ba04beababb54f20bfc1f74a34370fbdf51" + integrity sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw== + dependencies: + "@smithy/core" "^3.24.5" + "@smithy/types" "^4.14.2" + tslib "^2.6.2" + +"@smithy/protocol-http@5.4.5": + version "5.4.5" + resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-5.4.5.tgz#f510eba08ac21ab92d1947993d2b4c2b0bf7d923" + integrity sha512-jOD+4WNWQLntiLJn3r82C7BLheEbRCKTbU5U5bskZmT7nwRiGkh0IghuHwHRZ1ZEFXpHltQxxp9/koOPsdluJg== + dependencies: + "@smithy/core" "^3.24.5" + tslib "^2.6.2" + +"@smithy/signature-v4@5.4.5", "@smithy/signature-v4@^5.4.5": + version "5.4.5" + resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.4.5.tgz#61e369ce381f536f833a4c7c53afbe0175a47556" + integrity sha512-QBJKWGqIknH0dc9LWpfH1mkdokAx6iXYN3UcQ3eY6uIEyScuoQAhfl94ge7ozUy9WgFUdE8xsvwBjaYBbWmPNA== + dependencies: + "@smithy/core" "^3.24.5" + "@smithy/types" "^4.14.2" + tslib "^2.6.2" + +"@smithy/types@^4.14.2": + version "4.14.2" + resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.14.2.tgz#6034ff1e0e52bfb7d744ac371b651a8bf21f30f1" + integrity sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw== + dependencies: + tslib "^2.6.2" + +"@smithy/util-buffer-from@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz#6fc88585165ec73f8681d426d96de5d402021e4b" + integrity sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA== + dependencies: + "@smithy/is-array-buffer" "^2.2.0" + tslib "^2.6.2" + +"@smithy/util-utf8@^2.0.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@smithy/util-utf8/-/util-utf8-2.3.0.tgz#dd96d7640363259924a214313c3cf16e7dd329c5" + integrity sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A== + dependencies: + "@smithy/util-buffer-from" "^2.2.0" + tslib "^2.6.2" + "@swc/core-darwin-arm64@1.4.16": version "1.4.16" resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.4.16.tgz#2cd45d709ce76d448d96bf8d0006849541436611" @@ -1221,6 +1565,11 @@ baseline-browser-mapping@^2.9.0: resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz#3b6af0bc032445bca04de58caa9a87cfe921cbb3" integrity sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg== +bowser@^2.11.0: + version "2.14.1" + resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.14.1.tgz#4ea39bf31e305184522d7ad7bfd91389e4f0cb79" + integrity sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg== + brace-expansion@^2.0.2: version "2.1.1" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.1.1.tgz#c68b1c4111c76aae3a6fba55d496cee10c39dad8" @@ -1701,6 +2050,24 @@ fast-levenshtein@^2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-xml-builder@^1.1.7: + version "1.2.0" + resolved "https://registry.yarnpkg.com/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz#abd2363145a7625d9789ad96da375fabe3cff28c" + integrity sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q== + dependencies: + path-expression-matcher "^1.5.0" + xml-naming "^0.1.0" + +fast-xml-parser@5.7.3: + version "5.7.3" + resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz#309b04b08d835defc62ab657a0bb340c0e0fbe6a" + integrity sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg== + dependencies: + "@nodable/entities" "^2.1.0" + fast-xml-builder "^1.1.7" + path-expression-matcher "^1.5.0" + strnum "^2.2.3" + fastq@^1.6.0: version "1.17.1" resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.17.1.tgz#2a523f07a4e7b1e81a42b91b8bf2254107753b47" @@ -2800,6 +3167,11 @@ path-exists@^4.0.0: resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== +path-expression-matcher@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz#3b98545dc88ffebb593e2d8458d0929da9275f4a" + integrity sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ== + path-is-absolute@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" @@ -3111,6 +3483,11 @@ strip-json-comments@^3.1.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +strnum@^2.2.3: + version "2.3.0" + resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.3.0.tgz#81bfbfef53db8c3217ea62a98c026886ec4a2761" + integrity sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q== + superstruct@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/superstruct/-/superstruct-1.0.4.tgz#0adb99a7578bd2f1c526220da6571b2d485d91ca" @@ -3240,7 +3617,7 @@ tsconfig-paths@^4.0.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^2.8.1: +tslib@^2.6.2, tslib@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -3376,6 +3753,11 @@ ws@^8.18.0: resolved "https://registry.yarnpkg.com/ws/-/ws-8.20.0.tgz#4cd9532358eba60bc863aad1623dfb045a4d4af8" integrity sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA== +xml-naming@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/xml-naming/-/xml-naming-0.1.0.tgz#8ab7106c5b8d23caa2fabac1cadf17136379fbd8" + integrity sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw== + y18n@^5.0.5: version "5.0.8" resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" From b233e2843cb9a32d99a3db880748631f10d12aad Mon Sep 17 00:00:00 2001 From: Hayden Date: Fri, 12 Jun 2026 10:31:35 -0700 Subject: [PATCH 02/11] Add AWS-native Bedrock provider authentication --- README.md | 38 +- bedrock.md | 89 ++- .../ts-browser-webpack/src/index.ts | 17 + examples/bedrock/responses.ts | 11 +- jsr.json | 1 + package.json | 19 - scripts/build | 5 +- scripts/build-deno | 3 +- src/azure.ts | 5 +- src/bedrock.ts | 599 +++++------------- src/client.ts | 82 ++- src/internal/provider.ts | 50 ++ src/internal/utils/log.ts | 1 + src/lib/ResponsesParser.ts | 6 +- src/providers/bedrock.ts | 480 ++++++++++++++ tests/fixtures/bedrock/v1/sigv4.json | 22 + tests/lib/bedrock-provider.test.ts | 216 +++++++ tests/lib/bedrock.test.ts | 21 + tests/lib/provider.test.ts | 180 ++++++ yarn.lock | 16 +- 20 files changed, 1325 insertions(+), 536 deletions(-) create mode 100644 src/internal/provider.ts create mode 100644 src/providers/bedrock.ts create mode 100644 tests/fixtures/bedrock/v1/sigv4.json create mode 100644 tests/lib/bedrock-provider.test.ts create mode 100644 tests/lib/provider.test.ts diff --git a/README.md b/README.md index 24c65751e..74f1920d5 100644 --- a/README.md +++ b/README.md @@ -403,13 +403,15 @@ console.log(result.choices[0]!.message?.content); ## 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'; -// 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', @@ -419,33 +421,17 @@ const response = await client.responses.create({ console.log(response.output_text); ``` -`BedrockOpenAI` configures AWS authentication 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. - -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`: +Authentication is selected in this order: an explicit bearer or AWS credential option, `AWS_BEARER_TOKEN_BEDROCK`, then the default AWS credential chain. Pass `apiKey: null` to skip an ambient `AWS_BEARER_TOKEN_BEDROCK` and use the AWS credential chain. -```ts -const client = new BedrockOpenAI({ - awsRegion: 'us-west-2', - bedrockTokenProvider: async () => refreshBedrockToken(), -}); -``` +Bearer authentication requires no additional packages. For SigV4 authentication, install the optional AWS dependencies: -To use the standard AWS credential chain and SigV4 authentication, install the optional AWS signing dependencies and omit bearer-token configuration: - -```sh -npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/protocol-http @smithy/signature-v4 -``` - -```ts -const client = new BedrockOpenAI({ - awsRegion: 'us-west-2', - awsProfile: 'my-profile', // optional; otherwise uses the default AWS credential chain -}); +```bash +npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/signature-v4 ``` -You can also pass explicit temporary credentials or an `awsCredentialsProvider`. Explicit bearer and AWS credential options are mutually exclusive. Without explicit authentication, `AWS_BEARER_TOKEN_BEDROCK` takes precedence over the default AWS credential chain. +SigV4 authentication is supported in Node.js and compatible server runtimes. The SDK's current SigV4 mode requires replayable request bodies. Bearer authentication can be used in other runtimes. The legacy `BedrockOpenAI` class remains available for compatibility. For more information on support for Amazon Bedrock, see [bedrock.md](bedrock.md). diff --git a/bedrock.md b/bedrock.md index 3b7fe4b64..bdd279c13 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'; -// 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,30 +18,88 @@ const response = await client.responses.create({ console.log(response.output_text); ``` -`BedrockOpenAI` configures AWS authentication 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. +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 -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. +The provider selects authentication in this order: -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`: +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 BedrockOpenAI({ - awsRegion: 'us-west-2', - bedrockTokenProvider: async () => refreshBedrockToken(), +const client = new OpenAI({ + provider: bedrock({ + region: 'us-west-2', + tokenProvider: async () => refreshBedrockToken(), + }), +}); +``` + +Bearer authentication does not require any additional dependencies. Pass `apiKey: null` to skip an ambient `AWS_BEARER_TOKEN_BEDROCK` and explicitly select the default AWS credential chain: + +```ts +const client = new OpenAI({ + provider: bedrock({ region: 'us-west-2', apiKey: null }), }); ``` -To use the standard AWS credential chain and SigV4 authentication, install the optional AWS signing dependencies and omit bearer-token configuration: +### AWS credentials and SigV4 + +Install the optional AWS dependencies to sign requests with SigV4: ```sh -npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/protocol-http @smithy/signature-v4 +npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/signature-v4 +``` + +Omit explicit authentication to use the default AWS credential chain, or select a shared-config profile: + +```ts +const client = new OpenAI({ + provider: bedrock({ + region: 'us-west-2', + profile: 'my-profile', + }), +}); ``` +You can also pass `accessKeyId` and `secretAccessKey`, with an optional `sessionToken`, or provide refreshable credentials with `credentialProvider`. + +SigV4 authentication is supported in Node.js and compatible server runtimes. Bearer authentication can be used in other runtimes without loading the optional AWS packages. + +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 applications. It accepts the legacy `awsRegion`, `awsProfile`, `awsCredentialsProvider`, 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', - awsProfile: 'my-profile', // optional; otherwise uses the default AWS credential chain + awsProfile: 'my-profile', }); ``` -You can also pass explicit temporary credentials or an `awsCredentialsProvider`. Explicit bearer and AWS credential options are mutually exclusive. Without explicit authentication, `AWS_BEARER_TOKEN_BEDROCK` takes precedence over the default AWS credential chain. +New applications should prefer `new OpenAI({ provider: bedrock(...) })`. diff --git a/ecosystem-tests/ts-browser-webpack/src/index.ts b/ecosystem-tests/ts-browser-webpack/src/index.ts index 9a01bdc30..c0154dda8 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 e98a58bea..ef2249ba1 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'; -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 }); +// const client = new OpenAI({ +// provider: bedrock({ region: 'us-west-2', tokenProvider: getBedrockToken }), +// }); async function main() { const response = await client.responses.create({ diff --git a/jsr.json b/jsr.json index def955a3e..4a1afcc2c 100644 --- a/jsr.json +++ b/jsr.json @@ -3,6 +3,7 @@ "version": "6.42.0", "exports": { ".": "./index.ts", + "./providers/bedrock": "./providers/bedrock.ts", "./helpers/zod": "./helpers/zod.ts", "./beta/realtime/websocket": "./beta/realtime/websocket.ts" }, diff --git a/package.json b/package.json index cb57345a1..c713aaacb 100644 --- a/package.json +++ b/package.json @@ -29,10 +29,7 @@ "dependencies": {}, "devDependencies": { "@arethetypeswrong/cli": "^0.17.0", - "@aws-sdk/core": "3.974.15", "@aws-sdk/credential-provider-node": "3.972.47", - "@aws-sdk/token-providers": "3.1057.0", - "@smithy/core": "3.24.5", "@smithy/hash-node": "4.3.5", "@smithy/protocol-http": "5.4.5", "@smithy/signature-v4": "5.4.5", @@ -93,35 +90,19 @@ } }, "peerDependencies": { - "@aws-sdk/core": ">=3.974.0 <4", "@aws-sdk/credential-provider-node": ">=3.972.0 <4", - "@aws-sdk/token-providers": ">=3.1057.0 <4", - "@smithy/core": ">=3.24.0 <4", "@smithy/hash-node": ">=4.3.0 <5", - "@smithy/protocol-http": ">=5.4.0 <6", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { - "@aws-sdk/core": { - "optional": true - }, "@aws-sdk/credential-provider-node": { "optional": true }, - "@aws-sdk/token-providers": { - "optional": true - }, - "@smithy/core": { - "optional": true - }, "@smithy/hash-node": { "optional": true }, - "@smithy/protocol-http": { - "optional": true - }, "@smithy/signature-v4": { "optional": true }, diff --git a/scripts/build b/scripts/build index aa61a2943..d8d299883 100755 --- a/scripts/build +++ b/scripts/build @@ -15,7 +15,7 @@ rm -rf dist; mkdir dist # Copy src to dist/src and build from dist/src into dist, so that # the source map for index.js.map will refer to ./src/index.ts etc cp -rp src README.md dist -for file in LICENSE CHANGELOG.md; do +for file in LICENSE CHANGELOG.md bedrock.md; do if [ -e "${file}" ]; then cp "${file}" dist; fi done # this converts the export map paths for the dist directory @@ -36,6 +36,9 @@ 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) +(cd dist && node -e '(async () => { const { OpenAI } = require("openai"); const { bedrock } = await import("openai/providers/bedrock"); process.chdir(require("os").tmpdir()); const client = new OpenAI({ provider: bedrock({ region: "us-east-1", accessKeyId: "test", secretAccessKey: "test" }), fetch: async () => new Response("{}", { headers: { "content-type": "application/json" } }) }); await client.models.list(); })()') if [ "${OPENAI_DISABLE_DENO_BUILD:-0}" != "1" ] && [ -e ./scripts/build-deno ] then diff --git a/scripts/build-deno b/scripts/build-deno index 028d9dfe5..17d0933df 100755 --- a/scripts/build-deno +++ b/scripts/build-deno @@ -7,8 +7,9 @@ cd "$(dirname "$0")/.." rm -rf dist-deno; mkdir dist-deno cp -rp src/* jsr.json dist-deno -for file in README.md LICENSE CHANGELOG.md; do +for file in README.md LICENSE CHANGELOG.md bedrock.md; do if [ -e "${file}" ]; then cp "${file}" dist-deno; fi done node scripts/utils/convert-jsr-readme.cjs ./dist-deno/README.md +node scripts/utils/convert-jsr-readme.cjs ./dist-deno/bedrock.md diff --git a/src/azure.ts b/src/azure.ts index 3dff14b7a..a9130f4bc 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/bedrock.ts b/src/bedrock.ts index f030b730d..ec3b1fd68 100644 --- a/src/bedrock.ts +++ b/src/bedrock.ts @@ -1,344 +1,114 @@ import * as Errors from './error'; import { OpenAI } from './client'; import type { ApiKeySetter, ClientOptions } from './client'; -import type { NullableHeaders } from './internal/headers'; -import { buildHeaders } from './internal/headers'; -import type { FinalRequestOptions, RequestOptions } from './internal/request-options'; +import type { Provider } from './internal/provider'; import { readEnv } from './internal/utils'; -import { addOutputText } from './lib/ResponsesParser'; -import type { ResponseStreamParams } from './lib/responses/ResponseStream'; -import * as API from './resources/index'; -import type * as ResponsesAPI from './resources/responses/responses'; +import { bedrock, type AwsCredentialsProvider } from './providers/bedrock'; -export interface AwsCredentialIdentity { - accessKeyId: string; - secretAccessKey: string; - sessionToken?: string; - expiration?: Date; -} - -export type AwsCredentialsProvider = () => AwsCredentialIdentity | Promise; -type AsyncAwsCredentialsProvider = () => Promise; - -type BedrockBearerDependencies = { - authSchemePreference: typeof import('@aws-sdk/core/httpAuthSchemes').NODE_AUTH_SCHEME_PREFERENCE_OPTIONS; - fromEnvSigningName: typeof import('@aws-sdk/token-providers').fromEnvSigningName; - HttpBearerAuthSigner: typeof import('@smithy/core').HttpBearerAuthSigner; - HttpRequest: typeof import('@smithy/protocol-http').HttpRequest; -}; - -type BedrockSigV4Dependencies = { - defaultProvider: typeof import('@aws-sdk/credential-provider-node').defaultProvider; - Hash: typeof import('@smithy/hash-node').Hash; - HttpRequest: typeof import('@smithy/protocol-http').HttpRequest; - SignatureV4: typeof import('@smithy/signature-v4').SignatureV4; -}; - -let bedrockBearerDependencies: Promise | undefined; -let bedrockSigV4Dependencies: Promise | undefined; - -function loadBedrockBearerDependencies(): Promise { - return (bedrockBearerDependencies ??= Promise.all([ - import('@aws-sdk/core/httpAuthSchemes'), - import('@aws-sdk/token-providers'), - import('@smithy/core'), - import('@smithy/protocol-http'), - ]) - .then(([awsCore, tokenProviders, smithyCore, protocolHttp]) => ({ - authSchemePreference: awsCore.NODE_AUTH_SCHEME_PREFERENCE_OPTIONS, - fromEnvSigningName: tokenProviders.fromEnvSigningName, - HttpBearerAuthSigner: smithyCore.HttpBearerAuthSigner, - HttpRequest: protocolHttp.HttpRequest, - })) - .catch(() => undefined)); -} - -function loadBedrockSigV4Dependencies(): Promise { - return (bedrockSigV4Dependencies ??= Promise.all([ - import('@aws-sdk/credential-provider-node'), - import('@smithy/hash-node'), - import('@smithy/protocol-http'), - import('@smithy/signature-v4'), - ]).then(([credentialProvider, hashNode, protocolHttp, signatureV4]) => ({ - defaultProvider: credentialProvider.defaultProvider, - Hash: hashNode.Hash, - HttpRequest: protocolHttp.HttpRequest, - SignatureV4: signatureV4.SignatureV4, - }))); -} - -function bedrockAwsRequestTarget(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 }; -} - -class BedrockAwsBearerAuth { - constructor(private readonly fromEnvironment: boolean) {} - - async sign(url: string, request: RequestInit, token: string | null): Promise { - const dependencies = await loadBedrockBearerDependencies(); - if (!dependencies) return; - - let identity = token ? { token } : undefined; - if (this.fromEnvironment) { - dependencies.authSchemePreference.environmentVariableSelector(process.env, { - signingName: 'bedrock', - }); - identity = await dependencies.fromEnvSigningName({ signingName: 'bedrock' })(); - } - if (!identity) return; - - const parsedURL = new URL(url); - const target = bedrockAwsRequestTarget(parsedURL); - const headers = Object.fromEntries(new Headers(request.headers).entries()); - delete headers['authorization']; - const signed = await new dependencies.HttpBearerAuthSigner().sign( - new dependencies.HttpRequest({ - protocol: parsedURL.protocol, - hostname: parsedURL.hostname, - ...(parsedURL.port ? { port: Number(parsedURL.port) } : {}), - method: (request.method ?? 'GET').toUpperCase(), - ...target, - headers, - ...(request.body !== undefined && request.body !== null ? { body: request.body } : {}), - }), - identity, - {}, - ); - request.headers = new Headers(signed.headers); - } -} - -class BedrockAwsAuth { - private readonly region: string; - private readonly profile: string | undefined; - private readonly credentials: AwsCredentialIdentity | AwsCredentialsProvider | undefined; - private defaultCredentialsProvider: AsyncAwsCredentialsProvider | undefined; - private signer: InstanceType | undefined; - - constructor({ - region, - profile, - credentials, - }: { - region: string; - profile?: string | undefined; - credentials?: AwsCredentialIdentity | AwsCredentialsProvider | undefined; - }) { - this.region = region; - this.profile = profile; - this.credentials = credentials; - } - - async sign(url: string, request: RequestInit): Promise { - let dependencies: BedrockSigV4Dependencies; - try { - dependencies = await loadBedrockSigV4Dependencies(); - } catch (error) { - throw new Errors.OpenAIError( - `AWS credential authentication requires the optional AWS SDK dependencies. Install the Bedrock peer dependencies listed by the openai package. ${String( - error, - )}`, - ); - } - - const configuredCredentials = this.credentials; - const credentials = - typeof configuredCredentials === 'function' ? - async () => configuredCredentials() - : configuredCredentials ?? - (this.defaultCredentialsProvider ??= dependencies.defaultProvider( - this.profile ? { profile: this.profile } : {}, - )); - const signer = (this.signer ??= new dependencies.SignatureV4({ - credentials, - region: this.region, - service: 'bedrock-mantle', - sha256: dependencies.Hash.bind(null, 'sha256'), - })); - const parsedURL = new URL(url); - const target = bedrockAwsRequestTarget(parsedURL); - const headers = Object.fromEntries(new Headers(request.headers).entries()); - delete headers['authorization']; - headers['host'] = parsedURL.host; - const signed = await signer.sign( - new dependencies.HttpRequest({ - protocol: parsedURL.protocol, - hostname: parsedURL.hostname, - ...(parsedURL.port ? { port: Number(parsedURL.port) } : {}), - method: (request.method ?? 'GET').toUpperCase(), - ...target, - headers, - ...(request.body !== undefined && request.body !== null ? { body: request.body } : {}), - }), - ); - request.headers = new Headers(signed.headers); - } -} - -function hasExplicitAwsAuth(options: { - awsProfile?: string | undefined; - awsAccessKeyId?: string | undefined; - awsSecretAccessKey?: string | undefined; - awsSessionToken?: string | undefined; - awsCredentialsProvider?: AwsCredentialsProvider | undefined; -}): boolean { - return [ - options.awsProfile, - options.awsAccessKeyId, - options.awsSecretAccessKey, - options.awsSessionToken, - options.awsCredentialsProvider, - ].some((value) => value !== undefined); -} - -function validateExplicitAwsAuth(options: { - awsProfile?: string | undefined; - awsAccessKeyId?: string | undefined; - awsSecretAccessKey?: string | undefined; - awsSessionToken?: string | undefined; - awsCredentialsProvider?: AwsCredentialsProvider | undefined; -}): void { - if ((options.awsAccessKeyId === undefined) !== (options.awsSecretAccessKey === undefined)) { - throw new Errors.OpenAIError( - 'The `awsAccessKeyId` and `awsSecretAccessKey` arguments must be provided together.', - ); - } - const sources = [ - options.awsProfile !== undefined, - options.awsAccessKeyId !== undefined, - options.awsCredentialsProvider !== undefined, - ].filter(Boolean).length; - if (sources > 1) { - throw new Errors.OpenAIError( - 'The `awsProfile`, explicit AWS credentials, and `awsCredentialsProvider` arguments are mutually exclusive.', - ); - } - if (options.awsSessionToken !== undefined && options.awsAccessKeyId === undefined) { - throw new Errors.OpenAIError( - 'The `awsSessionToken` argument requires explicit AWS access key credentials.', - ); - } -} +export type { AwsCredentialIdentity, AwsCredentialsProvider } from './providers/bedrock'; export interface BedrockClientOptions - extends Omit { + extends Omit { /** - * Bedrock bearer token used for authentication. + * Bedrock bearer credential used for authentication. * * Defaults to process.env['AWS_BEARER_TOKEN_BEDROCK']. + * Pass null to skip the environment bearer fallback and use AWS credentials. */ apiKey?: string | null | undefined; /** * Bedrock API root. * - * Defaults to process.env['AWS_BEDROCK_BASE_URL'], or derives - * `https://bedrock-mantle..api.aws/openai/v1` from `awsRegion`, - * process.env['AWS_REGION'], or process.env['AWS_DEFAULT_REGION']. + * Defaults to process.env['AWS_BEDROCK_BASE_URL'], or derives the canonical + * `https://bedrock-mantle..api.aws/openai/v1` endpoint. */ baseURL?: string | null | undefined; - /** - * BedrockOpenAI only supports Bedrock bearer token or AWS credential authentication. - */ + /** BedrockOpenAI only supports Bedrock bearer or AWS credential authentication. */ adminAPIKey?: never; - /** - * BedrockOpenAI only supports Bedrock bearer token or AWS credential authentication. - */ + /** BedrockOpenAI only supports Bedrock bearer or AWS credential authentication. */ workloadIdentity?: never; - /** - * AWS region used to derive the default Bedrock Mantle endpoint. - * - * Defaults to process.env['AWS_REGION'] or process.env['AWS_DEFAULT_REGION']. - */ + /** AWS region used for SigV4 and to derive the default Bedrock Mantle endpoint. */ awsRegion?: string | undefined; /** AWS shared-config profile used by the standard credential chain. */ awsProfile?: string | undefined; - /** Explicit AWS access key ID. Must be provided with `awsSecretAccessKey`. */ + /** Explicit AWS access key ID. Must be provided with awsSecretAccessKey. */ awsAccessKeyId?: string | undefined; - /** Explicit AWS secret access key. Must be provided with `awsAccessKeyId`. */ + /** Explicit AWS secret access key. Must be provided with awsAccessKeyId. */ awsSecretAccessKey?: string | undefined; /** Optional session token for explicit temporary AWS credentials. */ awsSessionToken?: string | undefined; - /** Provider returning AWS SDK-compatible credentials. */ + /** Provider returning refreshable AWS credentials. */ awsCredentialsProvider?: AwsCredentialsProvider | undefined; - /** - * A function that returns a Bedrock bearer token and is invoked before each request. - */ + /** A function that resolves a Bedrock bearer credential before every request attempt. */ bedrockTokenProvider?: ApiKeySetter | undefined; } -/** Resolve the default Bedrock Mantle API root from the configured AWS region. */ -function deriveBedrockBaseURL(awsRegion: string | undefined): string { - const region = awsRegion?.trim(); - if (!region) { - throw new Errors.OpenAIError( - 'Must provide one of the `baseURL` or `awsRegion` arguments, or set the `AWS_BEDROCK_BASE_URL`, `AWS_REGION`, or `AWS_DEFAULT_REGION` environment variable.', - ); - } +type BedrockProviderState = { + provider?: Provider | undefined; + publicAPIKey: string | null; + usesEnvironmentBearerAuth: boolean; +}; - return `https://bedrock-mantle.${region}.api.aws/openai/v1`; -} +const bedrockProviderState = Symbol('bedrockProviderState'); -/** Normalize a Bedrock Responses URL variant back to the provider API root. */ -function normalizeBedrockBaseURL(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) || '/'; - } +type InternalBedrockClientOptions = BedrockClientOptions & { + provider?: Provider | undefined; + [bedrockProviderState]?: BedrockProviderState | undefined; +}; - return url.toString().replace(/\/$/, ''); +function hasOwn(object: object, key: PropertyKey): boolean { + return Object.prototype.hasOwnProperty.call(object, key); } -/** Restore the SDK convenience property when Bedrock omits it from a streamed final response. */ -function addBedrockOutputText(response: ResponseT): ResponseT { - if (!Object.getOwnPropertyDescriptor(response, 'output_text')) { - addOutputText(response); - } - - return response; +function hasExplicitAwsAuth(options: Partial): boolean { + return ( + hasOwn(options, 'awsProfile') || + hasOwn(options, 'awsAccessKeyId') || + hasOwn(options, 'awsSecretAccessKey') || + hasOwn(options, 'awsSessionToken') || + hasOwn(options, 'awsCredentialsProvider') + ); } -/** Keep the standard Responses surface while repairing Bedrock streamed final responses. */ -function restoreBedrockStreamOutputText(responses: API.Responses): API.Responses { - const stream = responses.stream.bind(responses); - - responses.stream = ((body: ResponseStreamParams, options?: RequestOptions) => { - const responseStream = stream(body, options); - const finalResponse = responseStream.finalResponse.bind(responseStream); - responseStream.finalResponse = async () => addBedrockOutputText(await finalResponse()); +function hasBearerAuthOverride(options: Partial): boolean { + return hasOwn(options, 'apiKey') || hasOwn(options, 'bedrockTokenProvider'); +} - return responseStream; - }) as API.Responses['stream']; +async function environmentBearerToken(): Promise { + 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 the default AWS credential chain.', + ); + } + return token; +} - return responses; +/** Resolve the Bedrock OpenAI-compatible endpoint from a region. */ +function deriveBedrockBaseURL(awsRegion: string | undefined): string { + const region = awsRegion?.trim(); + if (!region) { + throw new Errors.OpenAIError( + 'Must provide one of the `baseURL` or `awsRegion` arguments, or set the `AWS_BEDROCK_BASE_URL`, `AWS_REGION`, or `AWS_DEFAULT_REGION` environment variable.', + ); + } + return `https://bedrock-mantle.${region}.api.aws/openai/v1`; } /** API Client for interfacing with Amazon Bedrock's OpenAI-compatible endpoint. */ export class BedrockOpenAI extends OpenAI { + private readonly bedrockProvider: Provider; private readonly bedrockTokenProvider: ApiKeySetter | undefined; - private readonly bedrockAwsBearerAuth: BedrockAwsBearerAuth | undefined; - private readonly bedrockAwsAuth: BedrockAwsAuth | undefined; private readonly awsRegion: string | undefined; private readonly awsProfile: string | undefined; private readonly awsAccessKeyId: string | undefined; @@ -346,114 +116,84 @@ export class BedrockOpenAI extends OpenAI { private readonly awsSessionToken: string | undefined; private readonly awsCredentialsProvider: AwsCredentialsProvider | undefined; private readonly usesRegionDerivedBaseURL: boolean; - - /** - * API Client for interfacing with Amazon Bedrock's OpenAI-compatible endpoint. - * - * @param {string | null | undefined} [opts.apiKey=process.env['AWS_BEARER_TOKEN_BEDROCK'] ?? null] - * @param {string | null | undefined} [opts.baseURL=process.env['AWS_BEDROCK_BASE_URL'] ?? derived from opts.awsRegion or AWS_REGION/AWS_DEFAULT_REGION] - * @param {string | undefined} [opts.awsRegion=process.env['AWS_REGION'] ?? process.env['AWS_DEFAULT_REGION'] ?? undefined] - * @param {ApiKeySetter | undefined} opts.bedrockTokenProvider - A function that returns a Bedrock bearer token and is invoked before each request. - */ - constructor({ - baseURL = readEnv('AWS_BEDROCK_BASE_URL'), - apiKey, - awsRegion = readEnv('AWS_REGION') ?? readEnv('AWS_DEFAULT_REGION'), - awsProfile, - awsAccessKeyId, - awsSecretAccessKey, - awsSessionToken, - awsCredentialsProvider, - bedrockTokenProvider, - adminAPIKey, - workloadIdentity, - ...opts - }: BedrockClientOptions = {}) { - if (adminAPIKey || workloadIdentity) { - throw new Errors.OpenAIError( - 'BedrockOpenAI only supports Bedrock bearer token or AWS credential authentication.', - ); - } - - const explicitBearerAuth = apiKey != null || bedrockTokenProvider !== undefined; - const explicitAwsAuth = hasExplicitAwsAuth({ + private readonly usesEnvironmentBearerAuth: boolean; + + constructor(options?: BedrockClientOptions); + constructor(options: InternalBedrockClientOptions = {}) { + const { + [bedrockProviderState]: inheritedState, + provider: _inheritedProvider, + baseURL = readEnv('AWS_BEDROCK_BASE_URL'), + apiKey, + awsRegion = readEnv('AWS_REGION') ?? readEnv('AWS_DEFAULT_REGION'), awsProfile, awsAccessKeyId, awsSecretAccessKey, awsSessionToken, awsCredentialsProvider, - }); - if (explicitBearerAuth && explicitAwsAuth) { + bedrockTokenProvider, + adminAPIKey, + workloadIdentity, + ...opts + } = options; + + if (adminAPIKey != null || workloadIdentity != null) { throw new Errors.OpenAIError( - 'Bearer token and AWS credential authentication arguments are mutually exclusive.', + 'BedrockOpenAI only supports Bedrock bearer token or AWS credential authentication.', ); } - validateExplicitAwsAuth({ - awsProfile, - awsAccessKeyId, - awsSecretAccessKey, - awsSessionToken, - awsCredentialsProvider, - }); - - const ambientBearerAuth = !explicitBearerAuth && !explicitAwsAuth; - if (ambientBearerAuth) { - apiKey = readEnv('AWS_BEARER_TOKEN_BEDROCK') ?? null; - } - if (typeof (apiKey as unknown) === 'function') { throw new Errors.OpenAIError( 'Pass refreshable Bedrock credentials via `bedrockTokenProvider`, not `apiKey`.', ); } - if (apiKey === '') { - throw new Errors.OpenAIError('The `apiKey` argument must not be empty.'); - } - - if (apiKey && bedrockTokenProvider) { - throw new Errors.OpenAIError( - 'The `apiKey` and `bedrockTokenProvider` arguments are mutually exclusive; only one can be passed at a time.', - ); - } - - const useAwsAuth = !apiKey && !bedrockTokenProvider; - if (useAwsAuth && !awsRegion?.trim()) { - throw new Errors.OpenAIError( - 'AWS credential authentication requires `awsRegion`, `AWS_REGION`, or `AWS_DEFAULT_REGION`.', - ); - } - const explicitBaseURL = baseURL?.trim() ? baseURL : undefined; const usesRegionDerivedBaseURL = explicitBaseURL === undefined; const configuredBaseURL = explicitBaseURL ?? deriveBedrockBaseURL(awsRegion); + const explicitAwsAuth = + awsProfile !== undefined || + awsAccessKeyId !== undefined || + awsSecretAccessKey !== undefined || + awsSessionToken !== undefined || + awsCredentialsProvider !== undefined; + const usesEnvironmentBearerAuth = + inheritedState?.usesEnvironmentBearerAuth ?? + (!explicitAwsAuth && + !bedrockTokenProvider && + apiKey === undefined && + !!readEnv('AWS_BEARER_TOKEN_BEDROCK')); + const forceEnvironmentBearerAuth = + inheritedState !== undefined && usesEnvironmentBearerAuth && !inheritedState.provider; + const configuredProvider = + inheritedState?.provider ?? + bedrock({ + region: awsRegion, + baseURL: configuredBaseURL, + apiKey, + tokenProvider: forceEnvironmentBearerAuth ? environmentBearerToken : bedrockTokenProvider, + profile: awsProfile, + accessKeyId: awsAccessKeyId, + secretAccessKey: awsSecretAccessKey, + sessionToken: awsSessionToken, + credentialProvider: awsCredentialsProvider, + }); super({ - apiKey: useAwsAuth ? 'bedrock-aws-auth' : bedrockTokenProvider ?? apiKey, - adminAPIKey: null, - baseURL: normalizeBedrockBaseURL(configuredBaseURL), ...opts, + provider: configuredProvider, }); + const publicAPIKey = + inheritedState?.publicAPIKey ?? + (typeof apiKey === 'string' ? apiKey + : !explicitAwsAuth && !bedrockTokenProvider && apiKey !== null ? + readEnv('AWS_BEARER_TOKEN_BEDROCK') ?? null + : null); + + this.apiKey = publicAPIKey; + this.bedrockProvider = configuredProvider; this.bedrockTokenProvider = bedrockTokenProvider; - this.bedrockAwsBearerAuth = useAwsAuth ? undefined : new BedrockAwsBearerAuth(ambientBearerAuth); - this.bedrockAwsAuth = - useAwsAuth ? - new BedrockAwsAuth({ - region: awsRegion!, - profile: awsProfile, - credentials: - awsCredentialsProvider ?? - (awsAccessKeyId && awsSecretAccessKey ? - { - accessKeyId: awsAccessKeyId, - secretAccessKey: awsSecretAccessKey, - ...(awsSessionToken ? { sessionToken: awsSessionToken } : {}), - } - : undefined), - }) - : undefined; - if (useAwsAuth) this.apiKey = null; this.awsRegion = awsRegion; this.awsProfile = awsProfile; this.awsAccessKeyId = awsAccessKeyId; @@ -461,82 +201,65 @@ export class BedrockOpenAI extends OpenAI { this.awsSessionToken = awsSessionToken; this.awsCredentialsProvider = awsCredentialsProvider; this.usesRegionDerivedBaseURL = usesRegionDerivedBaseURL; - this.responses = restoreBedrockStreamOutputText(new API.Responses(this)); - } - - protected override async prepareOptions(options: FinalRequestOptions): Promise { - const security = options.__security ?? { bearerAuth: true }; - if (security.adminAPIKeyAuth && !security.bearerAuth) { - await this._callApiKey(); - } - - await super.prepareOptions(options); - } - - protected override async authHeaders( - opts: FinalRequestOptions, - schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, - ): Promise { - if (this.bedrockAwsAuth) return undefined; - - const security = schemes ?? { bearerAuth: true, adminAPIKeyAuth: true }; - if ((security.bearerAuth || security.adminAPIKeyAuth) && this.apiKey !== null) { - return buildHeaders([{ Authorization: `Bearer ${this.apiKey}` }]); - } - - return super.authHeaders(opts, security); - } - - protected override validateHeaders( - headers: NullableHeaders, - schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, - ): void { - if (this.bedrockAwsAuth) return; - super.validateHeaders(headers, schemes); - } - - protected override async prepareRequest( - request: RequestInit, - context: { url: string; options: FinalRequestOptions }, - ): Promise { - await super.prepareRequest(request, context); - if (this.bedrockAwsAuth) { - await this.bedrockAwsAuth.sign(context.url, request); - } else { - await this.bedrockAwsBearerAuth?.sign(context.url, request, this.apiKey); - } + this.usesEnvironmentBearerAuth = usesEnvironmentBearerAuth; } override withOptions(options: Partial): this { - const awsAuthOverride = hasExplicitAwsAuth(options); - const bedrockTokenProvider = - options.apiKey !== undefined || awsAuthOverride ? - undefined - : options.bedrockTokenProvider ?? this.bedrockTokenProvider; - const preserveAwsAuth = - this.bedrockAwsAuth !== undefined && - !awsAuthOverride && - options.apiKey === undefined && - !bedrockTokenProvider; + const bearerOverride = hasBearerAuthOverride(options); + const awsOverride = hasExplicitAwsAuth(options); + const routingOverride = hasOwn(options, 'baseURL') || hasOwn(options, 'awsRegion'); + const providerChanged = bearerOverride || awsOverride || routingOverride; + + const preserveBearer = !bearerOverride && !awsOverride; + const preserveAws = !bearerOverride && !awsOverride; const baseURL = - options.baseURL !== undefined ? options.baseURL - : options.awsRegion !== undefined && this.usesRegionDerivedBaseURL ? undefined + hasOwn(options, 'baseURL') ? options.baseURL + : hasOwn(options, 'awsRegion') && this.usesRegionDerivedBaseURL ? undefined : this.baseURL; - return super.withOptions({ + const nextOptions: InternalBedrockClientOptions = { ...options, + baseURL, awsRegion: options.awsRegion ?? this.awsRegion, - awsProfile: options.awsProfile ?? (preserveAwsAuth ? this.awsProfile : undefined), - awsAccessKeyId: options.awsAccessKeyId ?? (preserveAwsAuth ? this.awsAccessKeyId : undefined), + apiKey: + bearerOverride ? options.apiKey + : preserveBearer && !this.bedrockTokenProvider && !this.usesEnvironmentBearerAuth ? this.apiKey + : undefined, + bedrockTokenProvider: + bearerOverride ? options.bedrockTokenProvider + : preserveBearer ? this.bedrockTokenProvider + : undefined, + awsProfile: + awsOverride ? options.awsProfile + : preserveAws ? this.awsProfile + : undefined, + awsAccessKeyId: + awsOverride ? options.awsAccessKeyId + : preserveAws ? this.awsAccessKeyId + : undefined, awsSecretAccessKey: - options.awsSecretAccessKey ?? (preserveAwsAuth ? this.awsSecretAccessKey : undefined), - awsSessionToken: options.awsSessionToken ?? (preserveAwsAuth ? this.awsSessionToken : undefined), + awsOverride ? options.awsSecretAccessKey + : preserveAws ? this.awsSecretAccessKey + : undefined, + awsSessionToken: + awsOverride ? options.awsSessionToken + : preserveAws ? this.awsSessionToken + : undefined, awsCredentialsProvider: - options.awsCredentialsProvider ?? (preserveAwsAuth ? this.awsCredentialsProvider : undefined), - baseURL, - ...(bedrockTokenProvider || preserveAwsAuth || awsAuthOverride ? - { apiKey: undefined, bedrockTokenProvider } + awsOverride ? options.awsCredentialsProvider + : preserveAws ? this.awsCredentialsProvider + : undefined, + ...(!providerChanged || (routingOverride && preserveBearer && this.usesEnvironmentBearerAuth) ? + { + [bedrockProviderState]: { + provider: providerChanged ? undefined : this.bedrockProvider, + publicAPIKey: this.apiKey, + usesEnvironmentBearerAuth: this.usesEnvironmentBearerAuth, + }, + } : {}), - } as Partial); + }; + + return super.withOptions(nextOptions as Partial); } } diff --git a/src/client.ts b/src/client.ts index ac082a5e1..ac0beb8be 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,8 @@ 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 provider = options.provider ?? this._options.provider; + const inheritedOptions: ClientOptions = { ...this._options, baseURL: this.baseURL, maxRetries: this.maxRetries, @@ -501,7 +529,18 @@ 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; + } + + const client = new (this.constructor as any as new (props: ClientOptions) => typeof this)({ + ...inheritedOptions, ...options, + provider, }); return client; } @@ -510,7 +549,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 +631,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 +685,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 +761,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 +1159,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; } diff --git a/src/internal/provider.ts b/src/internal/provider.ts new file mode 100644 index 000000000..ad61cbbdf --- /dev/null +++ b/src/internal/provider.ts @@ -0,0 +1,50 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +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; +} + +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 94de74b5b..9e39b0f38 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/lib/ResponsesParser.ts b/src/lib/ResponsesParser.ts index 50a078ee9..ca1cbb472 100644 --- a/src/lib/ResponsesParser.ts +++ b/src/lib/ResponsesParser.ts @@ -31,7 +31,7 @@ export function maybeParseResponse< ParsedT = Params extends null ? null : ExtractParsedContentFromParams>, >(response: Response, params: Params): ParsedResponse { if (!params || !hasAutoParseableInput(params)) { - return { + const parsed: ParsedResponse = { ...response, output_parsed: null, output: response.output.map((item) => { @@ -55,6 +55,10 @@ export function maybeParseResponse< } }), }; + if (!Object.getOwnPropertyDescriptor(response, 'output_text')) { + addOutputText(parsed); + } + return parsed; } return parseResponse(response, params); diff --git a/src/providers/bedrock.ts b/src/providers/bedrock.ts new file mode 100644 index 000000000..3faa50d2e --- /dev/null +++ b/src/providers/bedrock.ts @@ -0,0 +1,480 @@ +import * as Errors from '../error'; +import type { ApiKeySetter } from '../client'; +import type { BodyInit } from '../internal/builtin-types'; +import type { FinalizedRequestInit } from '../internal/types'; +import { createProvider, type Provider, type ProviderRequestContext } from '../internal/provider'; +import { readEnv } from '../internal/utils'; + +const BEDROCK_SERVICE = 'bedrock-mantle'; +const BEDROCK_AWS_DEPENDENCIES = + 'npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/signature-v4'; + +export interface AwsCredentialIdentity { + accessKeyId: string; + secretAccessKey: string; + sessionToken?: string; + expiration?: Date; +} + +export type AwsCredentialsProvider = () => AwsCredentialIdentity | Promise; + +export interface BedrockProviderOptions { + /** AWS region used for SigV4 and 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; + + /** 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; + + /** 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; +} + +type SmithyRequest = { + protocol: string; + hostname: string; + port?: number; + method: string; + path: string; + query: Record; + headers: Record; + body?: string | ArrayBuffer | ArrayBufferView; +}; + +type SignatureV4 = { + sign( + request: SmithyRequest, + options?: { signingDate?: Date }, + ): Promise<{ headers: Record }>; +}; + +type SignatureV4Constructor = new (options: { + credentials: AwsCredentialIdentity | (() => Promise); + region: string; + service: string; + sha256: new (...args: any[]) => unknown; +}) => SignatureV4; + +type SigningDependencies = { + Hash: new (...args: any[]) => unknown; + SignatureV4: SignatureV4Constructor; +}; + +type DefaultProvider = (options?: { profile?: string }) => () => Promise; + +let signingDependencies: Promise | undefined; +let defaultProviderDependency: Promise | undefined; + +/** + * Keep optional AWS imports lazy and opaque to browser bundlers. TypeScript emits + * these as module-relative requires for CommonJS and preserves native dynamic + * imports for ESM, so resolution does not depend on the process working directory. + */ +async function importOptionalDependency(specifier: string): Promise> { + return import(/* webpackIgnore: true */ specifier); +} + +function loadSigningDependencies(): Promise { + return (signingDependencies ??= Promise.all([ + importOptionalDependency('@smithy/hash-node'), + importOptionalDependency('@smithy/signature-v4'), + ]).then(([hashNode, signatureV4]) => ({ + Hash: hashNode['Hash'], + SignatureV4: signatureV4['SignatureV4'], + }))); +} + +function loadDefaultProvider(): Promise { + return (defaultProviderDependency ??= importOptionalDependency('@aws-sdk/credential-provider-node').then( + (credentialProvider) => credentialProvider['defaultProvider'] as DefaultProvider, + )); +} + +function errorWithCause(message: string, cause: unknown): Errors.OpenAIError { + const error = new Errors.OpenAIError(message) as Errors.OpenAIError & { cause?: unknown }; + error.cause = cause; + return error; +} + +function isNodeLikeRuntime(): boolean { + return Object.prototype.toString.call((globalThis as any).process) === '[object process]'; +} + +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(/\/$/, ''); +} + +function resolveRegion(region: string | undefined): string | undefined { + return ( + normalizeOptionalString(region) ?? + normalizeOptionalString(readEnv('AWS_REGION')) ?? + normalizeOptionalString(readEnv('AWS_DEFAULT_REGION')) + ); +} + +function resolveBaseURL(baseURL: string | null | undefined, region: string | undefined): string { + const configured = + baseURL === undefined ? normalizeOptionalString(readEnv('AWS_BEDROCK_BASE_URL')) + : baseURL === null ? undefined + : normalizeOptionalString(baseURL); + if (configured) return normalizeBaseURL(configured); + if (!region) { + throw new Errors.OpenAIError( + 'Bedrock requires an AWS region. Pass `region` to `bedrock(...)`, or set `AWS_REGION` or `AWS_DEFAULT_REGION`.', + ); + } + return `https://bedrock-mantle.${region}.api.aws/openai/v1`; +} + +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 validateOptions(options: BedrockProviderOptions): { + staticCredentials: AwsCredentialIdentity | undefined; + explicitAwsAuth: boolean; + explicitBearerAuth: boolean; +} { + 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 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.'); + } + 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.'); + } + + const explicitBearerAuth = + (options.apiKey !== undefined && options.apiKey !== null) || !!options.tokenProvider; + 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; + if (explicitBearerAuth && 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.', + ); + } + if (options.apiKey && options.tokenProvider) { + throw new Errors.OpenAIError( + 'The `apiKey` and `tokenProvider` options are mutually exclusive. Configure only one.', + ); + } + + return { staticCredentials, explicitAwsAuth, explicitBearerAuth }; +} + +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 assertProviderOwnsAuthorization(headers: Headers): void { + if (headers.has('authorization')) { + throw new Errors.OpenAIError( + 'Bedrock provider authentication cannot be combined with a custom `Authorization` header.', + ); + } +} + +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 BedrockBearerAuth { + constructor(private readonly tokenProvider: ApiKeySetter) {} + + async prepareRequest(request: FinalizedRequestInit): 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; + } +} + +class BedrockSigV4Auth { + 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 async credentialsProvider(): Promise<() => 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())); + } + + let defaultProvider: DefaultProvider; + try { + defaultProvider = await loadDefaultProvider(); + } catch (cause) { + throw errorWithCause( + `Bedrock AWS authentication requires optional AWS dependencies. Run \`${BEDROCK_AWS_DEPENDENCIES}\` and try again.`, + cause, + ); + } + const provider = defaultProvider(this.options.profile ? { profile: this.options.profile } : {}); + return (this.resolvedCredentialsProvider = async () => validateCredentialIdentity(await provider())); + } + + private async signatureV4(): Promise { + if (this.signer) return this.signer; + + let dependencies: SigningDependencies; + try { + dependencies = await loadSigningDependencies(); + } catch (cause) { + throw errorWithCause( + `Bedrock AWS authentication requires optional AWS dependencies. Run \`${BEDROCK_AWS_DEPENDENCIES}\` and try again.`, + cause, + ); + } + const credentials = await this.credentialsProvider(); + return (this.signer = new dependencies.SignatureV4({ + credentials, + region: this.options.region, + service: BEDROCK_SERVICE, + sha256: dependencies.Hash.bind(null, 'sha256'), + })); + } + + async prepareRequest(request: FinalizedRequestInit, { url }: ProviderRequestContext): Promise { + if (!isNodeLikeRuntime()) { + 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); + const signer = await this.signatureV4(); + + let signed: { headers: Record }; + try { + signed = await signer.sign( + { + protocol: parsedURL.protocol, + hostname: parsedURL.hostname, + ...(parsedURL.port ? { port: Number(parsedURL.port) } : {}), + method, + ...requestTarget(parsedURL), + headers: Object.fromEntries(headers.entries()), + ...(body !== undefined ? { body } : {}), + }, + { signingDate: new Date() }, + ); + } catch (cause) { + const message = + this.options.usesDefaultChain ? + 'Could not find credentials for Bedrock. Pass a bearer credential or AWS credentials to `bedrock(...)`, set `AWS_BEARER_TOKEN_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 Mantle. */ +export function bedrock(options: BedrockProviderOptions = {}): Provider { + const { staticCredentials, explicitAwsAuth, explicitBearerAuth } = validateOptions(options); + const region = resolveRegion(options.region); + const baseURL = resolveBaseURL(options.baseURL, region); + const explicitAPIKey = options.apiKey; + const explicitTokenProvider = options.tokenProvider; + const profile = normalizeOptionalString(options.profile); + const credentialProvider = options.credentialProvider; + const environmentBearerAuth = + !explicitBearerAuth && + !explicitAwsAuth && + options.apiKey !== null && + !!readEnv('AWS_BEARER_TOKEN_BEDROCK'); + + return createProvider({ + configure() { + let auth: BedrockBearerAuth | BedrockSigV4Auth; + if (explicitBearerAuth) { + const tokenProvider = + explicitTokenProvider ?? + (async () => { + if (!explicitAPIKey) + throw new Errors.OpenAIError('The Bedrock bearer credential must not be empty.'); + return explicitAPIKey; + }); + auth = new BedrockBearerAuth(tokenProvider); + } else if (environmentBearerAuth) { + auth = 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 the default AWS credential chain.', + ); + } + return token; + }); + } else { + if (!region) { + throw new Errors.OpenAIError( + 'Bedrock requires an AWS region. Pass `region` to `bedrock(...)`, or set `AWS_REGION` or `AWS_DEFAULT_REGION`.', + ); + } + auth = new BedrockSigV4Auth({ + region, + staticCredentials, + profile, + credentialProvider, + usesDefaultChain: !explicitAwsAuth, + }); + } + + return { + name: 'bedrock', + baseURL, + prepareRequest: auth.prepareRequest.bind(auth), + }; + }, + }); +} diff --git a/tests/fixtures/bedrock/v1/sigv4.json b/tests/fixtures/bedrock/v1/sigv4.json new file mode 100644 index 000000000..e0e552ae1 --- /dev/null +++ b/tests/fixtures/bedrock/v1/sigv4.json @@ -0,0 +1,22 @@ +{ + "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/lib/bedrock-provider.test.ts b/tests/lib/bedrock-provider.test.ts new file mode 100644 index 000000000..1ec4959cc --- /dev/null +++ b/tests/lib/bedrock-provider.test.ts @@ -0,0 +1,216 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +import OpenAI from 'openai'; +import type { RequestInfo, RequestInit } from 'openai/internal/builtin-types'; +import { bedrock, type BedrockProviderOptions } from 'openai/providers/bedrock'; + +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(); + 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: bedrock({ 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: bedrock({ 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('baseURL: null skips the environment endpoint fallback', () => { + process.env['AWS_BEDROCK_BASE_URL'] = 'https://environment.example/v1'; + + const client = new OpenAI({ + provider: bedrock({ region: 'us-east-1', baseURL: null, apiKey: 'bedrock-token' }), + }); + + expect(client.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/openai/v1'); + }); + + 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: bedrock({ 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('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/); + }); +}); diff --git a/tests/lib/bedrock.test.ts b/tests/lib/bedrock.test.ts index 01714558c..bd01c1f2d 100644 --- a/tests/lib/bedrock.test.ts +++ b/tests/lib/bedrock.test.ts @@ -250,6 +250,27 @@ describe('instantiate bedrock client', () => { await client.responses.create({ model: 'gpt-4o', input: 'hello' }); }); + test('preserves ambient bearer mode when withOptions changes routing', async () => { + process.env['AWS_BEARER_TOKEN_BEDROCK'] = 'first token'; + process.env['AWS_REGION'] = 'us-east-1'; + const authorizationHeaders: string[] = []; + const fetch = async (_url: RequestInfo, init?: RequestInit): Promise => { + authorizationHeaders.push(new Headers(init?.headers).get('authorization') ?? ''); + return new globalThis.Response(JSON.stringify(RESPONSE_BODY), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }; + const client = new BedrockOpenAI({ fetch }); + + delete process.env['AWS_BEARER_TOKEN_BEDROCK']; + const copiedClient = client.withOptions({ baseURL: 'https://example.com/openai/v1' }); + process.env['AWS_BEARER_TOKEN_BEDROCK'] = 'refreshed token'; + await copiedClient.responses.create({ model: 'gpt-4o', input: 'hello' }); + + expect(authorizationHeaders).toEqual(['Bearer refreshed token']); + }); + test('explicit AWS credentials override ambient bearer', async () => { process.env['AWS_BEARER_TOKEN_BEDROCK'] = 'ambient token'; const requests: Headers[] = []; diff --git a/tests/lib/provider.test.ts b/tests/lib/provider.test.ts new file mode 100644 index 000000000..4527aac5c --- /dev/null +++ b/tests/lib/provider.test.ts @@ -0,0 +1,180 @@ +// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. + +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('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('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('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/yarn.lock b/yarn.lock index 1358eb4a3..78a79a850 100644 --- a/yarn.lock +++ b/yarn.lock @@ -85,7 +85,7 @@ "@smithy/util-utf8" "^2.0.0" tslib "^2.6.2" -"@aws-sdk/core@3.974.15", "@aws-sdk/core@^3.974.15": +"@aws-sdk/core@^3.974.15": version "3.974.15" resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.974.15.tgz#841395d805ed33b8e4f30b1b86749922a0c6a058" integrity sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw== @@ -245,18 +245,6 @@ "@smithy/types" "^4.14.2" tslib "^2.6.2" -"@aws-sdk/token-providers@3.1057.0": - version "3.1057.0" - resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1057.0.tgz#066287fdf6152f1156699fc5cf0acf6b346ecec5" - integrity sha512-nIypx3Pvn9l7XoCi1a1ruY/FdUyfQW0LXk/2BdazRzs7rOAZeoSdZx9E1A6bmXIDedrG+09hFb8QlxhEk40jfA== - dependencies: - "@aws-sdk/core" "^3.974.15" - "@aws-sdk/nested-clients" "^3.997.13" - "@aws-sdk/types" "^3.973.9" - "@smithy/core" "^3.24.5" - "@smithy/types" "^4.14.2" - tslib "^2.6.2" - "@aws-sdk/types@^3.222.0", "@aws-sdk/types@^3.973.9": version "3.973.9" resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.973.9.tgz#7d1c08cc6e82ec2ac2f2da102a7dd55806592f7f" @@ -986,7 +974,7 @@ dependencies: "@sinonjs/commons" "^3.0.0" -"@smithy/core@3.24.5", "@smithy/core@^3.24.5": +"@smithy/core@^3.24.5": version "3.24.5" resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.24.5.tgz#396ca5662afc6d83a8f41b7e492e427c48a0924e" integrity sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA== From 5a07b71c307a5a46aaac0462f42309da51e97716 Mon Sep 17 00:00:00 2001 From: Hayden Date: Fri, 12 Jun 2026 10:53:10 -0700 Subject: [PATCH 03/11] Expand Bedrock authentication test coverage --- src/internal/provider.ts | 2 - .../lib/bedrock-provider-dependencies.test.ts | 208 +++++++++++++++ tests/lib/bedrock-provider.test.ts | 242 ++++++++++++++++++ tests/lib/bedrock.test.ts | 77 ++++++ tests/lib/provider.test.ts | 58 +++++ 5 files changed, 585 insertions(+), 2 deletions(-) create mode 100644 tests/lib/bedrock-provider-dependencies.test.ts diff --git a/src/internal/provider.ts b/src/internal/provider.ts index ad61cbbdf..fd82d1d90 100644 --- a/src/internal/provider.ts +++ b/src/internal/provider.ts @@ -1,5 +1,3 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - import type { FinalRequestOptions } from './request-options'; import type { FinalizedRequestInit } from './types'; diff --git a/tests/lib/bedrock-provider-dependencies.test.ts b/tests/lib/bedrock-provider-dependencies.test.ts new file mode 100644 index 000000000..179ff622d --- /dev/null +++ b/tests/lib/bedrock-provider-dependencies.test.ts @@ -0,0 +1,208 @@ +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']; +}); + +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').bedrock; +}> { + const openai = require('openai') as typeof import('openai'); + const bedrockProvider = require('openai/providers/bedrock') as typeof import('openai/providers/bedrock'); + return { OpenAI: openai.default, bedrock: bedrockProvider.bedrock }; +} + +describe('Bedrock provider optional dependencies', () => { + 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('preserves an actionable message and cause when the default provider 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 () => { + const { OpenAI, bedrock } = await loadBedrockModules(); + const client = new OpenAI({ + provider: bedrock({ region: 'us-east-1', apiKey: null }), + maxRetries: 0, + fetch: jest.fn(async () => jsonResponse()), + }); + + let thrown: unknown; + try { + await client.request({ method: 'get', path: '/models' }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toContain( + 'npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/signature-v4', + ); + expect((thrown as Error & { cause?: unknown }).cause).toBe(missingDependency); + }); + }); + + test('preserves an actionable message and cause when a signing dependency is missing', async () => { + const missingDependency = new Error('Cannot find module @smithy/signature-v4'); + jest.doMock('@smithy/signature-v4', () => { + throw missingDependency; + }); + + await jest.isolateModulesAsync(async () => { + const { OpenAI, bedrock } = await loadBedrockModules(); + const client = new OpenAI({ + provider: bedrock({ + region: 'us-east-1', + accessKeyId: 'access-key', + secretAccessKey: 'secret-key', + }), + maxRetries: 0, + fetch: jest.fn(async () => jsonResponse()), + }); + + let thrown: unknown; + try { + await client.request({ method: 'get', path: '/models' }); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(Error); + expect((thrown as Error).message).toContain( + 'npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/signature-v4', + ); + expect((thrown as Error & { cause?: unknown }).cause).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 index 1ec4959cc..eca14cae0 100644 --- a/tests/lib/bedrock-provider.test.ts +++ b/tests/lib/bedrock-provider.test.ts @@ -2,7 +2,9 @@ import OpenAI from 'openai'; import type { RequestInfo, RequestInit } from 'openai/internal/builtin-types'; +import { configureProvider } from 'openai/internal/provider'; import { bedrock, type BedrockProviderOptions } from 'openai/providers/bedrock'; +import { SignatureV4 } from '@smithy/signature-v4'; import sigV4Fixture from '../fixtures/bedrock/v1/sigv4.json'; @@ -25,6 +27,7 @@ beforeEach(() => { afterEach(() => { jest.useRealTimers(); + jest.restoreAllMocks(); process.env = originalEnv; }); @@ -98,6 +101,24 @@ describe('bedrock provider', () => { 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(() => bedrock({ apiKey: 'bedrock-token' })).toThrow('Bedrock requires an AWS region'); + expect(() => + bedrock({ 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: bedrock({ + 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)); @@ -184,6 +205,212 @@ describe('bedrock provider', () => { 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: bedrock({ 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: bedrock({ 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({ @@ -213,4 +440,19 @@ describe('bedrock provider', () => { ])('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/bedrock.test.ts b/tests/lib/bedrock.test.ts index bd01c1f2d..953b09d2f 100644 --- a/tests/lib/bedrock.test.ts +++ b/tests/lib/bedrock.test.ts @@ -197,6 +197,20 @@ describe('instantiate bedrock client', () => { ).toThrow(/must be provided together/); }); + test.each([ + ['admin API key', { adminAPIKey: 'admin-key' }], + ['workload identity', { workloadIdentity: {} }], + ])('rejects an explicit %s', (_name, options) => { + expect( + () => + new BedrockOpenAI({ + baseURL: 'https://example.com/openai/v1', + apiKey: 'token', + ...options, + } as any), + ).toThrow('only supports Bedrock bearer token or AWS credential authentication'); + }); + test('requires refreshable tokens to use provider option', () => { expect( () => @@ -271,6 +285,21 @@ describe('instantiate bedrock client', () => { expect(authorizationHeaders).toEqual(['Bearer refreshed token']); }); + test('reports when ambient bearer auth disappears from a routing clone', async () => { + process.env['AWS_BEARER_TOKEN_BEDROCK'] = 'temporary token'; + process.env['AWS_REGION'] = 'us-east-1'; + const fetch = jest.fn(async () => new globalThis.Response(JSON.stringify(RESPONSE_BODY))); + const client = new BedrockOpenAI({ fetch }); + const copiedClient = client.withOptions({ baseURL: 'https://example.com/openai/v1' }); + delete process.env['AWS_BEARER_TOKEN_BEDROCK']; + + await expect(copiedClient.responses.create({ model: 'gpt-4o', input: 'hello' })).rejects.toMatchObject({ + message: 'Failed to resolve a bearer credential for Bedrock.', + cause: expect.objectContaining({ message: expect.stringContaining('Could not find credentials') }), + }); + expect(fetch).not.toHaveBeenCalled(); + }); + test('explicit AWS credentials override ambient bearer', async () => { process.env['AWS_BEARER_TOKEN_BEDROCK'] = 'ambient token'; const requests: Headers[] = []; @@ -488,6 +517,54 @@ describe('instantiate bedrock client', () => { ).not.toThrow(); }); + test('can switch from AWS credentials to bearer authentication in withOptions', async () => { + const authorizationHeaders: string[] = []; + const client = new BedrockOpenAI({ + baseURL: 'https://example.com/openai/v1', + awsRegion: 'us-east-1', + awsAccessKeyId: 'access key', + awsSecretAccessKey: 'secret key', + fetch: async (_url, init) => { + authorizationHeaders.push(new Headers(init?.headers).get('authorization') ?? ''); + return new globalThis.Response(JSON.stringify(RESPONSE_BODY), { + headers: { 'Content-Type': 'application/json' }, + }); + }, + }); + + await client + .withOptions({ apiKey: 'replacement bearer token' }) + .responses.create({ model: 'gpt-4o', input: 'hello' }); + + expect(authorizationHeaders).toEqual(['Bearer replacement bearer token']); + }); + + test('can switch from bearer authentication to an AWS credential provider in withOptions', async () => { + const authorizationHeaders: string[] = []; + const client = new BedrockOpenAI({ + baseURL: 'https://example.com/openai/v1', + awsRegion: 'us-east-1', + apiKey: 'bearer token', + fetch: async (_url, init) => { + authorizationHeaders.push(new Headers(init?.headers).get('authorization') ?? ''); + return new globalThis.Response(JSON.stringify(RESPONSE_BODY), { + headers: { 'Content-Type': 'application/json' }, + }); + }, + }); + + await client + .withOptions({ + awsCredentialsProvider: async () => ({ + accessKeyId: 'replacement access key', + secretAccessKey: 'replacement secret key', + }), + }) + .responses.create({ model: 'gpt-4o', input: 'hello' }); + + expect(authorizationHeaders[0]).toContain('AWS4-HMAC-SHA256 Credential=replacement access key/'); + }); + test('recomputes a region-derived base URL in withOptions', () => { const client = new BedrockOpenAI({ awsRegion: 'us-east-1', apiKey: 'token' }); diff --git a/tests/lib/provider.test.ts b/tests/lib/provider.test.ts index 4527aac5c..de4ca3ce3 100644 --- a/tests/lib/provider.test.ts +++ b/tests/lib/provider.test.ts @@ -85,6 +85,19 @@ describe('provider', () => { ).toThrow(`\`${key}\``); }); + test('reports every conflicting top-level option together', () => { + expect( + () => + new OpenAI({ + provider: provider(), + apiKey: 'openai-api-key', + adminAPIKey: 'openai-admin-key', + workloadIdentity: {}, + baseURL: 'https://override.example/v1', + }), + ).toThrow('`apiKey`, `adminAPIKey`, `workloadIdentity`, `baseURL`'); + }); + test('allows null top-level options', () => { expect( () => @@ -169,6 +182,51 @@ describe('provider', () => { '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('request logging redacts AWS session tokens', () => { From 9647947af13d467364f3ac72b2a95471a541af70 Mon Sep 17 00:00:00 2001 From: Hayden Date: Fri, 12 Jun 2026 10:58:24 -0700 Subject: [PATCH 04/11] Fix provider conflict test types --- tests/lib/provider.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/lib/provider.test.ts b/tests/lib/provider.test.ts index de4ca3ce3..e458fdf0d 100644 --- a/tests/lib/provider.test.ts +++ b/tests/lib/provider.test.ts @@ -92,7 +92,11 @@ describe('provider', () => { provider: provider(), apiKey: 'openai-api-key', adminAPIKey: 'openai-admin-key', - workloadIdentity: {}, + workloadIdentity: { + identityProviderId: 'identity-provider', + serviceAccountId: 'service-account', + provider: { tokenType: 'jwt', getToken: async () => 'subject-token' }, + }, baseURL: 'https://override.example/v1', }), ).toThrow('`apiKey`, `adminAPIKey`, `workloadIdentity`, `baseURL`'); From 2ae46820725a5cfc6e554e7b4128981d088741b1 Mon Sep 17 00:00:00 2001 From: Hayden Date: Mon, 15 Jun 2026 10:08:53 -0700 Subject: [PATCH 05/11] Align Bedrock auth options and endpoint --- README.md | 2 +- bedrock.md | 45 +++++++++++++++++++++++++--- src/bedrock.ts | 24 +++++++-------- src/providers/bedrock.ts | 2 +- tests/fixtures/bedrock/v1/sigv4.json | 6 ++-- tests/lib/bedrock-provider.test.ts | 10 +++---- tests/lib/bedrock.test.ts | 14 ++++----- 7 files changed, 70 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index 74f1920d5..5da21466c 100644 --- a/README.md +++ b/README.md @@ -421,7 +421,7 @@ const response = await client.responses.create({ console.log(response.output_text); ``` -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. +This uses the regional `https://bedrock-mantle..api.aws/v1` endpoint. The region can also come from `AWS_REGION` or `AWS_DEFAULT_REGION`, and `AWS_BEDROCK_BASE_URL` can override the endpoint. Authentication is selected in this order: an explicit bearer or AWS credential option, `AWS_BEARER_TOKEN_BEDROCK`, then the default AWS credential chain. Pass `apiKey: null` to skip an ambient `AWS_BEARER_TOKEN_BEDROCK` and use the AWS credential chain. diff --git a/bedrock.md b/bedrock.md index bdd279c13..b9729f61e 100644 --- a/bedrock.md +++ b/bedrock.md @@ -18,7 +18,7 @@ const response = await client.responses.create({ console.log(response.output_text); ``` -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 provider uses the regional `https://bedrock-mantle..api.aws/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: @@ -26,7 +26,7 @@ The region defaults to `AWS_REGION` or `AWS_DEFAULT_REGION`. Pass `baseURL` or s const client = new OpenAI({ provider: bedrock({ region: 'us-west-2', - baseURL: 'https://bedrock.example.com/openai/v1', + baseURL: 'https://bedrock.example.com/v1', }), }); ``` @@ -45,6 +45,17 @@ Explicit bearer and AWS credential modes are mutually exclusive. Similarly, conf 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: + ```ts const client = new OpenAI({ provider: bedrock({ @@ -81,7 +92,33 @@ const client = new OpenAI({ }); ``` -You can also pass `accessKeyId` and `secretAccessKey`, with an optional `sessionToken`, or provide refreshable credentials with `credentialProvider`. +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 optional AWS packages. @@ -91,7 +128,7 @@ Bedrock Mantle also supports `UNSIGNED-PAYLOAD` and AWS-chunked request signing, ## Legacy `BedrockOpenAI` class -The `BedrockOpenAI` class remains available for existing applications. It accepts the legacy `awsRegion`, `awsProfile`, `awsCredentialsProvider`, and `bedrockTokenProvider` option names and uses the same `/openai/v1` endpoint as the provider: +The `BedrockOpenAI` class remains available for existing applications. It accepts the `awsRegion`, `awsProfile`, `awsCredentialProvider`, and `bedrockTokenProvider` option names and uses the same `/v1` endpoint as the provider: ```ts import { BedrockOpenAI } from 'openai'; diff --git a/src/bedrock.ts b/src/bedrock.ts index ec3b1fd68..9cf4c30b8 100644 --- a/src/bedrock.ts +++ b/src/bedrock.ts @@ -21,7 +21,7 @@ export interface BedrockClientOptions * Bedrock API root. * * Defaults to process.env['AWS_BEDROCK_BASE_URL'], or derives the canonical - * `https://bedrock-mantle..api.aws/openai/v1` endpoint. + * `https://bedrock-mantle..api.aws/v1` endpoint. */ baseURL?: string | null | undefined; @@ -47,7 +47,7 @@ export interface BedrockClientOptions awsSessionToken?: string | undefined; /** Provider returning refreshable AWS credentials. */ - awsCredentialsProvider?: AwsCredentialsProvider | undefined; + awsCredentialProvider?: AwsCredentialsProvider | undefined; /** A function that resolves a Bedrock bearer credential before every request attempt. */ bedrockTokenProvider?: ApiKeySetter | undefined; @@ -76,7 +76,7 @@ function hasExplicitAwsAuth(options: Partial): boolean { hasOwn(options, 'awsAccessKeyId') || hasOwn(options, 'awsSecretAccessKey') || hasOwn(options, 'awsSessionToken') || - hasOwn(options, 'awsCredentialsProvider') + hasOwn(options, 'awsCredentialProvider') ); } @@ -102,7 +102,7 @@ function deriveBedrockBaseURL(awsRegion: string | undefined): string { 'Must provide one of the `baseURL` or `awsRegion` arguments, or set the `AWS_BEDROCK_BASE_URL`, `AWS_REGION`, or `AWS_DEFAULT_REGION` environment variable.', ); } - return `https://bedrock-mantle.${region}.api.aws/openai/v1`; + return `https://bedrock-mantle.${region}.api.aws/v1`; } /** API Client for interfacing with Amazon Bedrock's OpenAI-compatible endpoint. */ @@ -114,7 +114,7 @@ export class BedrockOpenAI extends OpenAI { private readonly awsAccessKeyId: string | undefined; private readonly awsSecretAccessKey: string | undefined; private readonly awsSessionToken: string | undefined; - private readonly awsCredentialsProvider: AwsCredentialsProvider | undefined; + private readonly awsCredentialProvider: AwsCredentialsProvider | undefined; private readonly usesRegionDerivedBaseURL: boolean; private readonly usesEnvironmentBearerAuth: boolean; @@ -130,7 +130,7 @@ export class BedrockOpenAI extends OpenAI { awsAccessKeyId, awsSecretAccessKey, awsSessionToken, - awsCredentialsProvider, + awsCredentialProvider, bedrockTokenProvider, adminAPIKey, workloadIdentity, @@ -156,7 +156,7 @@ export class BedrockOpenAI extends OpenAI { awsAccessKeyId !== undefined || awsSecretAccessKey !== undefined || awsSessionToken !== undefined || - awsCredentialsProvider !== undefined; + awsCredentialProvider !== undefined; const usesEnvironmentBearerAuth = inheritedState?.usesEnvironmentBearerAuth ?? (!explicitAwsAuth && @@ -176,7 +176,7 @@ export class BedrockOpenAI extends OpenAI { accessKeyId: awsAccessKeyId, secretAccessKey: awsSecretAccessKey, sessionToken: awsSessionToken, - credentialProvider: awsCredentialsProvider, + credentialProvider: awsCredentialProvider, }); super({ @@ -199,7 +199,7 @@ export class BedrockOpenAI extends OpenAI { this.awsAccessKeyId = awsAccessKeyId; this.awsSecretAccessKey = awsSecretAccessKey; this.awsSessionToken = awsSessionToken; - this.awsCredentialsProvider = awsCredentialsProvider; + this.awsCredentialProvider = awsCredentialProvider; this.usesRegionDerivedBaseURL = usesRegionDerivedBaseURL; this.usesEnvironmentBearerAuth = usesEnvironmentBearerAuth; } @@ -245,9 +245,9 @@ export class BedrockOpenAI extends OpenAI { awsOverride ? options.awsSessionToken : preserveAws ? this.awsSessionToken : undefined, - awsCredentialsProvider: - awsOverride ? options.awsCredentialsProvider - : preserveAws ? this.awsCredentialsProvider + awsCredentialProvider: + awsOverride ? options.awsCredentialProvider + : preserveAws ? this.awsCredentialProvider : undefined, ...(!providerChanged || (routingOverride && preserveBearer && this.usesEnvironmentBearerAuth) ? { diff --git a/src/providers/bedrock.ts b/src/providers/bedrock.ts index 3faa50d2e..d4f49afaa 100644 --- a/src/providers/bedrock.ts +++ b/src/providers/bedrock.ts @@ -150,7 +150,7 @@ function resolveBaseURL(baseURL: string | null | undefined, region: string | und 'Bedrock requires an AWS region. Pass `region` to `bedrock(...)`, or set `AWS_REGION` or `AWS_DEFAULT_REGION`.', ); } - return `https://bedrock-mantle.${region}.api.aws/openai/v1`; + return `https://bedrock-mantle.${region}.api.aws/v1`; } function validateStaticCredentials(options: BedrockProviderOptions): AwsCredentialIdentity | undefined { diff --git a/tests/fixtures/bedrock/v1/sigv4.json b/tests/fixtures/bedrock/v1/sigv4.json index e0e552ae1..81a0a0377 100644 --- a/tests/fixtures/bedrock/v1/sigv4.json +++ b/tests/fixtures/bedrock/v1/sigv4.json @@ -9,14 +9,14 @@ }, "request": { "method": "POST", - "url": "https://bedrock-mantle.us-east-1.api.aws/openai/v1/responses", + "url": "https://bedrock-mantle.us-east-1.api.aws/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" + "canonicalRequestHash": "af8212c327679e6b6a7092b129c24b82a921bf85bcce1fbd0126acded824e489", + "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=b81279dd7a01b9681012a7cf068f8ab7f663b85f0742741b509315ad22e32175" } } diff --git a/tests/lib/bedrock-provider.test.ts b/tests/lib/bedrock-provider.test.ts index eca14cae0..f90a2b4ef 100644 --- a/tests/lib/bedrock-provider.test.ts +++ b/tests/lib/bedrock-provider.test.ts @@ -52,8 +52,8 @@ describe('bedrock provider', () => { 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(client.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/v1'); + expect(String(requestedURL)).toBe('https://bedrock-mantle.us-east-1.api.aws/v1/models'); expect(new Headers(requestedInit?.headers).get('authorization')).toBe('Bearer bedrock-token'); }); @@ -98,7 +98,7 @@ describe('bedrock provider', () => { provider: bedrock({ region: 'us-east-1', baseURL: null, apiKey: 'bedrock-token' }), }); - expect(client.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/openai/v1'); + expect(client.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/v1'); }); test('requires a region only when deriving the default endpoint', () => { @@ -308,7 +308,7 @@ describe('bedrock provider', () => { 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', + url: 'https://bedrock-mantle.us-east-1.api.aws/v1/models', options: {} as any, }); } catch (error) { @@ -416,7 +416,7 @@ describe('bedrock provider', () => { const client = new OpenAI({ provider: bedrock({ region: 'us-east-1', - baseURL: 'https://bedrock-mantle.us-west-2.api.aws/openai/v1', + baseURL: 'https://bedrock-mantle.us-west-2.api.aws/v1', accessKeyId: 'access-key', secretAccessKey: 'secret-key', }), diff --git a/tests/lib/bedrock.test.ts b/tests/lib/bedrock.test.ts index 953b09d2f..b28052f9e 100644 --- a/tests/lib/bedrock.test.ts +++ b/tests/lib/bedrock.test.ts @@ -95,7 +95,7 @@ describe('instantiate bedrock client', () => { test('derives base URL from region', () => { const options: BedrockClientOptions = { awsRegion: 'us-east-1', apiKey: 'token' }; const client = new BedrockOpenAI(options); - expect(client.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/openai/v1'); + expect(client.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/v1'); }); test('uses Bedrock config precedence', () => { @@ -121,9 +121,9 @@ describe('instantiate bedrock client', () => { process.env['AWS_REGION'] = undefined; const defaultRegionClient = new BedrockOpenAI({ apiKey: 'token' }); - expect(explicitRegionClient.baseURL).toBe('https://bedrock-mantle.eu-west-1.api.aws/openai/v1'); - expect(awsRegionClient.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/openai/v1'); - expect(defaultRegionClient.baseURL).toBe('https://bedrock-mantle.us-west-2.api.aws/openai/v1'); + expect(explicitRegionClient.baseURL).toBe('https://bedrock-mantle.eu-west-1.api.aws/v1'); + expect(awsRegionClient.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/v1'); + expect(defaultRegionClient.baseURL).toBe('https://bedrock-mantle.us-west-2.api.aws/v1'); }); test('normalizes Responses URL', () => { @@ -439,7 +439,7 @@ describe('instantiate bedrock client', () => { const client = new BedrockOpenAI({ baseURL: 'https://example.com/openai/v1', awsRegion: 'us-east-1', - awsCredentialsProvider: async () => credentials.shift()!, + awsCredentialProvider: async () => credentials.shift()!, fetch: async (_url, init) => { requests.push(new Headers(init?.headers)); const status = requests.length === 1 ? 500 : 200; @@ -555,7 +555,7 @@ describe('instantiate bedrock client', () => { await client .withOptions({ - awsCredentialsProvider: async () => ({ + awsCredentialProvider: async () => ({ accessKeyId: 'replacement access key', secretAccessKey: 'replacement secret key', }), @@ -570,7 +570,7 @@ describe('instantiate bedrock client', () => { const copiedClient = client.withOptions({ awsRegion: 'eu-west-1' }); - expect(copiedClient.baseURL).toBe('https://bedrock-mantle.eu-west-1.api.aws/openai/v1'); + expect(copiedClient.baseURL).toBe('https://bedrock-mantle.eu-west-1.api.aws/v1'); }); test('keeps an explicit base URL when the region changes in withOptions', () => { From ce11aa00856c31e9c1276c98299c5b75269a5f5f Mon Sep 17 00:00:00 2001 From: Hayden Date: Mon, 15 Jun 2026 10:09:04 -0700 Subject: [PATCH 06/11] Add opt-in Bedrock live test --- jest.config.ts | 2 +- jest.live.config.ts | 11 +++ package.json | 1 + tests/live/bedrock.live.test.ts | 133 ++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 jest.live.config.ts create mode 100644 tests/live/bedrock.live.test.ts diff --git a/jest.config.ts b/jest.config.ts index 5d98b45f9..1c2d17173 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 000000000..a14ac9ffa --- /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/package.json b/package.json index c713aaacb..7f6428d5c 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "scripts": { "test": "./scripts/test", + "test:live:bedrock": "./node_modules/.bin/jest --config jest.live.config.ts --runInBand tests/live/bedrock.live.test.ts", "build": "./scripts/build", "prepublishOnly": "echo 'to publish, run yarn build && (cd dist; yarn publish)' && exit 1", "format": "./scripts/format", diff --git a/tests/live/bedrock.live.test.ts b/tests/live/bedrock.live.test.ts new file mode 100644 index 000000000..c54d7dd30 --- /dev/null +++ b/tests/live/bedrock.live.test.ts @@ -0,0 +1,133 @@ +import OpenAI from 'openai'; +import { bedrock, type BedrockProviderOptions } from 'openai/providers/bedrock'; + +/** + * Example: + * BEDROCK_LIVE_TEST=1 BEDROCK_LIVE_AUTH=profile AWS_PROFILE=my-profile \ + * AWS_REGION=us-west-2 BEDROCK_MODEL=openai.gpt-5.4 npm run test:live:bedrock + * + * 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 authOptions(mode: AuthMode): Promise { + switch (mode) { + case 'bearer': + return { apiKey: requiredEnv('AWS_BEARER_TOKEN_BEDROCK') }; + case 'environment-bearer': + requiredEnv('AWS_BEARER_TOKEN_BEDROCK'); + return {}; + case 'default-chain': + return { apiKey: null }; + case 'profile': + return { apiKey: null, profile: requiredEnv('AWS_PROFILE') }; + case 'static': { + const sessionToken = process.env['AWS_SESSION_TOKEN']?.trim(); + return { + 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 { + 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 \`npm run 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: bedrock({ + region, + ...(baseURL ? { baseURL } : {}), + ...(await authOptions(authMode)), + }), + 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); + }); +}); From 3dfd7f5e5c9574f02642a42b3790b70acf8072b5 Mon Sep 17 00:00:00 2001 From: Hayden Date: Mon, 15 Jun 2026 10:10:18 -0700 Subject: [PATCH 07/11] Keep confirmed Bedrock compatibility endpoint --- README.md | 2 +- bedrock.md | 6 +++--- src/bedrock.ts | 4 ++-- src/providers/bedrock.ts | 2 +- tests/fixtures/bedrock/v1/sigv4.json | 6 +++--- tests/lib/bedrock-provider.test.ts | 10 +++++----- tests/lib/bedrock.test.ts | 10 +++++----- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 5da21466c..74f1920d5 100644 --- a/README.md +++ b/README.md @@ -421,7 +421,7 @@ const response = await client.responses.create({ console.log(response.output_text); ``` -This uses the regional `https://bedrock-mantle..api.aws/v1` endpoint. The region can also come from `AWS_REGION` or `AWS_DEFAULT_REGION`, and `AWS_BEDROCK_BASE_URL` can override the endpoint. +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. Authentication is selected in this order: an explicit bearer or AWS credential option, `AWS_BEARER_TOKEN_BEDROCK`, then the default AWS credential chain. Pass `apiKey: null` to skip an ambient `AWS_BEARER_TOKEN_BEDROCK` and use the AWS credential chain. diff --git a/bedrock.md b/bedrock.md index b9729f61e..127459100 100644 --- a/bedrock.md +++ b/bedrock.md @@ -18,7 +18,7 @@ const response = await client.responses.create({ console.log(response.output_text); ``` -The provider uses the regional `https://bedrock-mantle..api.aws/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 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: @@ -26,7 +26,7 @@ The region defaults to `AWS_REGION` or `AWS_DEFAULT_REGION`. Pass `baseURL` or s const client = new OpenAI({ provider: bedrock({ region: 'us-west-2', - baseURL: 'https://bedrock.example.com/v1', + baseURL: 'https://bedrock.example.com/openai/v1', }), }); ``` @@ -128,7 +128,7 @@ Bedrock Mantle also supports `UNSIGNED-PAYLOAD` and AWS-chunked request signing, ## Legacy `BedrockOpenAI` class -The `BedrockOpenAI` class remains available for existing applications. It accepts the `awsRegion`, `awsProfile`, `awsCredentialProvider`, and `bedrockTokenProvider` option names and uses the same `/v1` endpoint as the provider: +The `BedrockOpenAI` class remains available for existing applications. It accepts the `awsRegion`, `awsProfile`, `awsCredentialProvider`, and `bedrockTokenProvider` option names and uses the same `/openai/v1` endpoint as the provider: ```ts import { BedrockOpenAI } from 'openai'; diff --git a/src/bedrock.ts b/src/bedrock.ts index 9cf4c30b8..b64762d44 100644 --- a/src/bedrock.ts +++ b/src/bedrock.ts @@ -21,7 +21,7 @@ export interface BedrockClientOptions * Bedrock API root. * * Defaults to process.env['AWS_BEDROCK_BASE_URL'], or derives the canonical - * `https://bedrock-mantle..api.aws/v1` endpoint. + * `https://bedrock-mantle..api.aws/openai/v1` endpoint. */ baseURL?: string | null | undefined; @@ -102,7 +102,7 @@ function deriveBedrockBaseURL(awsRegion: string | undefined): string { 'Must provide one of the `baseURL` or `awsRegion` arguments, or set the `AWS_BEDROCK_BASE_URL`, `AWS_REGION`, or `AWS_DEFAULT_REGION` environment variable.', ); } - return `https://bedrock-mantle.${region}.api.aws/v1`; + return `https://bedrock-mantle.${region}.api.aws/openai/v1`; } /** API Client for interfacing with Amazon Bedrock's OpenAI-compatible endpoint. */ diff --git a/src/providers/bedrock.ts b/src/providers/bedrock.ts index d4f49afaa..3faa50d2e 100644 --- a/src/providers/bedrock.ts +++ b/src/providers/bedrock.ts @@ -150,7 +150,7 @@ function resolveBaseURL(baseURL: string | null | undefined, region: string | und 'Bedrock requires an AWS region. Pass `region` to `bedrock(...)`, or set `AWS_REGION` or `AWS_DEFAULT_REGION`.', ); } - return `https://bedrock-mantle.${region}.api.aws/v1`; + return `https://bedrock-mantle.${region}.api.aws/openai/v1`; } function validateStaticCredentials(options: BedrockProviderOptions): AwsCredentialIdentity | undefined { diff --git a/tests/fixtures/bedrock/v1/sigv4.json b/tests/fixtures/bedrock/v1/sigv4.json index 81a0a0377..e0e552ae1 100644 --- a/tests/fixtures/bedrock/v1/sigv4.json +++ b/tests/fixtures/bedrock/v1/sigv4.json @@ -9,14 +9,14 @@ }, "request": { "method": "POST", - "url": "https://bedrock-mantle.us-east-1.api.aws/v1/responses", + "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": "af8212c327679e6b6a7092b129c24b82a921bf85bcce1fbd0126acded824e489", - "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=b81279dd7a01b9681012a7cf068f8ab7f663b85f0742741b509315ad22e32175" + "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/lib/bedrock-provider.test.ts b/tests/lib/bedrock-provider.test.ts index f90a2b4ef..eca14cae0 100644 --- a/tests/lib/bedrock-provider.test.ts +++ b/tests/lib/bedrock-provider.test.ts @@ -52,8 +52,8 @@ describe('bedrock provider', () => { await client.request({ method: 'get', path: '/models' }); - expect(client.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/v1'); - expect(String(requestedURL)).toBe('https://bedrock-mantle.us-east-1.api.aws/v1/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'); }); @@ -98,7 +98,7 @@ describe('bedrock provider', () => { provider: bedrock({ region: 'us-east-1', baseURL: null, apiKey: 'bedrock-token' }), }); - expect(client.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/v1'); + expect(client.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/openai/v1'); }); test('requires a region only when deriving the default endpoint', () => { @@ -308,7 +308,7 @@ describe('bedrock provider', () => { let thrown: unknown; try { await runtime.prepareRequest!({ headers: new Headers(), method: 'GET' } as any, { - url: 'https://bedrock-mantle.us-east-1.api.aws/v1/models', + url: 'https://bedrock-mantle.us-east-1.api.aws/openai/v1/models', options: {} as any, }); } catch (error) { @@ -416,7 +416,7 @@ describe('bedrock provider', () => { const client = new OpenAI({ provider: bedrock({ region: 'us-east-1', - baseURL: 'https://bedrock-mantle.us-west-2.api.aws/v1', + baseURL: 'https://bedrock-mantle.us-west-2.api.aws/openai/v1', accessKeyId: 'access-key', secretAccessKey: 'secret-key', }), diff --git a/tests/lib/bedrock.test.ts b/tests/lib/bedrock.test.ts index b28052f9e..4cc507e27 100644 --- a/tests/lib/bedrock.test.ts +++ b/tests/lib/bedrock.test.ts @@ -95,7 +95,7 @@ describe('instantiate bedrock client', () => { test('derives base URL from region', () => { const options: BedrockClientOptions = { awsRegion: 'us-east-1', apiKey: 'token' }; const client = new BedrockOpenAI(options); - expect(client.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/v1'); + expect(client.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/openai/v1'); }); test('uses Bedrock config precedence', () => { @@ -121,9 +121,9 @@ describe('instantiate bedrock client', () => { process.env['AWS_REGION'] = undefined; const defaultRegionClient = new BedrockOpenAI({ apiKey: 'token' }); - expect(explicitRegionClient.baseURL).toBe('https://bedrock-mantle.eu-west-1.api.aws/v1'); - expect(awsRegionClient.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/v1'); - expect(defaultRegionClient.baseURL).toBe('https://bedrock-mantle.us-west-2.api.aws/v1'); + expect(explicitRegionClient.baseURL).toBe('https://bedrock-mantle.eu-west-1.api.aws/openai/v1'); + expect(awsRegionClient.baseURL).toBe('https://bedrock-mantle.us-east-1.api.aws/openai/v1'); + expect(defaultRegionClient.baseURL).toBe('https://bedrock-mantle.us-west-2.api.aws/openai/v1'); }); test('normalizes Responses URL', () => { @@ -570,7 +570,7 @@ describe('instantiate bedrock client', () => { const copiedClient = client.withOptions({ awsRegion: 'eu-west-1' }); - expect(copiedClient.baseURL).toBe('https://bedrock-mantle.eu-west-1.api.aws/v1'); + expect(copiedClient.baseURL).toBe('https://bedrock-mantle.eu-west-1.api.aws/openai/v1'); }); test('keeps an explicit base URL when the region changes in withOptions', () => { From 18bccb7ce4e5b3da62d130ccb8af2107c934ff4b Mon Sep 17 00:00:00 2001 From: Hayden Date: Mon, 15 Jun 2026 11:33:10 -0700 Subject: [PATCH 08/11] Use Responses-compatible Bedrock model in examples --- README.md | 4 +++- bedrock.md | 4 +++- examples/bedrock/responses.ts | 2 +- tests/live/bedrock.live.test.ts | 5 ++++- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 74f1920d5..e8577f31c 100644 --- a/README.md +++ b/README.md @@ -414,13 +414,15 @@ const client = new OpenAI({ }); const response = await client.responses.create({ - model: 'openai.gpt-5.4', + model: 'openai.gpt-oss-120b', input: 'Say hello!', }); console.log(response.output_text); ``` +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. + 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. Authentication is selected in this order: an explicit bearer or AWS credential option, `AWS_BEARER_TOKEN_BEDROCK`, then the default AWS credential chain. Pass `apiKey: null` to skip an ambient `AWS_BEARER_TOKEN_BEDROCK` and use the AWS credential chain. diff --git a/bedrock.md b/bedrock.md index 127459100..d246a5dc3 100644 --- a/bedrock.md +++ b/bedrock.md @@ -11,13 +11,15 @@ const client = new OpenAI({ }); const response = await client.responses.create({ - model: 'openai.gpt-5.4', + model: 'openai.gpt-oss-120b', input: 'Say hello!', }); console.log(response.output_text); ``` +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: diff --git a/examples/bedrock/responses.ts b/examples/bedrock/responses.ts index 58a5203ac..e22ad6c29 100644 --- a/examples/bedrock/responses.ts +++ b/examples/bedrock/responses.ts @@ -14,7 +14,7 @@ const client = new OpenAI({ async function main() { const response = await client.responses.create({ - model: 'openai.gpt-5.4', + model: 'openai.gpt-oss-120b', input: 'Say hello!', }); diff --git a/tests/live/bedrock.live.test.ts b/tests/live/bedrock.live.test.ts index 58c089e63..b1b3ec90a 100644 --- a/tests/live/bedrock.live.test.ts +++ b/tests/live/bedrock.live.test.ts @@ -4,7 +4,10 @@ import { bedrock, type BedrockProviderOptions } from 'openai/providers/bedrock'; /** * Example: * BEDROCK_LIVE_TEST=1 BEDROCK_LIVE_AUTH=profile AWS_PROFILE=my-profile \ - * AWS_REGION=us-west-2 BEDROCK_MODEL=openai.gpt-5.4 pnpm test:live:bedrock + * 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. */ From 980c99d5caea8dde15f5c2bc9d22371f96a8a660 Mon Sep 17 00:00:00 2001 From: Hayden Date: Mon, 15 Jun 2026 11:44:33 -0700 Subject: [PATCH 09/11] Address Bedrock review feedback --- README.md | 6 ++++++ bedrock.md | 6 ++++++ package.json | 10 +++++----- pnpm-lock.yaml | 8 ++++---- src/client.ts | 3 +++ tests/lib/provider.test.ts | 23 +++++++++++++++++++++++ 6 files changed, 47 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e8577f31c..a5939caee 100644 --- a/README.md +++ b/README.md @@ -433,6 +433,12 @@ Bearer authentication requires no additional packages. For SigV4 authentication, npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/signature-v4 ``` +These dependencies load only when SigV4 authentication is used. If they are missing, the first signed request throws an `OpenAIError` with this message: + +```text +Bedrock AWS authentication requires optional AWS dependencies. Run `npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/signature-v4` and try again. +``` + SigV4 authentication is supported in Node.js and compatible server runtimes. The SDK's current SigV4 mode requires replayable request bodies. Bearer authentication can be used in other runtimes. The legacy `BedrockOpenAI` class remains available for compatibility. For more information on support for Amazon Bedrock, see [bedrock.md](bedrock.md). diff --git a/bedrock.md b/bedrock.md index d246a5dc3..d0b2c2397 100644 --- a/bedrock.md +++ b/bedrock.md @@ -83,6 +83,12 @@ Install the optional AWS dependencies to sign requests with SigV4: npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/signature-v4 ``` +These dependencies load only when SigV4 authentication is used. If they are missing, the first signed request throws an `OpenAIError` with this message: + +```text +Bedrock AWS authentication requires optional AWS dependencies. Run `npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/signature-v4` and try again. +``` + Omit explicit authentication to use the default AWS credential chain, or select a shared-config profile: ```ts diff --git a/package.json b/package.json index a00353622..1b914b764 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ }, "scripts": { "test": "./scripts/test", - "test:live:bedrock": "./node_modules/.bin/jest --config jest.live.config.ts --runInBand tests/live/bedrock.live.test.ts", + "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", @@ -29,10 +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", + "@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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb62fa60a..37f52648f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,16 +16,16 @@ importers: specifier: ^0.18.3 version: 0.18.3 '@aws-sdk/credential-provider-node': - specifier: 3.972.47 + specifier: ^3.972.47 version: 3.972.47 '@smithy/hash-node': - specifier: 4.3.5 + specifier: ^4.3.5 version: 4.3.5 '@smithy/protocol-http': - specifier: 5.4.5 + specifier: ^5.4.5 version: 5.4.5 '@smithy/signature-v4': - specifier: 5.4.5 + specifier: ^5.4.5 version: 5.4.5 '@swc/core': specifier: ^1.3.102 diff --git a/src/client.ts b/src/client.ts index ac0beb8be..df8631efb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -535,6 +535,9 @@ export class OpenAI { delete inheritedOptions.adminAPIKey; delete inheritedOptions.workloadIdentity; delete inheritedOptions.baseURL; + delete inheritedOptions.organization; + delete inheritedOptions.project; + delete inheritedOptions.defaultHeaders; } const client = new (this.constructor as any as new (props: ClientOptions) => typeof this)({ diff --git a/tests/lib/provider.test.ts b/tests/lib/provider.test.ts index e458fdf0d..b885cb24c 100644 --- a/tests/lib/provider.test.ts +++ b/tests/lib/provider.test.ts @@ -231,6 +231,29 @@ describe('provider', () => { 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', () => { From 24cebd4f50be0f3f95da45a345cdc2aa65c9a8b5 Mon Sep 17 00:00:00 2001 From: Hayden Date: Mon, 15 Jun 2026 14:02:03 -0700 Subject: [PATCH 10/11] Test Bedrock default credential chain --- scripts/build | 2 +- .../lib/bedrock-provider-dependencies.test.ts | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/scripts/build b/scripts/build index d8d299883..c7c08830e 100755 --- a/scripts/build +++ b/scripts/build @@ -38,7 +38,7 @@ node scripts/utils/postprocess-files.cjs (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) -(cd dist && node -e '(async () => { const { OpenAI } = require("openai"); const { bedrock } = await import("openai/providers/bedrock"); process.chdir(require("os").tmpdir()); const client = new OpenAI({ provider: bedrock({ region: "us-east-1", accessKeyId: "test", secretAccessKey: "test" }), fetch: async () => new Response("{}", { headers: { "content-type": "application/json" } }) }); await client.models.list(); })()') +(cd dist && node -e '(async () => { const { OpenAI } = require("openai"); const { bedrock } = await import("openai/providers/bedrock"); process.chdir(require("os").tmpdir()); const client = new OpenAI({ provider: bedrock({ region: "us-east-1", baseURL: null, accessKeyId: "test", secretAccessKey: "test" }), fetch: async () => new Response("{}", { headers: { "content-type": "application/json" } }) }); await client.models.list(); })()') if [ "${OPENAI_DISABLE_DENO_BUILD:-0}" != "1" ] && [ -e ./scripts/build-deno ] then diff --git a/tests/lib/bedrock-provider-dependencies.test.ts b/tests/lib/bedrock-provider-dependencies.test.ts index 179ff622d..9bf9e7eca 100644 --- a/tests/lib/bedrock-provider-dependencies.test.ts +++ b/tests/lib/bedrock-provider-dependencies.test.ts @@ -16,6 +16,12 @@ beforeEach(() => { 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(() => { @@ -38,6 +44,31 @@ async function loadBedrockModules(): Promise<{ } describe('Bedrock provider optional dependencies', () => { + 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', From 69c51656018de59d1e7e1aa4c6eb5a6e357426f6 Mon Sep 17 00:00:00 2001 From: Hayden Date: Mon, 22 Jun 2026 09:48:17 -0700 Subject: [PATCH 11/11] fix Bedrock provider packaging --- README.md | 14 +- bedrock.md | 35 +- examples/bedrock/responses.ts | 6 +- jsr.json | 4 + scripts/build | 3 +- scripts/build-deno | 3 +- src/bedrock.ts | 330 +++++------- src/client.ts | 11 +- src/internal/bedrock.ts | 153 ++++++ src/internal/provider.ts | 12 + src/providers/bedrock.ts | 483 +----------------- src/providers/bedrock/aws.ts | 255 +++++++++ tests/fixtures/bedrock/v1/sigv4.json | 1 + .../lib/bedrock-provider-dependencies.test.ts | 82 +-- tests/lib/bedrock-provider.test.ts | 29 +- tests/lib/bedrock.test.ts | 371 +------------- tests/lib/provider.test.ts | 18 +- tests/live/bedrock.live.test.ts | 30 +- 18 files changed, 690 insertions(+), 1150 deletions(-) create mode 100644 src/internal/bedrock.ts create mode 100644 src/providers/bedrock/aws.ts diff --git a/README.md b/README.md index a5939caee..eec420ac1 100644 --- a/README.md +++ b/README.md @@ -407,14 +407,14 @@ To use this library with [Amazon Bedrock's OpenAI-compatible API](https://docs.a ```ts import OpenAI from 'openai'; -import { bedrock } from 'openai/providers/bedrock'; +import { bedrock } from 'openai/providers/bedrock/aws'; const client = new OpenAI({ provider: bedrock({ region: 'us-west-2' }), }); const response = await client.responses.create({ - model: 'openai.gpt-oss-120b', + model: 'openai.gpt-5.4', input: 'Say hello!', }); @@ -425,21 +425,19 @@ Use a model that [supports the Responses API](https://docs.aws.amazon.com/bedroc 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. -Authentication is selected in this order: an explicit bearer or AWS credential option, `AWS_BEARER_TOKEN_BEDROCK`, then the default AWS credential chain. Pass `apiKey: null` to skip an ambient `AWS_BEARER_TOKEN_BEDROCK` and use the AWS credential chain. - -Bearer authentication requires no additional packages. For SigV4 authentication, install the optional AWS dependencies: +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: ```bash npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/signature-v4 ``` -These dependencies load only when SigV4 authentication is used. If they are missing, the first signed request throws an `OpenAIError` with this message: +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 -Bedrock AWS authentication requires optional AWS dependencies. Run `npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/signature-v4` and try again. +Cannot find module '@aws-sdk/credential-provider-node' ``` -SigV4 authentication is supported in Node.js and compatible server runtimes. The SDK's current SigV4 mode requires replayable request bodies. Bearer authentication can be used in other runtimes. The legacy `BedrockOpenAI` class remains available for compatibility. +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). diff --git a/bedrock.md b/bedrock.md index d0b2c2397..230da459b 100644 --- a/bedrock.md +++ b/bedrock.md @@ -4,14 +4,14 @@ To use this library with [Amazon Bedrock's OpenAI-compatible API](https://docs.a ```ts import OpenAI from 'openai'; -import { bedrock } from 'openai/providers/bedrock'; +import { bedrock } from 'openai/providers/bedrock/aws'; const client = new OpenAI({ provider: bedrock({ region: 'us-west-2' }), }); const response = await client.responses.create({ - model: 'openai.gpt-oss-120b', + model: 'openai.gpt-5.4', input: 'Say hello!', }); @@ -35,7 +35,7 @@ const client = new OpenAI({ ## Authentication -The provider selects authentication in this order: +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`. @@ -67,31 +67,40 @@ const client = new OpenAI({ }); ``` -Bearer authentication does not require any additional dependencies. Pass `apiKey: null` to skip an ambient `AWS_BEARER_TOKEN_BEDROCK` and explicitly select the default AWS credential chain: +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: null }), + 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 optional AWS dependencies to sign requests with 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 ``` -These dependencies load only when SigV4 authentication is used. If they are missing, the first signed request throws an `OpenAIError` with this message: +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 -Bedrock AWS authentication requires optional AWS dependencies. Run `npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/signature-v4` and try again. +Cannot find module '@aws-sdk/credential-provider-node' ``` -Omit explicit authentication to use the default AWS credential chain, or select a shared-config profile: +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', @@ -128,7 +137,7 @@ const client = new OpenAI({ }); ``` -SigV4 authentication is supported in Node.js and compatible server runtimes. Bearer authentication can be used in other runtimes without loading the optional AWS packages. +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. @@ -136,15 +145,15 @@ Bedrock Mantle also supports `UNSIGNED-PAYLOAD` and AWS-chunked request signing, ## Legacy `BedrockOpenAI` class -The `BedrockOpenAI` class remains available for existing applications. It accepts the `awsRegion`, `awsProfile`, `awsCredentialProvider`, and `bedrockTokenProvider` option names and uses the same `/openai/v1` endpoint as the provider: +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', - awsProfile: 'my-profile', + apiKey: process.env['AWS_BEARER_TOKEN_BEDROCK'], }); ``` -New applications should prefer `new OpenAI({ provider: bedrock(...) })`. +New applications using AWS credentials should prefer `new OpenAI({ provider: bedrock(...) })` with the `openai/providers/bedrock/aws` entrypoint. diff --git a/examples/bedrock/responses.ts b/examples/bedrock/responses.ts index e22ad6c29..ef39ff127 100644 --- a/examples/bedrock/responses.ts +++ b/examples/bedrock/responses.ts @@ -1,20 +1,20 @@ #!/usr/bin/env -S npm run tsn -- -T import OpenAI from 'openai'; -import { bedrock } from 'openai/providers/bedrock'; +import { bedrock } from 'openai/providers/bedrock/aws'; const client = new OpenAI({ provider: bedrock({ region: 'us-west-2' }), }); -// For refreshed Bedrock bearer tokens: +// 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({ - model: 'openai.gpt-oss-120b', + model: 'openai.gpt-5.4', input: 'Say hello!', }); diff --git a/jsr.json b/jsr.json index de809efc4..917509725 100644 --- a/jsr.json +++ b/jsr.json @@ -4,10 +4,14 @@ "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/scripts/build b/scripts/build index c7c08830e..436fdd6ca 100755 --- a/scripts/build +++ b/scripts/build @@ -15,7 +15,7 @@ rm -rf dist; mkdir dist # Copy src to dist/src and build from dist/src into dist, so that # the source map for index.js.map will refer to ./src/index.ts etc cp -rp src README.md dist -for file in LICENSE CHANGELOG.md bedrock.md; do +for file in LICENSE CHANGELOG.md; do if [ -e "${file}" ]; then cp "${file}" dist; fi done # this converts the export map paths for the dist directory @@ -38,7 +38,6 @@ node scripts/utils/postprocess-files.cjs (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) -(cd dist && node -e '(async () => { const { OpenAI } = require("openai"); const { bedrock } = await import("openai/providers/bedrock"); process.chdir(require("os").tmpdir()); const client = new OpenAI({ provider: bedrock({ region: "us-east-1", baseURL: null, accessKeyId: "test", secretAccessKey: "test" }), fetch: async () => new Response("{}", { headers: { "content-type": "application/json" } }) }); await client.models.list(); })()') if [ "${OPENAI_DISABLE_DENO_BUILD:-0}" != "1" ] && [ -e ./scripts/build-deno ] then diff --git a/scripts/build-deno b/scripts/build-deno index 17d0933df..028d9dfe5 100755 --- a/scripts/build-deno +++ b/scripts/build-deno @@ -7,9 +7,8 @@ cd "$(dirname "$0")/.." rm -rf dist-deno; mkdir dist-deno cp -rp src/* jsr.json dist-deno -for file in README.md LICENSE CHANGELOG.md bedrock.md; do +for file in README.md LICENSE CHANGELOG.md; do if [ -e "${file}" ]; then cp "${file}" dist-deno; fi done node scripts/utils/convert-jsr-readme.cjs ./dist-deno/README.md -node scripts/utils/convert-jsr-readme.cjs ./dist-deno/bedrock.md diff --git a/src/bedrock.ts b/src/bedrock.ts index b64762d44..cdb9151d4 100644 --- a/src/bedrock.ts +++ b/src/bedrock.ts @@ -1,265 +1,191 @@ import * as Errors from './error'; import { OpenAI } from './client'; import type { ApiKeySetter, ClientOptions } from './client'; -import type { Provider } from './internal/provider'; +import type { NullableHeaders } from './internal/headers'; +import { buildHeaders } from './internal/headers'; +import type { FinalRequestOptions, RequestOptions } from './internal/request-options'; import { readEnv } from './internal/utils'; -import { bedrock, type AwsCredentialsProvider } from './providers/bedrock'; - -export type { AwsCredentialIdentity, AwsCredentialsProvider } from './providers/bedrock'; +import { addOutputText } from './lib/ResponsesParser'; +import type { ResponseStreamParams } from './lib/responses/ResponseStream'; +import * as API from './resources/index'; +import type * as ResponsesAPI from './resources/responses/responses'; export interface BedrockClientOptions - extends Omit { + extends Omit { /** - * Bedrock bearer credential used for authentication. + * Bedrock bearer token used for authentication. * * Defaults to process.env['AWS_BEARER_TOKEN_BEDROCK']. - * Pass null to skip the environment bearer fallback and use AWS credentials. */ apiKey?: string | null | undefined; /** * Bedrock API root. * - * Defaults to process.env['AWS_BEDROCK_BASE_URL'], or derives the canonical - * `https://bedrock-mantle..api.aws/openai/v1` endpoint. + * Defaults to process.env['AWS_BEDROCK_BASE_URL'], or derives + * `https://bedrock-mantle..api.aws/openai/v1` from `awsRegion`, + * process.env['AWS_REGION'], or process.env['AWS_DEFAULT_REGION']. */ baseURL?: string | null | undefined; - /** BedrockOpenAI only supports Bedrock bearer or AWS credential authentication. */ + /** + * BedrockOpenAI only supports Bedrock bearer token authentication. + */ adminAPIKey?: never; - /** BedrockOpenAI only supports Bedrock bearer or AWS credential authentication. */ + /** + * BedrockOpenAI only supports Bedrock bearer token authentication. + */ workloadIdentity?: never; - /** AWS region used for SigV4 and to derive the default Bedrock Mantle endpoint. */ + /** + * AWS region used to derive the default Bedrock Mantle endpoint. + * + * Defaults to process.env['AWS_REGION'] or process.env['AWS_DEFAULT_REGION']. + */ awsRegion?: string | undefined; - /** AWS shared-config profile used by the standard credential chain. */ - awsProfile?: string | undefined; - - /** Explicit AWS access key ID. Must be provided with awsSecretAccessKey. */ - awsAccessKeyId?: string | undefined; - - /** Explicit AWS secret access key. Must be provided with awsAccessKeyId. */ - awsSecretAccessKey?: string | undefined; - - /** Optional session token for explicit temporary AWS credentials. */ - awsSessionToken?: string | undefined; - - /** Provider returning refreshable AWS credentials. */ - awsCredentialProvider?: AwsCredentialsProvider | undefined; - - /** A function that resolves a Bedrock bearer credential before every request attempt. */ + /** + * A function that returns a Bedrock bearer token and is invoked before each request. + */ bedrockTokenProvider?: ApiKeySetter | undefined; } -type BedrockProviderState = { - provider?: Provider | undefined; - publicAPIKey: string | null; - usesEnvironmentBearerAuth: boolean; -}; - -const bedrockProviderState = Symbol('bedrockProviderState'); - -type InternalBedrockClientOptions = BedrockClientOptions & { - provider?: Provider | undefined; - [bedrockProviderState]?: BedrockProviderState | undefined; -}; +/** Resolve the default Bedrock Mantle API root from the configured AWS region. */ +function deriveBedrockBaseURL(awsRegion: string | undefined): string { + const region = awsRegion?.trim(); + if (!region) { + throw new Errors.OpenAIError( + 'Must provide one of the `baseURL` or `awsRegion` arguments, or set the `AWS_BEDROCK_BASE_URL`, `AWS_REGION`, or `AWS_DEFAULT_REGION` environment variable.', + ); + } -function hasOwn(object: object, key: PropertyKey): boolean { - return Object.prototype.hasOwnProperty.call(object, key); + return `https://bedrock-mantle.${region}.api.aws/openai/v1`; } -function hasExplicitAwsAuth(options: Partial): boolean { - return ( - hasOwn(options, 'awsProfile') || - hasOwn(options, 'awsAccessKeyId') || - hasOwn(options, 'awsSecretAccessKey') || - hasOwn(options, 'awsSessionToken') || - hasOwn(options, 'awsCredentialProvider') - ); -} +/** Normalize a Bedrock Responses URL variant back to the provider API root. */ +function normalizeBedrockBaseURL(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) || '/'; + } -function hasBearerAuthOverride(options: Partial): boolean { - return hasOwn(options, 'apiKey') || hasOwn(options, 'bedrockTokenProvider'); + return url.toString().replace(/\/$/, ''); } -async function environmentBearerToken(): Promise { - 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 the default AWS credential chain.', - ); +/** Restore the SDK convenience property when Bedrock omits it from a streamed final response. */ +function addBedrockOutputText(response: ResponseT): ResponseT { + if (!Object.getOwnPropertyDescriptor(response, 'output_text')) { + addOutputText(response); } - return token; + + return response; } -/** Resolve the Bedrock OpenAI-compatible endpoint from a region. */ -function deriveBedrockBaseURL(awsRegion: string | undefined): string { - const region = awsRegion?.trim(); - if (!region) { - throw new Errors.OpenAIError( - 'Must provide one of the `baseURL` or `awsRegion` arguments, or set the `AWS_BEDROCK_BASE_URL`, `AWS_REGION`, or `AWS_DEFAULT_REGION` environment variable.', - ); - } - return `https://bedrock-mantle.${region}.api.aws/openai/v1`; +/** Keep the standard Responses surface while repairing Bedrock streamed final responses. */ +function restoreBedrockStreamOutputText(responses: API.Responses): API.Responses { + const stream = responses.stream.bind(responses); + + responses.stream = ((body: ResponseStreamParams, options?: RequestOptions) => { + const responseStream = stream(body, options); + const finalResponse = responseStream.finalResponse.bind(responseStream); + responseStream.finalResponse = async () => addBedrockOutputText(await finalResponse()); + + return responseStream; + }) as API.Responses['stream']; + + return responses; } /** API Client for interfacing with Amazon Bedrock's OpenAI-compatible endpoint. */ export class BedrockOpenAI extends OpenAI { - private readonly bedrockProvider: Provider; private readonly bedrockTokenProvider: ApiKeySetter | undefined; - private readonly awsRegion: string | undefined; - private readonly awsProfile: string | undefined; - private readonly awsAccessKeyId: string | undefined; - private readonly awsSecretAccessKey: string | undefined; - private readonly awsSessionToken: string | undefined; - private readonly awsCredentialProvider: AwsCredentialsProvider | undefined; - private readonly usesRegionDerivedBaseURL: boolean; - private readonly usesEnvironmentBearerAuth: boolean; - constructor(options?: BedrockClientOptions); - constructor(options: InternalBedrockClientOptions = {}) { - const { - [bedrockProviderState]: inheritedState, - provider: _inheritedProvider, - baseURL = readEnv('AWS_BEDROCK_BASE_URL'), - apiKey, - awsRegion = readEnv('AWS_REGION') ?? readEnv('AWS_DEFAULT_REGION'), - awsProfile, - awsAccessKeyId, - awsSecretAccessKey, - awsSessionToken, - awsCredentialProvider, - bedrockTokenProvider, - adminAPIKey, - workloadIdentity, - ...opts - } = options; + /** + * API Client for interfacing with Amazon Bedrock's OpenAI-compatible endpoint. + * + * @param {string | null | undefined} [opts.apiKey=process.env['AWS_BEARER_TOKEN_BEDROCK'] ?? null] + * @param {string | null | undefined} [opts.baseURL=process.env['AWS_BEDROCK_BASE_URL'] ?? derived from opts.awsRegion or AWS_REGION/AWS_DEFAULT_REGION] + * @param {string | undefined} [opts.awsRegion=process.env['AWS_REGION'] ?? process.env['AWS_DEFAULT_REGION'] ?? undefined] + * @param {ApiKeySetter | undefined} opts.bedrockTokenProvider - A function that returns a Bedrock bearer token and is invoked before each request. + */ + constructor({ + baseURL = readEnv('AWS_BEDROCK_BASE_URL'), + apiKey, + awsRegion = readEnv('AWS_REGION') ?? readEnv('AWS_DEFAULT_REGION'), + bedrockTokenProvider, + adminAPIKey, + workloadIdentity, + ...opts + }: BedrockClientOptions = {}) { + if (adminAPIKey || workloadIdentity) { + throw new Errors.OpenAIError('BedrockOpenAI only supports Bedrock bearer token authentication.'); + } - if (adminAPIKey != null || workloadIdentity != null) { - throw new Errors.OpenAIError( - 'BedrockOpenAI only supports Bedrock bearer token or AWS credential authentication.', - ); + if (apiKey === undefined && !bedrockTokenProvider) { + apiKey = readEnv('AWS_BEARER_TOKEN_BEDROCK') ?? null; } + if (typeof (apiKey as unknown) === 'function') { throw new Errors.OpenAIError( 'Pass refreshable Bedrock credentials via `bedrockTokenProvider`, not `apiKey`.', ); } - const explicitBaseURL = baseURL?.trim() ? baseURL : undefined; - const usesRegionDerivedBaseURL = explicitBaseURL === undefined; - const configuredBaseURL = explicitBaseURL ?? deriveBedrockBaseURL(awsRegion); - const explicitAwsAuth = - awsProfile !== undefined || - awsAccessKeyId !== undefined || - awsSecretAccessKey !== undefined || - awsSessionToken !== undefined || - awsCredentialProvider !== undefined; - const usesEnvironmentBearerAuth = - inheritedState?.usesEnvironmentBearerAuth ?? - (!explicitAwsAuth && - !bedrockTokenProvider && - apiKey === undefined && - !!readEnv('AWS_BEARER_TOKEN_BEDROCK')); - const forceEnvironmentBearerAuth = - inheritedState !== undefined && usesEnvironmentBearerAuth && !inheritedState.provider; - const configuredProvider = - inheritedState?.provider ?? - bedrock({ - region: awsRegion, - baseURL: configuredBaseURL, - apiKey, - tokenProvider: forceEnvironmentBearerAuth ? environmentBearerToken : bedrockTokenProvider, - profile: awsProfile, - accessKeyId: awsAccessKeyId, - secretAccessKey: awsSecretAccessKey, - sessionToken: awsSessionToken, - credentialProvider: awsCredentialProvider, - }); + if (apiKey && bedrockTokenProvider) { + throw new Errors.OpenAIError( + 'The `apiKey` and `bedrockTokenProvider` arguments are mutually exclusive; only one can be passed at a time.', + ); + } + + if (!apiKey && !bedrockTokenProvider) { + throw new Errors.OpenAIError( + 'Missing credentials. Please pass an `apiKey` or `bedrockTokenProvider`, or set the `AWS_BEARER_TOKEN_BEDROCK` environment variable.', + ); + } + + const configuredBaseURL = baseURL?.trim() ? baseURL : deriveBedrockBaseURL(awsRegion); super({ + apiKey: bedrockTokenProvider ?? apiKey, + adminAPIKey: null, + baseURL: normalizeBedrockBaseURL(configuredBaseURL), ...opts, - provider: configuredProvider, }); - const publicAPIKey = - inheritedState?.publicAPIKey ?? - (typeof apiKey === 'string' ? apiKey - : !explicitAwsAuth && !bedrockTokenProvider && apiKey !== null ? - readEnv('AWS_BEARER_TOKEN_BEDROCK') ?? null - : null); - - this.apiKey = publicAPIKey; - this.bedrockProvider = configuredProvider; this.bedrockTokenProvider = bedrockTokenProvider; - this.awsRegion = awsRegion; - this.awsProfile = awsProfile; - this.awsAccessKeyId = awsAccessKeyId; - this.awsSecretAccessKey = awsSecretAccessKey; - this.awsSessionToken = awsSessionToken; - this.awsCredentialProvider = awsCredentialProvider; - this.usesRegionDerivedBaseURL = usesRegionDerivedBaseURL; - this.usesEnvironmentBearerAuth = usesEnvironmentBearerAuth; + this.responses = restoreBedrockStreamOutputText(new API.Responses(this)); } - override withOptions(options: Partial): this { - const bearerOverride = hasBearerAuthOverride(options); - const awsOverride = hasExplicitAwsAuth(options); - const routingOverride = hasOwn(options, 'baseURL') || hasOwn(options, 'awsRegion'); - const providerChanged = bearerOverride || awsOverride || routingOverride; + protected override async prepareOptions(options: FinalRequestOptions): Promise { + const security = options.__security ?? { bearerAuth: true }; + if (security.adminAPIKeyAuth && !security.bearerAuth) { + await this._callApiKey(); + } - const preserveBearer = !bearerOverride && !awsOverride; - const preserveAws = !bearerOverride && !awsOverride; - const baseURL = - hasOwn(options, 'baseURL') ? options.baseURL - : hasOwn(options, 'awsRegion') && this.usesRegionDerivedBaseURL ? undefined - : this.baseURL; + await super.prepareOptions(options); + } - const nextOptions: InternalBedrockClientOptions = { - ...options, - baseURL, - awsRegion: options.awsRegion ?? this.awsRegion, - apiKey: - bearerOverride ? options.apiKey - : preserveBearer && !this.bedrockTokenProvider && !this.usesEnvironmentBearerAuth ? this.apiKey - : undefined, - bedrockTokenProvider: - bearerOverride ? options.bedrockTokenProvider - : preserveBearer ? this.bedrockTokenProvider - : undefined, - awsProfile: - awsOverride ? options.awsProfile - : preserveAws ? this.awsProfile - : undefined, - awsAccessKeyId: - awsOverride ? options.awsAccessKeyId - : preserveAws ? this.awsAccessKeyId - : undefined, - awsSecretAccessKey: - awsOverride ? options.awsSecretAccessKey - : preserveAws ? this.awsSecretAccessKey - : undefined, - awsSessionToken: - awsOverride ? options.awsSessionToken - : preserveAws ? this.awsSessionToken - : undefined, - awsCredentialProvider: - awsOverride ? options.awsCredentialProvider - : preserveAws ? this.awsCredentialProvider - : undefined, - ...(!providerChanged || (routingOverride && preserveBearer && this.usesEnvironmentBearerAuth) ? - { - [bedrockProviderState]: { - provider: providerChanged ? undefined : this.bedrockProvider, - publicAPIKey: this.apiKey, - usesEnvironmentBearerAuth: this.usesEnvironmentBearerAuth, - }, - } - : {}), - }; + protected override async authHeaders( + opts: FinalRequestOptions, + schemes?: { bearerAuth?: boolean; adminAPIKeyAuth?: boolean }, + ): Promise { + const security = schemes ?? { bearerAuth: true, adminAPIKeyAuth: true }; + if ((security.bearerAuth || security.adminAPIKeyAuth) && this.apiKey !== null) { + return buildHeaders([{ Authorization: `Bearer ${this.apiKey}` }]); + } + + return super.authHeaders(opts, security); + } + + override withOptions(options: Partial): this { + const bedrockTokenProvider = + options.apiKey !== undefined ? undefined : options.bedrockTokenProvider ?? this.bedrockTokenProvider; - return super.withOptions(nextOptions as Partial); + return super.withOptions({ + ...options, + ...(bedrockTokenProvider ? { apiKey: undefined, bedrockTokenProvider } : {}), + } as Partial); } } diff --git a/src/client.ts b/src/client.ts index df8631efb..1ce75ea15 100644 --- a/src/client.ts +++ b/src/client.ts @@ -513,7 +513,8 @@ 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 provider = options.provider ?? this._options.provider; + const inheritedProvider = this._options.provider; + const provider = options.provider ?? inheritedProvider; const inheritedOptions: ClientOptions = { ...this._options, baseURL: this.baseURL, @@ -535,9 +536,11 @@ export class OpenAI { delete inheritedOptions.adminAPIKey; delete inheritedOptions.workloadIdentity; delete inheritedOptions.baseURL; - delete inheritedOptions.organization; - delete inheritedOptions.project; - delete inheritedOptions.defaultHeaders; + 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)({ diff --git a/src/internal/bedrock.ts b/src/internal/bedrock.ts new file mode 100644 index 000000000..5b78c3a6c --- /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 index fd82d1d90..23af41ce3 100644 --- a/src/internal/provider.ts +++ b/src/internal/provider.ts @@ -23,6 +23,18 @@ 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 diff --git a/src/providers/bedrock.ts b/src/providers/bedrock.ts index 3faa50d2e..f29b97eb3 100644 --- a/src/providers/bedrock.ts +++ b/src/providers/bedrock.ts @@ -1,475 +1,28 @@ import * as Errors from '../error'; -import type { ApiKeySetter } from '../client'; -import type { BodyInit } from '../internal/builtin-types'; -import type { FinalizedRequestInit } from '../internal/types'; -import { createProvider, type Provider, type ProviderRequestContext } from '../internal/provider'; -import { readEnv } from '../internal/utils'; - -const BEDROCK_SERVICE = 'bedrock-mantle'; -const BEDROCK_AWS_DEPENDENCIES = - 'npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/signature-v4'; - -export interface AwsCredentialIdentity { - accessKeyId: string; - secretAccessKey: string; - sessionToken?: string; - expiration?: Date; -} - -export type AwsCredentialsProvider = () => AwsCredentialIdentity | Promise; - -export interface BedrockProviderOptions { - /** AWS region used for SigV4 and 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; - - /** 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; - - /** 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; -} - -type SmithyRequest = { - protocol: string; - hostname: string; - port?: number; - method: string; - path: string; - query: Record; - headers: Record; - body?: string | ArrayBuffer | ArrayBufferView; -}; - -type SignatureV4 = { - sign( - request: SmithyRequest, - options?: { signingDate?: Date }, - ): Promise<{ headers: Record }>; -}; - -type SignatureV4Constructor = new (options: { - credentials: AwsCredentialIdentity | (() => Promise); - region: string; - service: string; - sha256: new (...args: any[]) => unknown; -}) => SignatureV4; - -type SigningDependencies = { - Hash: new (...args: any[]) => unknown; - SignatureV4: SignatureV4Constructor; -}; - -type DefaultProvider = (options?: { profile?: string }) => () => Promise; - -let signingDependencies: Promise | undefined; -let defaultProviderDependency: Promise | undefined; - -/** - * Keep optional AWS imports lazy and opaque to browser bundlers. TypeScript emits - * these as module-relative requires for CommonJS and preserves native dynamic - * imports for ESM, so resolution does not depend on the process working directory. - */ -async function importOptionalDependency(specifier: string): Promise> { - return import(/* webpackIgnore: true */ specifier); -} - -function loadSigningDependencies(): Promise { - return (signingDependencies ??= Promise.all([ - importOptionalDependency('@smithy/hash-node'), - importOptionalDependency('@smithy/signature-v4'), - ]).then(([hashNode, signatureV4]) => ({ - Hash: hashNode['Hash'], - SignatureV4: signatureV4['SignatureV4'], - }))); -} - -function loadDefaultProvider(): Promise { - return (defaultProviderDependency ??= importOptionalDependency('@aws-sdk/credential-provider-node').then( - (credentialProvider) => credentialProvider['defaultProvider'] as DefaultProvider, - )); -} - -function errorWithCause(message: string, cause: unknown): Errors.OpenAIError { - const error = new Errors.OpenAIError(message) as Errors.OpenAIError & { cause?: unknown }; - error.cause = cause; - return error; -} - -function isNodeLikeRuntime(): boolean { - return Object.prototype.toString.call((globalThis as any).process) === '[object process]'; -} - -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(/\/$/, ''); -} - -function resolveRegion(region: string | undefined): string | undefined { - return ( - normalizeOptionalString(region) ?? - normalizeOptionalString(readEnv('AWS_REGION')) ?? - normalizeOptionalString(readEnv('AWS_DEFAULT_REGION')) - ); -} - -function resolveBaseURL(baseURL: string | null | undefined, region: string | undefined): string { - const configured = - baseURL === undefined ? normalizeOptionalString(readEnv('AWS_BEDROCK_BASE_URL')) - : baseURL === null ? undefined - : normalizeOptionalString(baseURL); - if (configured) return normalizeBaseURL(configured); - if (!region) { - throw new Errors.OpenAIError( - 'Bedrock requires an AWS region. Pass `region` to `bedrock(...)`, or set `AWS_REGION` or `AWS_DEFAULT_REGION`.', - ); - } - return `https://bedrock-mantle.${region}.api.aws/openai/v1`; -} - -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 validateOptions(options: BedrockProviderOptions): { - staticCredentials: AwsCredentialIdentity | undefined; - explicitAwsAuth: boolean; - explicitBearerAuth: boolean; -} { - 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 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.'); - } - 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.'); - } - - const explicitBearerAuth = - (options.apiKey !== undefined && options.apiKey !== null) || !!options.tokenProvider; - 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; - if (explicitBearerAuth && 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.', - ); - } - if (options.apiKey && options.tokenProvider) { - throw new Errors.OpenAIError( - 'The `apiKey` and `tokenProvider` options are mutually exclusive. Configure only one.', - ); - } - - return { staticCredentials, explicitAwsAuth, explicitBearerAuth }; -} - -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 assertProviderOwnsAuthorization(headers: Headers): void { - if (headers.has('authorization')) { +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 provider authentication cannot be combined with a custom `Authorization` header.', + 'Bedrock bearer authentication requires an `apiKey`, `tokenProvider`, or `AWS_BEARER_TOKEN_BEDROCK`. For AWS credential authentication, import `bedrock` from `openai/providers/bedrock/aws`.', ); } -} - -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 BedrockBearerAuth { - constructor(private readonly tokenProvider: ApiKeySetter) {} - - async prepareRequest(request: FinalizedRequestInit): 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; - } -} - -class BedrockSigV4Auth { - 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 async credentialsProvider(): Promise<() => 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())); - } - - let defaultProvider: DefaultProvider; - try { - defaultProvider = await loadDefaultProvider(); - } catch (cause) { - throw errorWithCause( - `Bedrock AWS authentication requires optional AWS dependencies. Run \`${BEDROCK_AWS_DEPENDENCIES}\` and try again.`, - cause, - ); - } - const provider = defaultProvider(this.options.profile ? { profile: this.options.profile } : {}); - return (this.resolvedCredentialsProvider = async () => validateCredentialIdentity(await provider())); - } - - private async signatureV4(): Promise { - if (this.signer) return this.signer; - - let dependencies: SigningDependencies; - try { - dependencies = await loadSigningDependencies(); - } catch (cause) { - throw errorWithCause( - `Bedrock AWS authentication requires optional AWS dependencies. Run \`${BEDROCK_AWS_DEPENDENCIES}\` and try again.`, - cause, - ); - } - const credentials = await this.credentialsProvider(); - return (this.signer = new dependencies.SignatureV4({ - credentials, - region: this.options.region, - service: BEDROCK_SERVICE, - sha256: dependencies.Hash.bind(null, 'sha256'), - })); - } - - async prepareRequest(request: FinalizedRequestInit, { url }: ProviderRequestContext): Promise { - if (!isNodeLikeRuntime()) { - 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); - const signer = await this.signatureV4(); - - let signed: { headers: Record }; - try { - signed = await signer.sign( - { - protocol: parsedURL.protocol, - hostname: parsedURL.hostname, - ...(parsedURL.port ? { port: Number(parsedURL.port) } : {}), - method, - ...requestTarget(parsedURL), - headers: Object.fromEntries(headers.entries()), - ...(body !== undefined ? { body } : {}), - }, - { signingDate: new Date() }, - ); - } catch (cause) { - const message = - this.options.usesDefaultChain ? - 'Could not find credentials for Bedrock. Pass a bearer credential or AWS credentials to `bedrock(...)`, set `AWS_BEARER_TOKEN_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 Mantle. */ -export function bedrock(options: BedrockProviderOptions = {}): Provider { - const { staticCredentials, explicitAwsAuth, explicitBearerAuth } = validateOptions(options); - const region = resolveRegion(options.region); - const baseURL = resolveBaseURL(options.baseURL, region); - const explicitAPIKey = options.apiKey; - const explicitTokenProvider = options.tokenProvider; - const profile = normalizeOptionalString(options.profile); - const credentialProvider = options.credentialProvider; - const environmentBearerAuth = - !explicitBearerAuth && - !explicitAwsAuth && - options.apiKey !== null && - !!readEnv('AWS_BEARER_TOKEN_BEDROCK'); return createProvider({ configure() { - let auth: BedrockBearerAuth | BedrockSigV4Auth; - if (explicitBearerAuth) { - const tokenProvider = - explicitTokenProvider ?? - (async () => { - if (!explicitAPIKey) - throw new Errors.OpenAIError('The Bedrock bearer credential must not be empty.'); - return explicitAPIKey; - }); - auth = new BedrockBearerAuth(tokenProvider); - } else if (environmentBearerAuth) { - auth = 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 the default AWS credential chain.', - ); - } - return token; - }); - } else { - if (!region) { - throw new Errors.OpenAIError( - 'Bedrock requires an AWS region. Pass `region` to `bedrock(...)`, or set `AWS_REGION` or `AWS_DEFAULT_REGION`.', - ); - } - auth = new BedrockSigV4Auth({ - region, - staticCredentials, - profile, - credentialProvider, - usesDefaultChain: !explicitAwsAuth, - }); - } - + const auth = factory(); return { name: 'bedrock', baseURL, diff --git a/src/providers/bedrock/aws.ts b/src/providers/bedrock/aws.ts new file mode 100644 index 000000000..9fa659bb2 --- /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/tests/fixtures/bedrock/v1/sigv4.json b/tests/fixtures/bedrock/v1/sigv4.json index e0e552ae1..441c63499 100644 --- a/tests/fixtures/bedrock/v1/sigv4.json +++ b/tests/fixtures/bedrock/v1/sigv4.json @@ -1,4 +1,5 @@ { + "$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", diff --git a/tests/lib/bedrock-provider-dependencies.test.ts b/tests/lib/bedrock-provider-dependencies.test.ts index 9bf9e7eca..929f8c82e 100644 --- a/tests/lib/bedrock-provider-dependencies.test.ts +++ b/tests/lib/bedrock-provider-dependencies.test.ts @@ -36,14 +36,37 @@ function jsonResponse(body: unknown = {}): Response { async function loadBedrockModules(): Promise<{ OpenAI: typeof import('openai').default; - bedrock: typeof import('openai/providers/bedrock').bedrock; + bedrock: typeof import('openai/providers/bedrock/aws').bedrock; }> { const openai = require('openai') as typeof import('openai'); - const bedrockProvider = require('openai/providers/bedrock') as typeof import('openai/providers/bedrock'); + 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'; @@ -150,65 +173,14 @@ describe('Bedrock provider optional dependencies', () => { }); }); - test('preserves an actionable message and cause when the default provider dependency is missing', async () => { + 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 () => { - const { OpenAI, bedrock } = await loadBedrockModules(); - const client = new OpenAI({ - provider: bedrock({ region: 'us-east-1', apiKey: null }), - maxRetries: 0, - fetch: jest.fn(async () => jsonResponse()), - }); - - let thrown: unknown; - try { - await client.request({ method: 'get', path: '/models' }); - } catch (error) { - thrown = error; - } - - expect(thrown).toBeInstanceOf(Error); - expect((thrown as Error).message).toContain( - 'npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/signature-v4', - ); - expect((thrown as Error & { cause?: unknown }).cause).toBe(missingDependency); - }); - }); - - test('preserves an actionable message and cause when a signing dependency is missing', async () => { - const missingDependency = new Error('Cannot find module @smithy/signature-v4'); - jest.doMock('@smithy/signature-v4', () => { - throw missingDependency; - }); - - await jest.isolateModulesAsync(async () => { - const { OpenAI, bedrock } = await loadBedrockModules(); - const client = new OpenAI({ - provider: bedrock({ - region: 'us-east-1', - accessKeyId: 'access-key', - secretAccessKey: 'secret-key', - }), - maxRetries: 0, - fetch: jest.fn(async () => jsonResponse()), - }); - - let thrown: unknown; - try { - await client.request({ method: 'get', path: '/models' }); - } catch (error) { - thrown = error; - } - - expect(thrown).toBeInstanceOf(Error); - expect((thrown as Error).message).toContain( - 'npm install @aws-sdk/credential-provider-node @smithy/hash-node @smithy/signature-v4', - ); - expect((thrown as Error & { cause?: unknown }).cause).toBe(missingDependency); + await expect(loadBedrockModules()).rejects.toBe(missingDependency); }); }); diff --git a/tests/lib/bedrock-provider.test.ts b/tests/lib/bedrock-provider.test.ts index eca14cae0..34b03a83d 100644 --- a/tests/lib/bedrock-provider.test.ts +++ b/tests/lib/bedrock-provider.test.ts @@ -1,9 +1,8 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - import OpenAI from 'openai'; import type { RequestInfo, RequestInit } from 'openai/internal/builtin-types'; import { configureProvider } from 'openai/internal/provider'; -import { bedrock, type BedrockProviderOptions } from 'openai/providers/bedrock'; +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'; @@ -42,7 +41,7 @@ describe('bedrock provider', () => { let requestedURL: RequestInfo | undefined; let requestedInit: RequestInit | undefined; const client = new OpenAI({ - provider: bedrock({ region: 'us-east-1', apiKey: 'bedrock-token' }), + provider: bearerBedrock({ region: 'us-east-1', apiKey: 'bedrock-token' }), fetch: async (url, init) => { requestedURL = url; requestedInit = init; @@ -64,7 +63,7 @@ describe('bedrock provider', () => { authorizationHeaders.push(new Headers(init?.headers).get('authorization') ?? ''); return jsonResponse(); }; - const client = new OpenAI({ provider: bedrock({ region: 'us-east-1' }), fetch }); + 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']; @@ -91,26 +90,32 @@ describe('bedrock provider', () => { 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: bedrock({ region: 'us-east-1', baseURL: null, apiKey: 'bedrock-token' }), + 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(() => bedrock({ apiKey: 'bedrock-token' })).toThrow('Bedrock requires an AWS region'); + expect(() => bearerBedrock({ apiKey: 'bedrock-token' })).toThrow('Bedrock requires an AWS region'); expect(() => - bedrock({ baseURL: 'https://bedrock.example.com/openai/v1', apiKey: 'bedrock-token' }), + 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: bedrock({ + provider: bearerBedrock({ baseURL: 'https://bedrock.example.com/responses/response-id', apiKey: 'bedrock-token', }), @@ -172,7 +177,7 @@ describe('bedrock provider', () => { test('rejects a custom Authorization header before fetch', async () => { const fetch = jest.fn(async () => jsonResponse()); const client = new OpenAI({ - provider: bedrock({ region: 'us-east-1', apiKey: 'bedrock-token' }), + provider: bearerBedrock({ region: 'us-east-1', apiKey: 'bedrock-token' }), fetch, }); @@ -230,7 +235,7 @@ describe('bedrock provider', () => { async (token) => { const fetch = jest.fn(async () => jsonResponse()); const client = new OpenAI({ - provider: bedrock({ region: 'us-east-1', tokenProvider: async () => token }), + provider: bearerBedrock({ region: 'us-east-1', tokenProvider: async () => token }), fetch, }); @@ -244,7 +249,7 @@ describe('bedrock provider', () => { 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: bedrock({ region: 'us-east-1' }), fetch }); + 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({ diff --git a/tests/lib/bedrock.test.ts b/tests/lib/bedrock.test.ts index 4cc507e27..75cefadff 100644 --- a/tests/lib/bedrock.test.ts +++ b/tests/lib/bedrock.test.ts @@ -1,8 +1,5 @@ import { BedrockOpenAI, NotFoundError, type BedrockClientOptions } from 'openai'; import { type RequestInfo, type RequestInit } from 'openai/internal/builtin-types'; -import { Hash } from '@smithy/hash-node'; -import { HttpRequest } from '@smithy/protocol-http'; -import { SignatureV4 } from '@smithy/signature-v4'; const RESPONSE_BODY = { id: 'resp_123', @@ -145,8 +142,7 @@ describe('instantiate bedrock client', () => { test('does not use OPENAI_API_KEY', () => { process.env['OPENAI_API_KEY'] = 'openai token'; process.env['AWS_REGION'] = 'us-west-2'; - const client = new BedrockOpenAI(); - expect(client.apiKey).toBeNull(); + expect(() => new BedrockOpenAI()).toThrow(/AWS_BEARER_TOKEN_BEDROCK/); }); test('requires endpoint configuration', () => { @@ -164,53 +160,6 @@ describe('instantiate bedrock client', () => { ).toThrow(/mutually exclusive/); }); - test('rejects an empty explicit bearer token', () => { - expect( - () => - new BedrockOpenAI({ - baseURL: 'https://example.com/openai/v1', - apiKey: '', - }), - ).toThrow(/must not be empty/); - }); - - test('rejects bearer and AWS credentials together', () => { - expect( - () => - new BedrockOpenAI({ - baseURL: 'https://example.com/openai/v1', - apiKey: 'token', - awsAccessKeyId: 'access key', - awsSecretAccessKey: 'secret key', - }), - ).toThrow(/mutually exclusive/); - }); - - test('rejects partial explicit AWS credentials', () => { - expect( - () => - new BedrockOpenAI({ - baseURL: 'https://example.com/openai/v1', - awsRegion: 'us-east-1', - awsAccessKeyId: 'access key', - }), - ).toThrow(/must be provided together/); - }); - - test.each([ - ['admin API key', { adminAPIKey: 'admin-key' }], - ['workload identity', { workloadIdentity: {} }], - ])('rejects an explicit %s', (_name, options) => { - expect( - () => - new BedrockOpenAI({ - baseURL: 'https://example.com/openai/v1', - apiKey: 'token', - ...options, - } as any), - ).toThrow('only supports Bedrock bearer token or AWS credential authentication'); - }); - test('requires refreshable tokens to use provider option', () => { expect( () => @@ -247,218 +196,6 @@ describe('instantiate bedrock client', () => { expect(authorizationHeaders).toEqual(['Bearer first', 'Bearer second']); }); - test('uses AWS token discovery and bearer signer for ambient bearer', async () => { - process.env['AWS_BEARER_TOKEN_BEDROCK'] = 'ambient token'; - process.env['AWS_REGION'] = 'us-east-1'; - const client = new BedrockOpenAI({ - fetch: async (_url, init) => { - expect(new Headers(init?.headers).get('authorization')).toBe('Bearer ambient token'); - return new globalThis.Response(JSON.stringify(RESPONSE_BODY), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - }, - }); - client.apiKey = 'mutated cached token'; - - await client.responses.create({ model: 'gpt-4o', input: 'hello' }); - }); - - test('preserves ambient bearer mode when withOptions changes routing', async () => { - process.env['AWS_BEARER_TOKEN_BEDROCK'] = 'first token'; - process.env['AWS_REGION'] = 'us-east-1'; - const authorizationHeaders: string[] = []; - const fetch = async (_url: RequestInfo, init?: RequestInit): Promise => { - authorizationHeaders.push(new Headers(init?.headers).get('authorization') ?? ''); - return new globalThis.Response(JSON.stringify(RESPONSE_BODY), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - }; - const client = new BedrockOpenAI({ fetch }); - - delete process.env['AWS_BEARER_TOKEN_BEDROCK']; - const copiedClient = client.withOptions({ baseURL: 'https://example.com/openai/v1' }); - process.env['AWS_BEARER_TOKEN_BEDROCK'] = 'refreshed token'; - await copiedClient.responses.create({ model: 'gpt-4o', input: 'hello' }); - - expect(authorizationHeaders).toEqual(['Bearer refreshed token']); - }); - - test('reports when ambient bearer auth disappears from a routing clone', async () => { - process.env['AWS_BEARER_TOKEN_BEDROCK'] = 'temporary token'; - process.env['AWS_REGION'] = 'us-east-1'; - const fetch = jest.fn(async () => new globalThis.Response(JSON.stringify(RESPONSE_BODY))); - const client = new BedrockOpenAI({ fetch }); - const copiedClient = client.withOptions({ baseURL: 'https://example.com/openai/v1' }); - delete process.env['AWS_BEARER_TOKEN_BEDROCK']; - - await expect(copiedClient.responses.create({ model: 'gpt-4o', input: 'hello' })).rejects.toMatchObject({ - message: 'Failed to resolve a bearer credential for Bedrock.', - cause: expect.objectContaining({ message: expect.stringContaining('Could not find credentials') }), - }); - expect(fetch).not.toHaveBeenCalled(); - }); - - test('explicit AWS credentials override ambient bearer', async () => { - process.env['AWS_BEARER_TOKEN_BEDROCK'] = 'ambient token'; - const requests: Headers[] = []; - const client = new BedrockOpenAI({ - baseURL: 'https://example.com/openai/v1', - awsRegion: 'us-east-1', - awsAccessKeyId: 'access key', - awsSecretAccessKey: 'secret key', - awsSessionToken: 'session token', - fetch: async (_url, init) => { - requests.push(new Headers(init?.headers)); - return new globalThis.Response(JSON.stringify(RESPONSE_BODY), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - }, - }); - - await client.responses.create({ model: 'gpt-4o', input: 'hello' }); - - expect(requests[0]!.get('authorization')).toContain('AWS4-HMAC-SHA256 Credential=access key/'); - expect(requests[0]!.get('authorization')).toContain('SignedHeaders='); - expect(requests[0]!.get('authorization')).toMatch(/SignedHeaders=[^,]*\bhost\b/); - expect(requests[0]!.get('host')).toBe('example.com'); - expect(requests[0]!.get('x-amz-security-token')).toBe('session token'); - }); - - test('signs the uppercase HTTP method transmitted by fetch', async () => { - let signatureMatches = false; - const client = new BedrockOpenAI({ - baseURL: 'https://example.com/openai/v1', - awsRegion: 'us-east-1', - awsAccessKeyId: 'access key', - awsSecretAccessKey: 'secret key', - fetch: async (url, init) => { - const parsedURL = new URL(String(url)); - const headers = Object.fromEntries(new Headers(init?.headers).entries()); - const actualAuthorization = headers['authorization']; - delete headers['authorization']; - const amzDate = headers['x-amz-date']!; - const signingDate = new Date( - amzDate.replace(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/, '$1-$2-$3T$4:$5:$6Z'), - ); - const expected = await new SignatureV4({ - credentials: { accessKeyId: 'access key', secretAccessKey: 'secret key' }, - region: 'us-east-1', - service: 'bedrock-mantle', - sha256: Hash.bind(null, 'sha256'), - }).sign( - new HttpRequest({ - protocol: parsedURL.protocol, - hostname: parsedURL.hostname, - method: init?.method?.toUpperCase() ?? 'GET', - path: parsedURL.pathname, - headers, - ...(init?.body ? { body: init.body } : {}), - }), - { signingDate }, - ); - signatureMatches = actualAuthorization === expected.headers['authorization']; - return new globalThis.Response(JSON.stringify(RESPONSE_BODY), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - }, - }); - - await client.responses.create({ model: 'gpt-4o', input: 'hello' }); - - expect(signatureMatches).toBe(true); - }); - - test('signs query parameters as a Smithy query bag', async () => { - let signatureMatches = false; - const client = new BedrockOpenAI({ - baseURL: 'https://example.com/openai/v1', - awsRegion: 'us-east-1', - awsAccessKeyId: 'access key', - awsSecretAccessKey: 'secret key', - fetch: async (url, init) => { - const parsedURL = new URL(String(url)); - const headers = Object.fromEntries(new Headers(init?.headers).entries()); - const actualAuthorization = headers['authorization']; - delete headers['authorization']; - 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]; - } - const amzDate = headers['x-amz-date']!; - const signingDate = new Date( - amzDate.replace(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/, '$1-$2-$3T$4:$5:$6Z'), - ); - const expected = await new SignatureV4({ - credentials: { accessKeyId: 'access key', secretAccessKey: 'secret key' }, - region: 'us-east-1', - service: 'bedrock-mantle', - sha256: Hash.bind(null, 'sha256'), - }).sign( - new HttpRequest({ - protocol: parsedURL.protocol, - hostname: parsedURL.hostname, - method: init?.method?.toUpperCase() ?? 'GET', - path: parsedURL.pathname, - query, - headers, - }), - { signingDate }, - ); - signatureMatches = actualAuthorization === expected.headers['authorization']; - return new globalThis.Response(responseStreamSSE(), { - status: 200, - headers: { 'Content-Type': 'text/event-stream' }, - }); - }, - }); - - for await (const _event of await client.responses.retrieve('resp_123', { - starting_after: 1, - stream: true, - })) { - // Consume the stream so the request completes. - } - - expect(signatureMatches).toBe(true); - }); - - test('refreshes AWS credentials before retries', async () => { - const requests: Headers[] = []; - const credentials = [ - { accessKeyId: 'first access key', secretAccessKey: 'first secret', sessionToken: 'first token' }, - { accessKeyId: 'second access key', secretAccessKey: 'second secret', sessionToken: 'second token' }, - ]; - const client = new BedrockOpenAI({ - baseURL: 'https://example.com/openai/v1', - awsRegion: 'us-east-1', - awsCredentialProvider: async () => credentials.shift()!, - fetch: async (_url, init) => { - requests.push(new Headers(init?.headers)); - const status = requests.length === 1 ? 500 : 200; - return new globalThis.Response( - JSON.stringify(status === 500 ? { error: 'server error' } : RESPONSE_BODY), - { status, headers: { 'Content-Type': 'application/json' } }, - ); - }, - maxRetries: 1, - }); - - await client.responses.create({ model: 'gpt-4o', input: 'hello' }); - - expect(requests[0]!.get('authorization')).toContain('Credential=first access key/'); - expect(requests[0]!.get('x-amz-security-token')).toBe('first token'); - expect(requests[1]!.get('authorization')).toContain('Credential=second access key/'); - expect(requests[1]!.get('x-amz-security-token')).toBe('second token'); - }); - test('preserves token provider across withOptions', async () => { const authorizationHeaders: string[] = []; const fetch = async (_url: RequestInfo, init?: RequestInit): Promise => { @@ -479,112 +216,6 @@ describe('instantiate bedrock client', () => { expect(authorizationHeaders).toEqual(['Bearer provider token']); }); - test('preserves AWS credentials across withOptions', async () => { - const requests: Headers[] = []; - const client = new BedrockOpenAI({ - baseURL: 'https://example.com/openai/v1', - awsRegion: 'us-east-1', - awsAccessKeyId: 'access key', - awsSecretAccessKey: 'secret key', - fetch: async (_url, init) => { - requests.push(new Headers(init?.headers)); - return new globalThis.Response(JSON.stringify(RESPONSE_BODY), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }); - }, - }); - - await client.withOptions({ timeout: 1 }).responses.create({ model: 'gpt-4o', input: 'hello' }); - - expect(requests[0]!.get('authorization')).toContain('Credential=access key/'); - }); - - test('replaces the AWS credential source in withOptions', () => { - const explicitCredentialsClient = new BedrockOpenAI({ - baseURL: 'https://example.com/openai/v1', - awsRegion: 'us-east-1', - awsAccessKeyId: 'access key', - awsSecretAccessKey: 'secret key', - }); - - const profileClient = explicitCredentialsClient.withOptions({ awsProfile: 'other-profile' }); - expect(() => - profileClient.withOptions({ - awsAccessKeyId: 'replacement access key', - awsSecretAccessKey: 'replacement secret key', - }), - ).not.toThrow(); - }); - - test('can switch from AWS credentials to bearer authentication in withOptions', async () => { - const authorizationHeaders: string[] = []; - const client = new BedrockOpenAI({ - baseURL: 'https://example.com/openai/v1', - awsRegion: 'us-east-1', - awsAccessKeyId: 'access key', - awsSecretAccessKey: 'secret key', - fetch: async (_url, init) => { - authorizationHeaders.push(new Headers(init?.headers).get('authorization') ?? ''); - return new globalThis.Response(JSON.stringify(RESPONSE_BODY), { - headers: { 'Content-Type': 'application/json' }, - }); - }, - }); - - await client - .withOptions({ apiKey: 'replacement bearer token' }) - .responses.create({ model: 'gpt-4o', input: 'hello' }); - - expect(authorizationHeaders).toEqual(['Bearer replacement bearer token']); - }); - - test('can switch from bearer authentication to an AWS credential provider in withOptions', async () => { - const authorizationHeaders: string[] = []; - const client = new BedrockOpenAI({ - baseURL: 'https://example.com/openai/v1', - awsRegion: 'us-east-1', - apiKey: 'bearer token', - fetch: async (_url, init) => { - authorizationHeaders.push(new Headers(init?.headers).get('authorization') ?? ''); - return new globalThis.Response(JSON.stringify(RESPONSE_BODY), { - headers: { 'Content-Type': 'application/json' }, - }); - }, - }); - - await client - .withOptions({ - awsCredentialProvider: async () => ({ - accessKeyId: 'replacement access key', - secretAccessKey: 'replacement secret key', - }), - }) - .responses.create({ model: 'gpt-4o', input: 'hello' }); - - expect(authorizationHeaders[0]).toContain('AWS4-HMAC-SHA256 Credential=replacement access key/'); - }); - - test('recomputes a region-derived base URL in withOptions', () => { - const client = new BedrockOpenAI({ awsRegion: 'us-east-1', apiKey: 'token' }); - - const copiedClient = client.withOptions({ awsRegion: 'eu-west-1' }); - - expect(copiedClient.baseURL).toBe('https://bedrock-mantle.eu-west-1.api.aws/openai/v1'); - }); - - test('keeps an explicit base URL when the region changes in withOptions', () => { - const client = new BedrockOpenAI({ - baseURL: 'https://example.com/openai/v1', - awsRegion: 'us-east-1', - apiKey: 'token', - }); - - const copiedClient = client.withOptions({ awsRegion: 'eu-west-1' }); - - expect(copiedClient.baseURL).toBe('https://example.com/openai/v1'); - }); - test('passes non-Responses resources through', async () => { const requests: string[] = []; const fetch = async (url: RequestInfo): Promise => { diff --git a/tests/lib/provider.test.ts b/tests/lib/provider.test.ts index b885cb24c..c9a262c7d 100644 --- a/tests/lib/provider.test.ts +++ b/tests/lib/provider.test.ts @@ -1,5 +1,3 @@ -// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. - import OpenAI from 'openai'; import { createProvider, type ProviderRuntime } from 'openai/internal/provider'; import { formatRequestDetails } from 'openai/internal/utils/log'; @@ -134,6 +132,22 @@ describe('provider', () => { 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' }), diff --git a/tests/live/bedrock.live.test.ts b/tests/live/bedrock.live.test.ts index b1b3ec90a..b1ff35a40 100644 --- a/tests/live/bedrock.live.test.ts +++ b/tests/live/bedrock.live.test.ts @@ -1,5 +1,7 @@ import OpenAI from 'openai'; -import { bedrock, type BedrockProviderOptions } from 'openai/providers/bedrock'; +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: @@ -39,33 +41,38 @@ function readAuthMode(): AuthMode { throw new Error(`${AUTH_MODE_ENV} must be one of: ${authModes.join(', ')}.`); } -async function authOptions(mode: AuthMode): Promise { +async function providerForAuth( + mode: AuthMode, + endpoint: { region: string; baseURL?: string | undefined }, +): Promise { switch (mode) { case 'bearer': - return { apiKey: requiredEnv('AWS_BEARER_TOKEN_BEDROCK') }; + return bearerBedrock({ ...endpoint, apiKey: requiredEnv('AWS_BEARER_TOKEN_BEDROCK') }); case 'environment-bearer': requiredEnv('AWS_BEARER_TOKEN_BEDROCK'); - return {}; + return bearerBedrock(endpoint); case 'default-chain': - return { apiKey: null }; + return awsBedrock({ ...endpoint, apiKey: null }); case 'profile': - return { apiKey: null, profile: requiredEnv('AWS_PROFILE') }; + return awsBedrock({ ...endpoint, apiKey: null, profile: requiredEnv('AWS_PROFILE') }); case 'static': { const sessionToken = process.env['AWS_SESSION_TOKEN']?.trim(); - return { + 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 { + return awsBedrock({ + ...endpoint, apiKey: null, credentialProvider: defaultProvider(profile ? { profile } : {}), - }; + }); } } } @@ -91,10 +98,9 @@ describe(`Amazon Bedrock live (${authMode})`, () => { beforeAll(async () => { client = new OpenAI({ - provider: bedrock({ + provider: await providerForAuth(authMode, { region, ...(baseURL ? { baseURL } : {}), - ...(await authOptions(authMode)), }), maxRetries: 0, timeout: 120_000,