From a0a16c7762e6985adf88f5eae081e3c371e42580 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 21 Apr 2026 13:20:10 +0200 Subject: [PATCH] fix(amazon-bedrock): restore regional endpoint resolution closes #3481 closes #3485 closes #3486 closes #3487 closes #3488 --- packages/ai/CHANGELOG.md | 4 + packages/ai/scripts/generate-models.ts | 10 +- packages/ai/src/models.generated.ts | 14 +- packages/ai/src/providers/amazon-bedrock.ts | 72 ++++++++-- .../test/bedrock-endpoint-resolution.test.ts | 131 ++++++++++++++++++ 5 files changed, 213 insertions(+), 18 deletions(-) create mode 100644 packages/ai/test/bedrock-endpoint-resolution.test.ts diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 6b2c8d06..46121275 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Bedrock runtime endpoint resolution to stop pinning built-in regional endpoints over `AWS_REGION` / `AWS_PROFILE`, restoring `us.*` and `eu.*` inference profile support after v0.68.0 while preserving custom VPC/proxy endpoint overrides ([#3481](https://github.com/badlogic/pi-mono/issues/3481), [#3485](https://github.com/badlogic/pi-mono/issues/3485), [#3486](https://github.com/badlogic/pi-mono/issues/3486), [#3487](https://github.com/badlogic/pi-mono/issues/3487), [#3488](https://github.com/badlogic/pi-mono/issues/3488)) + ## [0.68.0] - 2026-04-20 ### Added diff --git a/packages/ai/scripts/generate-models.ts b/packages/ai/scripts/generate-models.ts index cb0e6cdd..0647d46e 100644 --- a/packages/ai/scripts/generate-models.ts +++ b/packages/ai/scripts/generate-models.ts @@ -57,6 +57,12 @@ const AI_GATEWAY_MODELS_URL = "https://ai-gateway.vercel.sh/v1"; const AI_GATEWAY_BASE_URL = "https://ai-gateway.vercel.sh"; const ZAI_TOOL_STREAM_UNSUPPORTED_MODELS = new Set(["glm-4.5", "glm-4.5-air", "glm-4.5-flash", "glm-4.5v"]); +function getBedrockBaseUrl(modelId: string): string { + return modelId.startsWith("eu.") + ? "https://bedrock-runtime.eu-central-1.amazonaws.com" + : "https://bedrock-runtime.us-east-1.amazonaws.com"; +} + async function fetchOpenRouterModels(): Promise[]> { try { console.log("Fetching models from OpenRouter API..."); @@ -204,7 +210,7 @@ async function loadModelsDevData(): Promise[]> { name: m.name || id, api: "bedrock-converse-stream" as const, provider: "amazon-bedrock" as const, - baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + baseUrl: getBedrockBaseUrl(id), reasoning: m.reasoning === true, input: (m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"]) as ("text" | "image")[], cost: { @@ -740,7 +746,7 @@ async function generateModels() { name: "Claude Opus 4.6 (EU)", api: "bedrock-converse-stream", provider: "amazon-bedrock", - baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + baseUrl: getBedrockBaseUrl("eu.anthropic.claude-opus-4-6-v1"), reasoning: true, input: ["text", "image"], cost: { diff --git a/packages/ai/src/models.generated.ts b/packages/ai/src/models.generated.ts index 160d1a15..28d6ce84 100644 --- a/packages/ai/src/models.generated.ts +++ b/packages/ai/src/models.generated.ts @@ -384,7 +384,7 @@ export const MODELS = { name: "Claude Haiku 4.5 (EU)", api: "bedrock-converse-stream", provider: "amazon-bedrock", - baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com", reasoning: true, input: ["text", "image"], cost: { @@ -401,7 +401,7 @@ export const MODELS = { name: "Claude Opus 4.5 (EU)", api: "bedrock-converse-stream", provider: "amazon-bedrock", - baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com", reasoning: true, input: ["text", "image"], cost: { @@ -418,7 +418,7 @@ export const MODELS = { name: "Claude Opus 4.6 (EU)", api: "bedrock-converse-stream", provider: "amazon-bedrock", - baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com", reasoning: true, input: ["text", "image"], cost: { @@ -435,7 +435,7 @@ export const MODELS = { name: "Claude Opus 4.7 (EU)", api: "bedrock-converse-stream", provider: "amazon-bedrock", - baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com", reasoning: true, input: ["text", "image"], cost: { @@ -452,7 +452,7 @@ export const MODELS = { name: "Claude Sonnet 4 (EU)", api: "bedrock-converse-stream", provider: "amazon-bedrock", - baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com", reasoning: true, input: ["text", "image"], cost: { @@ -469,7 +469,7 @@ export const MODELS = { name: "Claude Sonnet 4.5 (EU)", api: "bedrock-converse-stream", provider: "amazon-bedrock", - baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com", reasoning: true, input: ["text", "image"], cost: { @@ -486,7 +486,7 @@ export const MODELS = { name: "Claude Sonnet 4.6 (EU)", api: "bedrock-converse-stream", provider: "amazon-bedrock", - baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com", + baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com", reasoning: true, input: ["text", "image"], cost: { diff --git a/packages/ai/src/providers/amazon-bedrock.ts b/packages/ai/src/providers/amazon-bedrock.ts index dd8d6123..c76dfbf6 100644 --- a/packages/ai/src/providers/amazon-bedrock.ts +++ b/packages/ai/src/providers/amazon-bedrock.ts @@ -114,10 +114,19 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt const config: BedrockRuntimeClientConfig = { profile: options.profile, }; + const configuredRegion = getConfiguredBedrockRegion(options); + const hasConfiguredProfile = hasConfiguredBedrockProfile(); + const endpointRegion = getStandardBedrockEndpointRegion(model.baseUrl); + const useExplicitEndpoint = shouldUseExplicitBedrockEndpoint( + model.baseUrl, + configuredRegion, + hasConfiguredProfile, + ); - // Pass custom endpoint when the model has a non-default baseUrl. - // This enables VPC endpoints, proxy setups, and custom routing. - if (model.baseUrl) { + // Only pin standard AWS Bedrock runtime endpoints when no region/profile is configured. + // This preserves custom endpoints (VPC/proxy) from #3402 without forcing built-in + // catalog defaults such as us-east-1 to override AWS_REGION/AWS_PROFILE. + if (useExplicitEndpoint) { config.endpoint = model.baseUrl; } @@ -130,10 +139,11 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt // Region resolution: explicit option > env vars > SDK default chain. // When AWS_PROFILE is set, we leave region undefined so the SDK can // resovle it from aws profile configs. Otherwise fall back to us-east-1. - const explicitRegion = options.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION; - if (explicitRegion) { - config.region = explicitRegion; - } else if (!process.env.AWS_PROFILE) { + if (configuredRegion) { + config.region = configuredRegion; + } else if (endpointRegion && useExplicitEndpoint) { + config.region = endpointRegion; + } else if (!hasConfiguredProfile) { config.region = "us-east-1"; } @@ -173,7 +183,8 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt } else { // Non-Node environment (browser): fall back to us-east-1 since // there's no config file resolution available. - config.region = options.region || "us-east-1"; + config.region = + configuredRegion || (endpointRegion && useExplicitEndpoint ? endpointRegion : undefined) || "us-east-1"; } if (useBearerToken) { @@ -785,8 +796,51 @@ function mapStopReason(reason: string | undefined): StopReason { } } +function getConfiguredBedrockRegion(options: BedrockOptions): string | undefined { + if (typeof process === "undefined") { + return options.region; + } + + return options.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || undefined; +} + +function hasConfiguredBedrockProfile(): boolean { + if (typeof process === "undefined") { + return false; + } + + return Boolean(process.env.AWS_PROFILE); +} + +function getStandardBedrockEndpointRegion(baseUrl: string | undefined): string | undefined { + if (!baseUrl) { + return undefined; + } + + try { + const { hostname } = new URL(baseUrl); + const match = hostname.toLowerCase().match(/^bedrock-runtime(?:-fips)?\.([a-z0-9-]+)\.amazonaws\.com(?:\.cn)?$/); + return match?.[1]; + } catch { + return undefined; + } +} + +function shouldUseExplicitBedrockEndpoint( + baseUrl: string, + configuredRegion: string | undefined, + hasConfiguredProfile: boolean, +): boolean { + const endpointRegion = getStandardBedrockEndpointRegion(baseUrl); + if (!endpointRegion) { + return true; + } + + return !configuredRegion && !hasConfiguredProfile; +} + function isGovCloudBedrockTarget(model: Model<"bedrock-converse-stream">, options: BedrockOptions): boolean { - const region = options.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION; + const region = getConfiguredBedrockRegion(options); if (region?.toLowerCase().startsWith("us-gov-")) { return true; } diff --git a/packages/ai/test/bedrock-endpoint-resolution.test.ts b/packages/ai/test/bedrock-endpoint-resolution.test.ts new file mode 100644 index 00000000..374599a4 --- /dev/null +++ b/packages/ai/test/bedrock-endpoint-resolution.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const bedrockMock = vi.hoisted(() => ({ + constructorCalls: [] as Array>, +})); + +vi.mock("@aws-sdk/client-bedrock-runtime", () => { + class BedrockRuntimeServiceException extends Error {} + + class BedrockRuntimeClient { + constructor(config: Record) { + bedrockMock.constructorCalls.push(config); + } + + send(): Promise { + return Promise.reject(new Error("mock send")); + } + } + + class ConverseStreamCommand { + readonly input: unknown; + + constructor(input: unknown) { + this.input = input; + } + } + + return { + BedrockRuntimeClient, + BedrockRuntimeServiceException, + ConverseStreamCommand, + StopReason: { + END_TURN: "end_turn", + STOP_SEQUENCE: "stop_sequence", + MAX_TOKENS: "max_tokens", + MODEL_CONTEXT_WINDOW_EXCEEDED: "model_context_window_exceeded", + TOOL_USE: "tool_use", + }, + CachePointType: { DEFAULT: "default" }, + CacheTTL: { ONE_HOUR: "ONE_HOUR" }, + ConversationRole: { ASSISTANT: "assistant", USER: "user" }, + ImageFormat: { JPEG: "jpeg", PNG: "png", GIF: "gif", WEBP: "webp" }, + ToolResultStatus: { ERROR: "error", SUCCESS: "success" }, + }; +}); + +import { getModel } from "../src/models.js"; +import { streamBedrock } from "../src/providers/amazon-bedrock.js"; +import type { Context, Model } from "../src/types.js"; + +const context: Context = { + messages: [{ role: "user", content: "hello", timestamp: Date.now() }], +}; + +const originalAwsRegion = process.env.AWS_REGION; +const originalAwsDefaultRegion = process.env.AWS_DEFAULT_REGION; +const originalAwsProfile = process.env.AWS_PROFILE; + +beforeEach(() => { + bedrockMock.constructorCalls.length = 0; + delete process.env.AWS_REGION; + delete process.env.AWS_DEFAULT_REGION; + delete process.env.AWS_PROFILE; +}); + +afterEach(() => { + if (originalAwsRegion === undefined) { + delete process.env.AWS_REGION; + } else { + process.env.AWS_REGION = originalAwsRegion; + } + + if (originalAwsDefaultRegion === undefined) { + delete process.env.AWS_DEFAULT_REGION; + } else { + process.env.AWS_DEFAULT_REGION = originalAwsDefaultRegion; + } + + if (originalAwsProfile === undefined) { + delete process.env.AWS_PROFILE; + } else { + process.env.AWS_PROFILE = originalAwsProfile; + } +}); + +async function captureClientConfig(model: Model<"bedrock-converse-stream">): Promise> { + await streamBedrock(model, context, { cacheRetention: "none" }).result(); + expect(bedrockMock.constructorCalls).toHaveLength(1); + return bedrockMock.constructorCalls[0]; +} + +describe("bedrock endpoint resolution", () => { + it("assigns eu-central-1 runtime URLs to built-in EU inference profiles", () => { + const model = getModel("amazon-bedrock", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"); + + expect(model.baseUrl).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com"); + }); + + it("does not pin standard AWS endpoints when AWS_REGION is configured", async () => { + process.env.AWS_REGION = "us-east-2"; + const model = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-7"); + + const config = await captureClientConfig(model); + + expect(config.region).toBe("us-east-2"); + expect(config.endpoint).toBeUndefined(); + }); + + it("derives region from a built-in EU endpoint when no region or profile is configured", async () => { + const model = getModel("amazon-bedrock", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"); + + const config = await captureClientConfig(model); + + expect(config.endpoint).toBe("https://bedrock-runtime.eu-central-1.amazonaws.com"); + expect(config.region).toBe("eu-central-1"); + }); + + it("still passes custom Bedrock endpoints through to the SDK client", async () => { + process.env.AWS_REGION = "us-west-2"; + const baseModel = getModel("amazon-bedrock", "us.anthropic.claude-opus-4-7"); + const model: Model<"bedrock-converse-stream"> = { + ...baseModel, + baseUrl: "https://bedrock-vpc.example.com", + }; + + const config = await captureClientConfig(model); + + expect(config.endpoint).toBe("https://bedrock-vpc.example.com"); + expect(config.region).toBe("us-west-2"); + }); +});