From 64cb46e24bf7b223ea71b14b6f5e86e78fa7ac45 Mon Sep 17 00:00:00 2001 From: Matt Haitana <107464365+mhaitana@users.noreply.github.com> Date: Wed, 12 Aug 2026 14:54:03 +1000 Subject: [PATCH] fix(proxy): pass through cross-region prefixed Bedrock model IDs directly (#2330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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/` 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 `, 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/ <-- 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 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: JD Davis --- headroom/backends/litellm.py | 15 +++++ headroom/dashboard/templates/dashboard.html | 4 +- tests/test_bedrock_region.py | 63 +++++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/headroom/backends/litellm.py b/headroom/backends/litellm.py index 01c73b032..0297474d4 100644 --- a/headroom/backends/litellm.py +++ b/headroom/backends/litellm.py @@ -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] diff --git a/headroom/dashboard/templates/dashboard.html b/headroom/dashboard/templates/dashboard.html index fe9b4562a..f195eec6f 100644 --- a/headroom/dashboard/templates/dashboard.html +++ b/headroom/dashboard/templates/dashboard.html @@ -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); }, diff --git a/tests/test_bedrock_region.py b/tests/test_bedrock_region.py index f96cff1a6..306748245 100644 --- a/tests/test_bedrock_region.py +++ b/tests/test_bedrock_region.py @@ -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/...' — 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)