refactor: scope /health response to caller's models and tidy display fields (#26935)

* refactor: scope /health response to caller's models and tidy display fields

Two small consistency changes to the /health response:

1. health_endpoint() now narrows _llm_model_list to deployments whose
   model_name is in user_api_key_dict.models, matching how other model
   listing endpoints already scope their output. The same narrowing applies
   to the cached health_check_results dict when background_health_checks is
   enabled, via a new _filter_health_check_results_by_model_ids helper.

2. ILLEGAL_DISPLAY_PARAMS in health_check.py picks up api_base and
   api_version, which are provider routing fields and not part of the
   health response shape.

Tests in tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py
pin both behaviors so future changes do not widen the response shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* address greptile review feedback (greploop iteration 1)

- tests: extend background-cache test with model_id on cached entries plus
  positive assertions that model-a's deployment is the one returned, so
  the test is no longer satisfied by an empty result.
- _health_endpoints.py: add a verbose_proxy_logger.debug line when a scoped
  key has accessible model_names but the matching deployments have no
  model_info.id, so the empty cache-result case is observable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* make background-cache test's non-vacuity explicit

Restructure test_health_endpoint_filters_background_cache_by_user_access
so the assertions positively pin the post-scoping result (one entry,
model_id == "id-a", api_base == https://example-a.test) and add fixture
sanity checks that confirm the source cache had two entries and every
cached entry carries a model_id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* gate api_base in /health response on proxy-admin role

Replace the blanket strip of api_base / api_version with a role-aware
post-processor:

- api_base is now left in the cleaned per-deployment dict that
  _clean_endpoint_data produces (api_version stays in the denylist).
- health_endpoint() removes api_base from each endpoint entry before
  returning when the caller's user_role is not PROXY_ADMIN /
  PROXY_ADMIN_VIEW_ONLY. The strip uses a copy so the shared
  health_check_results cache still carries api_base for subsequent
  admin reads.

Net effect: a proxy admin can still see which Vertex region or Azure
resource is healthy in the /health output, while non-admin keys (and
read-only keys) only see model / model_id / status fields.

Tests:
- test_health_endpoint_admin_sees_api_base_non_admin_does_not pins both
  branches and verifies the cache is not mutated.
- test_clean_endpoint_data_strips_credentials_but_keeps_api_base
  replaces the previous mask/drop tests now that the cleaning helper
  no longer touches api_base.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* address review feedback: api_version symmetry, missing-id warnings, deprecation header

Three blockers raised in review:

1. api_version asymmetry — api_base was role-gated for proxy admins, but
   api_version was unconditionally stripped via ILLEGAL_DISPLAY_PARAMS.
   Move api_version out of the credential denylist and into a new
   ADMIN_ONLY_HEALTH_DISPLAY_PARAMS tuple alongside api_base, so admins
   keep both routing fields and non-admins lose both. Useful for telling
   apart Vertex regions or Azure api-versions from the /health response.

2. Silent empty results when scoped key's deployments lack model_info.id —
   raise the existing log from .debug to .warning, and add a structured
   "warnings" field to the response so the caller can distinguish "no
   deployments configured" from "deployments excluded due to missing
   model_info.id".

3. Migration signal for the api_base / api_version removal — when a
   non-admin caller hits /health, set a "Litellm-Health-Field-Notice"
   response header so existing dashboards or scripts that parsed those
   fields can detect the change programmatically rather than silently
   seeing absent keys.

Tests adjusted: existing background-cache test injects a Response stub,
admin-vs-non-admin test now asserts both api_base and api_version are
gated and asserts the notice header. New test covers the warnings field
when a scoped key's deployments are missing model_info.id.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* defensive copies + clarifying comments in /health filter

- _filter_health_check_results_by_model_ids now shallow-copies each
  retained endpoint dict before returning. The shared module-level
  health_check_results cache should never be mutated by downstream
  transforms, even though _strip_admin_only_fields_from_health_result
  already builds new dicts today.
- Document the live (model_name) vs cache (model_id) scoping asymmetry
  so future readers do not have to derive it from the warnings field.
- Document why _PROXY_ADMIN_ROLES includes PROXY_ADMIN_VIEW_ONLY (read-
  only operators need routing fields to diagnose health).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: yuneng-jiang <yuneng@berri.ai>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krrish Dholakia
2026-05-01 12:01:27 -07:00
committed by GitHub
co-authored by yuneng-jiang Claude Opus 4.7
parent 02582466c4
commit b14e1d7d6a
3 changed files with 522 additions and 6 deletions
+4
View File
@@ -29,6 +29,10 @@ ILLEGAL_DISPLAY_PARAMS = [
"exception", # internal; not JSON-serializable, never for display
"litellm_metadata", # internal tracking metadata with auth objects; not for display
]
# Provider routing fields. Allowed for proxy admins so they can see which
# region/version a deployment is checking; gated at the endpoint layer for
# non-admin callers (see _strip_admin_only_fields_from_health_result).
ADMIN_ONLY_HEALTH_DISPLAY_PARAMS = ("api_base", "api_version")
MINIMAL_DISPLAY_PARAMS = ["model", "mode_error"]
@@ -20,6 +20,7 @@ from litellm.proxy._types import (
CallInfo,
EnterpriseLicenseData,
Litellm_EntityType,
LitellmUserRoles,
ProxyErrorTypes,
ProxyException,
UserAPIKeyAuth,
@@ -28,6 +29,7 @@ from litellm.proxy._types import (
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler
from litellm.proxy.health_check import (
ADMIN_ONLY_HEALTH_DISPLAY_PARAMS,
_clean_endpoint_data,
_update_litellm_params_for_health_check,
perform_health_check,
@@ -723,6 +725,90 @@ async def _save_background_health_checks_to_db(
# Continue execution - don't let database save failure break health checks
_PROXY_ADMIN_ROLES = frozenset(
{
LitellmUserRoles.PROXY_ADMIN.value,
# View-only admins are operators (oncall, support); they need the
# routing fields (api_base, api_version) to diagnose health and tell
# which provider region a check is hitting. They cannot mutate config
# so granting them the read-only view is safe.
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
}
)
def _is_proxy_admin(user_api_key_dict: UserAPIKeyAuth) -> bool:
"""
Return True if the caller has a proxy-admin role (full or view-only).
user_role on UserAPIKeyAuth can be either a LitellmUserRoles enum or its
string value depending on how the auth path constructed the object, so we
compare against the raw value rather than the enum identity.
"""
role = user_api_key_dict.user_role
if role is None:
return False
role_value = role.value if hasattr(role, "value") else role
return role_value in _PROXY_ADMIN_ROLES
def _strip_admin_only_fields_from_health_result(result: dict) -> dict:
"""
Return a copy of the /health response with provider routing fields
(``api_base``, ``api_version``) removed from each healthy/unhealthy
endpoint entry. Used to hide those fields from non-admin callers while
still showing them which deployments they own and whether each one is
healthy. Proxy admins receive the unmodified result.
"""
out = dict(result)
drop = set(ADMIN_ONLY_HEALTH_DISPLAY_PARAMS)
for key in ("healthy_endpoints", "unhealthy_endpoints"):
eps = out.get(key)
if isinstance(eps, list):
out[key] = [
(
{k: v for k, v in ep.items() if k not in drop}
if isinstance(ep, dict)
else ep
)
for ep in eps
]
return out
def _filter_health_check_results_by_model_ids(
results: dict, allowed_model_ids: set
) -> dict:
"""
Restrict a cached background health-check result dict to endpoints whose
model_id is in ``allowed_model_ids``.
Endpoints without a model_id (e.g. CLI-model entries that predate the
model_id wiring) are dropped conservatively — we cannot prove they belong
to the caller, so they are excluded rather than leaked.
Each retained endpoint is shallow-copied before being returned, so any
downstream transform (e.g. _strip_admin_only_fields_from_health_result)
cannot accidentally mutate the shared ``health_check_results`` cache.
"""
healthy = [
dict(ep)
for ep in (results.get("healthy_endpoints") or [])
if ep.get("model_id") in allowed_model_ids
]
unhealthy = [
dict(ep)
for ep in (results.get("unhealthy_endpoints") or [])
if ep.get("model_id") in allowed_model_ids
]
return {
"healthy_endpoints": healthy,
"unhealthy_endpoints": unhealthy,
"healthy_count": len(healthy),
"unhealthy_count": len(unhealthy),
}
async def _perform_health_check_and_save(
model_list,
target_model,
@@ -771,6 +857,7 @@ async def _perform_health_check_and_save(
@router.get("/health", tags=["health"], dependencies=[Depends(user_api_key_auth)])
async def health_endpoint(
response: Response,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
model: Optional[str] = fastapi.Query(
None, description="Specify the model name (optional)"
@@ -838,11 +925,26 @@ async def health_endpoint(
detail={"error": f"Model with ID {model_id} not found"},
)
is_admin = _is_proxy_admin(user_api_key_dict)
def _post_process(result: dict) -> dict:
# api_base / api_version reveal which provider/region/internal host the
# deployment talks to; only proxy admins receive them. Non-admin keys
# still see model/model_id and the healthy/unhealthy status. We also
# set a header so non-admin clients that previously parsed those
# fields can detect the change programmatically.
if is_admin:
return result
response.headers["Litellm-Health-Field-Notice"] = (
"api_base and api_version are admin-only on this endpoint"
)
return _strip_admin_only_fields_from_health_result(result)
try:
if llm_model_list is None:
# if no router set, check if user set a model using litellm --model ollama/llama2
if user_model is not None:
return await _perform_health_check_and_save(
cli_result = await _perform_health_check_and_save(
model_list=[],
target_model=None,
cli_model=user_model,
@@ -853,20 +955,59 @@ async def health_endpoint(
model_id=None, # CLI model doesn't have model_id
max_concurrency=health_check_concurrency,
)
return _post_process(cli_result)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"error": "Model list not initialized"},
)
_llm_model_list = copy.deepcopy(llm_model_list)
### FILTER MODELS FOR ONLY THOSE USER HAS ACCESS TO ###
# Live path: scope by model_name (every deployment has one).
# Cache path: scope by model_id (the cache is keyed on model_id).
# Consequence: a deployment whose model_name the caller can access
# but which lacks model_info.id will appear in the live /health
# response but NOT in the background-cache /health response. This is
# surfaced via the "warnings" field below so operators can fix the
# missing model_info.id rather than guess at the discrepancy.
if len(user_api_key_dict.models) > 0:
pass
else:
pass #
allowed_models = set(user_api_key_dict.models)
_llm_model_list = [
m for m in _llm_model_list if m.get("model_name") in allowed_models
]
if use_background_health_checks:
return health_check_results
if len(user_api_key_dict.models) > 0:
allowed_model_ids = {
(m.get("model_info") or {}).get("id")
for m in _llm_model_list
if (m.get("model_info") or {}).get("id")
}
filtered = _filter_health_check_results_by_model_ids(
health_check_results, allowed_model_ids
)
if not allowed_model_ids:
# Caller has accessible model_names but none of the
# matching deployments expose a model_info.id, so the
# cache filter (which keys on model_id) drops every
# entry. Surface this both as a warning log and a
# structured "warnings" field on the response so the
# caller can distinguish "no deployments found" from
# "deployments excluded due to missing model_info.id".
verbose_proxy_logger.warning(
"health_endpoint: scoped key %s has accessible models %s "
"but none of the matching deployments carry a model_info.id; "
"background health-check cache will return an empty result.",
user_api_key_dict.user_id,
list(user_api_key_dict.models),
)
filtered["warnings"] = [
"Some accessible deployments are missing model_info.id "
"and were excluded from this response. Ask a proxy admin "
"to populate model_info.id for these models."
]
return _post_process(filtered)
return _post_process(health_check_results)
else:
return await _perform_health_check_and_save(
router_result = await _perform_health_check_and_save(
model_list=_llm_model_list,
target_model=target_model,
cli_model=None,
@@ -877,6 +1018,7 @@ async def health_endpoint(
model_id=model_id,
max_concurrency=health_check_concurrency,
)
return _post_process(router_result)
except Exception as e:
verbose_proxy_logger.error(
"litellm.proxy.proxy_server.py::health_endpoint(): Exception occured - {}".format(
@@ -778,3 +778,373 @@ def test_get_callback_identifier_custom_logger_registry_and_fallback():
result = get_callback_identifier(my_callback_function)
# Should fall back to callback_name() which returns __name__
assert result == "my_callback_function"
# ---------------------------------------------------------------------------
# /health response shape: model-access scoping and display-field allowlist
# ---------------------------------------------------------------------------
# These tests pin the contract that the /health response (a) only includes
# deployments the calling key is allowed to see, and (b) does not return
# provider routing fields like api_base / api_version. They guard against
# regressions that would widen the response shape.
@pytest.mark.asyncio
async def test_health_endpoint_filters_model_list_by_user_access():
"""
health_endpoint() should restrict _llm_model_list to deployments whose
model_name appears in user_api_key_dict.models before running the health
check. A key scoped to ["model-a"] should only see model-a in the result,
not other deployments configured on the proxy.
"""
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.health_endpoints._health_endpoints import health_endpoint
full_model_list = [
{
"model_name": "model-a",
"litellm_params": {
"model": "openai/gpt-4o",
"api_base": "https://example-a.test",
},
"model_info": {"id": "id-a"},
},
{
"model_name": "model-b",
"litellm_params": {
"model": "openai/gpt-4o",
"api_base": "https://example-b.test",
"api_version": "2024-10-21",
},
"model_info": {"id": "id-b"},
},
]
user_api_key_dict = UserAPIKeyAuth(
api_key="hashed-test-key",
models=["model-a"],
)
captured: dict = {}
async def fake_perform(**kwargs):
captured["model_list"] = kwargs["model_list"]
return {
"healthy_endpoints": [],
"unhealthy_endpoints": [],
"healthy_count": 0,
"unhealthy_count": 0,
}
with (
patch("litellm.proxy.proxy_server.llm_model_list", full_model_list),
patch("litellm.proxy.proxy_server.llm_router", None),
patch("litellm.proxy.proxy_server.prisma_client", None),
patch("litellm.proxy.proxy_server.use_background_health_checks", False),
patch("litellm.proxy.proxy_server.user_model", None),
patch("litellm.proxy.proxy_server.health_check_results", {}),
patch("litellm.proxy.proxy_server.health_check_details", True),
patch("litellm.proxy.proxy_server.health_check_concurrency", 1),
patch(
"litellm.proxy.health_endpoints._health_endpoints._perform_health_check_and_save",
side_effect=fake_perform,
),
):
from fastapi import Response
await health_endpoint(response=Response(), user_api_key_dict=user_api_key_dict)
assert (
"model_list" in captured
), "health_endpoint did not call _perform_health_check_and_save"
returned_names = {m["model_name"] for m in captured["model_list"]}
assert returned_names == {
"model-a"
}, f"health_endpoint did not scope model_list to caller access: {returned_names}"
@pytest.mark.asyncio
async def test_health_endpoint_filters_background_cache_by_user_access():
"""
When background_health_checks is enabled, health_endpoint() should also
scope the cached result to the caller's allowed models rather than
returning the cache verbatim.
"""
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.health_endpoints._health_endpoints import health_endpoint
full_model_list = [
{
"model_name": "model-a",
"litellm_params": {
"model": "openai/gpt-4o",
"api_base": "https://example-a.test",
},
"model_info": {"id": "id-a"},
},
{
"model_name": "model-b",
"litellm_params": {
"model": "openai/gpt-4o",
"api_base": "https://example-b.test",
},
"model_info": {"id": "id-b"},
},
]
cached_results = {
"healthy_endpoints": [
{
"model": "openai/gpt-4o",
"model_id": "id-a",
"api_base": "https://example-a.test",
},
{
"model": "openai/gpt-4o",
"model_id": "id-b",
"api_base": "https://example-b.test",
},
],
"unhealthy_endpoints": [],
"healthy_count": 2,
"unhealthy_count": 0,
}
user_api_key_dict = UserAPIKeyAuth(
api_key="hashed-test-key",
models=["model-a"],
)
with (
patch("litellm.proxy.proxy_server.llm_model_list", full_model_list),
patch("litellm.proxy.proxy_server.llm_router", None),
patch("litellm.proxy.proxy_server.prisma_client", None),
patch("litellm.proxy.proxy_server.use_background_health_checks", True),
patch("litellm.proxy.proxy_server.user_model", None),
patch("litellm.proxy.proxy_server.health_check_results", cached_results),
patch("litellm.proxy.proxy_server.health_check_details", True),
patch("litellm.proxy.proxy_server.health_check_concurrency", 1),
):
from fastapi import Response
result = await health_endpoint(
response=Response(), user_api_key_dict=user_api_key_dict
)
# Sanity: the source cache had two entries before scoping; the scoping
# step is what reduces it to one. (This guards against the test passing
# vacuously when the cache filter drops everything because cached
# entries lack the model_id key — both entries carry model_id above.)
assert len(cached_results["healthy_endpoints"]) == 2
assert all(
ep.get("model_id") for ep in cached_results["healthy_endpoints"]
), "test fixture invariant: every cached entry must carry a model_id"
# The non-admin caller must not see api_base on the returned cache entries.
returned = result.get("healthy_endpoints", [])
assert (
len(returned) == 1
), f"expected exactly one cached entry after scoping, got {len(returned)}"
assert returned[0]["model_id"] == "id-a"
assert "api_base" not in returned[0]
assert result["healthy_count"] == 1
assert result["unhealthy_count"] == 0
@pytest.mark.asyncio
async def test_health_endpoint_admin_sees_routing_fields_non_admin_does_not():
"""
A proxy admin should still see ``api_base`` and ``api_version`` in the
/health response so they can tell which Vertex region / Azure resource
+ API version is healthy. A non-admin caller must not both fields
should be stripped, and the response should carry a notice header so
non-admin clients can detect the change programmatically.
"""
from fastapi import Response
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.health_endpoints._health_endpoints import health_endpoint
full_model_list = [
{
"model_name": "model-a",
"litellm_params": {
"model": "openai/gpt-4o",
"api_base": "https://example-a.test",
},
"model_info": {"id": "id-a"},
},
]
cached_results = {
"healthy_endpoints": [
{
"model": "openai/gpt-4o",
"model_id": "id-a",
"api_base": "https://us-central1-aiplatform.googleapis.com/v1/projects/p",
"api_version": "2024-10-21",
},
],
"unhealthy_endpoints": [],
"healthy_count": 1,
"unhealthy_count": 0,
}
admin_key = UserAPIKeyAuth(
api_key="hashed-admin-key",
models=["model-a"],
user_role=LitellmUserRoles.PROXY_ADMIN,
)
non_admin_key = UserAPIKeyAuth(
api_key="hashed-user-key",
models=["model-a"],
)
common_patches = [
patch("litellm.proxy.proxy_server.llm_model_list", full_model_list),
patch("litellm.proxy.proxy_server.llm_router", None),
patch("litellm.proxy.proxy_server.prisma_client", None),
patch("litellm.proxy.proxy_server.use_background_health_checks", True),
patch("litellm.proxy.proxy_server.user_model", None),
patch("litellm.proxy.proxy_server.health_check_results", cached_results),
patch("litellm.proxy.proxy_server.health_check_details", True),
patch("litellm.proxy.proxy_server.health_check_concurrency", 1),
]
for p in common_patches:
p.start()
try:
admin_response = Response()
non_admin_response = Response()
admin_result = await health_endpoint(
response=admin_response, user_api_key_dict=admin_key
)
non_admin_result = await health_endpoint(
response=non_admin_response, user_api_key_dict=non_admin_key
)
finally:
for p in common_patches:
p.stop()
admin_eps = admin_result.get("healthy_endpoints", [])
non_admin_eps = non_admin_result.get("healthy_endpoints", [])
assert len(admin_eps) == 1
assert (
admin_eps[0]["api_base"]
== "https://us-central1-aiplatform.googleapis.com/v1/projects/p"
), "admin must see the full api_base so they can identify the region"
assert (
admin_eps[0]["api_version"] == "2024-10-21"
), "admin must see api_version so they can distinguish provider deployments"
assert len(non_admin_eps) == 1
assert "api_base" not in non_admin_eps[0]
assert "api_version" not in non_admin_eps[0]
# Non-admin response must advertise that api_base/api_version were
# withheld so clients that previously parsed them can detect the change.
assert (
non_admin_response.headers.get("Litellm-Health-Field-Notice")
== "api_base and api_version are admin-only on this endpoint"
)
assert "Litellm-Health-Field-Notice" not in admin_response.headers
# Stripping must produce a copy — the shared cache must still carry the
# routing fields so the next admin caller can read them.
cached_first = cached_results["healthy_endpoints"][0]
assert (
cached_first["api_base"]
== "https://us-central1-aiplatform.googleapis.com/v1/projects/p"
)
assert cached_first["api_version"] == "2024-10-21"
@pytest.mark.asyncio
async def test_health_endpoint_warns_when_scoped_models_lack_model_id():
"""
When a scoped key's accessible models exist on the proxy but none of the
matching deployments expose a ``model_info.id``, the cache filter drops
everything. The response should include a structured ``warnings`` field
so the caller can distinguish "no deployments configured" from
"deployments excluded due to missing model_info.id".
"""
from fastapi import Response
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.health_endpoints._health_endpoints import health_endpoint
full_model_list = [
{
"model_name": "model-a",
"litellm_params": {
"model": "openai/gpt-4o",
"api_base": "https://example-a.test",
},
# Intentionally no model_info.id — this is the misconfiguration
# the warnings field is meant to flag.
"model_info": {},
},
]
cached_results = {
"healthy_endpoints": [
{
"model": "openai/gpt-4o",
"model_id": "id-a",
"api_base": "https://example-a.test",
},
],
"unhealthy_endpoints": [],
"healthy_count": 1,
"unhealthy_count": 0,
}
user_api_key_dict = UserAPIKeyAuth(
api_key="hashed-user-key",
models=["model-a"],
)
with (
patch("litellm.proxy.proxy_server.llm_model_list", full_model_list),
patch("litellm.proxy.proxy_server.llm_router", None),
patch("litellm.proxy.proxy_server.prisma_client", None),
patch("litellm.proxy.proxy_server.use_background_health_checks", True),
patch("litellm.proxy.proxy_server.user_model", None),
patch("litellm.proxy.proxy_server.health_check_results", cached_results),
patch("litellm.proxy.proxy_server.health_check_details", True),
patch("litellm.proxy.proxy_server.health_check_concurrency", 1),
):
result = await health_endpoint(
response=Response(), user_api_key_dict=user_api_key_dict
)
assert result["healthy_count"] == 0
assert result["unhealthy_count"] == 0
assert "warnings" in result, (
"empty cache result must surface a warnings field so the caller "
"can distinguish 'no deployments' from 'deployments excluded'"
)
assert any("model_info.id" in w for w in result["warnings"])
def test_clean_endpoint_data_strips_credentials_keeps_routing_fields():
"""
_clean_endpoint_data() drops credentials but leaves api_base /
api_version intact the per-caller hide/show happens in the endpoint
layer based on user role, not in the cleaning helper. This guarantees
proxy admins continue to see those fields in the /health response.
"""
from litellm.proxy.health_check import _clean_endpoint_data
raw = {
"model": "openai/gpt-4o",
"api_key": "sk-test",
"api_base": "https://example.test/v1",
"api_version": "2024-10-21",
"aws_access_key_id": "AKIAEXAMPLE",
}
cleaned = _clean_endpoint_data(raw, details=True)
assert "api_key" not in cleaned
assert "aws_access_key_id" not in cleaned
assert cleaned.get("api_base") == "https://example.test/v1"
assert cleaned.get("api_version") == "2024-10-21"