fix(proxy): pass through cross-region prefixed Bedrock model IDs directly (#2330)

## Description

When a model ID with a cross-region prefix (`au.`, `us.`, `eu.`,
`apac.`, `global.`) is sent to the Bedrock backend, `map_model_id` was
normalising it (e.g. `au.anthropic.claude-opus-4-8` → `claude-opus-4-8`)
then re-looking it up in the discovery map. If an APPLICATION inference
profile wrapping the same foundation model existed in the account, it
would be returned — routing the request to a profile the caller is not
authorised to invoke, resulting in a 403 from Bedrock even though the
system-defined profile is reachable directly.

Cross-region prefixed IDs are already fully-qualified system-defined
profile IDs; they must pass through unchanged.

Closes #

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- `headroom/backends/litellm.py`: added early-exit in `map_model_id` —
model IDs starting with `au.`, `us.`, `eu.`, `apac.`, or `global.` are
returned as `bedrock/<model_id>` without any discovery lookup
- `tests/test_bedrock_region.py`: two new regression tests covering the
exact failure mode and all five prefix families

## Testing

- [x] Unit tests pass (`pytest`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
tests/test_bedrock_region.py ........................................... [ 95%]
..                                                                       [100%]

45 passed in 4.45s
```

## Real Behavior Proof

- Environment: headroom 0.32.0-dev, `--backend bedrock`, `--region
ap-southeast-2`, `--bedrock-profile <BEDROCK_PROFILE>`, proxy on port
8788
- Exact command / steps: Output
  ```
# Before fix — old map_model_id logic with a contaminated discovery map:
  # Input:      au.anthropic.claude-opus-4-8
  # Normalized: claude-opus-4-8
  # Resolved:   bedrock/<application-inference-profile-arn>  <-- 403

  # After fix — cross-region prefix detected, passed through directly:
  curl -s -X POST http://localhost:8788/v1/messages \
    -H "Content-Type: application/json" \
    -H "x-api-key: sk-ant-dummy" \
    -H "anthropic-version: 2023-06-01" \
-d
'{"model":"au.anthropic.claude-opus-4-8","max_tokens":64,"messages":[{"role":"user","content":"Reply
with just: fix works"}]}'
  ```
- Observed result:
`{"type":"message","role":"assistant","content":[{"type":"text","text":"fix
works"}],"model":"au.anthropic.claude-opus-4-8","stop_reason":"end_turn",...}`
— HTTP 200, routed to `bedrock/au.anthropic.claude-opus-4-8`
(system-defined profile) rather than the APPLICATION profile ARN
- Not tested: `apac.` and `global.` prefixes against a live AWS account
(covered by unit tests only)

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes

## Additional Notes

The existing `_fetch_bedrock_inference_profiles` already filters to
`typeEquals="SYSTEM_DEFINED"` so APPLICATION profiles are not added to
the discovery map during normal startup. This fix closes the remaining
gap where a caller passes a cross-region prefixed ID directly —
previously that ID was normalised before lookup, which could
accidentally match a stale or externally-injected map entry.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: JD Davis <jd@jds-macbook-air.tail2a279.ts.net>
This commit is contained in:
Matt Haitana
2026-08-12 14:54:03 +10:00
committed by GitHub
parent dc163bcd1c
commit 64cb46e24b
3 changed files with 81 additions and 1 deletions
+15
View File
@@ -697,6 +697,21 @@ class LiteLLMBackend(Backend):
if anthropic_model.startswith("arn:aws:"):
return f"bedrock/converse/{anthropic_model}"
# Cross-region prefixed IDs are already fully qualified system-defined
# profile IDs — pass through directly. Normalizing and re-looking them
# up in the discovery map can route the request to a wrong or
# unauthorized profile (e.g. an APPLICATION profile in the same account
# that also wraps the same foundation model). This applies whether the
# prefix arrives bare ("us.anthropic...") or already LiteLLM-qualified
# ("bedrock/us.anthropic...").
_CROSS_REGION_PREFIXES = ("au.", "us.", "eu.", "apac.", "global.")
if anthropic_model.startswith(_CROSS_REGION_PREFIXES):
return f"bedrock/{anthropic_model}"
if anthropic_model.startswith("bedrock/") and anthropic_model[
len("bedrock/") :
].startswith(_CROSS_REGION_PREFIXES):
return anthropic_model
normalized = _normalize_bedrock_profile_id(anthropic_model)
if normalized and normalized in self._model_map:
return self._model_map[normalized]
+3 -1
View File
@@ -2145,7 +2145,9 @@
truncateModel(model) {
if (!model) return '-';
return model.replace(/^(anthropic\.|openai\.|bedrock\/)/, '')
return model.replace(/^(bedrock\/)/, '')
.replace(/^(au\.|us\.|eu\.|apac\.|global\.)/, '')
.replace(/^(anthropic\.|openai\.)/, '')
.replace(/-\d{8}$/, '')
.substring(0, 20);
},
+63
View File
@@ -374,6 +374,69 @@ class TestBedrockModelMapping:
"bedrock/global.anthropic.claude-opus-4-8"
)
def test_cross_region_prefixed_id_passes_through_directly(self):
"""au./us./eu./apac./global. prefixed IDs must be passed straight to
Bedrock without discovery remapping. Remapping can route to an
APPLICATION inference profile owned by another team, causing a 403.
Regression test for the bug reported in BUG_FIX.md."""
# Simulate a discovery map that contains an APPLICATION-type profile
# for the same underlying model (the bad case: wrong profile selected).
bad_app_profile = "bedrock/arn:aws:bedrock:ap-southeast-2:002037730852:application-inference-profile/6lgt8epqa0wf"
with patch(
"headroom.backends.litellm._fetch_bedrock_inference_profiles",
return_value={"claude-opus-4-8": bad_app_profile},
):
backend = LiteLLMBackend(provider="bedrock", region="ap-southeast-2")
# The au. prefix must bypass discovery and pass through directly.
assert backend.map_model_id("au.anthropic.claude-opus-4-8") == (
"bedrock/au.anthropic.claude-opus-4-8"
)
def test_cross_region_prefixes_all_pass_through(self):
"""All five cross-region prefix families pass through without remapping."""
with patch(
"headroom.backends.litellm._fetch_bedrock_inference_profiles",
return_value={},
):
backend = LiteLLMBackend(provider="bedrock", region="us-east-1")
cases = [
("au.anthropic.claude-opus-4-8", "bedrock/au.anthropic.claude-opus-4-8"),
(
"us.anthropic.claude-sonnet-4-20250514-v1:0",
"bedrock/us.anthropic.claude-sonnet-4-20250514-v1:0",
),
(
"eu.anthropic.claude-3-5-sonnet-20241022-v2:0",
"bedrock/eu.anthropic.claude-3-5-sonnet-20241022-v2:0",
),
(
"apac.anthropic.claude-3-5-haiku-20241022-v1:0",
"bedrock/apac.anthropic.claude-3-5-haiku-20241022-v1:0",
),
("global.anthropic.claude-opus-4-8", "bedrock/global.anthropic.claude-opus-4-8"),
]
for model_in, expected in cases:
assert backend.map_model_id(model_in) == expected, (
f"Expected {expected!r} for input {model_in!r}"
)
def test_litellm_qualified_cross_region_id_passes_through_with_contaminated_map(self):
"""'bedrock/<cross-region-prefix>...' — the already LiteLLM-qualified form
documented in map_model_id's docstring — must also bypass discovery
remapping. Regression for the gap where only the bare 'us.anthropic...'
form was checked, so 'bedrock/us.anthropic...' still fell through to
normalization and could be remapped to a contaminating APPLICATION
profile in the discovery map."""
bad_app_profile = "bedrock/arn:aws:bedrock:ap-southeast-2:002037730852:application-inference-profile/6lgt8epqa0wf"
with patch(
"headroom.backends.litellm._fetch_bedrock_inference_profiles",
return_value={"claude-opus-4-8": bad_app_profile},
):
backend = LiteLLMBackend(provider="bedrock", region="ap-southeast-2")
assert backend.map_model_id("bedrock/au.anthropic.claude-opus-4-8") == (
"bedrock/au.anthropic.claude-opus-4-8"
)
# =============================================================================
# Normalize Bedrock Profile ID (edge cases)