Repository navigation
fix(bedrock): list the account's invocable models behind bedrock/* when check_provider_endpoint is on #43981
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mateo-berri
merged 10 commits into
main
from
litellm_bedrock_wildcard_lists_account_models
Oct 9, 2026
Merged
Changes from 1 commit
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
5c0dce2
fix(bedrock): list the account's invocable models behind bedrock/* wh…
mateo-berri c3891a0
fix(bedrock): sign the listing query RFC 3986 style and raise Bedrock…
mateo-berri 35350f0
fix(bedrock): merge main and port the wildcard listing onto discover_…
mateo-berri 3176e0d
test(utils): keep test_utils.py at main's formatting, only the Bedroc…
mateo-berri b44ccc0
fix(bedrock): chain BedrockModelInfo's constructor so the passthrough…
mateo-berri b671cb7
fix(bedrock): list discovered ids without the provider prefix so part…
mateo-berri 25d5a61
test(bedrock): integration cells for wildcard discovery through the p…
mateo-berri 5ce9254
fix(proxy): keep a partial wildcard a filter when its deployment repe…
mateo-berri 8fa44a2
fix(proxy): prefix every discovered id under a custom wildcard prefix…
mateo-berri 3dbe39e
test(bedrock): Final and read-only annotations in the wildcard discov…
mateo-berri File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| from collections.abc import Iterator, Mapping | ||
| from types import MappingProxyType | ||
| from typing import Final | ||
| from urllib.parse import urlencode | ||
|
|
||
| import httpx | ||
| from pydantic import BaseModel, ConfigDict, Field | ||
|
|
||
| from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix | ||
| from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM, bedrock_bearer_token | ||
| from litellm.llms.custom_httpx.http_handler import HTTPHandler | ||
| from litellm.types.llms.bedrock import AwsAuthParams | ||
| from litellm.types.router import LiteLLM_Params | ||
|
|
||
| MODEL_PREFIX: Final = "bedrock/" | ||
| LIST_MODELS_TIMEOUT: Final = 10.0 | ||
| INFERENCE_PROFILES_PAGE_SIZE: Final = 1000 | ||
|
|
||
| QueryPairs = tuple[tuple[str, str], ...] | ||
|
|
||
|
|
||
| class FoundationModelSummary(BaseModel): | ||
| model_config = ConfigDict(extra="ignore") | ||
|
|
||
| id: str = Field(alias="modelId") | ||
|
|
||
|
|
||
| class ListFoundationModelsResponse(BaseModel): | ||
| model_config = ConfigDict(extra="ignore") | ||
|
|
||
| summaries: tuple[FoundationModelSummary, ...] = Field(default=(), alias="modelSummaries") | ||
|
|
||
|
|
||
| class InferenceProfileSummary(BaseModel): | ||
| model_config = ConfigDict(extra="ignore") | ||
|
|
||
| id: str = Field(alias="inferenceProfileId") | ||
| status: str | ||
|
|
||
|
|
||
| class ListInferenceProfilesResponse(BaseModel): | ||
| model_config = ConfigDict(extra="ignore") | ||
|
|
||
| summaries: tuple[InferenceProfileSummary, ...] = Field(default=(), alias="inferenceProfileSummaries") | ||
| next_token: str | None = Field(default=None, alias="nextToken") | ||
|
|
||
|
|
||
| class BedrockModelLister(BaseAWSLLM): | ||
| def __init__(self, deployment: LiteLLM_Params, client: HTTPHandler) -> None: | ||
| super().__init__() | ||
| self._aws_params: Final = deployment.model_dump(exclude_none=True) | ||
| self._aws_region_name: Final = self._get_aws_region_name(optional_params=self._aws_params) | ||
| self._bearer_token: Final = bedrock_bearer_token(deployment.api_key) | ||
| self._client: Final = client | ||
|
|
||
| def invocable_model_ids(self) -> frozenset[str]: | ||
| model_ids: Final = frozenset(self._active_inference_profile_ids(None)) | frozenset( | ||
| self._on_demand_foundation_model_ids() | ||
| ) | ||
|
greptile-apps[bot] marked this conversation as resolved.
Resolved
|
||
| return frozenset(MODEL_PREFIX + model_id for model_id in model_ids) | ||
|
greptile-apps[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| def _on_demand_foundation_model_ids(self) -> Iterator[str]: | ||
| response: Final = ListFoundationModelsResponse.model_validate( | ||
| self._get_json("/foundation-models", (("byInferenceType", "ON_DEMAND"),)) | ||
| ) | ||
| return (summary.id for summary in response.summaries) | ||
|
|
||
| def _active_inference_profile_ids(self, next_token: str | None) -> Iterator[str]: | ||
| continuation: Final[QueryPairs] = (("nextToken", next_token),) if next_token is not None else () | ||
| page: Final = ListInferenceProfilesResponse.model_validate( | ||
| self._get_json( | ||
| "/inference-profiles", | ||
| (("maxResults", str(INFERENCE_PROFILES_PAGE_SIZE)), ("typeEquals", "SYSTEM_DEFINED"), *continuation), | ||
| ) | ||
| ) | ||
| yield from (summary.id for summary in page.summaries if summary.status == "ACTIVE") | ||
| if page.next_token is not None: | ||
| yield from self._active_inference_profile_ids(page.next_token) | ||
|
greptile-apps[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| def _get_json(self, path: str, query: QueryPairs) -> object: | ||
| url: Final = f"https://bedrock.{self._aws_region_name}.{get_aws_dns_suffix(self._aws_region_name)}{path}?{urlencode(query)}" | ||
| response: Final = self._client.get(url=url, headers=self._auth_headers(url), timeout=LIST_MODELS_TIMEOUT) | ||
|
greptile-apps[bot] marked this conversation as resolved.
Resolved
|
||
| try: | ||
| response.raise_for_status() | ||
| except httpx.HTTPStatusError: | ||
| raise Exception( | ||
| f"Failed to list Bedrock models in {self._aws_region_name}. " | ||
| f"Status code: {response.status_code}, Response: {response.text}" | ||
| ) | ||
| return response.json() | ||
|
|
||
| def _auth_headers(self, url: str) -> Mapping[str, str]: | ||
| if self._bearer_token is not None: | ||
| return MappingProxyType({"Authorization": f"Bearer {self._bearer_token}"}) | ||
| from botocore.auth import SigV4Auth | ||
| from botocore.awsrequest import AWSRequest | ||
|
|
||
| credentials: Final = self.resolve_credentials( | ||
| AwsAuthParams.model_validate(self._aws_params), self._aws_region_name | ||
| ) | ||
| request: Final = AWSRequest(method="GET", url=url, data="") | ||
| SigV4Auth(credentials, "bedrock", self._aws_region_name).add_auth(request) | ||
| return MappingProxyType({name: str(value) for name, value in request.prepare().headers.items()}) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| import re | ||
| from collections.abc import Callable | ||
| from typing import Final | ||
|
|
||
| import httpx | ||
| import pytest | ||
|
|
||
| from litellm.llms.bedrock.common_utils import BedrockModelInfo | ||
| from litellm.llms.custom_httpx.http_handler import HTTPHandler | ||
| from litellm.types.router import LiteLLM_Params | ||
|
|
||
| ACCESS_KEY: Final = "AKIAIOSFODNN7EXAMPLE" | ||
| SECRET_KEY: Final = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" | ||
| REGION: Final = "eu-central-1" | ||
| BEARER_TOKEN: Final = "bedrock-api-key-for-tests" | ||
| SECOND_PAGE_TOKEN: Final = "page2+token/with=reserved chars" | ||
|
|
||
| ON_DEMAND_MODELS: Final = ( | ||
| {"modelId": "anthropic.claude-3-haiku-20240307-v1:0", "inferenceTypesSupported": ["ON_DEMAND"]}, | ||
| {"modelId": "amazon.nova-micro-v1:0", "inferenceTypesSupported": ["ON_DEMAND"]}, | ||
| ) | ||
| PROFILE_ONLY_MODEL: Final = { | ||
| "modelId": "anthropic.claude-opus-4-5-20251101-v1:0", | ||
| "inferenceTypesSupported": ["INFERENCE_PROFILE"], | ||
| } | ||
| PROFILE_PAGES: Final = { | ||
| None: { | ||
| "inferenceProfileSummaries": [ | ||
| {"inferenceProfileId": "global.anthropic.claude-opus-4-5-20251101-v1:0", "status": "ACTIVE"}, | ||
| {"inferenceProfileId": "eu.anthropic.claude-3-haiku-20240307-v1:0", "status": "INACTIVE"}, | ||
| ], | ||
| "nextToken": SECOND_PAGE_TOKEN, | ||
| }, | ||
| SECOND_PAGE_TOKEN: { | ||
| "inferenceProfileSummaries": [{"inferenceProfileId": "eu.amazon.nova-micro-v1:0", "status": "ACTIVE"}], | ||
| }, | ||
| } | ||
| EXPECTED_MODELS: Final = [ | ||
| "bedrock/amazon.nova-micro-v1:0", | ||
| "bedrock/anthropic.claude-3-haiku-20240307-v1:0", | ||
| "bedrock/eu.amazon.nova-micro-v1:0", | ||
| "bedrock/global.anthropic.claude-opus-4-5-20251101-v1:0", | ||
| ] | ||
| SIGV4_CREDENTIAL: Final = re.compile( | ||
| rf"^AWS4-HMAC-SHA256 Credential={ACCESS_KEY}/\d{{8}}/{REGION}/bedrock/aws4_request, SignedHeaders=([^,]+), Signature=" | ||
| ) | ||
|
|
||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def no_ambient_bearer_token(monkeypatch: pytest.MonkeyPatch) -> None: | ||
| monkeypatch.delenv("AWS_BEARER_TOKEN_BEDROCK", raising=False) | ||
|
|
||
|
|
||
| def _authorized(request: httpx.Request, bearer_token: str | None) -> bool: | ||
| authorization: Final = request.headers.get("authorization", "") | ||
| if bearer_token is not None: | ||
| return authorization == f"Bearer {bearer_token}" | ||
| signed: Final = SIGV4_CREDENTIAL.match(authorization) | ||
| if signed is None or "x-amz-date" not in request.headers: | ||
| return False | ||
| signed_headers: Final = signed.group(1).split(";") | ||
| return "host" in signed_headers and "x-amz-date" in signed_headers | ||
|
|
||
|
|
||
| def _control_plane(bearer_token: str | None = None) -> Callable[[httpx.Request], httpx.Response]: | ||
| def handler(request: httpx.Request) -> httpx.Response: | ||
| if request.url.host != f"bedrock.{REGION}.amazonaws.com": | ||
| return httpx.Response(404, json={"message": f"no Bedrock at {request.url.host}"}) | ||
| if not _authorized(request, bearer_token): | ||
| return httpx.Response(403, json={"message": "not signed for this account, region and service"}) | ||
| if request.url.path == "/foundation-models": | ||
| on_demand_only: Final = request.url.params.get("byInferenceType") == "ON_DEMAND" | ||
| summaries: Final = ON_DEMAND_MODELS if on_demand_only else (*ON_DEMAND_MODELS, PROFILE_ONLY_MODEL) | ||
| return httpx.Response(200, json={"modelSummaries": list(summaries)}) | ||
| if request.url.path == "/inference-profiles": | ||
| if request.url.params.get("typeEquals") != "SYSTEM_DEFINED": | ||
| return httpx.Response(400, json={"message": "application profiles are not listable here"}) | ||
| return httpx.Response(200, json=PROFILE_PAGES[request.url.params.get("nextToken")]) | ||
| return httpx.Response(404, json={"message": f"unknown path {request.url.path}"}) | ||
|
|
||
| return handler | ||
|
|
||
|
|
||
| def _bedrock(handler: Callable[[httpx.Request], httpx.Response]) -> BedrockModelInfo: | ||
| return BedrockModelInfo(client=HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(handler)))) | ||
|
|
||
|
|
||
| def _deployment(region: str = REGION, api_key: str | None = None) -> LiteLLM_Params: | ||
| return LiteLLM_Params( | ||
| model="bedrock/*", | ||
| aws_access_key_id=ACCESS_KEY, | ||
| aws_secret_access_key=SECRET_KEY, | ||
| aws_region_name=region, | ||
| api_key=api_key, | ||
| ) | ||
|
|
||
|
|
||
| def test_lists_active_profiles_and_on_demand_models_signed_for_the_deployment() -> None: | ||
| assert _bedrock(_control_plane()).get_models_for_deployment(_deployment()) == EXPECTED_MODELS | ||
|
|
||
|
|
||
| def test_lists_in_the_deployment_region_not_the_ambient_one(monkeypatch: pytest.MonkeyPatch) -> None: | ||
| monkeypatch.setenv("AWS_REGION_NAME", REGION) | ||
|
|
||
| with pytest.raises(Exception, match=r"us-west-2.*404"): | ||
| _bedrock(_control_plane()).get_models_for_deployment(_deployment(region="us-west-2")) | ||
|
|
||
|
|
||
| def test_bearer_token_api_key_replaces_sigv4() -> None: | ||
| bedrock: Final = _bedrock(_control_plane(bearer_token=BEARER_TOKEN)) | ||
|
|
||
| assert bedrock.get_models_for_deployment(_deployment(api_key=BEARER_TOKEN)) == EXPECTED_MODELS | ||
| with pytest.raises(Exception, match="403"): | ||
| bedrock.get_models_for_deployment(_deployment()) | ||
|
|
||
|
|
||
| def test_get_models_without_a_deployment_uses_ambient_credentials(monkeypatch: pytest.MonkeyPatch) -> None: | ||
| monkeypatch.setenv("AWS_ACCESS_KEY_ID", ACCESS_KEY) | ||
| monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", SECRET_KEY) | ||
| monkeypatch.setenv("AWS_REGION_NAME", REGION) | ||
|
|
||
| assert _bedrock(_control_plane()).get_models() == EXPECTED_MODELS | ||
|
|
||
|
|
||
| def test_listing_failure_names_the_region_and_response() -> None: | ||
| def throttled(request: httpx.Request) -> httpx.Response: | ||
| return httpx.Response(429, json={"message": "Too many requests"}) | ||
|
|
||
| with pytest.raises(Exception, match=rf"{REGION}.*429.*Too many requests"): | ||
| _bedrock(throttled).get_models_for_deployment(_deployment()) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.