diff --git a/bun.lock b/bun.lock index 7536a044b16..9b726797938 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,7 @@ "name": "@opencode-ai/ai", "version": "1.17.20", "dependencies": { + "@aws-sdk/credential-providers": "3.1057.0", "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", diff --git a/package.json b/package.json index d50c2b4a0a5..d8aa0f17409 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "type": "module", "packageManager": "bun@1.4.2", "scripts": { - "dev": "bun run --cwd packages/cli --conditions=browser src/index.ts", + "dev": "bun run --cwd packages/cli src/index.ts", "dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --", "dev:desktop": "bun --cwd packages/desktop dev", "dev:web": "bun --cwd packages/app dev", diff --git a/packages/ai/package.json b/packages/ai/package.json index c8a16bbf7cf..e8e9a8e1ec9 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -28,6 +28,7 @@ "typescript": "catalog:" }, "dependencies": { + "@aws-sdk/credential-providers": "3.1057.0", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "@opencode-ai/schema": "workspace:*", diff --git a/packages/ai/src/protocols/utils/bedrock-auth.ts b/packages/ai/src/protocols/utils/bedrock-auth.ts index f198fdd308c..7be6c260063 100644 --- a/packages/ai/src/protocols/utils/bedrock-auth.ts +++ b/packages/ai/src/protocols/utils/bedrock-auth.ts @@ -2,13 +2,12 @@ import { AwsV4Signer } from "aws4fetch" import { Effect } from "effect" import { Headers } from "effect/unstable/http" import { Auth, type AuthInput } from "../../route/auth.js" +import { AIError, AuthenticationError } from "../../schema/index.js" import { ProviderShared } from "../shared.js" /** * AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth, - * which provider facades configure as route auth instead of SigV4. STS-vended - * credentials should be refreshed by the consumer (rebuild the model) before - * they expire; the route does not refresh. + * which provider facades configure as route auth instead of SigV4. */ export interface Credentials { readonly region: string @@ -17,6 +16,44 @@ export interface Credentials { readonly sessionToken?: string } +/** Static credentials or an effect resolved before every request. */ +export type CredentialSource = Credentials | Effect.Effect + +export interface DefaultChainOptions { + readonly region: string + /** Shared config profile passed to the AWS default chain. */ + readonly profile?: string +} + +/** + * Resolve credentials through the AWS default provider chain: environment + * variables, shared config and SSO caches, web identity tokens, process + * credentials, and container or instance metadata. A fresh chain runs on every + * request so credentials rotated on disk without an expiration (for example + * shared-config keys rewritten by a corporate SSO tool) are always re-read; + * the SDK's own memoization would otherwise pin them for the process lifetime. + */ +export const defaultChain = (options: DefaultChainOptions): Effect.Effect => + Effect.tryPromise({ + try: async () => { + const { fromNodeProviderChain } = await import("@aws-sdk/credential-providers") + const identity = await fromNodeProviderChain(options.profile === undefined ? {} : { profile: options.profile })() + return { + region: options.region, + accessKeyId: identity.accessKeyId, + secretAccessKey: identity.secretAccessKey, + ...(identity.sessionToken === undefined ? {} : { sessionToken: identity.sessionToken }), + } + }, + catch: (error) => + new AIError({ + reason: new AuthenticationError({ + message: `AWS default credential chain failed: ${ProviderShared.errorText(error)}`, + cause: error, + }), + }), + }) + const signRequest = (input: { readonly url: string readonly body: string @@ -48,16 +85,17 @@ const signRequest = (input: { /** Sign the exact JSON bytes with SigV4 using credentials configured on the route. */ export const sigV4 = ( - credentials: Credentials | undefined, + source: CredentialSource | undefined, options: { readonly service?: string; readonly name?: string } = {}, ) => Auth.custom((input: AuthInput) => { return Effect.gen(function* () { - if (!credentials) { + if (!source) { return yield* ProviderShared.invalidRequest( `${options.name ?? "Bedrock Converse"} requires either route bearer auth or AWS credentials configured on the route`, ) } + const credentials = Effect.isEffect(source) ? yield* source : source const headersForSigning = Headers.set(input.headers, "content-type", "application/json") const signed = yield* signRequest({ url: input.url, @@ -74,4 +112,35 @@ export const sigV4 = ( /** Bedrock route auth defaults to SigV4 and expects credentials from route configuration. */ export const auth = sigV4(undefined) +export const resolveRegion = (input: { + readonly region?: string + readonly credentials?: { readonly region: string } +}) => + input.region ?? input.credentials?.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? "us-east-1" + +export interface ResolveAuthInput { + readonly apiKey?: string + readonly credentials?: Credentials + readonly profile?: string +} + +export interface ResolveAuthOptions { + readonly service?: string + readonly name?: string + /** `sigv4` ignores an ambient `AWS_BEARER_TOKEN_BEDROCK`; `bearer` is validated by the caller. */ + readonly mode?: "bearer" | "sigv4" +} + +/** + * Bearer wins over SigV4 and explicit static credentials win over the default + * chain, matching the AWS SDK's own precedence for `AWS_BEARER_TOKEN_BEDROCK`. + * The region is applied to the SigV4 scope so it always matches the endpoint host. + */ +export const resolveAuth = (input: ResolveAuthInput, region: string, options: ResolveAuthOptions = {}) => { + const apiKey = options.mode === "sigv4" ? undefined : (input.apiKey ?? process.env.AWS_BEARER_TOKEN_BEDROCK) + if (apiKey !== undefined) return Auth.bearer(apiKey) + if (input.credentials !== undefined) return sigV4({ ...input.credentials, region }, options) + return sigV4(defaultChain({ region, profile: input.profile }), options) +} + export * as BedrockAuth from "./bedrock-auth.js" diff --git a/packages/ai/src/providers/amazon-bedrock-mantle.ts b/packages/ai/src/providers/amazon-bedrock-mantle.ts index a36d3eacefd..f58d0f52783 100644 --- a/packages/ai/src/providers/amazon-bedrock-mantle.ts +++ b/packages/ai/src/providers/amazon-bedrock-mantle.ts @@ -1,4 +1,3 @@ -import { Auth } from "../route/auth.js" import { Route, type RouteDefaultsInput } from "../route/client.js" import type { ProviderPackage } from "../provider-package.js" import { OpenAIChat } from "../protocols/openai-chat.js" @@ -10,9 +9,15 @@ import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-opt export const id = ProviderID.make("amazon-bedrock") export type Config = RouteDefaultsInput & { + /** Bedrock API key. Falls back to `AWS_BEARER_TOKEN_BEDROCK`; bearer auth takes precedence over SigV4. */ readonly apiKey?: string + /** `sigv4` ignores `apiKey` fallbacks from the environment; `bearer` requires a token. */ + readonly auth?: "bearer" | "sigv4" readonly baseURL?: string + /** Static SigV4 credentials. When omitted the AWS default credential chain resolves them per request. */ readonly credentials?: Credentials + /** Shared config profile for the default credential chain. */ + readonly profile?: string readonly region?: string readonly providerOptions?: OpenAIProviderOptionsInput } @@ -22,6 +27,7 @@ export interface Settings extends ProviderPackage.Settings { readonly auth?: "bearer" | "sigv4" readonly baseURL?: string readonly credentials?: Credentials + readonly profile?: string readonly region?: string readonly topP?: number readonly providerOptions?: OpenAIProviderOptionsInput @@ -47,23 +53,35 @@ const chatRoute = OpenAIChat.route.with({ export const routes = [responsesRoute, chatRoute] const configuredRoute = (route: Route, input: Config) => { - const region = input.region ?? input.credentials?.region ?? "us-east-1" - const credentials = input.credentials === undefined ? undefined : { ...input.credentials, region } + const region = BedrockAuth.resolveRegion(input) return route.with({ endpoint: { baseURL: input.baseURL ?? `https://bedrock-mantle.${region}.api.aws/v1` }, - auth: - input.apiKey === undefined - ? BedrockAuth.sigV4(credentials, { service: "bedrock-mantle", name: "Bedrock Mantle" }) - : Auth.bearer(input.apiKey), + auth: BedrockAuth.resolveAuth(input, region, { + service: "bedrock-mantle", + name: "Bedrock Mantle", + mode: input.auth, + }), }) } const defaults = (input: Config) => { - const { apiKey: _, baseURL: _baseURL, credentials: _credentials, region: _region, ...rest } = input + const { + apiKey: _, + auth: _auth, + baseURL: _baseURL, + credentials: _credentials, + profile: _profile, + region: _region, + ...rest + } = input return rest } export const configure = (input: Config = {}) => { + if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined) + throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey") + if (input.auth === "sigv4" && input.apiKey !== undefined) + throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey") const configuredResponsesRoute = configuredRoute(responsesRoute, input) const configuredChatRoute = configuredRoute(chatRoute, input) const modelDefaults = defaults(input) @@ -87,29 +105,26 @@ export const configure = (input: Config = {}) => { export const provider = configure() -const config = (settings: Settings): Config => { - if (settings.auth === "bearer" && settings.apiKey === undefined) - throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey") - if (settings.auth === "sigv4" && settings.apiKey !== undefined) - throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey") - return { - apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey, +const fromSettings = (settings: Settings) => + configure({ + apiKey: settings.apiKey, + auth: settings.auth, baseURL: settings.baseURL, credentials: settings.credentials, generation: settings.topP === undefined ? undefined : { topP: settings.topP }, headers: settings.headers === undefined ? undefined : { ...settings.headers }, http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + profile: settings.profile, providerOptions: settings.providerOptions, region: settings.region, - } -} + }) export const chatModel: ProviderPackage.Definition["model"] = ( modelID, settings, -) => configure(config(settings)).chat(modelID) +) => fromSettings(settings).chat(modelID) export const responsesModel: ProviderPackage.Definition["model"] = ( modelID, settings, -) => configure(config(settings)).responses(modelID) +) => fromSettings(settings).responses(modelID) export const model = responsesModel diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index d0850b5af57..56a545ee014 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -1,17 +1,23 @@ import type { RouteDefaultsInput } from "../route/client.js" -import { Auth } from "../route/auth.js" import type { ProviderPackage } from "../provider-package.js" import { ProviderID, type ModelID } from "../schema/index.js" import * as BedrockConverse from "../protocols/bedrock-converse.js" import type { BedrockCredentials } from "../protocols/bedrock-converse.js" +import { BedrockAuth } from "../protocols/utils/bedrock-auth.js" export const id = ProviderID.make("amazon-bedrock") export type Config = RouteDefaultsInput & { + /** Bedrock API key. Falls back to `AWS_BEARER_TOKEN_BEDROCK`; bearer auth takes precedence over SigV4. */ readonly apiKey?: string + /** `sigv4` ignores `apiKey` fallbacks from the environment; `bearer` requires a token. */ + readonly auth?: "bearer" | "sigv4" readonly headers?: Record + /** Static SigV4 credentials. When omitted the AWS default credential chain resolves them per request. */ readonly credentials?: BedrockCredentials - /** AWS region. Defaults to `us-east-1` when neither this nor `credentials.region` is set. */ + /** Shared config profile for the default credential chain. */ + readonly profile?: string + /** AWS region. Falls back to `credentials.region`, `AWS_REGION`, `AWS_DEFAULT_REGION`, then `us-east-1`. */ readonly region?: string /** Override the computed `https://bedrock-runtime..amazonaws.com` URL. */ readonly baseURL?: string @@ -22,6 +28,7 @@ export interface Settings extends ProviderPackage.Settings { readonly auth?: "bearer" | "sigv4" readonly baseURL?: string readonly credentials?: BedrockCredentials + readonly profile?: string readonly region?: string readonly topP?: number } @@ -30,14 +37,17 @@ export const routes = [BedrockConverse.route] const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.amazonaws.com` const configuredRoute = (input: Config) => { - const { apiKey, credentials, region, baseURL, ...rest } = input - const resolvedRegion = region ?? credentials?.region ?? "us-east-1" + const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input + if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined) + throw new Error("Amazon Bedrock bearer auth requires apiKey") + if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey") + const resolvedRegion = BedrockAuth.resolveRegion(input) return BedrockConverse.route.with({ ...rest, provider: id, providerMetadataKey: "bedrock", endpoint: { baseURL: baseURL ?? bedrockBaseURL(resolvedRegion) }, - auth: apiKey === undefined ? BedrockConverse.sigV4Auth(credentials) : Auth.bearer(apiKey), + auth: BedrockAuth.resolveAuth({ apiKey, credentials, profile }, resolvedRegion, { mode: auth }), }) } @@ -51,18 +61,15 @@ export const configure = (input: Config = {}) => { } export const provider = configure() -export const model: ProviderPackage.Definition["model"] = (modelID, settings) => { - if (settings.auth === "bearer" && settings.apiKey === undefined) - throw new Error("Amazon Bedrock bearer auth requires apiKey") - if (settings.auth === "sigv4" && settings.apiKey !== undefined) - throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey") - return configure({ - apiKey: settings.auth === "sigv4" ? undefined : settings.apiKey, +export const model: ProviderPackage.Definition["model"] = (modelID, settings) => + configure({ + apiKey: settings.apiKey, + auth: settings.auth, baseURL: settings.baseURL, credentials: settings.credentials, generation: settings.topP === undefined ? undefined : { topP: settings.topP }, headers: settings.headers === undefined ? undefined : { ...settings.headers }, http: settings.body === undefined ? undefined : { body: { ...settings.body } }, + profile: settings.profile, region: settings.region, }).model(modelID) -} diff --git a/packages/ai/test/lib/env.ts b/packages/ai/test/lib/env.ts new file mode 100644 index 00000000000..388556d723f --- /dev/null +++ b/packages/ai/test/lib/env.ts @@ -0,0 +1,30 @@ +import { Effect } from "effect" + +/** + * Run an effect with `process.env` overrides, restoring the previous values + * afterwards. Use for code that reads `process.env` directly, such as AWS SDK + * credential providers, where Effect `ConfigProvider` layers do not apply. + * `undefined` removes a variable for the duration of the effect. + */ +export const withProcessEnv = + (env: Record) => + (effect: Effect.Effect) => + Effect.acquireUseRelease( + Effect.sync(() => { + const previous = Object.fromEntries(Object.keys(env).map((name) => [name, process.env[name]])) + apply(env) + return previous + }), + () => effect, + (previous) => Effect.sync(() => apply(previous)), + ) + +const apply = (env: Record) => { + for (const [name, value] of Object.entries(env)) { + if (value === undefined) { + delete process.env[name] + continue + } + process.env[name] = value + } +} diff --git a/packages/ai/test/provider/bedrock-converse.test.ts b/packages/ai/test/provider/bedrock-converse.test.ts index cca4d24984f..05891cf32df 100644 --- a/packages/ai/test/provider/bedrock-converse.test.ts +++ b/packages/ai/test/provider/bedrock-converse.test.ts @@ -2,9 +2,11 @@ import { EventStreamCodec } from "@smithy/eventstream-codec" import { fromUtf8, toUtf8 } from "@smithy/util-utf8" import { describe, expect } from "bun:test" import { Effect, Encoding, Ref, Stream } from "effect" +import { HttpClientRequest } from "effect/unstable/http" import { CacheHint, GenerationOptions, + type LanguageModel, LLM, LLMEvent, LLMRequest, @@ -18,7 +20,8 @@ import { compileRequest } from "../../src/route/client.js" import { AmazonBedrock } from "../../src/providers.js" import * as BedrockConverse from "../../src/protocols/bedrock-converse.js" import { it } from "../lib/effect.js" -import { fixedResponse } from "../lib/http.js" +import { withProcessEnv } from "../lib/env.js" +import { dynamicResponse, fixedResponse } from "../lib/http.js" import { eventSummary, expectWeatherToolLoop, @@ -95,6 +98,41 @@ const fixedByteChunks = (...chunks: ReadonlyArray) => { headers: { "content-type": "application/vnd.amazon.eventstream" } }, ) +// Clears every input the AWS default chain reads so tests do not pick up the +// developer's profiles, SSO cache, or instance metadata. +const noAmbientAWS = { + AWS_ACCESS_KEY_ID: undefined, + AWS_SECRET_ACCESS_KEY: undefined, + AWS_SESSION_TOKEN: undefined, + AWS_BEARER_TOKEN_BEDROCK: undefined, + AWS_PROFILE: undefined, + AWS_REGION: undefined, + AWS_DEFAULT_REGION: undefined, + AWS_WEB_IDENTITY_TOKEN_FILE: undefined, + AWS_CONTAINER_CREDENTIALS_RELATIVE_URI: undefined, + AWS_CONTAINER_CREDENTIALS_FULL_URI: undefined, + AWS_EC2_METADATA_DISABLED: "true", +} + +const captureHeaders = (target: LanguageModel) => + Effect.gen(function* () { + const seen: Array = [] + yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: target })).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.toWeb(input.request) + seen.push(request.headers) + return input.respond(eventStreamBody(["messageStop", { stopReason: "end_turn" }]), { + headers: { "content-type": "application/vnd.amazon.eventstream" }, + }) + }), + ), + ), + ) + return seen[0]! + }) + const model = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", apiKey: "test-bearer", @@ -1311,35 +1349,137 @@ describe("Bedrock Converse route", () => { }), ) - it.effect("rejects requests with no auth path", () => + it.effect("fails with an authentication error when the default chain cannot resolve credentials", () => Effect.gen(function* () { const unsignedModel = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", + profile: "opencode-test-missing-profile", }).model("anthropic.claude-3-5-sonnet-20240620-v1:0") const error = yield* LLMClient.generate(LLMRequest.update(baseRequest, { model: unsignedModel })).pipe( Effect.provide(fixedBytes(eventStreamBody(["messageStop", { stopReason: "end_turn" }]))), Effect.flip, ) - expect(error.message).toContain("Bedrock Converse requires either route bearer auth or AWS credentials") - }), + expect(error.reason._tag).toBe("Authentication") + expect(error.message).toContain("AWS default credential chain failed") + }).pipe(withProcessEnv(noAmbientAWS)), ) - it.effect("signs requests with SigV4 when AWS credentials are provided (deterministic plumbing check)", () => + it.effect("signs with static credentials using the configured region for the SigV4 scope", () => Effect.gen(function* () { const signed = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", + region: "eu-west-1", credentials: { region: "us-east-1", accessKeyId: "AKIAIOSFODNN7EXAMPLE", secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", }, }).model("anthropic.claude-3-5-sonnet-20240620-v1:0") - const prepared = yield* compileRequest(LLMRequest.update(baseRequest, { model: signed })) + const headers = yield* captureHeaders(signed) - expect(prepared.route).toBe("bedrock-converse") - expect(prepared.model).toBe(signed) - }), + expect(headers.get("authorization")).toContain("Credential=AKIAIOSFODNN7EXAMPLE/") + expect(headers.get("authorization")).toContain("/eu-west-1/bedrock/aws4_request") + expect(headers.get("x-amz-security-token")).toBeNull() + }).pipe(withProcessEnv(noAmbientAWS)), + ) + + it.effect("resolves SigV4 credentials through the AWS default chain on every request", () => + Effect.gen(function* () { + const chained = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", region: "us-west-2" }).model( + "anthropic.claude-3-5-sonnet-20240620-v1:0", + ) + const headers = yield* captureHeaders(chained) + + expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/") + expect(headers.get("authorization")).toContain("/us-west-2/bedrock/aws4_request") + expect(headers.get("x-amz-security-token")).toBe("chain-session-token") + }).pipe( + withProcessEnv({ + ...noAmbientAWS, + AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE", + AWS_SECRET_ACCESS_KEY: "chain-secret", + AWS_SESSION_TOKEN: "chain-session-token", + }), + ), + ) + + it.effect("re-resolves rotated chain credentials on the next request without rebuilding the model", () => + Effect.gen(function* () { + const chained = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test", region: "us-west-2" }).model( + "anthropic.claude-3-5-sonnet-20240620-v1:0", + ) + const first = yield* captureHeaders(chained) + const second = yield* captureHeaders(chained).pipe( + withProcessEnv({ AWS_ACCESS_KEY_ID: "AKIAROTATEDEXAMPLE", AWS_SECRET_ACCESS_KEY: "rotated-secret" }), + ) + + expect(first.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/") + expect(second.get("authorization")).toContain("Credential=AKIAROTATEDEXAMPLE/") + }).pipe( + withProcessEnv({ + ...noAmbientAWS, + AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE", + AWS_SECRET_ACCESS_KEY: "chain-secret", + }), + ), + ) + + it.effect("prefers AWS_BEARER_TOKEN_BEDROCK over the credential chain", () => + Effect.gen(function* () { + const bearer = AmazonBedrock.configure({ baseURL: "https://bedrock-runtime.test" }).model( + "anthropic.claude-3-5-sonnet-20240620-v1:0", + ) + const headers = yield* captureHeaders(bearer) + + expect(headers.get("authorization")).toBe("Bearer env-bearer-token") + }).pipe( + withProcessEnv({ + ...noAmbientAWS, + AWS_BEARER_TOKEN_BEDROCK: "env-bearer-token", + AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE", + AWS_SECRET_ACCESS_KEY: "chain-secret", + }), + ), + ) + + it.effect("auth: sigv4 ignores an ambient bearer token from configure and settings", () => + Effect.gen(function* () { + const configured = AmazonBedrock.configure({ auth: "sigv4", baseURL: "https://bedrock-runtime.test" }).model( + "anthropic.claude-3-5-sonnet-20240620-v1:0", + ) + const fromSettings = AmazonBedrock.model("anthropic.claude-3-5-sonnet-20240620-v1:0", { + auth: "sigv4", + baseURL: "https://bedrock-runtime.test", + }) + + for (const target of [configured, fromSettings]) { + const headers = yield* captureHeaders(target) + expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/") + expect(headers.get("authorization")).toContain("/ap-southeast-2/bedrock/aws4_request") + } + expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey") + }).pipe( + withProcessEnv({ + ...noAmbientAWS, + AWS_BEARER_TOKEN_BEDROCK: "env-bearer-token", + AWS_REGION: "ap-southeast-2", + AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE", + AWS_SECRET_ACCESS_KEY: "chain-secret", + }), + ), + ) + + it.effect("derives the endpoint region from AWS_REGION then AWS_DEFAULT_REGION", () => + Effect.gen(function* () { + const fromRegion = AmazonBedrock.configure({ apiKey: "k" }).model("anthropic.claude-3-5-sonnet-20240620-v1:0") + expect(fromRegion.route.endpoint.baseURL).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com") + + const fromDefault = yield* Effect.sync(() => + AmazonBedrock.configure({ apiKey: "k" }).model("anthropic.claude-3-5-sonnet-20240620-v1:0"), + ).pipe(withProcessEnv({ AWS_REGION: undefined })) + expect(fromDefault.route.endpoint.baseURL).toBe("https://bedrock-runtime.us-gov-west-1.amazonaws.com") + }).pipe(withProcessEnv({ ...noAmbientAWS, AWS_REGION: "eu-central-1", AWS_DEFAULT_REGION: "us-gov-west-1" })), ) it.effect("emits cachePoint markers after system, user-text, and assistant-text with cache hints", () => diff --git a/packages/ai/test/provider/bedrock-mantle.test.ts b/packages/ai/test/provider/bedrock-mantle.test.ts index 0c11de31f3f..5807228c553 100644 --- a/packages/ai/test/provider/bedrock-mantle.test.ts +++ b/packages/ai/test/provider/bedrock-mantle.test.ts @@ -7,6 +7,7 @@ import { model } from "../../src/providers/amazon-bedrock/mantle.js" import { OpenAIResponses } from "../../src/protocols/openai-responses.js" import { compileRequest, LLMClient } from "../../src/route/client.js" import { it } from "../lib/effect.js" +import { withProcessEnv } from "../lib/env.js" import { dynamicResponse, fixedResponse } from "../lib/http.js" import { sseEvents } from "../lib/sse.js" import { recordedTests } from "../recorded-test.js" @@ -82,6 +83,36 @@ describe("Amazon Bedrock Mantle provider", () => { }), ) + it.effect("signs with the Mantle service using default-chain credentials", () => + Effect.gen(function* () { + const seen: Array = [] + const model = AmazonBedrockMantle.configure({ region: "us-west-1" }).responses("openai.gpt-oss-120b") + yield* LLMClient.generate(LLM.request({ model, prompt: "Hi" })).pipe( + Effect.provide( + dynamicResponse((input) => + Effect.gen(function* () { + const request = yield* HttpClientRequest.toWeb(input.request) + seen.push(request.headers.get("authorization") ?? undefined) + return input.respond("", { headers: { "content-type": "text/event-stream" } }) + }), + ), + ), + Effect.flip, + ) + + expect(seen[0]).toContain("Credential=AKIACHAINEXAMPLE/") + expect(seen[0]).toContain("/us-west-1/bedrock-mantle/aws4_request") + }).pipe( + withProcessEnv({ + AWS_BEARER_TOKEN_BEDROCK: undefined, + AWS_PROFILE: undefined, + AWS_ACCESS_KEY_ID: "AKIACHAINEXAMPLE", + AWS_SECRET_ACCESS_KEY: "chain-secret", + AWS_SESSION_TOKEN: undefined, + }), + ), + ) + it.effect("supports bearer authentication and custom base URLs", () => Effect.gen(function* () { const seen: Array<{ readonly url: string; readonly authorization: string | undefined }> = []