diff --git a/hermes_cli/model_setup_flows.py b/hermes_cli/model_setup_flows.py index 9bad641b1f3b..fd7029f56dfe 100644 --- a/hermes_cli/model_setup_flows.py +++ b/hermes_cli/model_setup_flows.py @@ -27,6 +27,54 @@ import subprocess from hermes_cli.config import clear_model_endpoint_credentials +# AWS cross-region inference profile prefixes. Any geo-prefixed profile only +# routes from endpoints in its own geography, so the Bedrock picker must not +# offer (e.g.) us.* profiles to an eu-central-2 endpoint — selecting one +# produces a config AWS rejects regardless of credentials (#28156). +# global.* routes from everywhere. Full set per the AWS cross-region +# inference docs. +BEDROCK_GEO_PREFIXES = ( + "us.", "eu.", "ap.", "apac.", "jp.", "ca.", "sa.", "me.", "af.", +) + + +def bedrock_region_geo_prefix(region_name: str) -> str: + """Map an AWS region name to its inference-profile geo prefix ('' = unknown).""" + r = (region_name or "").lower() + for geo, region_prefixes in ( + ("us.", ("us-", "us_gov")), + ("eu.", ("eu-",)), + ("ap.", ("ap-",)), + ("ca.", ("ca-",)), + ("sa.", ("sa-",)), + ("me.", ("me-",)), + ("af.", ("af-",)), + ): + if r.startswith(region_prefixes): + return geo + return "" + + +def bedrock_model_routable_from_region(model_id: str, region_name: str) -> bool: + """True when *model_id* can be invoked from *region_name*'s endpoint. + + Bare foundation-model ids and ``global.*`` profiles route from anywhere. + Geo-prefixed inference profiles (``us.*``, ``eu.*``, ...) only route from + endpoints in their own geography. Unknown region shapes hide nothing. + """ + mid = (model_id or "").lower() + matched_geo = next((p for p in BEDROCK_GEO_PREFIXES if mid.startswith(p)), None) + if matched_geo is None or mid.startswith("global."): + return True + geo = bedrock_region_geo_prefix(region_name) + if not geo: + return True + if geo == "ap.": + # Asia-Pacific regions can carry ap./apac./jp. profile spellings. + return matched_geo in ("ap.", "apac.", "jp.") + return matched_geo == geo + + def _prune_replaced_custom_model_config_credentials( base_url: str, *, @@ -2265,6 +2313,8 @@ def _model_flow_bedrock(config, current_model=""): "global.twelvelabs.", ) _EXCLUDE_SUBSTRINGS = ("safeguard", "voxtral", "palmyra-vision") + + filtered = [] for m in live_models: mid = m["id"] @@ -2272,44 +2322,59 @@ def _model_flow_bedrock(config, current_model=""): continue if any(s in mid.lower() for s in _EXCLUDE_SUBSTRINGS): continue + if not bedrock_model_routable_from_region(mid, region): + continue filtered.append(m) - # Deduplicate: prefer inference profiles (us.*, global.*) over bare - # foundation model IDs. + # Deduplicate: prefer inference profiles (geo-prefixed or global.*) + # over bare foundation model IDs. + _PROFILE_PREFIXES = BEDROCK_GEO_PREFIXES + ("global.",) profile_base_ids = set() for m in filtered: mid = m["id"] - if mid.startswith(("us.", "global.")): - base = mid.split(".", 1)[1] if "." in mid[3:] else mid - profile_base_ids.add(base) + _pp = next((p for p in _PROFILE_PREFIXES if mid.startswith(p)), None) + if _pp: + profile_base_ids.add(mid[len(_pp):]) deduped = [] for m in filtered: mid = m["id"] - if not mid.startswith(("us.", "global.")) and mid in profile_base_ids: + if ( + not mid.startswith(_PROFILE_PREFIXES) + and mid in profile_base_ids + ): continue deduped.append(m) - _RECOMMENDED = [ - "us.anthropic.claude-sonnet-4-6", - "us.anthropic.claude-opus-4-6", - "us.anthropic.claude-haiku-4-5", - "us.amazon.nova-pro", - "us.amazon.nova-lite", - "us.amazon.nova-micro", + # Recommended models, matched geo-agnostically so an EU (eu.*) or + # APAC (apac.*) picker pins its own region's profile of the same + # model rather than a us.* one it can't route to (#28156). + _RECOMMENDED_BASES = [ + "anthropic.claude-sonnet-4-6", + "anthropic.claude-opus-4-6", + "anthropic.claude-haiku-4-5", + "amazon.nova-pro", + "amazon.nova-lite", + "amazon.nova-micro", "deepseek.v3", - "us.meta.llama4-maverick", - "us.meta.llama4-scout", + "meta.llama4-maverick", + "meta.llama4-scout", ] + def _base_id(mid: str) -> str: + _pp = next((p for p in _PROFILE_PREFIXES if mid.startswith(p)), None) + return mid[len(_pp):] if _pp else mid + def _sort_key(m): mid = m["id"] - for i, rec in enumerate(_RECOMMENDED): - if mid.startswith(rec): - return (0, i, mid) + base = _base_id(mid) + for i, rec in enumerate(_RECOMMENDED_BASES): + if base.startswith(rec): + # In-region geo profile beats global.* for the same model + return (0, i, 0 if not mid.startswith("global.") else 1, mid) if mid.startswith("global."): - return (1, 0, mid) - return (2, 0, mid) + return (1, 0, 0, mid) + return (2, 0, 0, mid) deduped.sort(key=_sort_key) model_list = [m["id"] for m in deduped] diff --git a/tests/hermes_cli/test_bedrock_region_scoped_picker.py b/tests/hermes_cli/test_bedrock_region_scoped_picker.py new file mode 100644 index 000000000000..2ef83bf09abc --- /dev/null +++ b/tests/hermes_cli/test_bedrock_region_scoped_picker.py @@ -0,0 +1,82 @@ +"""Regression tests for #28156 — Bedrock picker must be region-scoped. + +Geo-prefixed cross-region inference profiles (us.*, eu.*, apac.*, ...) only +route from endpoints in their own geography. Offering us.* profiles to an +eu-central-2 picker produces configs AWS rejects regardless of credentials. +""" + +from hermes_cli.model_setup_flows import ( + BEDROCK_GEO_PREFIXES, + bedrock_model_routable_from_region, + bedrock_region_geo_prefix, +) + + +class TestRegionGeoPrefix: + def test_known_geographies(self): + assert bedrock_region_geo_prefix("us-east-1") == "us." + assert bedrock_region_geo_prefix("eu-central-2") == "eu." + assert bedrock_region_geo_prefix("ap-southeast-1") == "ap." + assert bedrock_region_geo_prefix("ca-central-1") == "ca." + assert bedrock_region_geo_prefix("sa-east-1") == "sa." + assert bedrock_region_geo_prefix("me-south-1") == "me." + assert bedrock_region_geo_prefix("af-south-1") == "af." + + def test_unknown_region_is_empty(self): + assert bedrock_region_geo_prefix("") == "" + assert bedrock_region_geo_prefix("moon-base-1") == "" + + +class TestRoutableFromRegion: + def test_us_profile_not_offered_in_eu(self): + assert not bedrock_model_routable_from_region( + "us.anthropic.claude-sonnet-4-6", "eu-central-2" + ) + + def test_eu_profile_offered_in_eu(self): + assert bedrock_model_routable_from_region( + "eu.anthropic.claude-sonnet-4-6", "eu-central-2" + ) + + def test_global_profile_offered_everywhere(self): + for region in ("eu-central-2", "us-east-1", "ap-southeast-1"): + assert bedrock_model_routable_from_region( + "global.anthropic.claude-sonnet-4-6", region + ) + + def test_bare_foundation_id_offered_everywhere(self): + assert bedrock_model_routable_from_region( + "anthropic.claude-3-sonnet-20240229-v1:0", "eu-central-2" + ) + + def test_apac_spellings_route_in_ap_regions(self): + for prefix in ("ap.", "apac.", "jp."): + assert bedrock_model_routable_from_region( + f"{prefix}anthropic.claude-sonnet-4-6", "ap-northeast-1" + ) + + def test_eu_profile_not_offered_in_us(self): + assert not bedrock_model_routable_from_region( + "eu.anthropic.claude-sonnet-4-6", "us-east-1" + ) + + def test_unknown_region_hides_nothing(self): + assert bedrock_model_routable_from_region( + "us.anthropic.claude-sonnet-4-6", "" + ) + + +class TestGeoPrefixContract: + def test_every_geo_prefix_maps_to_a_routable_region_or_is_alias(self): + """Invariant: each geo prefix is either reachable from some region's + geo mapping or an ap-family alias — no dead entries.""" + mapped = { + bedrock_region_geo_prefix(r) + for r in ( + "us-east-1", "eu-central-2", "ap-southeast-1", + "ca-central-1", "sa-east-1", "me-south-1", "af-south-1", + ) + } + ap_aliases = {"apac.", "jp."} + for prefix in BEDROCK_GEO_PREFIXES: + assert prefix in mapped or prefix in ap_aliases