detached d8976c9

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) {
285285 return out, nil
286286 }
287287
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+
288376 func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
289377 wire, err := c.buildRequest(req)
290378 if err != nil {
@@ -294,7 +382,8 @@ func (c *bedrockClient) Stream(ctx context.Context, req Request) (<-chan Event,
294382 if err != nil {
295383 return nil, err
296384 }
297- url := c.baseURL + "/model/" + req.Model + "/converse-stream"
385+ modelID := resolveBedrockInferenceProfileID(req.Model, c.region)
386+ url := c.baseURL + "/model/" + modelID + "/converse-stream"
298387 httpReq, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
299388 if err != nil {
300389 return nil, err
@@ -285,6 +285,94 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
285 return out, nil285 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, err383 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, err389 return nil, err
M packages/provider/amazon_bedrock_test.go +31 -0
@@ -81,3 +81,34 @@ func TestReadAWSCredentialsFile(t *testing.T) {
8181 t.Logf("no aws creds available (expected on CI): %v", err)
8282 }
8383 }
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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close