fix(amazon-bedrock): restore regional endpoint resolution
closes #3481 closes #3485 closes #3486 closes #3487 closes #3488
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.68.0] - 2026-04-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -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 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"]);
|
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<Model<any>[]> {
|
async function fetchOpenRouterModels(): Promise<Model<any>[]> {
|
||||||
try {
|
try {
|
||||||
console.log("Fetching models from OpenRouter API...");
|
console.log("Fetching models from OpenRouter API...");
|
||||||
@@ -204,7 +210,7 @@ async function loadModelsDevData(): Promise<Model<any>[]> {
|
|||||||
name: m.name || id,
|
name: m.name || id,
|
||||||
api: "bedrock-converse-stream" as const,
|
api: "bedrock-converse-stream" as const,
|
||||||
provider: "amazon-bedrock" as const,
|
provider: "amazon-bedrock" as const,
|
||||||
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
baseUrl: getBedrockBaseUrl(id),
|
||||||
reasoning: m.reasoning === true,
|
reasoning: m.reasoning === true,
|
||||||
input: (m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"]) as ("text" | "image")[],
|
input: (m.modalities?.input?.includes("image") ? ["text", "image"] : ["text"]) as ("text" | "image")[],
|
||||||
cost: {
|
cost: {
|
||||||
@@ -740,7 +746,7 @@ async function generateModels() {
|
|||||||
name: "Claude Opus 4.6 (EU)",
|
name: "Claude Opus 4.6 (EU)",
|
||||||
api: "bedrock-converse-stream",
|
api: "bedrock-converse-stream",
|
||||||
provider: "amazon-bedrock",
|
provider: "amazon-bedrock",
|
||||||
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
baseUrl: getBedrockBaseUrl("eu.anthropic.claude-opus-4-6-v1"),
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text", "image"],
|
input: ["text", "image"],
|
||||||
cost: {
|
cost: {
|
||||||
|
|||||||
@@ -384,7 +384,7 @@ export const MODELS = {
|
|||||||
name: "Claude Haiku 4.5 (EU)",
|
name: "Claude Haiku 4.5 (EU)",
|
||||||
api: "bedrock-converse-stream",
|
api: "bedrock-converse-stream",
|
||||||
provider: "amazon-bedrock",
|
provider: "amazon-bedrock",
|
||||||
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com",
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text", "image"],
|
input: ["text", "image"],
|
||||||
cost: {
|
cost: {
|
||||||
@@ -401,7 +401,7 @@ export const MODELS = {
|
|||||||
name: "Claude Opus 4.5 (EU)",
|
name: "Claude Opus 4.5 (EU)",
|
||||||
api: "bedrock-converse-stream",
|
api: "bedrock-converse-stream",
|
||||||
provider: "amazon-bedrock",
|
provider: "amazon-bedrock",
|
||||||
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com",
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text", "image"],
|
input: ["text", "image"],
|
||||||
cost: {
|
cost: {
|
||||||
@@ -418,7 +418,7 @@ export const MODELS = {
|
|||||||
name: "Claude Opus 4.6 (EU)",
|
name: "Claude Opus 4.6 (EU)",
|
||||||
api: "bedrock-converse-stream",
|
api: "bedrock-converse-stream",
|
||||||
provider: "amazon-bedrock",
|
provider: "amazon-bedrock",
|
||||||
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com",
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text", "image"],
|
input: ["text", "image"],
|
||||||
cost: {
|
cost: {
|
||||||
@@ -435,7 +435,7 @@ export const MODELS = {
|
|||||||
name: "Claude Opus 4.7 (EU)",
|
name: "Claude Opus 4.7 (EU)",
|
||||||
api: "bedrock-converse-stream",
|
api: "bedrock-converse-stream",
|
||||||
provider: "amazon-bedrock",
|
provider: "amazon-bedrock",
|
||||||
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com",
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text", "image"],
|
input: ["text", "image"],
|
||||||
cost: {
|
cost: {
|
||||||
@@ -452,7 +452,7 @@ export const MODELS = {
|
|||||||
name: "Claude Sonnet 4 (EU)",
|
name: "Claude Sonnet 4 (EU)",
|
||||||
api: "bedrock-converse-stream",
|
api: "bedrock-converse-stream",
|
||||||
provider: "amazon-bedrock",
|
provider: "amazon-bedrock",
|
||||||
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com",
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text", "image"],
|
input: ["text", "image"],
|
||||||
cost: {
|
cost: {
|
||||||
@@ -469,7 +469,7 @@ export const MODELS = {
|
|||||||
name: "Claude Sonnet 4.5 (EU)",
|
name: "Claude Sonnet 4.5 (EU)",
|
||||||
api: "bedrock-converse-stream",
|
api: "bedrock-converse-stream",
|
||||||
provider: "amazon-bedrock",
|
provider: "amazon-bedrock",
|
||||||
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com",
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text", "image"],
|
input: ["text", "image"],
|
||||||
cost: {
|
cost: {
|
||||||
@@ -486,7 +486,7 @@ export const MODELS = {
|
|||||||
name: "Claude Sonnet 4.6 (EU)",
|
name: "Claude Sonnet 4.6 (EU)",
|
||||||
api: "bedrock-converse-stream",
|
api: "bedrock-converse-stream",
|
||||||
provider: "amazon-bedrock",
|
provider: "amazon-bedrock",
|
||||||
baseUrl: "https://bedrock-runtime.us-east-1.amazonaws.com",
|
baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com",
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text", "image"],
|
input: ["text", "image"],
|
||||||
cost: {
|
cost: {
|
||||||
|
|||||||
@@ -114,10 +114,19 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
|
|||||||
const config: BedrockRuntimeClientConfig = {
|
const config: BedrockRuntimeClientConfig = {
|
||||||
profile: options.profile,
|
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.
|
// Only pin standard AWS Bedrock runtime endpoints when no region/profile is configured.
|
||||||
// This enables VPC endpoints, proxy setups, and custom routing.
|
// This preserves custom endpoints (VPC/proxy) from #3402 without forcing built-in
|
||||||
if (model.baseUrl) {
|
// catalog defaults such as us-east-1 to override AWS_REGION/AWS_PROFILE.
|
||||||
|
if (useExplicitEndpoint) {
|
||||||
config.endpoint = model.baseUrl;
|
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.
|
// Region resolution: explicit option > env vars > SDK default chain.
|
||||||
// When AWS_PROFILE is set, we leave region undefined so the SDK can
|
// 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.
|
// 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 (configuredRegion) {
|
||||||
if (explicitRegion) {
|
config.region = configuredRegion;
|
||||||
config.region = explicitRegion;
|
} else if (endpointRegion && useExplicitEndpoint) {
|
||||||
} else if (!process.env.AWS_PROFILE) {
|
config.region = endpointRegion;
|
||||||
|
} else if (!hasConfiguredProfile) {
|
||||||
config.region = "us-east-1";
|
config.region = "us-east-1";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +183,8 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream", BedrockOpt
|
|||||||
} else {
|
} else {
|
||||||
// Non-Node environment (browser): fall back to us-east-1 since
|
// Non-Node environment (browser): fall back to us-east-1 since
|
||||||
// there's no config file resolution available.
|
// 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) {
|
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 {
|
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-")) {
|
if (region?.toLowerCase().startsWith("us-gov-")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
131
packages/ai/test/bedrock-endpoint-resolution.test.ts
Normal file
131
packages/ai/test/bedrock-endpoint-resolution.test.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const bedrockMock = vi.hoisted(() => ({
|
||||||
|
constructorCalls: [] as Array<Record<string, unknown>>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@aws-sdk/client-bedrock-runtime", () => {
|
||||||
|
class BedrockRuntimeServiceException extends Error {}
|
||||||
|
|
||||||
|
class BedrockRuntimeClient {
|
||||||
|
constructor(config: Record<string, unknown>) {
|
||||||
|
bedrockMock.constructorCalls.push(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
send(): Promise<never> {
|
||||||
|
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<Record<string, unknown>> {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user