litellm/tests/integration/_support/bedrock_discovery.py
devin-ai-integration[bot] 43f07d0bd8
fix(bedrock): list the account's invocable models behind bedrock/* when check_provider_endpoint is on (#43981)
* fix(bedrock): list the account's invocable models behind bedrock/* when check_provider_endpoint is on

* fix(bedrock): sign the listing query RFC 3986 style and raise BedrockError on a failed listing

* test(utils): keep test_utils.py at main's formatting, only the Bedrock discovery test is new

* fix(bedrock): chain BedrockModelInfo's constructor so the passthrough config keeps its event parser

* fix(bedrock): list discovered ids without the provider prefix so partial wildcards filter

get_valid_models returns bare vendor ids for every other provider and for the static bedrock catalog, and the proxy's wildcard expansion adds the provider prefix itself. The lister prefixed its ids, so a partial wildcard like bedrock/anthropic.* never matched the proxy's filter and listed uncallable bedrock/anthropic.bedrock/<id> entries

* test(bedrock): integration cells for wildcard discovery through the proxy and the SDK

* fix(proxy): keep a partial wildcard a filter when its deployment repeats the prefix

The wildcard expansion guessed filter-or-alias by whether any provider id carried the prefix. A deployment such as model_name bedrock/anthropic.* over model bedrock/anthropic.* can only ever route names that share the prefix, so when the account has no on-demand anthropic.* id (every Anthropic model behind an inference profile) the guess fell into the alias branch and listed bedrock/anthropic.<every id>, none of them callable. A deployment whose model repeats the suffix now always filters, and lists nothing when nothing matches

* fix(proxy): prefix every discovered id under a custom wildcard prefix that starts a vendor id

* test(bedrock): Final and read-only annotations in the wildcard discovery tests

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-09 14:14:17 -07:00

294 lines
11 KiB
Python

"""Owned rig and assertions for Bedrock wildcard model discovery through a scripted AWS control plane."""
from __future__ import annotations
import os
import re
import uuid
from collections import Counter
from collections.abc import Iterator, Mapping
from contextlib import contextmanager
from pathlib import Path
from typing import Final
from urllib.parse import urlsplit
import psutil
import yaml
from integration._support.aws_control_plane import (
FOUNDATION_MODELS,
INFERENCE_PROFILES,
Catalog,
ControlPlane,
ControlPlaneRequest,
verified_scope,
)
from integration._support.client import JSON_OBJECT, Gateway, Scenario, eventually, held, object_value, string_value
from integration._support.process import OwnedProxy, owned_proxy_process
from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix
from pydantic import JsonValue
HOSTED_REGIONS: Final = ("us-east-1", "us-west-2", "eu-west-1", "us-gov-west-1", "cn-north-1")
UNHOSTED_REGION: Final = "eu-central-1"
CONTROL_MODEL: Final = "integration-discovery-control"
CONVERSE_MODEL: Final = "us.openai.gpt-5.6-sol"
WORKERS: Final = 2
RELOAD_SECONDS: Final = 5
LISTING_TIMEOUT_SECONDS: Final = 10.0
PROFILE_PAGE_CAP: Final = 20
LISTING_PATHS: Final = ("/v1/models", "/models")
INFO_PATHS: Final = ("/model/info", "/v1/model/info")
PROFILES_QUERY: Final[Mapping[str, str]] = {"maxResults": "1000", "typeEquals": "SYSTEM_DEFINED"}
FOUNDATION_QUERY: Final[Mapping[str, str]] = {"byInferenceType": "ON_DEMAND"}
CLOSE: Final[Mapping[str, str]] = {"connection": "close"}
LISTING_FAILURE_LOG: Final = "Error getting valid models"
STARTED_WORKER: Final = re.compile(r"Started server process \[(\d+)\]")
STARTUP_COMPLETE: Final = "Application startup complete."
def control_plane_host(region: str) -> str:
return f"bedrock.{region}.{get_aws_dns_suffix(region)}"
def credential() -> tuple[str, str]:
return f"AKIA{uuid.uuid4().hex[:16].upper()}", f"secret-{uuid.uuid4().hex}"
def stem() -> str:
return f"disc{uuid.uuid4().hex[:10]}"
def catalog_for(marker: str, *, page_size: int | None = None) -> Catalog:
return Catalog(
active_profiles=(f"us.{marker}.sonnet-v1:0", f"eu.{marker}.haiku-v1:0"),
inactive_profiles=(f"us.{marker}.retired-v1:0",),
on_demand_models=(f"{marker}.nova-micro-v1:0",),
page_size=page_size,
)
def discovery_config(parent: Gateway, directory: Path, *, check_provider_endpoint: bool) -> Path:
configuration: Final = yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())
configuration["model_list"] = [
{
"model_name": CONTROL_MODEL,
"litellm_params": {
"model": "openai/gpt-4o-mini",
"api_key": "integration-provider-key",
"api_base": f"{parent.upstream_url}/v1",
},
}
]
configuration["litellm_settings"] = {
**configuration.get("litellm_settings", {}),
"check_provider_endpoint": check_provider_endpoint,
}
path: Final = directory / f"discovery-{'on' if check_provider_endpoint else 'off'}.yaml"
path.write_text(yaml.safe_dump(configuration))
return path
def discovery_environment(plane: ControlPlane, directory: Path) -> Mapping[str, str]:
empty: Final = directory / "empty-aws-config"
empty.write_text("")
return {
**plane.environment(),
"AWS_CONFIG_FILE": str(empty),
"AWS_SHARED_CREDENTIALS_FILE": str(empty),
"AWS_EC2_METADATA_DISABLED": "true",
"LITELLM_LOCAL_MODEL_COST_MAP": "True",
"PROXY_CONFIG_RELOAD_INTERVAL_SECONDS": str(RELOAD_SECONDS),
}
def aws_environment_names() -> tuple[str, ...]:
return tuple(name for name in os.environ if name.startswith("AWS_"))
@contextmanager
def discovery_proxy(
parent: Gateway, directory: Path, plane: ControlPlane, *, check_provider_endpoint: bool, workers: int
) -> Iterator[OwnedProxy]:
with owned_proxy_process(
parent,
directory,
discovery_environment(plane, directory),
config=discovery_config(parent, directory, check_provider_endpoint=check_provider_endpoint),
remove_environment=aws_environment_names(),
workers=workers,
) as owned:
wait_for_every_worker(owned.log, workers)
yield owned
def deployment_ids(gateway: Gateway) -> frozenset[str]:
entries: Final = gateway.get("/model/info")["data"]
assert isinstance(entries, list)
return frozenset(string_value(object_value(object_value(entry)["model_info"])["id"]) for entry in entries)
def remove_deployment(gateway: Gateway, identity: str) -> None:
gateway.post("/model/delete", {"id": identity})
eventually(lambda: deployment_ids(gateway), lambda ids: identity not in ids, seconds=RELOAD_SECONDS * 4)
def deployment(
scenario: Scenario,
*,
model_name: str = "bedrock/*",
model: str = "bedrock/*",
model_info: Mapping[str, JsonValue] | None = None,
litellm_params: Mapping[str, JsonValue] | None = None,
) -> str:
"""A wildcard deployment posted straight to /model/new, since Scenario.model fixes a non-wildcard name."""
created: Final = scenario.gateway.post(
"/model/new",
{
"model_name": model_name,
"litellm_params": {"model": model, **(litellm_params or {})},
"model_info": dict(model_info) if model_info is not None else {},
},
)
identity: Final = string_value(object_value(created["model_info"])["id"])
scenario.cleanups.callback(remove_deployment, scenario.gateway, identity)
settled_on_every_worker(scenario.gateway, identity)
return identity
def settled_on_every_worker(gateway: Gateway, identity: str) -> None:
"""Each worker picks a new deployment up on its own reload tick, so a listing straight after /model/new can land on
a worker without it; the by-id lookup never lists, so holding on it leaves every worker's discovery cache cold."""
def present() -> bool:
lookup: Final = gateway.request("GET", "/model/info", params={"litellm_model_id": identity}, headers=CLOSE)
return lookup.status_code == 200
held(present, lambda found: found, holding=RELOAD_SECONDS * 2, seconds=RELOAD_SECONDS * 6)
def sigv4_deployment(
scenario: Scenario,
key: str,
secret: str,
region: str,
*,
model_name: str = "bedrock/*",
model: str = "bedrock/*",
model_info: Mapping[str, JsonValue] | None = None,
litellm_params: Mapping[str, JsonValue] | None = None,
) -> str:
return deployment(
scenario,
model_name=model_name,
model=model,
model_info=model_info,
litellm_params={
"aws_access_key_id": key,
"aws_secret_access_key": secret,
"aws_region_name": region,
**(litellm_params or {}),
},
)
def listed_ids(
gateway: Gateway,
path: str = "/v1/models",
*,
key: str | None = None,
params: Mapping[str, str] | None = None,
) -> frozenset[str]:
response: Final = gateway.request("GET", path, key=key, params=params, headers=CLOSE)
assert response.status_code == 200, f"GET {path}: {response.status_code} {response.text}"
data: Final = JSON_OBJECT.validate_json(response.content)["data"]
assert isinstance(data, list), response.text
return frozenset(string_value(object_value(entry)["id"]) for entry in data)
def from_stem(marker: str, ids: frozenset[str]) -> frozenset[str]:
return frozenset(name for name in ids if marker in name)
def discovered(
gateway: Gateway,
catalog: Catalog,
marker: str,
path: str = "/v1/models",
*,
key: str | None = None,
params: Mapping[str, str] | None = None,
) -> frozenset[str]:
"""Poll until the catalog's invocable ids are listed; returns every listed id carrying the stem."""
listed: Final = eventually(
lambda: listed_ids(gateway, path, key=key, params=params),
lambda ids: catalog.invocable_ids() <= ids,
seconds=RELOAD_SECONDS * 4,
)
return from_stem(marker, listed)
def mine(plane: ControlPlane, credential_id: str) -> tuple[ControlPlaneRequest, ...]:
return tuple(request for request in plane.drain() if request.credential == credential_id)
def listings(requests: tuple[ControlPlaneRequest, ...]) -> Mapping[str, int]:
return Counter(request.path for request in requests)
def assert_listing_shape(requests: tuple[ControlPlaneRequest, ...], *, pages: int = 1) -> int:
"""Every listing is one foundation-models GET plus `pages` inference-profiles GETs; returns the listing count."""
assert requests, "no listing reached the control plane"
counts: Final = listings(requests)
assert set(counts) == {FOUNDATION_MODELS, INFERENCE_PROFILES}, counts
assert 1 <= counts[FOUNDATION_MODELS] <= WORKERS, counts
assert counts[INFERENCE_PROFILES] == counts[FOUNDATION_MODELS] * pages, counts
for request in requests:
assert request.method == "GET" and request.body == b"", request
if request.path == FOUNDATION_MODELS:
assert request.query == FOUNDATION_QUERY, request
else:
assert {name: value for name, value in request.query.items() if name != "nextToken"} == PROFILES_QUERY
return counts[FOUNDATION_MODELS]
def assert_sigv4(requests: tuple[ControlPlaneRequest, ...], *, key: str, secret: str, region: str) -> None:
host: Final = control_plane_host(region)
for request in requests:
assert request.host == host, request
assert request.headers["host"] == host, request
scope: Final = verified_scope(request, secret)
assert scope == f"{request.headers['x-amz-date'][:8]}/{region}/bedrock/aws4_request", request
assert request.headers["authorization"].startswith(f"AWS4-HMAC-SHA256 Credential={key}/"), request
assert "x-amz-security-token" not in request.headers, request
def worker_pids(log: Path) -> tuple[int, ...]:
return tuple(int(pid) for pid in STARTED_WORKER.findall(log.read_text()))
def live_worker_pids(log: Path) -> tuple[int, ...]:
return tuple(pid for pid in worker_pids(log) if psutil.pid_exists(pid))
def wait_for_every_worker(log: Path, workers: int) -> None:
"""Readiness answers from the first worker up; uvicorn replaces a child that dies at boot, so the rest can lag."""
def every_worker_serving() -> bool:
return len(live_worker_pids(log)) >= workers and log.read_text().count(STARTUP_COMPLETE) >= workers
eventually(every_worker_serving, bool, seconds=150)
def wait_for_replacement_worker(log: Path, original: tuple[int, ...]) -> None:
def replacement_is_serving(pids: tuple[int, ...]) -> bool:
return len(pids) > len(original) and log.read_text().count(STARTUP_COMPLETE) > len(original)
eventually(lambda: worker_pids(log), replacement_is_serving, seconds=150)
def open_connections_to(pid: int, url: str) -> int:
port: Final = urlsplit(url).port
return sum(
1
for connection in psutil.Process(pid).net_connections(kind="tcp")
if connection.status == psutil.CONN_ESTABLISHED and connection.raddr and connection.raddr.port == port
)