diff --git a/extensions/amazon-bedrock/index.test.ts b/extensions/amazon-bedrock/index.test.ts index 7659b60b0d3..e4addeb776b 100644 --- a/extensions/amazon-bedrock/index.test.ts +++ b/extensions/amazon-bedrock/index.test.ts @@ -140,4 +140,74 @@ describe("amazon-bedrock provider plugin", () => { cacheRetention: "none", }); }); + + it("injects region from bedrockDiscovery config into stream options", () => { + const provider = registerSingleProviderPlugin(amazonBedrockPlugin); + const baseFn = (_model: never, _context: never, options: Record) => options; + const wrapped = provider.wrapStreamFn?.({ + provider: "amazon-bedrock", + modelId: "eu.anthropic.claude-sonnet-4-6", + config: { + models: { + bedrockDiscovery: { region: "eu-west-1" }, + }, + }, + streamFn: baseFn, + } as never); + + const result = wrapped?.( + { + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + id: "eu.anthropic.claude-sonnet-4-6", + } as never, + { messages: [] } as never, + {}, + ); + expect(result).toMatchObject({ region: "eu-west-1" }); + }); + + it("injects region extracted from provider baseUrl into stream options", () => { + const provider = registerSingleProviderPlugin(amazonBedrockPlugin); + const baseFn = (_model: never, _context: never, options: Record) => options; + const wrapped = provider.wrapStreamFn?.({ + provider: "amazon-bedrock", + modelId: "eu.anthropic.claude-sonnet-4-6", + config: { + models: { + providers: { + "amazon-bedrock": { + baseUrl: "https://bedrock-runtime.eu-central-1.amazonaws.com", + models: [], + }, + }, + }, + }, + streamFn: baseFn, + } as never); + + const result = wrapped?.( + { + api: "bedrock-converse-stream", + provider: "amazon-bedrock", + id: "eu.anthropic.claude-sonnet-4-6", + } as never, + { messages: [] } as never, + {}, + ); + expect(result).toMatchObject({ region: "eu-central-1" }); + }); + + it("does not inject region when neither bedrockDiscovery nor baseUrl is configured", () => { + const provider = registerSingleProviderPlugin(amazonBedrockPlugin); + const baseFn = (_model: never, _context: never, options: Record) => options; + const result = provider.wrapStreamFn?.({ + provider: "amazon-bedrock", + modelId: "anthropic.claude-sonnet-4-6", + streamFn: baseFn, + } as never); + + // Without region config, Claude model returns the base streamFn directly + expect(result).toBe(baseFn); + }); }); diff --git a/extensions/amazon-bedrock/index.ts b/extensions/amazon-bedrock/index.ts index 4dc69af95a2..88a7fb622e5 100644 --- a/extensions/amazon-bedrock/index.ts +++ b/extensions/amazon-bedrock/index.ts @@ -8,6 +8,15 @@ import { const PROVIDER_ID = "amazon-bedrock"; const CLAUDE_46_MODEL_RE = /claude-(?:opus|sonnet)-4(?:\.|-)6(?:$|[-.])/i; +/** Extract the AWS region from a bedrock-runtime baseUrl, e.g. "https://bedrock-runtime.eu-west-1.amazonaws.com". */ +function extractRegionFromBaseUrl(baseUrl: string | undefined): string | undefined { + if (!baseUrl) { + return undefined; + } + const match = /bedrock-runtime\.([a-z0-9-]+)\.amazonaws\.com/.exec(baseUrl); + return match?.[1]; +} + export default definePluginEntry({ id: PROVIDER_ID, name: "Amazon Bedrock Provider", @@ -19,26 +28,58 @@ export default definePluginEntry({ docsPath: "/providers/models", auth: [], wrapStreamFn: ({ modelId, config, streamFn }) => { - // Look up model name from provider config for inference profile detection. + // Look up model name and region from provider config. // Use normalized key matching so aliases like "bedrock" / "aws-bedrock" are found. let modelName: string | undefined; + let providerBaseUrl: string | undefined; const providers = config?.models?.providers; if (providers) { for (const [key, value] of Object.entries(providers)) { if (normalizeProviderId(key) !== PROVIDER_ID) { continue; } - const models = (value as { models?: Array<{ id?: string; name?: string }> })?.models; - const modelDef = models?.find((m) => m.id === modelId); + const typedValue = value as { + baseUrl?: string; + models?: Array<{ id?: string; name?: string }>; + }; + if (!providerBaseUrl && typedValue.baseUrl) { + providerBaseUrl = typedValue.baseUrl; + } + const modelDef = typedValue.models?.find((m) => m.id === modelId); if (modelDef?.name) { modelName = modelDef.name; break; } } } - return isAnthropicBedrockModel(modelId, modelName) + + // Extract region from provider baseUrl or bedrockDiscovery config so the + // pi-ai BedrockRuntimeClient uses the correct endpoint. Without this, the + // gateway process (which may not inherit AWS_REGION) falls back to us-east-1 + // and rejects cross-region inference profile IDs like "eu.anthropic.claude-*". + const region = + config?.models?.bedrockDiscovery?.region ?? extractRegionFromBaseUrl(providerBaseUrl); + + const baseFn = isAnthropicBedrockModel(modelId, modelName) ? streamFn : createBedrockNoCacheWrapper(streamFn); + + if (!region) { + return baseFn; + } + + // Wrap to inject the region into every stream call. + const underlying = baseFn ?? streamFn; + if (!underlying) { + return baseFn; + } + return (model, context, options) => { + // pi-ai's bedrock provider reads `options.region` at runtime but the + // StreamFn type does not declare it. Merge via Object.assign to avoid + // an unsafe type assertion. + const merged = Object.assign({}, options, { region }); + return underlying(model, context, merged); + }; }, resolveDefaultThinkingLevel: ({ modelId }) => CLAUDE_46_MODEL_RE.test(modelId.trim()) ? "adaptive" : undefined,