Send Bedrock inference-profile IDs for on-demand models
Newer Bedrock models (Anthropic Claude 4.x, DeepSeek) reject invocation by their bare foundation-model ID with on-demand throughput, demanding a cross-region inference-profile ID instead (HTTP 400). Rewrite such IDs at request time by prepending the region-matched geo prefix (us/eu/apac/us-gov), so selecting anthropic.claude-sonnet-4-5-... in a us-east-1 setup invokes us.anthropic.claude-sonnet-4-5-... Already-prefixed IDs, ARNs, and families that don't need a profile are left untouched, preserving explicit choices and custom application inference profiles.
- commit
- d8976c94df4c9305fed46b535bf54c8129be23fd
- author
- patriceckhart · · 2026-06-04 17:25 UTC
- parent
- 4bcdf88
- signature
- unsigned
2 files · +121 -1
M packages/provider/amazon_bedrock.go +90 -1
| @@ -285,6 +285,94 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) { | |||
| 285 | 285 | return out, nil | |
| 286 | 286 | } | |
| 287 | 287 | ||
| 288 | + | // resolveBedrockInferenceProfileID maps a bare foundation-model ID to | |
| 289 | + | // its region-matched cross-region inference-profile ID. | |
| 290 | + | // | |
| 291 | + | // Several newer Bedrock models (Anthropic Claude 4.x, DeepSeek, etc.) | |
| 292 | + | // cannot be invoked with on-demand throughput by their plain | |
| 293 | + | // foundation-model ID; Bedrock returns HTTP 400 demanding "the ID or | |
| 294 | + | // ARN of an inference profile that contains this model". The profile | |
| 295 | + | // ID is the same model ID with a geographic prefix (us/eu/apac/...). | |
| 296 | + | // | |
| 297 | + | // We only rewrite IDs that (a) lack an existing geo prefix and (b) | |
| 298 | + | // belong to a model family that requires a profile. IDs that already | |
| 299 | + | // carry a prefix (e.g. "eu.anthropic...", "global.anthropic...") or | |
| 300 | + | // fully-qualified ARNs are returned unchanged, so explicit user | |
| 301 | + | // choices and custom application inference profiles still work. | |
| 302 | + | func resolveBedrockInferenceProfileID(modelID, region string) string { | |
| 303 | + | if modelID == "" { | |
| 304 | + | return modelID | |
| 305 | + | } | |
| 306 | + | // ARNs are already inference-profile references; leave untouched. | |
| 307 | + | if strings.HasPrefix(modelID, "arn:") { | |
| 308 | + | return modelID | |
| 309 | + | } | |
| 310 | + | // Already geo-prefixed (us. / eu. / apac. / ap. / us-gov. / global.)? | |
| 311 | + | if bedrockHasGeoPrefix(modelID) { | |
| 312 | + | return modelID | |
| 313 | + | } | |
| 314 | + | if !bedrockRequiresInferenceProfile(modelID) { | |
| 315 | + | return modelID | |
| 316 | + | } | |
| 317 | + | prefix := bedrockGeoPrefixForRegion(region) | |
| 318 | + | if prefix == "" { | |
| 319 | + | return modelID | |
| 320 | + | } | |
| 321 | + | return prefix + "." + modelID | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | // bedrockGeoPrefixes are the cross-region inference-profile geo | |
| 325 | + | // prefixes Bedrock uses. A model ID that starts with one of these | |
| 326 | + | // (followed by a dot) is already a profile reference. | |
| 327 | + | var bedrockGeoPrefixes = []string{"us-gov", "us", "eu", "apac", "ap", "global", "au"} | |
| 328 | + | ||
| 329 | + | func bedrockHasGeoPrefix(modelID string) bool { | |
| 330 | + | for _, p := range bedrockGeoPrefixes { | |
| 331 | + | if strings.HasPrefix(modelID, p+".") { | |
| 332 | + | return true | |
| 333 | + | } | |
| 334 | + | } | |
| 335 | + | return false | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // bedrockRequiresInferenceProfile reports whether a bare | |
| 339 | + | // foundation-model ID is one of the families AWS only exposes through | |
| 340 | + | // a cross-region inference profile for on-demand throughput. | |
| 341 | + | func bedrockRequiresInferenceProfile(modelID string) bool { | |
| 342 | + | switch { | |
| 343 | + | case strings.HasPrefix(modelID, "anthropic.claude-"): | |
| 344 | + | return true | |
| 345 | + | case strings.HasPrefix(modelID, "deepseek."): | |
| 346 | + | return true | |
| 347 | + | default: | |
| 348 | + | return false | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | ||
| 352 | + | // bedrockGeoPrefixForRegion maps an AWS region to the geo prefix used | |
| 353 | + | // by its cross-region inference profiles. Returns "" when the region | |
| 354 | + | // has no known mapping, in which case the model ID is left unchanged. | |
| 355 | + | func bedrockGeoPrefixForRegion(region string) string { | |
| 356 | + | switch { | |
| 357 | + | case region == "": | |
| 358 | + | return "us" | |
| 359 | + | case strings.HasPrefix(region, "us-gov-"): | |
| 360 | + | return "us-gov" | |
| 361 | + | case strings.HasPrefix(region, "us-"): | |
| 362 | + | return "us" | |
| 363 | + | case strings.HasPrefix(region, "eu-"): | |
| 364 | + | return "eu" | |
| 365 | + | case strings.HasPrefix(region, "ap-"): | |
| 366 | + | return "apac" | |
| 367 | + | case strings.HasPrefix(region, "ca-"): | |
| 368 | + | return "us" | |
| 369 | + | case strings.HasPrefix(region, "sa-"): | |
| 370 | + | return "us" | |
| 371 | + | default: | |
| 372 | + | return "us" | |
| 373 | + | } | |
| 374 | + | } | |
| 375 | + | ||
| 288 | 376 | func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | |
| 289 | 377 | wire, err := c.buildRequest(req) | |
| 290 | 378 | if err != nil { | |
| @@ -294,7 +382,8 @@ func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, | |||
| 294 | 382 | if err != nil { | |
| 295 | 383 | return nil, err | |
| 296 | 384 | } | |
| 297 | - | url := c.baseURL + "/model/" + req.Model + "/converse-stream" | |
| 385 | + | modelID := resolveBedrockInferenceProfileID(req.Model, c.region) | |
| 386 | + | url := c.baseURL + "/model/" + modelID + "/converse-stream" | |
| 298 | 387 | httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) | |
| 299 | 388 | if err != nil { | |
| 300 | 389 | return nil, err | |
| @@ -285,6 +285,94 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) { | |||||
| 285 | return out, nil | 285 | return out, nil | ||
| 286 | } | 286 | } | ||
| 287 | 287 | ||||
| 288 | + | // resolveBedrockInferenceProfileID maps a bare foundation-model ID to | |||
| 289 | + | // its region-matched cross-region inference-profile ID. | |||
| 290 | + | // | |||
| 291 | + | // Several newer Bedrock models (Anthropic Claude 4.x, DeepSeek, etc.) | |||
| 292 | + | // cannot be invoked with on-demand throughput by their plain | |||
| 293 | + | // foundation-model ID; Bedrock returns HTTP 400 demanding "the ID or | |||
| 294 | + | // ARN of an inference profile that contains this model". The profile | |||
| 295 | + | // ID is the same model ID with a geographic prefix (us/eu/apac/...). | |||
| 296 | + | // | |||
| 297 | + | // We only rewrite IDs that (a) lack an existing geo prefix and (b) | |||
| 298 | + | // belong to a model family that requires a profile. IDs that already | |||
| 299 | + | // carry a prefix (e.g. "eu.anthropic...", "global.anthropic...") or | |||
| 300 | + | // fully-qualified ARNs are returned unchanged, so explicit user | |||
| 301 | + | // choices and custom application inference profiles still work. | |||
| 302 | + | func resolveBedrockInferenceProfileID(modelID, region string) string { | |||
| 303 | + | if modelID == "" { | |||
| 304 | + | return modelID | |||
| 305 | + | } | |||
| 306 | + | // ARNs are already inference-profile references; leave untouched. | |||
| 307 | + | if strings.HasPrefix(modelID, "arn:") { | |||
| 308 | + | return modelID | |||
| 309 | + | } | |||
| 310 | + | // Already geo-prefixed (us. / eu. / apac. / ap. / us-gov. / global.)? | |||
| 311 | + | if bedrockHasGeoPrefix(modelID) { | |||
| 312 | + | return modelID | |||
| 313 | + | } | |||
| 314 | + | if !bedrockRequiresInferenceProfile(modelID) { | |||
| 315 | + | return modelID | |||
| 316 | + | } | |||
| 317 | + | prefix := bedrockGeoPrefixForRegion(region) | |||
| 318 | + | if prefix == "" { | |||
| 319 | + | return modelID | |||
| 320 | + | } | |||
| 321 | + | return prefix + "." + modelID | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | // bedrockGeoPrefixes are the cross-region inference-profile geo | |||
| 325 | + | // prefixes Bedrock uses. A model ID that starts with one of these | |||
| 326 | + | // (followed by a dot) is already a profile reference. | |||
| 327 | + | var bedrockGeoPrefixes = []string{"us-gov", "us", "eu", "apac", "ap", "global", "au"} | |||
| 328 | + | ||||
| 329 | + | func bedrockHasGeoPrefix(modelID string) bool { | |||
| 330 | + | for _, p := range bedrockGeoPrefixes { | |||
| 331 | + | if strings.HasPrefix(modelID, p+".") { | |||
| 332 | + | return true | |||
| 333 | + | } | |||
| 334 | + | } | |||
| 335 | + | return false | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | // bedrockRequiresInferenceProfile reports whether a bare | |||
| 339 | + | // foundation-model ID is one of the families AWS only exposes through | |||
| 340 | + | // a cross-region inference profile for on-demand throughput. | |||
| 341 | + | func bedrockRequiresInferenceProfile(modelID string) bool { | |||
| 342 | + | switch { | |||
| 343 | + | case strings.HasPrefix(modelID, "anthropic.claude-"): | |||
| 344 | + | return true | |||
| 345 | + | case strings.HasPrefix(modelID, "deepseek."): | |||
| 346 | + | return true | |||
| 347 | + | default: | |||
| 348 | + | return false | |||
| 349 | + | } | |||
| 350 | + | } | |||
| 351 | + | ||||
| 352 | + | // bedrockGeoPrefixForRegion maps an AWS region to the geo prefix used | |||
| 353 | + | // by its cross-region inference profiles. Returns "" when the region | |||
| 354 | + | // has no known mapping, in which case the model ID is left unchanged. | |||
| 355 | + | func bedrockGeoPrefixForRegion(region string) string { | |||
| 356 | + | switch { | |||
| 357 | + | case region == "": | |||
| 358 | + | return "us" | |||
| 359 | + | case strings.HasPrefix(region, "us-gov-"): | |||
| 360 | + | return "us-gov" | |||
| 361 | + | case strings.HasPrefix(region, "us-"): | |||
| 362 | + | return "us" | |||
| 363 | + | case strings.HasPrefix(region, "eu-"): | |||
| 364 | + | return "eu" | |||
| 365 | + | case strings.HasPrefix(region, "ap-"): | |||
| 366 | + | return "apac" | |||
| 367 | + | case strings.HasPrefix(region, "ca-"): | |||
| 368 | + | return "us" | |||
| 369 | + | case strings.HasPrefix(region, "sa-"): | |||
| 370 | + | return "us" | |||
| 371 | + | default: | |||
| 372 | + | return "us" | |||
| 373 | + | } | |||
| 374 | + | } | |||
| 375 | + | ||||
| 288 | func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | 376 | func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, error) { | ||
| 289 | wire, err := c.buildRequest(req) | 377 | wire, err := c.buildRequest(req) | ||
| 290 | if err != nil { | 378 | if err != nil { | ||
| @@ -294,7 +382,8 @@ func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, | |||||
| 294 | if err != nil { | 382 | if err != nil { | ||
| 295 | return nil, err | 383 | return nil, err | ||
| 296 | } | 384 | } | ||
| 297 | - | url := c.baseURL + "/model/" + req.Model + "/converse-stream" | 385 | + | modelID := resolveBedrockInferenceProfileID(req.Model, c.region) |
| 386 | + | url := c.baseURL + "/model/" + modelID + "/converse-stream" | |||
| 298 | httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) | 387 | httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) | ||
| 299 | if err != nil { | 388 | if err != nil { | ||
| 300 | return nil, err | 389 | return nil, err | ||
M packages/provider/amazon_bedrock_test.go +31 -0
| @@ -81,3 +81,34 @@ func TestReadAWSCredentialsFile(t *testing.T) { | |||
| 81 | 81 | t.Logf("no aws creds available (expected on CI): %v", err) | |
| 82 | 82 | } | |
| 83 | 83 | } | |
| 84 | + | ||
| 85 | + | func TestResolveBedrockInferenceProfileID(t *testing.T) { | |
| 86 | + | cases := []struct { | |
| 87 | + | model string | |
| 88 | + | region string | |
| 89 | + | want string | |
| 90 | + | }{ | |
| 91 | + | // Bare Anthropic foundation IDs get the region-matched prefix. | |
| 92 | + | {"anthropic.claude-sonnet-4-5-20250929-v1:0", "us-east-1", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"}, | |
| 93 | + | {"anthropic.claude-sonnet-4-5-20250929-v1:0", "eu-central-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"}, | |
| 94 | + | {"anthropic.claude-opus-4-6-v1", "ap-southeast-2", "apac.anthropic.claude-opus-4-6-v1"}, | |
| 95 | + | {"anthropic.claude-opus-4-6-v1", "us-gov-west-1", "us-gov.anthropic.claude-opus-4-6-v1"}, | |
| 96 | + | {"deepseek.r1-v1:0", "eu-west-1", "eu.deepseek.r1-v1:0"}, | |
| 97 | + | // Empty region defaults to us. | |
| 98 | + | {"anthropic.claude-opus-4-6-v1", "", "us.anthropic.claude-opus-4-6-v1"}, | |
| 99 | + | // Already-prefixed IDs are left untouched. | |
| 100 | + | {"eu.anthropic.claude-sonnet-4-5-20250929-v1:0", "us-east-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"}, | |
| 101 | + | {"global.anthropic.claude-opus-4-6-v1", "us-east-1", "global.anthropic.claude-opus-4-6-v1"}, | |
| 102 | + | {"us.anthropic.claude-opus-4-6-v1", "eu-central-1", "us.anthropic.claude-opus-4-6-v1"}, | |
| 103 | + | // ARNs are passed through verbatim. | |
| 104 | + | {"arn:aws:bedrock:us-east-1:123:inference-profile/us.anthropic.claude-opus-4-6-v1", "eu-west-1", "arn:aws:bedrock:us-east-1:123:inference-profile/us.anthropic.claude-opus-4-6-v1"}, | |
| 105 | + | // Families that don't need a profile are untouched. | |
| 106 | + | {"amazon.nova-pro-v1:0", "us-east-1", "amazon.nova-pro-v1:0"}, | |
| 107 | + | {"", "us-east-1", ""}, | |
| 108 | + | } | |
| 109 | + | for _, c := range cases { | |
| 110 | + | if got := resolveBedrockInferenceProfileID(c.model, c.region); got != c.want { | |
| 111 | + | t.Errorf("resolveBedrockInferenceProfileID(%q, %q) = %q; want %q", c.model, c.region, got, c.want) | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | } | |
| @@ -81,3 +81,34 @@ func TestReadAWSCredentialsFile(t *testing.T) { | |||||
| 81 | t.Logf("no aws creds available (expected on CI): %v", err) | 81 | t.Logf("no aws creds available (expected on CI): %v", err) | ||
| 82 | } | 82 | } | ||
| 83 | } | 83 | } | ||
| 84 | + | ||||
| 85 | + | func TestResolveBedrockInferenceProfileID(t *testing.T) { | |||
| 86 | + | cases := []struct { | |||
| 87 | + | model string | |||
| 88 | + | region string | |||
| 89 | + | want string | |||
| 90 | + | }{ | |||
| 91 | + | // Bare Anthropic foundation IDs get the region-matched prefix. | |||
| 92 | + | {"anthropic.claude-sonnet-4-5-20250929-v1:0", "us-east-1", "us.anthropic.claude-sonnet-4-5-20250929-v1:0"}, | |||
| 93 | + | {"anthropic.claude-sonnet-4-5-20250929-v1:0", "eu-central-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"}, | |||
| 94 | + | {"anthropic.claude-opus-4-6-v1", "ap-southeast-2", "apac.anthropic.claude-opus-4-6-v1"}, | |||
| 95 | + | {"anthropic.claude-opus-4-6-v1", "us-gov-west-1", "us-gov.anthropic.claude-opus-4-6-v1"}, | |||
| 96 | + | {"deepseek.r1-v1:0", "eu-west-1", "eu.deepseek.r1-v1:0"}, | |||
| 97 | + | // Empty region defaults to us. | |||
| 98 | + | {"anthropic.claude-opus-4-6-v1", "", "us.anthropic.claude-opus-4-6-v1"}, | |||
| 99 | + | // Already-prefixed IDs are left untouched. | |||
| 100 | + | {"eu.anthropic.claude-sonnet-4-5-20250929-v1:0", "us-east-1", "eu.anthropic.claude-sonnet-4-5-20250929-v1:0"}, | |||
| 101 | + | {"global.anthropic.claude-opus-4-6-v1", "us-east-1", "global.anthropic.claude-opus-4-6-v1"}, | |||
| 102 | + | {"us.anthropic.claude-opus-4-6-v1", "eu-central-1", "us.anthropic.claude-opus-4-6-v1"}, | |||
| 103 | + | // ARNs are passed through verbatim. | |||
| 104 | + | {"arn:aws:bedrock:us-east-1:123:inference-profile/us.anthropic.claude-opus-4-6-v1", "eu-west-1", "arn:aws:bedrock:us-east-1:123:inference-profile/us.anthropic.claude-opus-4-6-v1"}, | |||
| 105 | + | // Families that don't need a profile are untouched. | |||
| 106 | + | {"amazon.nova-pro-v1:0", "us-east-1", "amazon.nova-pro-v1:0"}, | |||
| 107 | + | {"", "us-east-1", ""}, | |||
| 108 | + | } | |||
| 109 | + | for _, c := range cases { | |||
| 110 | + | if got := resolveBedrockInferenceProfileID(c.model, c.region); got != c.want { | |||
| 111 | + | t.Errorf("resolveBedrockInferenceProfileID(%q, %q) = %q; want %q", c.model, c.region, got, c.want) | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | } | |||