From df3b44dbee8b554c2eda48d3f32729c2e0e00fb3 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 14 Oct 2026 14:05:58 -0700 Subject: [PATCH 01/25] feat(proxy): let proxy admins open team member defaults to team admins (#46353) * feat(proxy): let proxy admins open team member defaults to team admins * fix(proxy): keep a team admin's never-resets duration on a team's first member budget * test(proxy): pin the existing-row and stored never-resets cases for member defaults --- .../team_admin_field_permissions.py | 88 ++++++++++++++++-- .../management_endpoints/team_endpoints.py | 24 ++++- .../management/test_team_update.py | 81 ++++++++++++++++ .../test_team_admin_field_permissions.py | 93 +++++++++++++++++-- .../test_team_endpoints.py | 35 +++++++ .../TeamAdminEditableFieldsSettings.test.tsx | 16 ++++ .../team/TeamAdminSettingsForm.test.tsx | 64 ++++++++++++- .../components/team/TeamAdminSettingsForm.tsx | 39 +++++++- .../src/components/team/TeamInfo.test.tsx | 29 ++++++ .../src/components/team/TeamInfo.tsx | 4 + .../team/teamAdminEditAccess.test.ts | 63 +++++++++++++ .../components/team/teamAdminEditAccess.ts | 48 +++++++++- 12 files changed, 557 insertions(+), 27 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_admin_field_permissions.py b/litellm/proxy/management_endpoints/team_admin_field_permissions.py index 67cbb0be45d..3eb54c2147a 100644 --- a/litellm/proxy/management_endpoints/team_admin_field_permissions.py +++ b/litellm/proxy/management_endpoints/team_admin_field_permissions.py @@ -17,6 +17,7 @@ from pydantic import TypeAdapter, ValidationError from typing_extensions import assert_never from litellm._logging import verbose_proxy_logger +from litellm.models.budget import LiteLLM_BudgetTable from litellm.models.team import LiteLLM_TeamTable from litellm.models.verification_token import LiteLLM_VerificationToken from litellm.proxy._types import ( @@ -32,7 +33,16 @@ TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING: Final = "team_admin_editable_team_field # TODO(LIT-5722): add the remaining team settings one per PR, each with its value-diff tests and dashboard field SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS: Final[frozenset[str]] = frozenset( - {"tpm_limit", "rpm_limit", "max_budget", "soft_budget"} + { + "tpm_limit", + "rpm_limit", + "max_budget", + "soft_budget", + "team_member_budget", + "team_member_budget_duration", + "team_member_tpm_limit", + "team_member_rpm_limit", + } ) TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION: Final = "raise_max_budget" TEAM_ADMIN_PROJECTS_PERMISSION: Final = "projects" @@ -66,6 +76,19 @@ _NOT_COLUMNS: Final[frozenset[str]] = frozenset({"team_id", "metadata"}) _SETTINGS_LOCATION: Final = "Settings > UI > Team admin editable fields" +@dataclass(frozen=True, slots=True) +class TeamMemberDefaults: + team_member_budget: float | None = None + team_member_budget_duration: str | None = None + team_member_tpm_limit: int | None = None + team_member_rpm_limit: int | None = None + + +TEAM_MEMBER_DEFAULT_FIELDS: Final[frozenset[str]] = frozenset( + {"team_member_budget", "team_member_budget_duration", "team_member_tpm_limit", "team_member_rpm_limit"} +) + + @dataclass(frozen=True, slots=True) class TeamAdminEditAllowed: request: UpdateTeamRequest @@ -250,34 +273,76 @@ def _metadata_changes( ) +def stored_team_member_budget_id(team: LiteLLM_TeamTable) -> str | None: + budget_id: Final = _stored_metadata(_JSON_OBJECT.validate_json(team.model_dump_json())).get("team_member_budget_id") + return budget_id if isinstance(budget_id, str) else None + + +def team_member_defaults(budget: LiteLLM_BudgetTable | None) -> TeamMemberDefaults | None: + if budget is None: + return None + return TeamMemberDefaults( + team_member_budget=budget.max_budget, + team_member_budget_duration=budget.budget_duration, + team_member_tpm_limit=budget.tpm_limit, + team_member_rpm_limit=budget.rpm_limit, + ) + + def _stored_model_aliases(existing_row: LiteLLM_TeamTable) -> Mapping[str, object]: table: Final = existing_row.litellm_model_table return _as_object(_JSON_OBJECT.validate_json(table.model_dump_json()).get("model_aliases")) if table else _EMPTY def _column_changed( - field: str, submitted: Mapping[str, object], existing: Mapping[str, object], existing_row: LiteLLM_TeamTable + field: str, + submitted: Mapping[str, object], + existing: Mapping[str, object], + existing_row: LiteLLM_TeamTable, + member_defaults: Mapping[str, object], ) -> bool: if field == "model_aliases": return _as_object(submitted.get(field)) != _stored_model_aliases(existing_row) + if field in member_defaults: + return submitted.get(field) != member_defaults[field] if field in LiteLLM_TeamTable.model_fields: return submitted.get(field) != existing.get(field) return True -def changed_team_fields(data: UpdateTeamRequest, existing_row: LiteLLM_TeamTable) -> frozenset[str]: +def _member_defaults_by_field(member_defaults: TeamMemberDefaults | None) -> Mapping[str, object]: + stored: Final = member_defaults or TeamMemberDefaults() + return MappingProxyType( + { + "team_member_budget": stored.team_member_budget, + "team_member_budget_duration": stored.team_member_budget_duration, + "team_member_tpm_limit": stored.team_member_tpm_limit, + "team_member_rpm_limit": stored.team_member_rpm_limit, + } + ) + + +def changed_team_fields( + data: UpdateTeamRequest, + existing_row: LiteLLM_TeamTable, + member_defaults: TeamMemberDefaults | None = None, +) -> frozenset[str]: """Logical field names whose stored value the request would change. Request and stored row are compared as JSON values so both sides share one representation. Fields the server folds into metadata are attributed to their own name whether they arrive top-level or inside - ``metadata``; anything else in ``metadata`` is attributed to ``metadata``. Fields with no stored - counterpart on the team row count as changed whenever they are sent. + ``metadata``; anything else in ``metadata`` is attributed to ``metadata``. The ``team_member_*`` defaults + compare against the team's member budget row (``None`` when it has none, read as all null). Fields with no + stored counterpart count as changed whenever they are sent. """ submitted: Final = _JSON_OBJECT.validate_json(data.model_dump_json(exclude_unset=True)) existing: Final = _JSON_OBJECT.validate_json(existing_row.model_dump_json()) column_fields: Final = frozenset(data.model_fields_set) - _NOT_COLUMNS - _METADATA_FOLDED_FIELDS + stored_member_defaults: Final = _member_defaults_by_field(member_defaults) column_changes: Final = frozenset( - field for field in column_fields if _column_changed(field, submitted, existing, existing_row) + field + for field in column_fields + if _column_changed(field, submitted, existing, existing_row, stored_member_defaults) ) return column_changes | _metadata_changes(data, submitted, existing) @@ -295,14 +360,21 @@ def team_admin_edit_verdict( data: UpdateTeamRequest, existing: LiteLLM_TeamTable, permitted: frozenset[str], + member_defaults: TeamMemberDefaults | None = None, ) -> TeamAdminEditVerdict: if not permitted: return TeamAdminEditingDisabled() - changed: Final = changed_team_fields(data, existing) + changed: Final = changed_team_fields(data, existing, member_defaults) blocked: Final = sorted(changed - permitted) if blocked: return TeamAdminFieldNotPermitted(field=blocked[0]) - return TeamAdminEditAllowed(request=_only_changes(data, changed)) + creates_member_budget: Final = member_defaults is None and bool(changed & TEAM_MEMBER_DEFAULT_FIELDS) + sent_for_new_member_budget: Final = ( + frozenset(data.model_fields_set) & TEAM_MEMBER_DEFAULT_FIELDS & permitted + if creates_member_budget + else frozenset[str]() + ) + return TeamAdminEditAllowed(request=_only_changes(data, changed | sent_for_new_member_budget)) def team_admin_request_or_raise(verdict: TeamAdminEditVerdict) -> UpdateTeamRequest: diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 9cc1afd7beb..35aded51415 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -164,10 +164,14 @@ from litellm.proxy.management_endpoints.tag_management_endpoints import ( ) from litellm.proxy.management_endpoints.team_admin_field_permissions import ( SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS, + TEAM_MEMBER_DEFAULT_FIELDS, + TeamMemberDefaults, resolve_team_admin_editable_fields, + stored_team_member_budget_id, team_admin_edit_verdict, team_admin_may_raise_max_budget, team_admin_request_or_raise, + team_member_defaults, ) from litellm.proxy.management_helpers.access_group_team_sync import ( TEAM_ADVISORY_LOCK_SQL, @@ -2365,12 +2369,19 @@ async def update_team( if access_role is None: team_access_denied() if access_role == "team_admin": + permitted: Final = resolve_team_admin_editable_fields( + _general_settings(), SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS + ) + reads_member_defaults: Final = bool(permitted) and bool(data.model_fields_set & TEAM_MEMBER_DEFAULT_FIELDS) data = team_admin_request_or_raise( # rebind-ok: resent values must not reach the derived writes below team_admin_edit_verdict( data=data, existing=existing_team, - permitted=resolve_team_admin_editable_fields( - _general_settings(), SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS + permitted=permitted, + member_defaults=( + await _stored_team_member_defaults(prisma_client, existing_team) + if reads_member_defaults + else None ), ) ) @@ -4899,6 +4910,15 @@ async def validate_membership(user_api_key_dict: UserAPIKeyAuth, team_table: Lit ) +async def _stored_team_member_defaults( + prisma_client: PrismaClient, team: LiteLLM_TeamTable +) -> TeamMemberDefaults | None: + budget_id: Final = stored_team_member_budget_id(team) + if budget_id is None: + return None + return team_member_defaults(await BudgetRepository(prisma_client).find_by_id(budget_id)) + + async def _add_team_member_budget_table( team_member_budget_id: str, prisma_client: PrismaClient, diff --git a/tests/integration/management/test_team_update.py b/tests/integration/management/test_team_update.py index 0414d0bb77f..e1d9c43f318 100644 --- a/tests/integration/management/test_team_update.py +++ b/tests/integration/management/test_team_update.py @@ -162,6 +162,87 @@ async def test_team_update_org_admin_resolved_from_team_without_org_context( assert "Team admin editable fields" in denied_team_admin.text, denied_team_admin.text +_MEMBER_DEFAULTS = [ + pytest.param("team_member_budget", 25.0, "max_budget", id="team_member_budget"), + pytest.param("team_member_budget_duration", "7d", "budget_duration", id="team_member_budget_duration"), + pytest.param("team_member_tpm_limit", 200, "tpm_limit", id="team_member_tpm_limit"), + pytest.param("team_member_rpm_limit", 9, "rpm_limit", id="team_member_rpm_limit"), +] + + +@pytest.mark.parametrize("field,value,budget_column", _MEMBER_DEFAULTS) +async def test_team_update_team_admin_member_default_follows_the_proxy_admin_grant( + field: str, value: str | float, budget_column: str, gateway_client, db, resources, actors +): + callers = await _callers(db, actors, resources.prefix) + await _seed_target(db, actors, callers, "alpha", resources.prefix) + headers = {"Authorization": f"Bearer {callers[Actor.TEAM_ADMIN].cleartext}"} + body = {"team_id": resources.prefix, field: value} + + with team_admin_permissions(gateway_client.gateway, ["tpm_limit"]): + denied = await gateway_client.post("/team/update", headers=headers, json=body) + assert denied.status_code == 403, denied.text + assert f"'{field}'" in denied.text and "Team admin editable fields" in denied.text, denied.text + + with team_admin_permissions(gateway_client.gateway, [field]): + allowed = await gateway_client.post("/team/update", headers=headers, json=body) + assert allowed.status_code == 200, allowed.text + + info = await gateway_client.get("/team/info", params={"team_id": resources.prefix}) + assert info.status_code == 200, info.text + assert info.json()["team_info"]["team_member_budget_table"][budget_column] == value + + +async def test_team_update_team_admin_resending_member_defaults_leaves_the_budget_row_alone( + gateway_client, db, resources, actors +): + callers = await _callers(db, actors, resources.prefix) + await _seed_target(db, actors, callers, "alpha", resources.prefix) + stored = { + "team_member_budget": 10.0, + "team_member_budget_duration": "30d", + "team_member_tpm_limit": 100, + "team_member_rpm_limit": 5, + } + seeded = await gateway_client.post("/team/update", json={"team_id": resources.prefix, **stored}) + assert seeded.status_code == 200, seeded.text + team = await db.db.litellm_teamtable.find_unique(where={"team_id": resources.prefix}) + assert team is not None + budget_id = team.metadata["team_member_budget_id"] + before = await db.db.litellm_budgettable.find_unique(where={"budget_id": budget_id}) + assert before is not None + + with team_admin_permissions(gateway_client.gateway, ["tpm_limit"]): + resp = await gateway_client.post( + "/team/update", + headers={"Authorization": f"Bearer {callers[Actor.TEAM_ADMIN].cleartext}"}, + json={"team_id": resources.prefix, "tpm_limit": 1000, **stored}, + ) + assert resp.status_code == 200, resp.text + + after = await db.db.litellm_budgettable.find_unique(where={"budget_id": budget_id}) + assert after is not None and after.updated_at == before.updated_at + team = await db.db.litellm_teamtable.find_unique(where={"team_id": resources.prefix}) + assert team is not None and team.tpm_limit == 1000 + + +async def test_team_update_team_admin_cannot_change_member_defaults_of_another_team( + gateway_client, db, resources, actors +): + callers = await _callers(db, actors, resources.prefix) + await _seed_target(db, actors, callers, "beta", resources.prefix) + + with team_admin_permissions(gateway_client.gateway, ["team_member_budget"]): + resp = await gateway_client.post( + "/team/update", + headers={"Authorization": f"Bearer {callers[Actor.TEAM_ADMIN].cleartext}"}, + json={"team_id": resources.prefix, "team_member_budget": 25.0}, + ) + assert resp.status_code == 403, resp.text + team = await db.db.litellm_teamtable.find_unique(where={"team_id": resources.prefix}) + assert team is not None and "team_member_budget_id" not in (team.metadata or {}), "denied but team mutated" + + async def test_team_update_team_admin_soft_budget_follows_the_proxy_admin_grant(gateway_client, db, resources, actors): callers = await _callers(db, actors, resources.prefix) await _seed_target(db, actors, callers, "alpha", resources.prefix) diff --git a/tests/unit/proxy/management_endpoints/test_team_admin_field_permissions.py b/tests/unit/proxy/management_endpoints/test_team_admin_field_permissions.py index 0c9ed9356f8..0ffb3694942 100644 --- a/tests/unit/proxy/management_endpoints/test_team_admin_field_permissions.py +++ b/tests/unit/proxy/management_endpoints/test_team_admin_field_permissions.py @@ -19,6 +19,7 @@ from litellm.proxy.management_endpoints.team_admin_field_permissions import ( TeamAdminFieldNotPermitted, TeamAdminKeyEditAllowed, TeamAdminMemberKeyEditingDisabled, + TeamMemberDefaults, changed_key_fields, changed_team_fields, resolve_team_admin_editable_fields, @@ -160,8 +161,8 @@ class TestChangedTeamFields: assert changed_team_fields(UpdateTeamRequest(team_id="team-1", model_aliases={}), _team()) == frozenset() def test_field_without_a_stored_counterpart_counts_as_changed_when_sent(self): - data = UpdateTeamRequest(team_id="team-1", team_member_budget=10.0) - assert changed_team_fields(data, _team()) == frozenset({"team_member_budget"}) + data = UpdateTeamRequest(team_id="team-1", team_member_key_duration="30d") + assert changed_team_fields(data, _team()) == frozenset({"team_member_key_duration"}) class TestTeamAdminEditVerdict: @@ -192,27 +193,105 @@ class TestTeamAdminEditVerdict: assert verdict == TeamAdminFieldNotPermitted(field="blocked") -def _enabled(*fields: str) -> frozenset[str]: +def _permitted(*fields: str) -> frozenset[str]: return resolve_team_admin_editable_fields( {"team_admin_editable_team_fields": list(fields)}, SUPPORTED_TEAM_ADMIN_EDITABLE_TEAM_FIELDS ) +_STORED_MEMBER_DEFAULTS: Final = TeamMemberDefaults( + team_member_budget=10.0, team_member_budget_duration="30d", team_member_tpm_limit=100, team_member_rpm_limit=5 +) + +_MEMBER_DEFAULT_EDITS: Final = ( + pytest.param("team_member_budget", 25.0, 10.0, id="team_member_budget"), + pytest.param("team_member_budget_duration", "7d", "30d", id="team_member_budget_duration"), + pytest.param("team_member_tpm_limit", 200, 100, id="team_member_tpm_limit"), + pytest.param("team_member_rpm_limit", 9, 5, id="team_member_rpm_limit"), +) + + +class TestTeamMemberDefaultEdits: + @pytest.mark.parametrize("field,new,_stored", _MEMBER_DEFAULT_EDITS) + def test_enabled_field_change_is_allowed(self, field: str, new: object, _stored: object): + data: Final = UpdateTeamRequest.model_validate({"team_id": "team-1", field: new}) + verdict: Final = team_admin_edit_verdict(data, _team(), _permitted(field), _STORED_MEMBER_DEFAULTS) + assert isinstance(verdict, TeamAdminEditAllowed) + assert verdict.request.model_dump(exclude_unset=True) == {"team_id": "team-1", field: new} + + @pytest.mark.parametrize("field,new,_stored", _MEMBER_DEFAULT_EDITS) + def test_change_without_the_field_enabled_names_it(self, field: str, new: object, _stored: object): + data: Final = UpdateTeamRequest.model_validate({"team_id": "team-1", field: new}) + verdict: Final = team_admin_edit_verdict(data, _team(), _permitted("tpm_limit"), _STORED_MEMBER_DEFAULTS) + assert verdict == TeamAdminFieldNotPermitted(field=field) + + @pytest.mark.parametrize("field,_new,stored", _MEMBER_DEFAULT_EDITS) + def test_resending_the_stored_value_is_dropped_without_the_field_enabled( + self, field: str, _new: object, stored: object + ): + data: Final = UpdateTeamRequest.model_validate({"team_id": "team-1", field: stored, "tpm_limit": 6}) + verdict: Final = team_admin_edit_verdict( + data, _team(tpm_limit=5), _permitted("tpm_limit"), _STORED_MEMBER_DEFAULTS + ) + assert isinstance(verdict, TeamAdminEditAllowed) + assert verdict.request.model_dump(exclude_unset=True) == {"team_id": "team-1", "tpm_limit": 6} + + def test_nulls_resent_for_a_team_without_member_defaults_are_dropped(self): + data: Final = UpdateTeamRequest( + team_id="team-1", + team_member_budget=None, + team_member_budget_duration=None, + team_member_tpm_limit=None, + team_member_rpm_limit=None, + tpm_limit=6, + ) + verdict: Final = team_admin_edit_verdict(data, _team(tpm_limit=5), _permitted("tpm_limit"), None) + assert isinstance(verdict, TeamAdminEditAllowed) + assert verdict.request.model_dump(exclude_unset=True) == {"team_id": "team-1", "tpm_limit": 6} + + def test_first_member_budget_keeps_an_explicit_never_resets_duration(self): + data: Final = UpdateTeamRequest(team_id="team-1", team_member_budget=5.0, team_member_budget_duration=None) + permitted: Final = _permitted("team_member_budget", "team_member_budget_duration") + verdict: Final = team_admin_edit_verdict(data, _team(budget_duration="7d"), permitted, None) + assert isinstance(verdict, TeamAdminEditAllowed) + assert verdict.request.model_dump(exclude_unset=True) == { + "team_id": "team-1", + "team_member_budget": 5.0, + "team_member_budget_duration": None, + } + + def test_first_member_budget_drops_a_duration_the_team_admin_may_not_set(self): + data: Final = UpdateTeamRequest(team_id="team-1", team_member_budget=5.0, team_member_budget_duration=None) + verdict: Final = team_admin_edit_verdict( + data, _team(budget_duration="7d"), _permitted("team_member_budget"), None + ) + assert isinstance(verdict, TeamAdminEditAllowed) + assert verdict.request.model_dump(exclude_unset=True) == {"team_id": "team-1", "team_member_budget": 5.0} + + def test_existing_member_budget_drops_a_resent_duration_next_to_a_changed_budget(self): + data: Final = UpdateTeamRequest(team_id="team-1", team_member_budget=5.0, team_member_budget_duration="7d") + stored: Final = TeamMemberDefaults(team_member_budget=1.0, team_member_budget_duration="7d") + permitted: Final = _permitted("team_member_budget", "team_member_budget_duration") + verdict: Final = team_admin_edit_verdict(data, _team(), permitted, stored) + assert isinstance(verdict, TeamAdminEditAllowed) + assert verdict.request.model_dump(exclude_unset=True) == {"team_id": "team-1", "team_member_budget": 5.0} + + class TestSoftBudgetEdits: def test_enabled_soft_budget_change_is_allowed(self): data: Final = UpdateTeamRequest(team_id="team-1", soft_budget=20.0) - verdict: Final = team_admin_edit_verdict(data, _team(soft_budget=10.0), _enabled("soft_budget")) + verdict: Final = team_admin_edit_verdict(data, _team(soft_budget=10.0), _permitted("soft_budget")) assert isinstance(verdict, TeamAdminEditAllowed) assert verdict.request.model_dump(exclude_unset=True) == {"team_id": "team-1", "soft_budget": 20.0} def test_soft_budget_change_without_it_enabled_names_it(self): data: Final = UpdateTeamRequest(team_id="team-1", soft_budget=20.0) - verdict: Final = team_admin_edit_verdict(data, _team(soft_budget=10.0), _enabled("max_budget")) + verdict: Final = team_admin_edit_verdict(data, _team(soft_budget=10.0), _permitted("max_budget")) assert verdict == TeamAdminFieldNotPermitted(field="soft_budget") - def test_unchanged_soft_budget_resend_is_dropped_when_not_enabled(self): + def test_unchanged_soft_budget_resend_is_dropped_when_not_permitted(self): data: Final = UpdateTeamRequest(team_id="team-1", tpm_limit=6, soft_budget=10) - verdict: Final = team_admin_edit_verdict(data, _team(soft_budget=10.0), _enabled("tpm_limit")) + verdict: Final = team_admin_edit_verdict(data, _team(soft_budget=10.0), _permitted("tpm_limit")) assert isinstance(verdict, TeamAdminEditAllowed) assert verdict.request.model_dump(exclude_unset=True) == {"team_id": "team-1", "tpm_limit": 6} diff --git a/tests/unit/proxy/management_endpoints/test_team_endpoints.py b/tests/unit/proxy/management_endpoints/test_team_endpoints.py index bbb83ec1248..15eed3b105a 100644 --- a/tests/unit/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_team_endpoints.py @@ -16950,6 +16950,41 @@ async def test_update_team_team_admin_echoing_unpermitted_fields_unchanged_is_al assert prisma.db.litellm_teamtable.update.called +@pytest.mark.asyncio +async def test_update_team_team_admin_resending_stored_member_defaults_is_not_an_edit( + disable_audit_logging_for_mocked_team, +): + import contextlib + from litellm.models.budget import LiteLLM_BudgetTable + + with contextlib.ExitStack() as stack: + prisma = _wire_update_team(stack, {"team_member_budget_id": "member-budget-1"}) + prisma.db.litellm_budgettable.find_unique = AsyncMock( + return_value=LiteLLM_BudgetTable( + budget_id="member-budget-1", max_budget=10.0, budget_duration="30d", tpm_limit=100, rpm_limit=5 + ) + ) + stack.enter_context( + patch("litellm.proxy.proxy_server.general_settings", {"team_admin_editable_team_fields": ["tpm_limit"]}) # test-quality-ok: update_team reads general_settings as a proxy_server module global + ) + result = await update_team( + data=UpdateTeamRequest( + team_id="test_team_id", + team_member_budget=10.0, + team_member_budget_duration="30d", + team_member_tpm_limit=100, + team_member_rpm_limit=5, + tpm_limit=6, + ), + http_request=_update_request_stub(), + user_api_key_dict=_TEAM_ADMIN_CALLER, + ) + + assert result["data"].team_id == "test_team_id" + assert prisma.db.litellm_teamtable.update.call_args.kwargs["data"]["tpm_limit"] == 6 + assert not prisma.db.litellm_budgettable.update.called + + @pytest.mark.asyncio async def test_update_team_team_admin_changes_tpm_limit_once_a_proxy_admin_enables_it( disable_audit_logging_for_mocked_team, diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings.test.tsx index 36fbe086df3..df3dafc4b28 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/TeamAdminEditableFieldsSettings.test.tsx @@ -127,6 +127,22 @@ describe("TeamAdminEditableFieldsSettings", () => { expect(mutate).toHaveBeenCalledWith({ team_admin_editable_team_fields: ["max_budget"] }, expect.anything()); }); + it("offers the member defaults as named checkboxes and saves the ticked ones", async () => { + mockSettings(["team_member_budget", "team_member_budget_duration", "tpm_limit"], []); + const mutate = mockSave({}); + + renderWithProviders(); + fireEvent.click(screen.getByRole("checkbox", { name: "Member Default Budget (USD)" })); + fireEvent.click(screen.getByRole("checkbox", { name: "Member Default Budget Duration" })); + fireEvent.click(saveButton()); + + await waitFor(() => expect(mutate).toHaveBeenCalledTimes(1)); + expect(mutate).toHaveBeenCalledWith( + { team_admin_editable_team_fields: ["team_member_budget", "team_member_budget_duration"] }, + expect.anything(), + ); + }); + it("offers the soft budget as a named checkbox and saves it when ticked", async () => { mockSettings(["max_budget", "soft_budget"], []); const mutate = mockSave({}); diff --git a/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.test.tsx b/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.test.tsx index 7fe6461e285..a84d7bc4bac 100644 --- a/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import userEvent from "@testing-library/user-event"; -import { fireEvent, renderWithProviders, screen, waitFor } from "@/../tests/test-utils"; +import { chooseSelectOption, fireEvent, renderWithProviders, screen, waitFor } from "@/../tests/test-utils"; import TeamAdminSettingsForm from "./TeamAdminSettingsForm"; @@ -13,7 +13,16 @@ const renderForm = ( const onCancel = vi.fn(); renderWithProviders( { expect(save).toBeDisabled(); }); + it("shows the team's member defaults when the proxy lets team admins edit them", () => { + renderForm( + new Set(["team_member_budget", "team_member_budget_duration", "team_member_tpm_limit", "team_member_rpm_limit"]), + ); + + expect(screen.getByLabelText("Member Default Budget (USD)")).toHaveValue(10); + expect(screen.getByLabelText("Member Default Budget Duration")).toHaveTextContent("monthly"); + expect(screen.getByLabelText("Member Default TPM Limit")).toHaveValue(100); + expect(screen.getByLabelText("Member Default RPM Limit")).toHaveValue(5); + }); + + it("saves a new member default budget and a weekly reset without resending the unchanged limits", async () => { + const user = userEvent.setup(); + const { onSave } = renderForm( + new Set(["team_member_budget", "team_member_budget_duration", "team_member_tpm_limit", "team_member_rpm_limit"]), + ); + + fireEvent.change(screen.getByLabelText("Member Default Budget (USD)"), { target: { value: "25" } }); + await chooseSelectOption(user, screen.getByLabelText("Member Default Budget Duration"), "weekly"); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => + expect(onSave).toHaveBeenCalledWith({ team_member_budget: 25, team_member_budget_duration: "7d" }), + ); + }); + + it("saves a member budget that never resets as a null duration", async () => { + const user = userEvent.setup(); + const { onSave } = renderForm(new Set(["team_member_budget_duration"])); + + await chooseSelectOption(user, screen.getByLabelText("Member Default Budget Duration"), "Never resets"); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(onSave).toHaveBeenCalledWith({ team_member_budget_duration: null })); + }); + + it("shows a stored member budget that never resets as Never resets", () => { + renderWithProviders( + , + ); + + expect(screen.getByLabelText("Member Default Budget Duration")).toHaveTextContent("Never resets"); + }); + it("shows the soft budget when the proxy lets team admins edit it", () => { renderForm(new Set(["soft_budget"])); diff --git a/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.tsx b/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.tsx index 5ce50bb6c72..05595d6a1a4 100644 --- a/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamAdminSettingsForm.tsx @@ -10,8 +10,10 @@ import { FieldGroup } from "@/components/ui/field"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { useZodForm } from "@/lib/forms/useZodForm"; +import BudgetDurationDropdown, { NEVER_RESETS_BUDGET_DURATION } from "../common_components/budget_duration_dropdown"; import NumericalInput from "../shared/numerical_input"; import { + isTeamAdminDurationSettingsField, TEAM_ADMIN_SETTINGS_FIELDS, teamAdminFieldLabel, teamAdminSettingsChanges, @@ -27,15 +29,22 @@ const teamAdminSettingsShape = { rpm_limit: numericInputSchema, max_budget: numericInputSchema, soft_budget: numericInputSchema, + team_member_budget: numericInputSchema, + team_member_budget_duration: z.string().nullish(), + team_member_tpm_limit: numericInputSchema, + team_member_rpm_limit: numericInputSchema, }; const teamAdminSettingsSchema = z.object(teamAdminSettingsShape); -const INPUT_STEP: Readonly> = { +const INPUT_STEP: Readonly, number>> = { tpm_limit: 1, rpm_limit: 1, max_budget: 0.01, soft_budget: 0.01, + team_member_budget: 0.01, + team_member_tpm_limit: 1, + team_member_rpm_limit: 1, }; const MAX_BUDGET_KEEP_OR_LOWER_HINT = "You can keep or lower this budget. Ask a proxy admin to raise it."; @@ -47,6 +56,11 @@ const fieldHint = (name: TeamAdminSettingsField, mayRaiseMaxBudget: boolean): st return mayRaiseMaxBudget ? MAX_BUDGET_RAISE_HINT : MAX_BUDGET_KEEP_OR_LOWER_HINT; }; +const durationSelectValue = (value: string | number | null | undefined): string | undefined => { + if (value === null) return NEVER_RESETS_BUDGET_DURATION; + return typeof value === "string" ? value : undefined; +}; + interface TeamAdminSettingsFormProps { initialValues: TeamAdminSettingsValues; editableFields: ReadonlySet; @@ -83,9 +97,26 @@ export default function TeamAdminSettingsForm({ label={teamAdminFieldLabel(name)} description={fieldHint(name, mayRaiseMaxBudget)} > - {({ ref, value, ...field }) => ( - - )} + {({ ref, id, value, onChange, ...field }) => + isTeamAdminDurationSettingsField(name) ? ( + onChange(next === NEVER_RESETS_BUDGET_DURATION ? null : next ?? undefined)} + /> + ) : ( + + ) + } ))} diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index 31d3016d4be..973b2ecee80 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -2047,6 +2047,35 @@ describe("TeamInfoView", () => { expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled(); }); + it("prefills the member defaults a team admin may edit from the team's member budget", async () => { + const user = userEvent.setup({ delay: null }); + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ + team_member_budget_table: { max_budget: 10, budget_duration: "30d", tpm_limit: 100, rpm_limit: 5 }, + caller_edit_access: { + kind: "team_admin", + editable_fields: [ + "team_member_budget", + "team_member_budget_duration", + "team_member_tpm_limit", + "team_member_rpm_limit", + ], + }, + }), + ); + + renderWithProviders(); + + await user.click(await screen.findByRole("tab", { name: "Settings" })); + await user.click(await screen.findByRole("button", { name: /edit settings/i })); + + expect(await screen.findByLabelText("Member Default Budget (USD)")).toHaveValue(10); + expect(screen.getByLabelText("Member Default Budget Duration")).toHaveTextContent("monthly"); + expect(screen.getByLabelText("Member Default TPM Limit")).toHaveValue(100); + expect(screen.getByLabelText("Member Default RPM Limit")).toHaveValue(5); + expect(screen.getByRole("button", { name: /save changes/i })).toBeDisabled(); + }); + it("opens the form when the proxy reports unrestricted access although the props only mark a team admin", async () => { const user = userEvent.setup({ delay: null }); vi.mocked(networking.teamInfoCall).mockResolvedValue( diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index cfac81cb64b..0743e73005e 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -1311,6 +1311,10 @@ const TeamInfoView: React.FC = ({ rpm_limit: info.rpm_limit, max_budget: info.max_budget, soft_budget: info.soft_budget, + team_member_budget: info.team_member_budget_table?.max_budget, + team_member_budget_duration: info.team_member_budget_table?.budget_duration, + team_member_tpm_limit: info.team_member_budget_table?.tpm_limit, + team_member_rpm_limit: info.team_member_budget_table?.rpm_limit, }} editableFields={teamEditAccess.editableFields} mayRaiseMaxBudget={teamEditAccess.mayRaiseMaxBudget} diff --git a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts index 590dd806d7f..84898563899 100644 --- a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts +++ b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts @@ -16,6 +16,10 @@ describe("teamAdminFieldLabel", () => { ["soft_budget", "Soft Budget (USD)"], ["raise_max_budget", "Raise the team's max budget"], ["projects", "Create and update projects"], + ["team_member_budget", "Member Default Budget (USD)"], + ["team_member_budget_duration", "Member Default Budget Duration"], + ["team_member_tpm_limit", "Member Default TPM Limit"], + ["team_member_rpm_limit", "Member Default RPM Limit"], ["create_member_keys", "Create keys for team members"], ])("names %s the way the team settings form does", (field, label) => { expect(teamAdminFieldLabel(field)).toBe(label); @@ -77,6 +81,65 @@ describe("teamAdminSettingsChanges", () => { }); }); +describe("teamAdminSettingsChanges for team member defaults", () => { + const stored = { + team_member_budget: 10, + team_member_budget_duration: "30d", + team_member_tpm_limit: 100, + team_member_rpm_limit: 5, + }; + const enabled = new Set([ + "team_member_budget", + "team_member_budget_duration", + "team_member_tpm_limit", + "team_member_rpm_limit", + ]); + + const durationEnabled = new Set(["team_member_budget_duration"]); + + it("sends every changed member default and skips the unchanged ones", () => { + const values = { + team_member_budget: "25", + team_member_budget_duration: "7d", + team_member_tpm_limit: "100", + team_member_rpm_limit: "9", + }; + + expect(teamAdminSettingsChanges(values, stored, enabled)).toStrictEqual({ + team_member_budget: 25, + team_member_budget_duration: "7d", + team_member_rpm_limit: 9, + }); + }); + + it("sends a never-resetting duration as null", () => { + expect(teamAdminSettingsChanges({ team_member_budget_duration: null }, stored, durationEnabled)).toStrictEqual({ + team_member_budget_duration: null, + }); + }); + + it("sends a never-resetting duration for a team without member defaults yet", () => { + expect(teamAdminSettingsChanges({ team_member_budget_duration: null }, {}, durationEnabled)).toStrictEqual({ + team_member_budget_duration: null, + }); + }); + + it.each([ + ["the stored duration", "30d", stored], + ["an untouched duration on a team with a stored duration", undefined, stored], + ["an untouched duration on a team without member defaults", undefined, {}], + ["a stored never-resetting duration picked again", null, { team_member_budget_duration: null }], + ])("sends no duration for %s", (_label, team_member_budget_duration, initialValues) => { + expect(teamAdminSettingsChanges({ team_member_budget_duration }, initialValues, durationEnabled)).toStrictEqual({}); + }); + + it("leaves out member defaults the proxy did not enable", () => { + const values = { team_member_budget: "25", team_member_budget_duration: "7d" }; + + expect(teamAdminSettingsChanges(values, stored, new Set(["max_budget"]))).toStrictEqual({}); + }); +}); + describe("teamAdminSettingsChanges for the soft budget", () => { const stored = { soft_budget: 50 }; const enabled = new Set(["soft_budget"]); diff --git a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts index 3ab51b4cdbb..28ba5eed3e4 100644 --- a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts +++ b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts @@ -48,15 +48,36 @@ export const parseSupportedTeamAdminEditableFields = (uiSettingsFieldSchema: unk return items.success ? fieldListSchema.parse(items.data.enum) : []; }; -export const TEAM_ADMIN_SETTINGS_FIELDS = ["tpm_limit", "rpm_limit", "max_budget", "soft_budget"] as const; +export const TEAM_ADMIN_SETTINGS_FIELDS = [ + "tpm_limit", + "rpm_limit", + "max_budget", + "soft_budget", + "team_member_budget", + "team_member_budget_duration", + "team_member_tpm_limit", + "team_member_rpm_limit", +] as const; export type TeamAdminSettingsField = (typeof TEAM_ADMIN_SETTINGS_FIELDS)[number]; +type TeamAdminDurationSettingsField = "team_member_budget_duration"; + +type TeamAdminNumericSettingsField = Exclude; + +export const isTeamAdminDurationSettingsField = ( + field: TeamAdminSettingsField, +): field is TeamAdminDurationSettingsField => field === "team_member_budget_duration"; + const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap = new Map([ ["tpm_limit", "Tokens per minute Limit (TPM)"], ["rpm_limit", "Requests per minute Limit (RPM)"], ["max_budget", "Max Budget (USD)"], ["soft_budget", "Soft Budget (USD)"], + ["team_member_budget", "Member Default Budget (USD)"], + ["team_member_budget_duration", "Member Default Budget Duration"], + ["team_member_tpm_limit", "Member Default TPM Limit"], + ["team_member_rpm_limit", "Member Default RPM Limit"], [TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION, "Raise the team's max budget"], ["projects", "Create and update projects"], ["member_key_budgets", "Update budgets on team members' keys"], @@ -67,7 +88,26 @@ export const teamAdminFieldLabel = (field: string): string => TEAM_ADMIN_FIELD_L export type TeamAdminSettingsValues = { readonly [F in TeamAdminSettingsField]?: string | number | null }; -export type TeamAdminSettingsChanges = { readonly [F in TeamAdminSettingsField]?: number | null }; +export type TeamAdminSettingsChanges = { readonly [F in TeamAdminNumericSettingsField]?: number | null } & { + readonly [F in TeamAdminDurationSettingsField]?: string | null; +}; + +const durationOrNull = (value: string | number | null | undefined): string | null => + typeof value === "string" && value !== "" ? value : null; + +const changedValue = ( + field: TeamAdminSettingsField, + value: string | number | null | undefined, + initialValue: string | number | null | undefined, +): readonly [TeamAdminSettingsField, string | number | null] | null => { + if (!isTeamAdminDurationSettingsField(field)) { + const number = numberOrNull(value); + return number === numberOrNull(initialValue) ? null : [field, number]; + } + if (value === undefined) return null; + const duration = durationOrNull(value); + return initialValue !== undefined && duration === durationOrNull(initialValue) ? null : [field, duration]; +}; export const teamAdminSettingsChanges = ( values: TeamAdminSettingsValues, @@ -76,8 +116,8 @@ export const teamAdminSettingsChanges = ( ): TeamAdminSettingsChanges => Object.fromEntries( TEAM_ADMIN_SETTINGS_FIELDS.flatMap((field) => { - const value = numberOrNull(values[field]); - return editableFields.has(field) && value !== numberOrNull(initialValues[field]) ? [[field, value]] : []; + const change = editableFields.has(field) ? changedValue(field, values[field], initialValues[field]) : null; + return change === null ? [] : [change]; }), ); From 542b254ca2aab496d1c0db8450a454cb92707476 Mon Sep 17 00:00:00 2001 From: Kerry Lu Date: Wed, 14 Oct 2026 14:07:59 -0700 Subject: [PATCH 02/25] fix(anthropic): forward service_tier on /v1/messages for anthropic, azure_ai and bedrock_mantle (#46370) * test(integration): add known-bug translation cases for request fields LiteLLM drops service_tier on /v1/messages, conversation on /v1/responses and metadata on /v1/chat/completions never reach the provider. Each gets a TranslationTestCase with the correct provider body, marked xfail(strict=True) through the new TranslationTestCase.known_bug field, plus a passing service_tier case for /v1/messages on the openai route Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(integration): widen the known-bug cases to azure_ai, bedrock_mantle, openai/responses and azure deployments Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(anthropic): forward service_tier on /v1/messages for anthropic, azure_ai and bedrock_mantle Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/anthropic/chat/transformation.py | 35 +++++++++++++--- .../pass_through/messages/transformation.py | 1 + .../anthropic/pass_through/messages/utils.py | 5 +++ .../bedrock/messages/mantle_transformation.py | 4 ++ litellm/types/llms/anthropic.py | 1 + .../messages/service_tier/__init__.py | 0 .../test_messages_service_tier_anthropic.py | 29 +++++++++++++ .../test_messages_service_tier_azure_ai.py | 29 +++++++++++++ ...st_messages_service_tier_bedrock_mantle.py | 31 ++++++++++++++ .../test_messages_service_tier_openai.py | 42 +++++++++++++++++++ .../test_anthropic_messages_service_tier.py | 28 +++++++++++++ 11 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 tests/integration/translation/messages/service_tier/__init__.py create mode 100644 tests/integration/translation/messages/service_tier/test_messages_service_tier_anthropic.py create mode 100644 tests/integration/translation/messages/service_tier/test_messages_service_tier_azure_ai.py create mode 100644 tests/integration/translation/messages/service_tier/test_messages_service_tier_bedrock_mantle.py create mode 100644 tests/integration/translation/messages/service_tier/test_messages_service_tier_openai.py create mode 100644 tests/unit/llms/anthropic/pass_through/messages/test_anthropic_messages_service_tier.py diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 7abc13d5508..f4e50bf3acd 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -294,7 +294,6 @@ DROP_UNSUPPORTED_ADAPTIVE_THINKING_WARNING: Final = ( DROP_UNSUPPORTED_SPEED_WARNING: Final = ( "Dropping unsupported `speed` for model=%s (drop_params=True). Fast mode is only supported on select Opus models." ) - SERVICE_TIER_PROVIDERS: Final = frozenset({"anthropic", "azure_ai"}) OPENAI_TO_ANTHROPIC_SERVICE_TIER: Final = MappingProxyType( @@ -306,6 +305,12 @@ DROP_UNSUPPORTED_SERVICE_TIER_WARNING: Final = ( "Anthropic only accepts 'auto' and 'standard_only'." ) +MESSAGES_SERVICE_TIER_PROVIDERS: Final = SERVICE_TIER_PROVIDERS | {"bedrock_mantle"} + +DROP_SERVICE_TIER_PROVIDER_WARNING: Final = ( + "Dropping `service_tier` for model=%s on provider=%s. Only the Anthropic API routes accept it." +) + class AnthropicConfig(AnthropicModelInfo, BaseConfig): """ @@ -539,6 +544,24 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): ) -> None: return cls._maybe_drop_speed_param(model, optional_params, drop_params, custom_llm_provider) + @staticmethod + def provider_supports_service_tier(custom_llm_provider: str | None) -> bool: + """Anthropic's ``service_tier`` is accepted by the direct API and the Foundry and Mantle gateways in + front of it. Vertex rejects the field and Bedrock Invoke only takes its own tier names.""" + return custom_llm_provider is None or custom_llm_provider in MESSAGES_SERVICE_TIER_PROVIDERS + + @classmethod + def maybe_drop_service_tier_param( + cls, + model: str, + optional_params: dict[str, object], # mutable-ok: mirrors override contract + custom_llm_provider: str | None = None, + ) -> None: + if "service_tier" not in optional_params or cls.provider_supports_service_tier(custom_llm_provider): + return + litellm.verbose_logger.warning(DROP_SERVICE_TIER_PROVIDER_WARNING, model, custom_llm_provider) + optional_params.pop("service_tier", None) + @staticmethod def _raise_invalid_reasoning_effort(model: str, value: object, llm_provider: str) -> NoReturn: """Raise a ``BadRequestError`` for an unrecognised ``reasoning_effort``. @@ -717,12 +740,12 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): # Already handled above continue - if key == "properties" and isinstance(value, dict): - result[key] = {k: AnthropicConfig.filter_anthropic_output_schema(v) for k, v in value.items()} + if key in ("properties", "$defs") and isinstance(value, dict): + nested_schemas: dict[str, Any] = value # mutable-ok: filter_anthropic_output_schema takes a dict + result[key] = {k: AnthropicConfig.filter_anthropic_output_schema(v) for k, v in nested_schemas.items()} elif key == "items" and isinstance(value, dict): - result[key] = AnthropicConfig.filter_anthropic_output_schema(value) - elif key == "$defs" and isinstance(value, dict): - result[key] = {k: AnthropicConfig.filter_anthropic_output_schema(v) for k, v in value.items()} + nested_schema: dict[str, Any] = value # mutable-ok: filter_anthropic_output_schema takes a dict + result[key] = AnthropicConfig.filter_anthropic_output_schema(nested_schema) elif key == "anyOf" and isinstance(value, list): result["anyOf"] = result.get("anyOf", []) + [ AnthropicConfig.filter_anthropic_output_schema(item) for item in value diff --git a/litellm/llms/anthropic/pass_through/messages/transformation.py b/litellm/llms/anthropic/pass_through/messages/transformation.py index f1eb30b63c1..4c667574d40 100644 --- a/litellm/llms/anthropic/pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/pass_through/messages/transformation.py @@ -98,6 +98,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): "output_format", "inference_geo", "speed", + "service_tier", "output_config", "reasoning_effort", "safeguards", diff --git a/litellm/llms/anthropic/pass_through/messages/utils.py b/litellm/llms/anthropic/pass_through/messages/utils.py index 8371615baea..36bd10ab8e6 100644 --- a/litellm/llms/anthropic/pass_through/messages/utils.py +++ b/litellm/llms/anthropic/pass_through/messages/utils.py @@ -205,6 +205,11 @@ class AnthropicMessagesRequestUtils: drop_params=drop_params, custom_llm_provider=custom_llm_provider, ) + AnthropicConfig.maybe_drop_service_tier_param( + model=model, + optional_params=filtered_params, + custom_llm_provider=custom_llm_provider, + ) for param in ("temperature", "top_p", "top_k"): if param in filtered_params: AnthropicModelInfo._apply_sampling_param( # pyright: ignore[reportPrivateUsage] # same gating the /chat/completions path applies; forking it would drift diff --git a/litellm/llms/bedrock/messages/mantle_transformation.py b/litellm/llms/bedrock/messages/mantle_transformation.py index 0ee5dcbe557..ca93cc3e617 100644 --- a/litellm/llms/bedrock/messages/mantle_transformation.py +++ b/litellm/llms/bedrock/messages/mantle_transformation.py @@ -56,6 +56,10 @@ class AmazonMantleMessagesConfig(AmazonAnthropicClaudeMessagesConfig): model ID in the request body (unlike Bedrock Invoke which puts it in the URL). """ + BEDROCK_INVOKE_ALLOWED_TOP_LEVEL_FIELDS = ( + AmazonAnthropicClaudeMessagesConfig.BEDROCK_INVOKE_ALLOWED_TOP_LEVEL_FIELDS | {"service_tier"} + ) + @property def beta_headers_provider(self) -> str: return "bedrock_mantle" diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index 25f55ddb84b..712f41ecc61 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -430,6 +430,7 @@ class AnthropicMessagesRequestOptionalParams(TypedDict, total=False): cache_control: dict[str, Any] | None # Automatic prompt caching reasoning_effort: str | None safeguards: ReadOnly[list[dict[str, object]] | None] + service_tier: ReadOnly[str | None] class AnthropicMessagesRequest(AnthropicMessagesRequestOptionalParams, total=False): diff --git a/tests/integration/translation/messages/service_tier/__init__.py b/tests/integration/translation/messages/service_tier/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/integration/translation/messages/service_tier/test_messages_service_tier_anthropic.py b/tests/integration/translation/messages/service_tier/test_messages_service_tier_anthropic.py new file mode 100644 index 00000000000..de453564e15 --- /dev/null +++ b/tests/integration/translation/messages/service_tier/test_messages_service_tier_anthropic.py @@ -0,0 +1,29 @@ +from dataclasses import replace +from typing import Final + +import pytest +from integration._support.client import Gateway +from integration._support.provider import SharedProvider +from integration.translation.case import TranslationTestCase +from integration.translation.messages.bases.anthropic import CLAUDE_SONNET_4_6_TEST_CASE +from integration.translation.runner import assert_translation + +CLAUDE_SONNET_4_6_SERVICE_TIER_TEST_CASE: Final = replace( + CLAUDE_SONNET_4_6_TEST_CASE, + scenario="service_tier", + litellm_request={**CLAUDE_SONNET_4_6_TEST_CASE.litellm_request, "service_tier": "standard_only"}, + expected_provider_request={ + **CLAUDE_SONNET_4_6_TEST_CASE.expected_provider_request, + "service_tier": "standard_only", + }, + mock_provider_response={**CLAUDE_SONNET_4_6_TEST_CASE.mock_provider_response, "id": "msg_011Cg1RG36pCcTLE26htFFqC"}, + expected_litellm_response={ + **CLAUDE_SONNET_4_6_TEST_CASE.expected_litellm_response, + "id": "msg_011Cg1RG36pCcTLE26htFFqC", + }, +) + + +@pytest.mark.parametrize("case", [CLAUDE_SONNET_4_6_SERVICE_TIER_TEST_CASE], ids=lambda case: case.id) +def test_messages_service_tier_anthropic(case: TranslationTestCase, gateway: Gateway, provider: SharedProvider) -> None: + assert_translation(case, gateway, provider) diff --git a/tests/integration/translation/messages/service_tier/test_messages_service_tier_azure_ai.py b/tests/integration/translation/messages/service_tier/test_messages_service_tier_azure_ai.py new file mode 100644 index 00000000000..13bf9dd888f --- /dev/null +++ b/tests/integration/translation/messages/service_tier/test_messages_service_tier_azure_ai.py @@ -0,0 +1,29 @@ +from dataclasses import replace +from typing import Final + +import pytest +from integration._support.client import Gateway +from integration._support.provider import SharedProvider +from integration.translation.case import TranslationTestCase +from integration.translation.messages.bases.azure_ai import CLAUDE_SONNET_4_6_TEST_CASE +from integration.translation.runner import assert_translation + +CLAUDE_SONNET_4_6_SERVICE_TIER_TEST_CASE: Final = replace( + CLAUDE_SONNET_4_6_TEST_CASE, + scenario="service_tier", + litellm_request={**CLAUDE_SONNET_4_6_TEST_CASE.litellm_request, "service_tier": "standard_only"}, + expected_provider_request={ + **CLAUDE_SONNET_4_6_TEST_CASE.expected_provider_request, + "service_tier": "standard_only", + }, + mock_provider_response={**CLAUDE_SONNET_4_6_TEST_CASE.mock_provider_response, "id": "msg_011Cg1SnrstCdzZPwmTk2qQa"}, + expected_litellm_response={ + **CLAUDE_SONNET_4_6_TEST_CASE.expected_litellm_response, + "id": "msg_011Cg1SnrstCdzZPwmTk2qQa", + }, +) + + +@pytest.mark.parametrize("case", [CLAUDE_SONNET_4_6_SERVICE_TIER_TEST_CASE], ids=lambda case: case.id) +def test_messages_service_tier_azure_ai(case: TranslationTestCase, gateway: Gateway, provider: SharedProvider) -> None: + assert_translation(case, gateway, provider) diff --git a/tests/integration/translation/messages/service_tier/test_messages_service_tier_bedrock_mantle.py b/tests/integration/translation/messages/service_tier/test_messages_service_tier_bedrock_mantle.py new file mode 100644 index 00000000000..261d34271e9 --- /dev/null +++ b/tests/integration/translation/messages/service_tier/test_messages_service_tier_bedrock_mantle.py @@ -0,0 +1,31 @@ +from dataclasses import replace +from typing import Final + +import pytest +from integration._support.client import Gateway +from integration._support.provider import SharedProvider +from integration.translation.case import TranslationTestCase +from integration.translation.messages.bases.bedrock_mantle import CLAUDE_SONNET_5_TEST_CASE +from integration.translation.runner import assert_translation + +CLAUDE_SONNET_5_SERVICE_TIER_TEST_CASE: Final = replace( + CLAUDE_SONNET_5_TEST_CASE, + scenario="service_tier", + litellm_request={**CLAUDE_SONNET_5_TEST_CASE.litellm_request, "service_tier": "standard_only"}, + expected_provider_request={**CLAUDE_SONNET_5_TEST_CASE.expected_provider_request, "service_tier": "standard_only"}, + mock_provider_response={ + **CLAUDE_SONNET_5_TEST_CASE.mock_provider_response, + "id": "msg_bdrk_6oqdfuf56lp47vwldyos5f7l4lyyot337z2cv3h3lbaj3a3ei7vq", + }, + expected_litellm_response={ + **CLAUDE_SONNET_5_TEST_CASE.expected_litellm_response, + "id": "msg_bdrk_6oqdfuf56lp47vwldyos5f7l4lyyot337z2cv3h3lbaj3a3ei7vq", + }, +) + + +@pytest.mark.parametrize("case", [CLAUDE_SONNET_5_SERVICE_TIER_TEST_CASE], ids=lambda case: case.id) +def test_messages_service_tier_bedrock_mantle( + case: TranslationTestCase, gateway: Gateway, provider: SharedProvider +) -> None: + assert_translation(case, gateway, provider) diff --git a/tests/integration/translation/messages/service_tier/test_messages_service_tier_openai.py b/tests/integration/translation/messages/service_tier/test_messages_service_tier_openai.py new file mode 100644 index 00000000000..433c5bc4b2f --- /dev/null +++ b/tests/integration/translation/messages/service_tier/test_messages_service_tier_openai.py @@ -0,0 +1,42 @@ +from dataclasses import replace +from typing import Final + +import pytest +from integration._support.client import Gateway +from integration._support.provider import SharedProvider +from integration.translation.case import TranslationTestCase +from integration.translation.messages.bases.openai import GPT_5_4_TEST_CASE +from integration.translation.runner import assert_translation + +GPT_5_4_SERVICE_TIER_TEST_CASE: Final = replace( + GPT_5_4_TEST_CASE, + scenario="service_tier", + litellm_request={**GPT_5_4_TEST_CASE.litellm_request, "service_tier": "auto"}, + expected_provider_request={**GPT_5_4_TEST_CASE.expected_provider_request, "service_tier": "auto"}, + mock_provider_response={ + **GPT_5_4_TEST_CASE.mock_provider_response, + "id": "resp_0738fe6d3b086cd1006acef6c23a1487d2860e02c53af945a6", + "created_at": 1791948482, + "access_programs": None, + "completed_at": 1791948483, + "output": [ + { + "id": "msg_0738fe6d3b086cd1006acef6c310cc87d283ecc2cc1d6ff29c", + "type": "message", + "status": "completed", + "content": [{"type": "output_text", "annotations": [], "logprobs": [], "text": "Hello."}], + "phase": "final_answer", + "role": "assistant", + } + ], + }, + expected_litellm_response={ + **GPT_5_4_TEST_CASE.expected_litellm_response, + "id": "resp_bGl0ZWxsbTpjdXN0b21fbGxtX3Byb3ZpZGVyOm9wZW5haTttb2RlbF9pZDo3NGI3ZDQzZjBmNjQ1MjlkYWY4NWQ4MjRmNjE3ZGYzNDRjZTdhNGI3NjI3YTYzMDMzZGUzMDVmNTE2NTRlZjc2O3Jlc3BvbnNlX2lkOnJlc3BfMDczOGZlNmQzYjA4NmNkMTAwNmFjZWY2YzIzYTE0ODdkMjg2MGUwMmM1M2FmOTQ1YTY=", + }, +) + + +@pytest.mark.parametrize("case", [GPT_5_4_SERVICE_TIER_TEST_CASE], ids=lambda case: case.id) +def test_messages_service_tier_openai(case: TranslationTestCase, gateway: Gateway, provider: SharedProvider) -> None: + assert_translation(case, gateway, provider) diff --git a/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_messages_service_tier.py b/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_messages_service_tier.py new file mode 100644 index 00000000000..6472ccd1b2e --- /dev/null +++ b/tests/unit/llms/anthropic/pass_through/messages/test_anthropic_messages_service_tier.py @@ -0,0 +1,28 @@ +from typing import Final + +import pytest + +from litellm.llms.anthropic.pass_through.messages.utils import AnthropicMessagesRequestUtils + +REQUEST: Final[dict[str, object]] = { + "max_tokens": 64, + "service_tier": "standard_only", + "messages": [{"role": "user", "content": "Hello"}], +} + + +@pytest.mark.parametrize("custom_llm_provider", ["anthropic", "azure_ai", "bedrock_mantle", None]) +def test_messages_forwards_service_tier_on_anthropic_api_routes(custom_llm_provider: str | None) -> None: + optional_params = AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param( + params=dict(REQUEST), model="claude-sonnet-4-6", drop_params=False, custom_llm_provider=custom_llm_provider + ) + assert dict(optional_params).get("service_tier") == "standard_only" + + +@pytest.mark.parametrize("custom_llm_provider", ["vertex_ai", "bedrock"]) +def test_messages_drops_service_tier_where_the_provider_rejects_it(custom_llm_provider: str) -> None: + optional_params = AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param( + params=dict(REQUEST), model="claude-sonnet-4-6", drop_params=False, custom_llm_provider=custom_llm_provider + ) + assert "service_tier" not in optional_params + assert dict(optional_params).get("max_tokens") == 64 From 83980c9d63fe7a205c66ba45639ac2ea6bca7ebf Mon Sep 17 00:00:00 2001 From: Kerry Lu Date: Wed, 14 Oct 2026 14:19:25 -0700 Subject: [PATCH 03/25] fix(router): read the first stream chunk before returning so a failure before any token runs through the retry and fallback budget (#46493) * fix(router): read the first stream chunk before returning so a failure before any token runs through the retry and fallback budget Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(router): keep only the raising call inside pytest.raises for the pre-content stream tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(router): yield the primed first chunk from a helper generator so the stream iterators keep their complexity Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(bedrock): expect the empty invoke stream to be reported from router.completion now that the first chunk is read there Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test: give router stream mocks a first chunk or an ending so the eager first read neither hangs nor retries Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(router): name the first-chunk helpers after what they do (_read_first_chunk, _FirstChunk, _chunks_with_first) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(router): drop the string annotation on _FirstChunk.chunk, ModelResponseStream is imported at runtime --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/router.py | 75 ++++- .../test_router_sync_stream_fallback_wire.py | 14 +- ...r_tool_call_stream_fallback_budget_wire.py | 289 ++++++++++++++++++ .../test_base_invoke_transformation.py | 17 +- tests/unit/llms/chat/test_converse_handler.py | 17 +- tests/unit/test_router/test_router.py | 119 +++++++- 6 files changed, 496 insertions(+), 35 deletions(-) create mode 100644 tests/integration/sdk/test_router_tool_call_stream_fallback_budget_wire.py diff --git a/litellm/router.py b/litellm/router.py index ee8a58f21e2..16257775d3f 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -23,6 +23,7 @@ import weakref from collections import defaultdict from collections.abc import ( AsyncGenerator, + AsyncIterable, AsyncIterator, Awaitable, Callable, @@ -691,6 +692,67 @@ def _mid_stream_retry_trigger(error: "MidStreamFallbackError") -> Exception: return error.original_exception if error.original_exception is not None else error +@dataclass(frozen=True, slots=True) +class _FirstChunk: + """The chunk Router._acompletion / _completion already read off a stream; None means the provider closed it empty.""" + + chunk: ModelResponseStream | None + + +async def _aread_first_chunk(stream: CustomStreamWrapper) -> _FirstChunk: + """ + Read the first chunk while Router._acompletion still holds the request, so a + stream that fails before any token raises there, inside async_function_with_retries + and the fallback chain, instead of surfacing to the caller mid-iteration. + """ + from litellm.exceptions import MidStreamFallbackError + + try: + return _FirstChunk(chunk=await stream.__anext__()) + except StopAsyncIteration: + return _FirstChunk(chunk=None) + except MidStreamFallbackError as e: + raise _mid_stream_retry_trigger(e) from e + + +def _read_first_chunk(stream: CustomStreamWrapper) -> _FirstChunk: + """Sync twin of _aread_first_chunk for Router._completion.""" + from litellm.exceptions import MidStreamFallbackError + + try: + return _FirstChunk(chunk=next(stream)) + except StopIteration: + return _FirstChunk(chunk=None) + except MidStreamFallbackError as e: + raise _mid_stream_retry_trigger(e) from e + + +def _chunks_with_first(model_response: Iterable[object], taken: _FirstChunk | None) -> Iterator[object]: + """The stream with the chunk _read_first_chunk already read put back in front (None: the stream as is).""" + if taken is None: + yield from model_response + return + if taken.chunk is None: + return + yield taken.chunk + yield from model_response + + +async def _achunks_with_first( + model_response: AsyncIterable[object], taken: _FirstChunk | None +) -> AsyncIterator[object]: + """Async twin of _chunks_with_first.""" + if taken is None: + async for item in model_response: + yield item + return + if taken.chunk is None: + return + yield taken.chunk + async for item in model_response: + yield item + + def _stream_commits_now(chunk: object, has_generated_content: bool, buffered_chunk_count: int) -> bool: """ Whether `chunk` should make Router._aanthropic_messages_streaming_iterator @@ -2791,6 +2853,7 @@ class Router: model_response=response, messages=messages, initial_kwargs=input_kwargs_for_streaming_fallback, + first_chunk=_read_first_chunk(response), ) return response @@ -3050,6 +3113,7 @@ class Router: messages: list[dict[str, str]], initial_kwargs: dict, deployment_slot: contextlib.AsyncExitStack | None = None, + first_chunk: _FirstChunk | None = None, ) -> CustomStreamWrapper: """ Helper to iterate over a streaming response. @@ -3058,6 +3122,8 @@ class Router: `deployment_slot` holds the deployment's max_parallel_requests semaphore; it is released when the stream is exhausted, closed, or falls back to another deployment + + `first_chunk` carries the chunk _acompletion already read (None: iterate from the start) """ from litellm.exceptions import MidStreamFallbackError @@ -3102,7 +3168,7 @@ class Router: async def stream_with_fallbacks(): fallback_response = None # Track for cleanup in finally try: - async for item in model_response: + async for item in _achunks_with_first(model_response, first_chunk): yield item except MidStreamFallbackError as e: with anyio.CancelScope(shield=True): @@ -3677,6 +3743,7 @@ class Router: model_response: CustomStreamWrapper, messages: list[dict[str, str]], initial_kwargs: dict, + first_chunk: _FirstChunk | None = None, ) -> CustomStreamWrapper: """ Sync equivalent of _acompletion_streaming_iterator. @@ -3712,7 +3779,7 @@ class Router: def stream_with_fallbacks(): fallback_response = None try: - for item in model_response: + for item in _chunks_with_first(model_response, first_chunk): yield item except MidStreamFallbackError as e: if fallbacks_disabled_for_request(initial_kwargs) or ( @@ -3959,6 +4026,9 @@ class Router: and response.make_call is not None ): await response.fetch_stream() + first_chunk: Final = ( + await _aread_first_chunk(response) if isinstance(response, CustomStreamWrapper) else None + ) self.success_calls[model_name] += 1 verbose_router_logger.info("litellm.acompletion(model=%s)\x1b[32m 200 OK\x1b[0m", model_name) @@ -3975,6 +4045,7 @@ class Router: messages=messages, initial_kwargs=input_kwargs_for_streaming_fallback, deployment_slot=deployment_slot.pop_all(), + first_chunk=first_chunk, ) return response diff --git a/tests/integration/sdk/test_router_sync_stream_fallback_wire.py b/tests/integration/sdk/test_router_sync_stream_fallback_wire.py index fc5fb028123..36616eda50e 100644 --- a/tests/integration/sdk/test_router_sync_stream_fallback_wire.py +++ b/tests/integration/sdk/test_router_sync_stream_fallback_wire.py @@ -154,7 +154,8 @@ def test_walks_every_configured_fallback(client: str) -> None: router: Final = _router(wire, ("primary", "fb1", "fb2"), fallbacks=[{"primary": ["fb1", "fb2"]}]) streamed: Final = _stream(client, router) assert streamed.text == "answered by fb2", streamed - assert streamed.attempted_fallbacks == 2, streamed + # the header a non-streaming request with the same chain carries today + assert streamed.attempted_fallbacks == 1, streamed assert _deployments_hit(wire) == ("primary", "fb1", "fb2") @@ -192,11 +193,12 @@ def test_router_retries_configured(client: str) -> None: model_list=_router(wire, ("primary", "backup")).model_list, fallbacks=_PRIMARY_TO_BACKUP, num_retries=2, + retry_after=0, disable_cooldowns=True, ) streamed: Final = _stream(client, router) assert streamed.text == "answered by the backup", streamed - assert _deployments_hit(wire) == ("primary", "backup") + assert _deployments_hit(wire) == ("primary", "primary", "primary", "backup") @pytest.mark.parametrize("client", _CLIENTS) @@ -219,12 +221,12 @@ def test_per_request_fallbacks_none_turns_the_router_list_off(client: str) -> No @pytest.mark.parametrize("client", _CLIENTS) -def test_max_fallbacks_caps_the_walk(client: str) -> None: +def test_max_fallbacks_walks_the_list_like_a_non_streaming_request(client: str) -> None: with wire_server(_peer(_PRIMARY_AND_FB1_DIE)) as wire: router: Final = _router(wire, ("primary", "fb1", "fb2"), fallbacks=[{"primary": ["fb1", "fb2"]}], max_fallbacks=1) - with pytest.raises(litellm.APIConnectionError, match="overloaded"): - _stream(client, router) - assert _deployments_hit(wire) == ("primary", "fb1") + streamed: Final = _stream(client, router) + assert streamed.text == "answered by fb2", streamed + assert _deployments_hit(wire) == ("primary", "fb1", "fb2") def test_called_inside_a_running_loop() -> None: diff --git a/tests/integration/sdk/test_router_tool_call_stream_fallback_budget_wire.py b/tests/integration/sdk/test_router_tool_call_stream_fallback_budget_wire.py new file mode 100644 index 00000000000..4b9bc8588a7 --- /dev/null +++ b/tests/integration/sdk/test_router_tool_call_stream_fallback_budget_wire.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +import asyncio +import itertools +import json +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Final + +import pytest +from integration._support.wire import Reply, Request, Wire, wire_server + +import litellm +from litellm import Router + +_MODEL: Final = "gpt-5.6" +_API_KEY: Final = "synthetic-tool-call-fallback-key" +_PROMPT: Final = "what is the weather in the city the primary deployment never finishes naming?" +_TOOLS: Final = ( + { + "type": "function", + "function": { + "name": "get_weather", + "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}, + }, + }, +) +_ERROR_FRAME: Final = ( + b"data: " + json.dumps({"error": {"message": "overloaded", "type": "server_error", "code": 500}}).encode() + b"\n\n" +) +_DONE: Final = b"data: [DONE]\n\n" +_PRIMARY_TO_BACKUP: Final = [{"primary": ["backup"]}] +_BOTH_WAYS: Final = [{"primary": ["backup"]}, {"backup": ["primary"]}] + +pytestmark = pytest.mark.timeout(120) + + +def _chunk(delta: Mapping[str, object], finish_reason: str | None) -> bytes: + body: Final = { + "id": "chatcmpl-tool-call-fallback-wire", + "object": "chat.completion.chunk", + "created": 1700000000, + "model": _MODEL, + "choices": [{"index": 0, "delta": dict(delta), "finish_reason": finish_reason}], + } + return b"data: " + json.dumps(body).encode() + b"\n\n" + + +def _tool_call_open(call_id: str) -> bytes: + return _chunk( + { + "role": "assistant", + "tool_calls": [ + {"index": 0, "id": call_id, "type": "function", "function": {"name": "get_weather", "arguments": ""}} + ], + }, + None, + ) + + +def _tool_call_arguments(fragment: str) -> bytes: + return _chunk({"tool_calls": [{"index": 0, "function": {"arguments": fragment}}]}, None) + + +_FINISH: Final = _chunk({}, "tool_calls") + + +def _serves_tool_call(call_id: str, city: str) -> Reply: + return Reply( + content_type="text/event-stream", + chunks=( + _tool_call_open(call_id), + _tool_call_arguments('{"city": "'), + _tool_call_arguments(f'{city}"}}'), + _FINISH, + _DONE, + ), + ) + + +def _dies_after_tool_call_opens(call_id: str) -> Reply: + return Reply( + content_type="text/event-stream", + chunks=(_tool_call_open(call_id), _tool_call_arguments('{"city": "'), _ERROR_FRAME, _DONE), + ) + + +_DIES_BEFORE_CONTENT: Final = Reply(content_type="text/event-stream", chunks=(_ERROR_FRAME, _DONE)) +_DROPS_BEFORE_CONTENT: Final = Reply(content_type="text/event-stream", chunks=(_DONE,), abort_after=0) +_REJECTS_BEFORE_STREAMING: Final = Reply( + status=500, body=json.dumps({"error": {"message": "overloaded", "type": "server_error", "code": 500}}).encode() +) + + +def _deployment_of(request: Request) -> str: + deployment, _, route = request.target.lstrip("/").partition("/") + assert request.method == "POST", request.method + assert route == "chat/completions", request.target + return deployment + + +def _peer(replies: Mapping[str, Reply]) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + body: Final = json.loads(request.body) + assert body.get("stream") is True, body + assert [tool["function"]["name"] for tool in body["tools"]] == ["get_weather"], body + return replies[_deployment_of(request)] + + return respond + + +def _always(reply: Reply) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + _deployment_of(request) + return reply + + return respond + + +def _fails_then_serves(failures: int, served: Reply) -> Callable[[Request], Reply]: + attempts: Final = itertools.count(1) + + def respond(request: Request) -> Reply: + _deployment_of(request) + return _DIES_BEFORE_CONTENT if next(attempts) <= failures else served + + return respond + + +def _deployments_hit(wire: Wire) -> tuple[str, ...]: + return tuple(_deployment_of(request) for request in wire.drain()) + + +def _router(wire: Wire, deployments: tuple[str, ...], **settings: object) -> Router: + return Router( + model_list=[ + { + "model_name": name, + "litellm_params": {"model": f"openai/{_MODEL}", "api_base": f"{wire.url}/{name}", "api_key": _API_KEY}, + } + for name in deployments + ], + disable_cooldowns=True, + **settings, + ) + + +@dataclass(frozen=True, slots=True) +class _ToolCallStream: + call_ids: tuple[str, ...] + arguments: str + finish_reasons: tuple[str, ...] + + +def _tool_call_deltas(chunk: object) -> tuple[object, ...]: + choices: Final = getattr(chunk, "choices", None) or () + return tuple(delta for choice in choices for delta in (choice.delta.tool_calls or ())) + + +def _collect(chunks: tuple[object, ...]) -> _ToolCallStream: + deltas: Final = tuple(delta for chunk in chunks for delta in _tool_call_deltas(chunk)) + finish_reasons: Final = tuple( + choice.finish_reason + for chunk in chunks + for choice in (getattr(chunk, "choices", None) or ()) + if choice.finish_reason + ) + return _ToolCallStream( + call_ids=tuple(delta.id for delta in deltas if delta.id), + arguments="".join(delta.function.arguments or "" for delta in deltas), + finish_reasons=finish_reasons, + ) + + +def _request(**extra: object) -> dict[str, object]: + return { + "model": "primary", + "messages": [{"role": "user", "content": _PROMPT}], + "tools": list(_TOOLS), + "stream": True, + **extra, + } + + +def _stream_sync(router: Router, **extra: object) -> _ToolCallStream: + return _collect(tuple(router.completion(**_request(**extra)))) + + +async def _stream_async(router: Router, **extra: object) -> _ToolCallStream: + stream: Final = await router.acompletion(**_request(**extra)) + return _collect(tuple([chunk async for chunk in stream])) + + +def _stream(client: str, router: Router, **extra: object) -> _ToolCallStream: + if client == "async": + return asyncio.run(_stream_async(router, **extra)) + return _stream_sync(router, **extra) + + +_CLIENTS: Final = ("sync", "async") +_BACKUP_ANSWERS: Final = _ToolCallStream( + call_ids=("call_backup",), arguments='{"city": "Lisbon"}', finish_reasons=("tool_calls",) +) + + +@pytest.mark.parametrize("client", _CLIENTS) +def test_primary_dies_before_the_tool_call_opens(client: str) -> None: + replies: Final = {"primary": _DIES_BEFORE_CONTENT, "backup": _serves_tool_call("call_backup", "Lisbon")} + with wire_server(_peer(replies)) as wire: + router: Final = _router(wire, ("primary", "backup"), num_retries=0, fallbacks=_PRIMARY_TO_BACKUP) + assert _stream(client, router) == _BACKUP_ANSWERS + assert _deployments_hit(wire) == ("primary", "backup") + + +@pytest.mark.parametrize("client", _CLIENTS) +def test_primary_drops_the_connection_before_the_tool_call_opens(client: str) -> None: + replies: Final = {"primary": _DROPS_BEFORE_CONTENT, "backup": _serves_tool_call("call_backup", "Lisbon")} + with wire_server(_peer(replies)) as wire: + router: Final = _router(wire, ("primary", "backup"), num_retries=0, fallbacks=_PRIMARY_TO_BACKUP) + assert _stream(client, router) == _BACKUP_ANSWERS + assert _deployments_hit(wire) == ("primary", "backup") + + +@pytest.mark.parametrize("client", _CLIENTS) +def test_primary_dies_after_tool_call_deltas_streamed(client: str) -> None: + replies: Final = { + "primary": _dies_after_tool_call_opens("call_primary"), + "backup": _serves_tool_call("call_backup", "Lisbon"), + } + with wire_server(_peer(replies)) as wire: + router: Final = _router(wire, ("primary", "backup"), num_retries=2, fallbacks=_PRIMARY_TO_BACKUP) + with pytest.raises(litellm.APIConnectionError, match="overloaded"): + _stream(client, router) + assert _deployments_hit(wire) == ("primary",) + + +@pytest.mark.parametrize("client", _CLIENTS) +def test_disable_fallbacks_holds_before_the_tool_call_opens(client: str) -> None: + replies: Final = {"primary": _DIES_BEFORE_CONTENT, "backup": _serves_tool_call("call_backup", "Lisbon")} + with wire_server(_peer(replies)) as wire: + router: Final = _router(wire, ("primary", "backup"), num_retries=0, fallbacks=_PRIMARY_TO_BACKUP) + with pytest.raises(litellm.APIConnectionError, match="overloaded"): + _stream(client, router, disable_fallbacks=True) + assert _deployments_hit(wire) == ("primary",) + + +@pytest.mark.parametrize("client", _CLIENTS) +def test_primary_dies_before_the_tool_call_opens_is_retried_within_the_group(client: str) -> None: + with wire_server(_fails_then_serves(1, _serves_tool_call("call_primary", "Lisbon"))) as wire: + router: Final = _router(wire, ("primary",), num_retries=1, retry_after=0) + assert _stream(client, router) == _ToolCallStream( + call_ids=("call_primary",), arguments='{"city": "Lisbon"}', finish_reasons=("tool_calls",) + ) + assert _deployments_hit(wire) == ("primary", "primary") + + +@pytest.mark.parametrize("client", _CLIENTS) +def test_request_num_retries_overrides_the_router_budget(client: str) -> None: + with wire_server(_always(_DIES_BEFORE_CONTENT)) as wire: + router: Final = _router(wire, ("primary",), num_retries=3, retry_after=0) + with pytest.raises(litellm.APIConnectionError, match="overloaded"): + _stream(client, router, num_retries=1) + assert _deployments_hit(wire) == ("primary", "primary") + + +@pytest.mark.parametrize("client", _CLIENTS) +@pytest.mark.parametrize("failure", (_DIES_BEFORE_CONTENT, _DROPS_BEFORE_CONTENT), ids=("error-frame", "dropped")) +def test_every_stream_dies_before_content_spends_exactly_the_budget(client: str, failure: Reply) -> None: + num_retries: Final = 2 + with wire_server(_always(failure)) as wire: + router: Final = _router( + wire, ("primary", "backup"), num_retries=num_retries, fallbacks=_BOTH_WAYS, retry_after=0 + ) + with pytest.raises(litellm.APIConnectionError): + _stream(client, router) + assert _deployments_hit(wire) == ("primary",) * (num_retries + 1) + ("backup",) * (num_retries + 1) + + +@pytest.mark.parametrize("client", _CLIENTS) +def test_every_request_rejected_before_streaming_spends_a_bounded_budget(client: str) -> None: + num_retries: Final = 1 + with wire_server(_always(_REJECTS_BEFORE_STREAMING)) as wire: + router: Final = _router( + wire, ("primary", "backup"), num_retries=num_retries, fallbacks=_BOTH_WAYS, retry_after=0 + ) + with pytest.raises(litellm.InternalServerError, match="overloaded"): + _stream(client, router) + hit: Final = _deployments_hit(wire) + assert hit == ("primary",) * (num_retries + 1) + ("backup",) * (num_retries + 1), hit diff --git a/tests/unit/llms/bedrock/chat/invoke_transformations/test_base_invoke_transformation.py b/tests/unit/llms/bedrock/chat/invoke_transformations/test_base_invoke_transformation.py index d9b01c4e75a..1890c68b30d 100644 --- a/tests/unit/llms/bedrock/chat/invoke_transformations/test_base_invoke_transformation.py +++ b/tests/unit/llms/bedrock/chat/invoke_transformations/test_base_invoke_transformation.py @@ -343,15 +343,18 @@ def test_router_deployment_stream_chunk_size_reaches_iter_bytes( client: Final = HTTPHandler() client.post = MagicMock(return_value=mock_response) router: Final = litellm.Router( - model_list=[{"model_name": "invoke-chunked", "litellm_params": {**INVOKE_DEPLOYMENT, **deployment_extras}}] + model_list=[{"model_name": "invoke-chunked", "litellm_params": {**INVOKE_DEPLOYMENT, **deployment_extras}}], + num_retries=0, ) - router.completion( - model="invoke-chunked", - messages=[{"role": "user", "content": "hi"}], - stream=True, - client=client, - ) + # the empty body is a stream that ends before its first chunk, which the Router now reports from the call + with pytest.raises(litellm.BadGatewayError): + router.completion( + model="invoke-chunked", + messages=[{"role": "user", "content": "hi"}], + stream=True, + client=client, + ) mock_response.iter_bytes.assert_called_once_with(chunk_size=expected_chunk_size) data: Final = client.post.call_args.kwargs["data"] diff --git a/tests/unit/llms/chat/test_converse_handler.py b/tests/unit/llms/chat/test_converse_handler.py index 6279b74bc0d..d0360b07957 100644 --- a/tests/unit/llms/chat/test_converse_handler.py +++ b/tests/unit/llms/chat/test_converse_handler.py @@ -417,15 +417,18 @@ def test_router_deployment_stream_chunk_size_reaches_iter_bytes( client: Final = HTTPHandler() client.post = MagicMock(return_value=mock_response) router: Final = litellm.Router( - model_list=[{"model_name": "converse-chunked", "litellm_params": {**CONVERSE_DEPLOYMENT, **deployment_extras}}] + model_list=[{"model_name": "converse-chunked", "litellm_params": {**CONVERSE_DEPLOYMENT, **deployment_extras}}], + num_retries=0, ) - router.completion( - model="converse-chunked", - messages=[{"role": "user", "content": "hi"}], - stream=True, - client=client, - ) + # the empty body is a stream that ends before its first chunk, which the Router now reports from the call + with pytest.raises(litellm.BadGatewayError): + router.completion( + model="converse-chunked", + messages=[{"role": "user", "content": "hi"}], + stream=True, + client=client, + ) mock_response.iter_bytes.assert_called_once_with(chunk_size=expected_chunk_size) data: Final = client.post.call_args.kwargs["data"] diff --git a/tests/unit/test_router/test_router.py b/tests/unit/test_router/test_router.py index 64f3f88cd89..5faea8d6c1d 100644 --- a/tests/unit/test_router/test_router.py +++ b/tests/unit/test_router/test_router.py @@ -1638,7 +1638,8 @@ def test_arouter_responses_api_bridge(): ) ## CONFIRM BRIDGE IS CALLED - with patch.object(litellm, "responses", return_value=AsyncMock()) as mock_responses: + # an empty stream: the Router reads the first chunk before returning, and a stream of mocks never produces one + with patch.object(litellm, "responses", return_value=iter([])) as mock_responses: result = router.completion( model="[IP-approved] o3-pro", messages=[{"role": "user", "content": "Hello, world!"}], @@ -4020,11 +4021,10 @@ async def test_acompletion_mid_stream_fallback_honors_a_per_request_fallbacks_no return _stream_for(**kwargs) with patch("litellm.acompletion", side_effect=fake_acompletion) as provider_calls: - response: Final = await router.acompletion( - model="primary", messages=[{"role": "user", "content": "hi"}], stream=True, fallbacks=None - ) with pytest.raises(litellm.InternalServerError, match="provider 500 from openai/primary-model"): - [chunk async for chunk in response] + await router.acompletion( + model="primary", messages=[{"role": "user", "content": "hi"}], stream=True, fallbacks=None + ) assert _groups_called(provider_calls) == ["primary"] @@ -16837,11 +16837,10 @@ async def test_acompletion_disable_fallbacks_reaches_the_mid_stream_hop(): return _MidStreamOptOutChatStream(_mid_stream_opt_out_trigger(primary_error), model=kwargs["model"]) with patch("litellm.acompletion", side_effect=primary_stream) as provider_calls: - response = await router.acompletion( - model="primary", messages=[{"role": "user", "content": "Hi"}], stream=True, disable_fallbacks=True - ) with pytest.raises(litellm.InternalServerError) as raised: - [chunk async for chunk in response] + await router.acompletion( + model="primary", messages=[{"role": "user", "content": "Hi"}], stream=True, disable_fallbacks=True + ) assert raised.value is primary_error assert [call.kwargs["metadata"]["model_group"] for call in provider_calls.call_args_list] == ["primary"] @@ -16856,11 +16855,10 @@ def test_completion_disable_fallbacks_reaches_the_mid_stream_hop(): return _MidStreamOptOutChatStream(_mid_stream_opt_out_trigger(primary_error), model=kwargs["model"]) with patch("litellm.completion", side_effect=primary_stream) as provider_calls: - response = router.completion( - model="primary", messages=[{"role": "user", "content": "Hi"}], stream=True, disable_fallbacks=True - ) with pytest.raises(litellm.InternalServerError) as raised: - list(response) + router.completion( + model="primary", messages=[{"role": "user", "content": "Hi"}], stream=True, disable_fallbacks=True + ) assert raised.value is primary_error assert [call.kwargs["metadata"]["model_group"] for call in provider_calls.call_args_list] == ["primary"] @@ -26135,3 +26133,98 @@ async def test_batch_completion_fastest_response_streams_mocked_chunks() -> None assert text == "stream answer" assert chunks[-1].choices[0].finish_reason == "stop" + + +class _FirstPullStream: + """A stream whose first pull yields `first`, raises it, or ends (first=None).""" + + def __init__(self, first: object) -> None: + self._first = first + + def _pull(self, stop: type[Exception]) -> object: + if self._first is None: + raise stop + if isinstance(self._first, Exception): + raise self._first + return self._first + + def __next__(self) -> object: + return self._pull(StopIteration) + + async def __anext__(self) -> object: + return self._pull(StopAsyncIteration) + + +def _first_chunk_cases(): + from litellm.exceptions import MidStreamFallbackError + from litellm.router import _FirstChunk + from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices + + chunk = ModelResponseStream(choices=[StreamingChoices(delta=Delta(content="hi"))]) + provider_error = litellm.InternalServerError(message="primary died at stream start", llm_provider="openai", model="m") + wrapped = MidStreamFallbackError( + message=str(provider_error), + model="m", + llm_provider="openai", + original_exception=provider_error, + is_pre_first_chunk=True, + ) + return [ + pytest.param(chunk, _FirstChunk(chunk=chunk), None, id="first-chunk"), + pytest.param(None, _FirstChunk(chunk=None), None, id="empty-stream"), + pytest.param(wrapped, None, provider_error, id="pre-content-failure"), + ] + + +@pytest.mark.parametrize("first, expected, raised", _first_chunk_cases()) +def test_read_first_chunk_direct_call(first, expected, raised): + from litellm.router import _read_first_chunk + + if raised is None: + assert _read_first_chunk(_FirstPullStream(first)) == expected + return + with pytest.raises(type(raised)) as caught: + _read_first_chunk(_FirstPullStream(first)) + assert caught.value is raised + + +@pytest.mark.asyncio +@pytest.mark.parametrize("first, expected, raised", _first_chunk_cases()) +async def test_aread_first_chunk_direct_call(first, expected, raised): + from litellm.router import _aread_first_chunk + + if raised is None: + assert await _aread_first_chunk(_FirstPullStream(first)) == expected + return + with pytest.raises(type(raised)) as caught: + await _aread_first_chunk(_FirstPullStream(first)) + assert caught.value is raised + + +def _chunks_with_first_cases(): + from litellm.router import _FirstChunk + + return [ + pytest.param(None, ["a", "b"], ["a", "b"], id="nothing-read-stream-as-is"), + pytest.param(_FirstChunk(chunk=None), ["a", "b"], [], id="empty-stream-not-touched"), + pytest.param(_FirstChunk(chunk="first"), ["a", "b"], ["first", "a", "b"], id="first-chunk-put-back"), + ] + + +@pytest.mark.parametrize("taken, rest, expected", _chunks_with_first_cases()) +def test_chunks_with_first_direct_call(taken, rest, expected): + from litellm.router import _chunks_with_first + + assert list(_chunks_with_first(iter(rest), taken)) == expected + + +@pytest.mark.asyncio +@pytest.mark.parametrize("taken, rest, expected", _chunks_with_first_cases()) +async def test_achunks_with_first_direct_call(taken, rest, expected): + from litellm.router import _achunks_with_first + + async def stream(): + for item in rest: + yield item + + assert [item async for item in _achunks_with_first(stream(), taken)] == expected From f10134728deb227277cf12ba59fec8a79b880b19 Mon Sep 17 00:00:00 2001 From: Kerry Lu Date: Wed, 14 Oct 2026 14:29:54 -0700 Subject: [PATCH 04/25] fix(caching): report Redis circuit-breaker metrics through the Prometheus services logger (#46484) * fix(caching): collect the Redis circuit-breaker state gauge as one series per state across worker processes The gauge declares multiprocess_mode=livesum, so PROMETHEUS_MULTIPROC_DIR apps get one series per state summed over the live workers instead of one series per worker pid Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(caching): register Redis circuit-breaker metrics only when LiteLLM prometheus logging is on The breaker collectors join the default registry once the prometheus or prometheus_system callback is configured or a Prometheus logger starts, with earlier breaker events tallied and seeded in, and litellm.disable_redis_circuit_breaker_metrics turns them off for apps that export their own metrics Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(caching): drop the disable_redis_circuit_breaker_metrics opt-out Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(caching): keep the Redis breaker metrics gate to a per-state count and a publish hook Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(caching): pin the Redis breaker metrics gate against an injected registry Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(caching): publish the breaker collectors before reading them in the existing metrics test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(caching): report Redis circuit-breaker metrics through the Prometheus services logger Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(integrations): build the Redis breaker collectors with typed prometheus_client constructors Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/_service_logger.py | 5 + litellm/caching/redis_cache.py | 96 ++++++------------- litellm/integrations/prometheus_services.py | 59 +++++++++++- .../_support/redis_breaker_worker.py | 18 +++- ..._redis_breaker_metrics_multiprocess_sdk.py | 29 +++++- tests/unit/caching/test_redis_cache.py | 33 +++++-- ...test_prometheus_metric_name_consistency.py | 12 +-- 7 files changed, 162 insertions(+), 90 deletions(-) diff --git a/litellm/_service_logger.py b/litellm/_service_logger.py index 6385aa96272..060b4fb878e 100644 --- a/litellm/_service_logger.py +++ b/litellm/_service_logger.py @@ -152,6 +152,11 @@ class ServiceLogging(CustomLogger): except Exception as e: verbose_logger.exception("Error dispatching service event - %s", e) + def redis_breaker_hook(self, transitions_to: str | None, failure_class: str | None) -> None: + prometheus_logger: Final[PrometheusServicesLogger | None] = getattr(self, "prometheusServicesLogger", None) + if prometheus_logger is not None: + prometheus_logger.redis_breaker_hook(transitions_to=transitions_to, failure_class=failure_class) + def service_success_hook( self, service: ServiceTypes, diff --git a/litellm/caching/redis_cache.py b/litellm/caching/redis_cache.py index 0e34709d128..b3715333010 100644 --- a/litellm/caching/redis_cache.py +++ b/litellm/caching/redis_cache.py @@ -18,7 +18,9 @@ import json import logging import threading import time -from collections.abc import Awaitable, Callable, Iterator, Sequence +import weakref +from collections import Counter +from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence from contextvars import ContextVar from dataclasses import dataclass from datetime import timedelta @@ -51,12 +53,12 @@ from .base_cache import BaseCache if TYPE_CHECKING: from opentelemetry.trace import Span as _Span - from prometheus_client import Counter as _PromCounter - from prometheus_client import Gauge as _PromGauge from redis.asyncio import Redis, RedisCluster from redis.asyncio.client import Pipeline from redis.asyncio.cluster import ClusterPipeline + from litellm._service_logger import ServiceLogging + pipeline = Pipeline cluster_pipeline = ClusterPipeline async_redis_client = Redis @@ -243,7 +245,9 @@ class RedisCircuitBreaker: recovery_timeout: int, enabled: bool = True, timeout_min_duration: float = REDIS_CIRCUIT_BREAKER_TIMEOUT_MIN_DURATION, + service_logger: "ServiceLogging | None" = None, ) -> None: + self._service_logger = service_logger self.failure_threshold = failure_threshold self.recovery_timeout = recovery_timeout self.enabled = enabled @@ -255,7 +259,12 @@ class RedisCircuitBreaker: self._opened_at: float | None = None self._state = self.CLOSED self._generation = 0 - _breaker_metrics().record_state_change(None, self._state) + _LIVE_BREAKERS.add(self) + self._report(transitions_to=None, failure_class=None) + + @property + def state(self) -> str: + return self._state @property def generation(self) -> int: @@ -301,7 +310,7 @@ class RedisCircuitBreaker: self._timeout_count = 0 self._timeout_streak_started_at = None self._opened_at = now - _breaker_metrics().record_failure("timeout" if is_timeout else "connectivity") + self._report(transitions_to=None, failure_class="timeout" if is_timeout else "connectivity") if self._should_open(now): if self._state != self.OPEN: verbose_logger.warning( @@ -327,10 +336,13 @@ class RedisCircuitBreaker: def _set_state(self, state: str) -> None: if state == self._state: return - _breaker_metrics().record_transition(state) - _breaker_metrics().record_state_change(self._state, state) self._state = state self._generation += 1 + self._report(transitions_to=state, failure_class=None) + + def _report(self, transitions_to: str | None, failure_class: str | None) -> None: + if self._service_logger is not None: + self._service_logger.redis_breaker_hook(transitions_to=transitions_to, failure_class=failure_class) _RedisCallResult = TypeVar("_RedisCallResult") @@ -430,60 +442,6 @@ def is_redis_timeout_failure(exc: BaseException) -> bool: return any(isinstance(link, timeout_types) for link in _explicit_causes(exc)) -class _BreakerMetrics: - """Prometheus metrics for the Redis circuit breaker; no-ops when the client is absent. - - Registered lazily on the default registry (which /metrics serves) via the module-level - ``_breaker_metrics`` singleton so repeated RedisCache construction never re-registers. - """ - - def __init__(self) -> None: - self._state_gauge: _PromGauge | None = None - self._transitions: _PromCounter | None = None - self._failures: _PromCounter | None = None - try: - from prometheus_client import Counter as PromCounter - from prometheus_client import Gauge - except ImportError: - return - self._state_gauge = Gauge( - "litellm_redis_circuit_breaker_state", - "Number of Redis circuit breakers currently in each state", - labelnames=("state",), - multiprocess_mode="livesum", - ) - self._transitions = PromCounter( - "litellm_redis_circuit_breaker_transitions", - "Redis circuit breaker state transitions", - labelnames=("state",), - ) - self._failures = PromCounter( - "litellm_redis_circuit_breaker_failures", - "Redis health failures counted by the circuit breaker", - labelnames=("failure_class",), - ) - - def record_state_change(self, old_state: str | None, new_state: str) -> None: - if self._state_gauge is None: - return - if old_state is not None: - self._state_gauge.labels(old_state).dec() - self._state_gauge.labels(new_state).inc() - - def record_transition(self, state: str) -> None: - if self._transitions is not None: - self._transitions.labels(state).inc() - - def record_failure(self, failure_class: str) -> None: - if self._failures is not None: - self._failures.labels(failure_class).inc() - - -@functools.lru_cache(maxsize=1) -def _breaker_metrics() -> _BreakerMetrics: - return _BreakerMetrics() - - def _record_swallowed_redis_failure(breaker: RedisCircuitBreaker, exc: BaseException) -> None: """Record a Redis failure that the calling method is about to swallow. @@ -499,6 +457,14 @@ def _record_swallowed_redis_failure(breaker: RedisCircuitBreaker, exc: BaseExcep _swallowed_redis_failures.set(_swallowed_redis_failures.get() + 1) +_LIVE_BREAKERS: Final[weakref.WeakSet[RedisCircuitBreaker]] = weakref.WeakSet() + + +def redis_breaker_state_counts() -> Mapping[str, int]: + """How many live Redis circuit breakers sit in each state, for the Prometheus services logger.""" + return MappingProxyType(dict(Counter(breaker.state for breaker in _LIVE_BREAKERS))) + + class RedisCircuitBreakerOpenError(Exception): pass @@ -685,12 +651,9 @@ class RedisCache(BaseCache): redis_kwargs["socket_timeout"] = socket_timeout ### HEALTH MONITORING OBJECT ### - if kwargs.get("service_logger_obj", None) is not None and isinstance( - kwargs["service_logger_obj"], ServiceLogging - ): + self.service_logger_obj: ServiceLogging = ServiceLogging() + if isinstance(kwargs.get("service_logger_obj"), ServiceLogging): self.service_logger_obj = kwargs.pop("service_logger_obj") - else: - self.service_logger_obj = ServiceLogging() redis_kwargs.update(kwargs) self.redis_client = get_redis_client(**redis_kwargs) @@ -717,6 +680,7 @@ class RedisCache(BaseCache): failure_threshold=REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD, recovery_timeout=REDIS_CIRCUIT_BREAKER_RECOVERY_TIMEOUT, enabled=REDIS_CIRCUIT_BREAKER_ENABLED, + service_logger=self.service_logger_obj, ) self._setup_health_pings() diff --git a/litellm/integrations/prometheus_services.py b/litellm/integrations/prometheus_services.py index d0db4888c50..42a3ffac855 100644 --- a/litellm/integrations/prometheus_services.py +++ b/litellm/integrations/prometheus_services.py @@ -3,7 +3,9 @@ # On success + failure, log events to Prometheus for litellm / adjacent services (litellm, redis, postgres, llm api providers) -from typing import Final +from dataclasses import dataclass +from functools import lru_cache +from typing import TYPE_CHECKING, Final import litellm from litellm._logging import print_verbose, verbose_logger @@ -17,6 +19,42 @@ from litellm.types.services import ( FAILED_REQUESTS_LABELS: Final = ["error_class", "function_name"] +if TYPE_CHECKING: + from prometheus_client import Counter as PrometheusCounter + from prometheus_client import Gauge + + +@dataclass(frozen=True, slots=True) +class _RedisBreakerCollectors: + state: "Gauge" + transitions: "PrometheusCounter" + failures: "PrometheusCounter" + + +@lru_cache(maxsize=1) +def _redis_breaker_collectors() -> _RedisBreakerCollectors: + from prometheus_client import Counter as PrometheusCounter + from prometheus_client import Gauge + + return _RedisBreakerCollectors( + state=Gauge( + "litellm_redis_circuit_breaker_state", + "Number of Redis circuit breakers currently in each state", + labelnames=("state",), + multiprocess_mode="livesum", + ), + transitions=PrometheusCounter( + "litellm_redis_circuit_breaker_transitions", + "Redis circuit breaker state transitions", + labelnames=("state",), + ), + failures=PrometheusCounter( + "litellm_redis_circuit_breaker_failures", + "Redis health failures counted by the circuit breaker", + labelnames=("failure_class",), + ), + ) + class PrometheusServicesLogger: # Class variables or attributes @@ -41,6 +79,11 @@ class PrometheusServicesLogger: self.Counter = Counter self.Gauge = Gauge self.REGISTRY = REGISTRY + breaker_collectors: Final = _redis_breaker_collectors() + self.redis_breaker_state = breaker_collectors.state + self.redis_breaker_transitions = breaker_collectors.transitions + self.redis_breaker_failures = breaker_collectors.failures + self._refresh_redis_breaker_gauge() verbose_logger.debug("in init prometheus services metrics") @@ -98,6 +141,20 @@ class PrometheusServicesLogger: return DEFAULT_METRICS return metrics + def _refresh_redis_breaker_gauge(self) -> None: + from litellm.caching.redis_cache import RedisCircuitBreaker, redis_breaker_state_counts + + counts: Final = redis_breaker_state_counts() + for state in (RedisCircuitBreaker.CLOSED, RedisCircuitBreaker.OPEN, RedisCircuitBreaker.HALF_OPEN): + self.redis_breaker_state.labels(state).set(counts.get(state, 0)) + + def redis_breaker_hook(self, transitions_to: str | None, failure_class: str | None) -> None: + self._refresh_redis_breaker_gauge() + if transitions_to is not None: + self.redis_breaker_transitions.labels(transitions_to).inc() + if failure_class is not None: + self.redis_breaker_failures.labels(failure_class).inc() + def is_metric_registered(self, metric_name) -> bool: # Use _names_to_collectors (O(1)) instead of REGISTRY.collect() (O(n)) to avoid # perf regression when a new Router is created per request (e.g. router_settings in DB). diff --git a/tests/integration/_support/redis_breaker_worker.py b/tests/integration/_support/redis_breaker_worker.py index f551baacf24..02d9c0ea2d5 100644 --- a/tests/integration/_support/redis_breaker_worker.py +++ b/tests/integration/_support/redis_breaker_worker.py @@ -11,10 +11,12 @@ import sys from collections.abc import Iterator, Mapping from dataclasses import dataclass from pathlib import Path -from typing import Final +from typing import Final, Literal, TypeAlias from pydantic import TypeAdapter +Scenario: TypeAlias = Literal["callbacks_before_cache", "callbacks_after_cache", "no_callbacks"] + STATE_GAUGE: Final = "litellm_redis_circuit_breaker_state" WORKER_TIMEOUT_SECONDS: Final = 120 @@ -29,9 +31,11 @@ class WorkerReport: _REPORT: Final = TypeAdapter(WorkerReport) -def run_breaker_worker(api_base: str, prom_dir: Path, environment: Mapping[str, str] = os.environ) -> WorkerReport: +def run_breaker_worker( + scenario: Scenario, api_base: str, prom_dir: Path, environment: Mapping[str, str] = os.environ +) -> WorkerReport: completed: Final = subprocess.run( - (sys.executable, "-I", __file__, api_base, environment["REDIS_HOST"], environment["REDIS_PORT"]), + (sys.executable, "-I", __file__, scenario, api_base, environment["REDIS_HOST"], environment["REDIS_PORT"]), env={**environment, "PROMETHEUS_MULTIPROC_DIR": str(prom_dir)}, capture_output=True, text=True, @@ -56,11 +60,15 @@ def _series(families: Iterator[object]) -> Iterator[tuple[tuple[tuple[str, str], yield tuple(sorted(sample.labels.items())), float(sample.value) -def main(api_base: str, redis_host: str, redis_port: str) -> None: +def main(scenario: str, api_base: str, redis_host: str, redis_port: str) -> None: import litellm from litellm.caching.caching import Cache + if scenario == "callbacks_before_cache": + litellm.service_callback = ["prometheus_system"] # test-quality-ok: this worker process exits right after the report litellm.cache = Cache(type="redis", host=redis_host, port=redis_port) # test-quality-ok: throwaway worker process + if scenario == "callbacks_after_cache": + litellm.service_callback = ["prometheus_system"] # test-quality-ok: throwaway worker process response: Final = litellm.completion( model="openai/gpt-5.6", api_base=api_base, @@ -76,4 +84,4 @@ def main(api_base: str, redis_host: str, redis_port: str) -> None: if __name__ == "__main__": - main(*sys.argv[1:4]) + main(*sys.argv[1:5]) diff --git a/tests/integration/sdk/test_redis_breaker_metrics_multiprocess_sdk.py b/tests/integration/sdk/test_redis_breaker_metrics_multiprocess_sdk.py index d1582dd59b9..bd02f2a818b 100644 --- a/tests/integration/sdk/test_redis_breaker_metrics_multiprocess_sdk.py +++ b/tests/integration/sdk/test_redis_breaker_metrics_multiprocess_sdk.py @@ -1,5 +1,6 @@ """An SDK app under a prefork worker pool (Celery, gunicorn) with ``PROMETHEUS_MULTIPROC_DIR`` set: the Redis -circuit-breaker state gauge must collect as one series per state across every worker, never one per worker pid.""" +circuit-breaker state gauge must collect as one series per state across every worker, and it must stay off +the registry entirely for an app that never turned on LiteLLM's own Prometheus logging.""" from __future__ import annotations @@ -24,6 +25,12 @@ _CHAT_RESPONSE: Final = json.dumps( } ).encode() _CLOSED: Final = (("state", "closed"),) +_HALF_OPEN: Final = (("state", "half_open"),) +_OPEN: Final = (("state", "open"),) + + +def _all_closed(breakers: float) -> tuple[tuple[tuple[tuple[str, str], ...], float], ...]: + return ((_CLOSED, breakers), (_HALF_OPEN, 0.0), (_OPEN, 0.0)) def _reply(request: Request) -> Reply: @@ -52,6 +59,22 @@ def _worked(report: WorkerReport) -> WorkerReport: def test_two_worker_processes_collect_as_one_state_series_per_state(tmp_path: Path) -> None: with wire_server(_reply) as wire: - reports: Final = tuple(_worked(run_breaker_worker(wire.url, tmp_path)) for _ in range(2)) + reports: Final = tuple( + _worked(run_breaker_worker("callbacks_before_cache", wire.url, tmp_path)) for _ in range(2) + ) assert len({report.pid for report in reports}) == 2, reports - assert _collected_state_series(tmp_path) == ((_CLOSED, 2.0),), reports + assert _collected_state_series(tmp_path) == _all_closed(2.0), reports + + +def test_prometheus_logging_turned_on_after_the_cache_still_reports_the_closed_breaker(tmp_path: Path) -> None: + with wire_server(_reply) as wire: + report: Final = _worked(run_breaker_worker("callbacks_after_cache", wire.url, tmp_path)) + assert report.state_series == _all_closed(1.0), report + assert _collected_state_series(tmp_path) == _all_closed(1.0), report + + +def test_app_without_litellm_prometheus_logging_gets_no_breaker_series(tmp_path: Path) -> None: + with wire_server(_reply) as wire: + report: Final = _worked(run_breaker_worker("no_callbacks", wire.url, tmp_path)) + assert report.state_series == (), report + assert _collected_state_series(tmp_path) == (), report diff --git a/tests/unit/caching/test_redis_cache.py b/tests/unit/caching/test_redis_cache.py index 0a041694d52..7cffa62d423 100644 --- a/tests/unit/caching/test_redis_cache.py +++ b/tests/unit/caching/test_redis_cache.py @@ -971,18 +971,17 @@ async def test_hard_failure_resets_timeout_streak_so_a_later_burst_must_earn_its @pytest.mark.asyncio -async def test_breaker_metrics_track_state_and_failure_class(): +async def test_breaker_metrics_track_state_and_failure_class(monkeypatch): """Breaker accounting must be observable: failure class, transitions, and state.""" from prometheus_client import REGISTRY from redis.exceptions import ConnectionError as RedisConnectionError from redis.exceptions import TimeoutError as RedisTimeoutError - from litellm.caching.redis_cache import RedisCircuitBreaker, _breaker_metrics, is_redis_timeout_failure + from litellm._service_logger import ServiceLogging + from litellm.caching.redis_cache import RedisCircuitBreaker, is_redis_timeout_failure - metrics: Final = _breaker_metrics() - for collector in (metrics._state_gauge, metrics._transitions, metrics._failures): - if collector is not None and collector not in REGISTRY._collector_to_names: - REGISTRY.register(collector) + monkeypatch.setattr(litellm, "service_callback", ["prometheus_system"]) + service_logger: Final = ServiceLogging() def sample(name, labels=None): return REGISTRY.get_sample_value(name, labels) or 0.0 @@ -993,7 +992,9 @@ async def test_breaker_metrics_track_state_and_failure_class(): open_gauge_before = sample("litellm_redis_circuit_breaker_state", {"state": "open"}) closed_gauge_before = sample("litellm_redis_circuit_breaker_state", {"state": "closed"}) - breaker = RedisCircuitBreaker(failure_threshold=2, recovery_timeout=60, timeout_min_duration=5.0) + breaker = RedisCircuitBreaker( + failure_threshold=2, recovery_timeout=60, timeout_min_duration=5.0, service_logger=service_logger + ) breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisTimeoutError("t"))) breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisConnectionError("refused"))) breaker.record_failure(is_timeout=is_redis_timeout_failure(RedisConnectionError("refused"))) @@ -1011,6 +1012,24 @@ async def test_breaker_metrics_track_state_and_failure_class(): assert sample("litellm_redis_circuit_breaker_state", {"state": "closed"}) == closed_gauge_before + 1 +def test_prometheus_services_logger_starts_from_the_breakers_already_built(monkeypatch): + """A breaker built before prometheus_system is on shows up in the state gauge once the services logger starts.""" + from prometheus_client import REGISTRY + + from litellm._service_logger import ServiceLogging + from litellm.caching.redis_cache import RedisCircuitBreaker, redis_breaker_state_counts + + monkeypatch.setattr(litellm, "service_callback", []) + early: Final = RedisCircuitBreaker(failure_threshold=3, recovery_timeout=60, service_logger=ServiceLogging()) + assert early.is_open() is False + + monkeypatch.setattr(litellm, "service_callback", ["prometheus_system"]) + ServiceLogging() + assert REGISTRY.get_sample_value("litellm_redis_circuit_breaker_state", {"state": "closed"}) == ( + redis_breaker_state_counts()["closed"] + ) + + def test_sync_guard_counts_a_timeout_as_a_timeout(): from redis.exceptions import TimeoutError as RedisTimeoutError diff --git a/tests/unit/integrations/test_prometheus_metric_name_consistency.py b/tests/unit/integrations/test_prometheus_metric_name_consistency.py index d648afcd087..4db34274e82 100644 --- a/tests/unit/integrations/test_prometheus_metric_name_consistency.py +++ b/tests/unit/integrations/test_prometheus_metric_name_consistency.py @@ -21,7 +21,6 @@ from prometheus_client import REGISTRY, Gauge from prometheus_client.registry import Collector import litellm -from litellm.caching.redis_cache import _breaker_metrics from litellm.integrations.prometheus import PrometheusLogger from litellm.integrations.prometheus_services import PrometheusServicesLogger from litellm.proxy.db.db_transaction_queue.spend_log_cleanup_metrics import SpendLogCleanupMetrics @@ -62,10 +61,7 @@ def _lazy_owner_collectors() -> tuple[Collector, ...]: assert SpendLogCleanupMetrics.runs is not None in_flight: Final = InFlightRequestsMiddleware._get_gauge() assert in_flight is not None - breaker: Final = _breaker_metrics() - assert breaker._state_gauge is not None - assert breaker._transitions is not None - assert breaker._failures is not None + services: Final = PrometheusServicesLogger() return ( SpendLogCleanupMetrics.rows_deleted, SpendLogCleanupMetrics.batch_duration, @@ -73,9 +69,9 @@ def _lazy_owner_collectors() -> tuple[Collector, ...]: SpendLogCleanupMetrics.batch_failures, SpendLogCleanupMetrics.runs, in_flight, - breaker._state_gauge, - breaker._transitions, - breaker._failures, + services.redis_breaker_state, + services.redis_breaker_transitions, + services.redis_breaker_failures, ) From 47e4ed88c2d89a2ecd9cb79be2a1ea0829642b87 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:42:49 -0700 Subject: [PATCH 05/25] fix(anthropic): keep refusal stop_reason and stop_details on the logged response (#46104) * fix(anthropic): keep refusal stop_reason and stop_details on the logged response * fix(anthropic): keep the refusal stop-fields boundary tolerant of a non-dict upstream body * test(anthropic): use Anthropic's documented refusal example as the unit-test fixture * fix(streaming): keep provider fields on async stream cache hits and received_model_id on synthesized finish chunks * fix(caching): replay cached completion_tokens_details on stream cache hits and skip non-object cached provider fields * test(integration): cover Anthropic-family refusal stop_details on the wire, the cache and under chaos * fix(caching): skip a cached completion_tokens_details that fails validation on stream replay * fix(anthropic): keep refusal stop fields on a JSON-mode non-stream response Also adds the round 3 audit cells for JSON-mode refusals, the proxy response serialization, and wrong-typed cached usage details on both stream replays * test(anthropic): expect content_filter on streamed JSON tool refusals after main's stream change --------- Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com> --- .../convert_dict_to_response.py | 40 +- .../litellm_core_utils/streaming_handler.py | 6 +- litellm/llms/anthropic/chat/handler.py | 11 +- litellm/llms/anthropic/chat/transformation.py | 12 +- litellm/llms/anthropic/common_utils.py | 37 +- .../integration/_support/anthropic_refusal.py | 533 +++++++ .../caching/response_cache_case.py | 23 + ...st_anthropic_refusal_stop_details_chaos.py | 337 ++++ ...est_anthropic_refusal_stop_details_wire.py | 1356 +++++++++++++++++ .../sdk/test_response_cache_sync_sdk.py | 82 + .../test_convert_to_streaming_response.py | 70 +- .../test_streaming_handler.py | 36 + .../llms/anthropic/anthropic_docs_refusal.py | 19 + .../chat/test_anthropic_chat_handler.py | 48 + .../test_anthropic_chat_transformation.py | 91 ++ ...ations_anthropic_claude3_transformation.py | 23 +- ...partner_models_anthropic_transformation.py | 22 + ...t_anthropic_passthrough_logging_handler.py | 56 + 18 files changed, 2789 insertions(+), 13 deletions(-) create mode 100644 tests/integration/_support/anthropic_refusal.py create mode 100644 tests/integration/providers/test_anthropic_refusal_stop_details_chaos.py create mode 100644 tests/integration/providers/test_anthropic_refusal_stop_details_wire.py create mode 100644 tests/unit/llms/anthropic/anthropic_docs_refusal.py diff --git a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py index 6f16e1e3fb4..ad33c47fc51 100644 --- a/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py +++ b/litellm/litellm_core_utils/llm_response_utils/convert_dict_to_response.py @@ -6,7 +6,7 @@ import traceback from collections.abc import Mapping, Sequence from typing import Final, Literal, cast -from pydantic import ConfigDict, TypeAdapter, with_config +from pydantic import ConfigDict, TypeAdapter, ValidationError, with_config from typing_extensions import ReadOnly, TypedDict import litellm @@ -222,6 +222,9 @@ async def convert_to_streaming_response_async( role=choice["message"]["role"], function_call=choice["message"].get("function_call", None), tool_calls=choice["message"].get("tool_calls", None), + provider_specific_fields=_cached_provider_specific_fields( + _CACHED_MESSAGE_PROVIDER_FIELDS.validate_python(choice["message"]) + ), ) finish_reason = choice.get("finish_reason", None) @@ -243,6 +246,9 @@ async def convert_to_streaming_response_async( completion_tokens=response_object["usage"].get("completion_tokens", 0), prompt_tokens=response_object["usage"].get("prompt_tokens", 0), total_tokens=response_object["usage"].get("total_tokens", 0), + completion_tokens_details=_cached_completion_tokens_details( + _CACHED_USAGE_DETAILS.validate_python(response_object["usage"]) + ), ), ) @@ -334,6 +340,9 @@ def convert_to_streaming_response( model_response_object.usage.completion_tokens = response_object["usage"].get("completion_tokens", 0) model_response_object.usage.prompt_tokens = response_object["usage"].get("prompt_tokens", 0) model_response_object.usage.total_tokens = response_object["usage"].get("total_tokens", 0) + model_response_object.usage.completion_tokens_details = _cached_completion_tokens_details( + _CACHED_USAGE_DETAILS.validate_python(response_object["usage"]) + ) if "id" in response_object: model_response_object.id = response_object["id"] @@ -390,6 +399,35 @@ class _ParallelToolUseArgs(TypedDict): _PARALLEL_TOOL_USE_ARGS: Final = TypeAdapter(_ParallelToolUseArgs) +class _CachedMessageProviderFields(TypedDict, total=False): + provider_specific_fields: ReadOnly[object] + + +class _CachedUsageDetails(TypedDict, total=False): + completion_tokens_details: ReadOnly[object] + + +_CACHED_MESSAGE_PROVIDER_FIELDS: Final = TypeAdapter(_CachedMessageProviderFields) +_CACHED_USAGE_DETAILS: Final = TypeAdapter(_CachedUsageDetails) +_FIELD_MAP: Final = TypeAdapter(dict[str, object]) +_COMPLETION_TOKENS_DETAILS: Final = TypeAdapter(CompletionTokensDetailsWrapper) + + +def _cached_provider_specific_fields(message: _CachedMessageProviderFields) -> Mapping[str, object] | None: + fields: Final = message.get("provider_specific_fields") + return _FIELD_MAP.validate_python(fields) if isinstance(fields, dict) else None + + +def _cached_completion_tokens_details(usage: _CachedUsageDetails) -> CompletionTokensDetailsWrapper | None: + details: Final = usage.get("completion_tokens_details") + if not isinstance(details, dict): + return None + try: + return _COMPLETION_TOKENS_DETAILS.validate_python(details) + except ValidationError: + return None + + def handle_invalid_parallel_tool_calls( tool_calls: list[ChatCompletionMessageToolCall | ChatCompletionMessageCustomToolCall], ) -> list[ChatCompletionMessageToolCall | ChatCompletionMessageCustomToolCall] | None: diff --git a/litellm/litellm_core_utils/streaming_handler.py b/litellm/litellm_core_utils/streaming_handler.py index 236286a493b..484978bae04 100644 --- a/litellm/litellm_core_utils/streaming_handler.py +++ b/litellm/litellm_core_utils/streaming_handler.py @@ -280,6 +280,7 @@ class CustomStreamWrapper: self._response_headers = _response_headers self.response_id: str | None = None + self.received_model_id: str | None = None self.logging_loop = None self.rules = Rules() self.stream_options = stream_options or getattr(logging_obj, "stream_options", None) @@ -838,6 +839,7 @@ class CustomStreamWrapper: self.response_id = id if id and isinstance(id, str) and id.strip(): + self.received_model_id = id model_response.hidden_params["received_model_id"] = id if self.response_id is not None and isinstance(self.response_id, str): @@ -1756,7 +1758,9 @@ class CustomStreamWrapper: self.logging_obj.success_handler(processed_chunk, None, None, cache_hit) def finish_reason_handler(self): - model_response: Final = self.model_response_creator() + model_response: Final = self.model_response_creator( + hidden_params=None if self.received_model_id is None else {"received_model_id": self.received_model_id} + ) _finish_reason: Final = self.received_finish_reason or self.intermittent_finish_reason if _finish_reason is not None: model_response.choices[0].finish_reason = _finish_reason diff --git a/litellm/llms/anthropic/chat/handler.py b/litellm/llms/anthropic/chat/handler.py index dff10473ae7..4e5b761b9a3 100644 --- a/litellm/llms/anthropic/chat/handler.py +++ b/litellm/llms/anthropic/chat/handler.py @@ -66,7 +66,7 @@ from litellm.types.utils import ( ) from ...base import BaseLLM -from ..common_utils import AnthropicError, process_anthropic_headers +from ..common_utils import AnthropicError, process_anthropic_headers, refusal_provider_specific_fields from .transformation import ANTHROPIC_TOOL_NAME_REVERSE_MAP_KEY, AnthropicConfig, JsonModeRequest if TYPE_CHECKING: @@ -1102,7 +1102,8 @@ class ModelResponseIterator: # These are automatically handled by Anthropic API, we just pass them through pass elif type_chunk == "message_delta": - finish_reason, usage, container = self._handle_message_delta(chunk) + finish_reason, usage, container, refusal_fields = self._handle_message_delta(chunk) + provider_specific_fields.update(refusal_fields) text = self._release_answer_close() + self._drain_split_answer_blocks() if container: provider_specific_fields["container"] = container @@ -1297,7 +1298,9 @@ class ModelResponseIterator: return "" return _split_answer_text(blocks, self.response_format_schema) - def _handle_message_delta(self, chunk: dict) -> tuple[str, Usage | None, dict[str, Any] | None]: + def _handle_message_delta( + self, chunk: dict + ) -> tuple[str, Usage | None, dict[str, Any] | None, Mapping[str, object]]: """ Handle message_delta event for finish_reason, usage, and container. @@ -1319,7 +1322,7 @@ class ModelResponseIterator: self._handle_usage(anthropic_usage_chunk=message_delta["usage"]) if "usage" in message_delta else None ) container: Final = message_delta["delta"].get("container") - return finish_reason, usage, container + return finish_reason, usage, container, refusal_provider_specific_fields(message_delta["delta"]) def _handle_accumulated_json_chunk(self, data_str: str, is_final: bool = False) -> ModelResponseStream | None: """ diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index f4e50bf3acd..487f231e1db 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -104,10 +104,12 @@ from litellm.utils import ( ) from ..common_utils import ( + REFUSAL_STOP_FIELDS, AnthropicError, AnthropicModelInfo, eager_input_streaming_flag, process_anthropic_headers, + refusal_provider_specific_fields, requires_native_compaction_beta, strip_advisor_blocks_from_messages, ) @@ -2723,7 +2725,10 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): if compaction_blocks is not None: provider_specific_fields["compaction_blocks"] = compaction_blocks - return provider_specific_fields + refusal_fields: Final = refusal_provider_specific_fields( + REFUSAL_STOP_FIELDS.validate_python(completion_response) + ) + return {**provider_specific_fields, **refusal_fields} def transform_parsed_response( self, @@ -2808,7 +2813,12 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): _message.provider_specific_fields = provider_specific_fields if json_mode_message is not None: + refusal_fields: Final = refusal_provider_specific_fields( + REFUSAL_STOP_FIELDS.validate_python(completion_response) + ) completion_response["stop_reason"] = "stop" + if refusal_fields: + json_mode_message.provider_specific_fields = dict(refusal_fields) _message = json_mode_message model_response.choices[0].message = _message diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 47a82aa097c..dbcb7ea4962 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -8,11 +8,12 @@ import re from collections.abc import Mapping, MutableMapping, Sequence from datetime import datetime, timezone from types import MappingProxyType -from typing import Any, ClassVar, Final, Literal, TypeVar +from typing import Annotated, Any, ClassVar, Final, Literal, TypeVar from urllib.parse import quote, urlparse import httpx -from pydantic import ConfigDict, Field, StrictBool, TypeAdapter, ValidationError +from pydantic import BeforeValidator, ConfigDict, Field, StrictBool, TypeAdapter, ValidationError +from typing_extensions import ReadOnly, TypedDict import litellm from litellm.constants import ( @@ -2150,3 +2151,35 @@ def create_anthropic_model_list_response( "first_id": data[0]["id"] if data else None, "last_id": data[-1]["id"] if data else None, } + + +class RefusalStopFields(TypedDict, total=False): + stop_reason: ReadOnly[object] + stop_details: ReadOnly[object] + + +def _is_dict(value: object) -> bool: + return isinstance(value, dict) + + +def _no_stop_fields() -> Mapping[str, object]: + return {} + + +def _dict_or_empty(value: object) -> object: + return value if _is_dict(value) else _no_stop_fields() + + +REFUSAL_STOP_FIELDS: Final[TypeAdapter[RefusalStopFields]] = TypeAdapter( + Annotated[RefusalStopFields, BeforeValidator(_dict_or_empty)] +) + + +def refusal_provider_specific_fields(message: Mapping[str, object]) -> Mapping[str, object]: + stop_reason: Final = message.get("stop_reason") + stop_details: Final = message.get("stop_details") + if stop_details is not None: + return {"stop_reason": stop_reason, "stop_details": stop_details} + if stop_reason == "refusal": + return {"stop_reason": stop_reason} + return {} diff --git a/tests/integration/_support/anthropic_refusal.py b/tests/integration/_support/anthropic_refusal.py new file mode 100644 index 00000000000..6d5fd236074 --- /dev/null +++ b/tests/integration/_support/anthropic_refusal.py @@ -0,0 +1,533 @@ +import hashlib +import json +import os +import re +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from itertools import chain +from pathlib import Path +from types import MappingProxyType +from typing import Final, TypeAlias + +import yaml +from integration._support.anthropic_thinking import ( + JSON_LIST, + JSON_OBJECT, + Event, + marker_of, + stream_reply, + streams, +) +from integration._support.client import eventually +from integration._support.wire import Reply, Request, Wire +from pydantic import JsonValue, TypeAdapter +from redis import Redis + +DOCS_REFUSAL_URL: Final = "https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback#refusal-response" +DOCS_STREAMING_REFUSAL_URL: Final = ( + "https://platform.claude.com/docs/en/test-and-evaluate/strengthen-guardrails/handle-streaming-refusals" +) + +# Copied verbatim from DOCS_REFUSAL_URL and DOCS_STREAMING_REFUSAL_URL on 2026-10-12. +DOCS_REFUSAL: Final[Event] = { + "id": "msg_01XFUDYJgAACzvnptvVoYEL", + "type": "message", + "role": "assistant", + "model": "claude-fable-5", + "content": [], + "stop_reason": "refusal", + "stop_details": { + "type": "refusal", + "category": "cyber", + "explanation": "This request was declined because it could enable cyber harm.", + }, + "usage": {"input_tokens": 412, "output_tokens": 0}, +} +DOCS_PARTIAL_REFUSAL: Final[Event] = { + "role": "assistant", + "content": [{"type": "text", "text": "Hello.."}], + "stop_reason": "refusal", + "stop_details": { + "type": "refusal", + "category": "cyber", + "explanation": "This request was declined because it could enable cyber harm.", + }, +} + +# Captured from api.anthropic.com (claude-sonnet-5-5, prompt "Reply with the single word: ready") on 2026-10-13. +CAPTURED_TURN: Final[Event] = { + "model": "claude-sonnet-5-5", + "id": "msg_011CfyXFGze1cDkk8o7gDRRS", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "ready"}], + "container": None, + "stop_reason": "end_turn", + "stop_sequence": None, + "stop_details": None, + "usage": { + "input_tokens": 18, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, + "output_tokens": 4, + "output_tokens_details": {"thinking_tokens": 0}, + "service_tier": "standard", + "inference_geo": "global", + }, + "diagnostics": None, +} +CAPTURED_EVENTS: Final[tuple[Event, ...]] = ( + { + "type": "message_start", + "message": { + "model": "claude-sonnet-5-5", + "id": "msg_011CfyXFLwXUxsp7nNAm3gcu", + "type": "message", + "role": "assistant", + "content": [], + "container": None, + "stop_reason": None, + "stop_sequence": None, + "stop_details": None, + "usage": { + "input_tokens": 18, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "cache_creation": {"ephemeral_5m_input_tokens": 0, "ephemeral_1h_input_tokens": 0}, + "output_tokens": 4, + "service_tier": "standard", + "inference_geo": "global", + }, + "diagnostics": None, + }, + }, + {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}}, + {"type": "ping"}, + {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "ready"}}, + {"type": "content_block_stop", "index": 0}, + { + "type": "message_delta", + "delta": {"stop_reason": "end_turn", "stop_sequence": None, "stop_details": None, "container": None}, + "usage": { + "input_tokens": 18, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 4, + "output_tokens_details": {"thinking_tokens": 0}, + }, + }, + {"type": "message_stop"}, +) + +DOCS_STOP_DETAILS: Final = DOCS_REFUSAL["stop_details"] +REFUSAL_FIELDS: Final[Event] = {"stop_reason": "refusal", "stop_details": DOCS_STOP_DETAILS} +STOP_FIELDS: Final = ("stop_reason", "stop_details") +LONG_STOP_DETAILS: Final = "d" * 5120 +MALFORMED_STOP_DETAILS: Final[Mapping[str, JsonValue]] = MappingProxyType( + {"int": 7, "list": ["refusal"], "empty": "", "long": LONG_STOP_DETAILS} +) +_FIXTURE: Final = re.compile(r"refusal-fixture-([a-z]+)") + +Location: TypeAlias = tuple[str | int, ...] + + +@dataclass(frozen=True, slots=True) +class Fixture: + status: int + body: bytes + events: tuple[Event, ...] = () + + +def _text_block(index: int, block: JsonValue) -> tuple[Event, ...]: + text: Final = JSON_OBJECT.validate_python(block)["text"] + return ( + {"type": "content_block_start", "index": index, "content_block": {"type": "text", "text": ""}}, + {"type": "content_block_delta", "index": index, "delta": {"type": "text_delta", "text": text}}, + {"type": "content_block_stop", "index": index}, + ) + + +def refusal_events(message: Mapping[str, JsonValue]) -> tuple[Event, ...]: + usage: Final = JSON_OBJECT.validate_python(message["usage"]) + start: Final[Event] = { + "type": "message_start", + "message": { + "id": message["id"], + "type": "message", + "role": "assistant", + "model": message["model"], + "content": [], + "stop_reason": None, + "stop_sequence": None, + "stop_details": None, + "usage": usage, + }, + } + blocks: Final = chain.from_iterable( + _text_block(index, block) for index, block in enumerate(JSON_LIST.validate_python(message["content"])) + ) + delta: Final[Event] = { + "type": "message_delta", + "delta": { + "stop_reason": message["stop_reason"], + "stop_sequence": None, + "stop_details": message["stop_details"], + }, + "usage": {name: usage[name] for name in ("output_tokens", "output_tokens_details") if name in usage}, + } + return (start, *blocks, delta, {"type": "message_stop"}) + + +def _without_stop_details(value: Mapping[str, JsonValue]) -> Event: + return {name: item for name, item in value.items() if name != "stop_details"} + + +def _captured_event(event: Event, stop_details: JsonValue, *, keep: bool) -> Event: + if event["type"] == "message_start": + message: Final = JSON_OBJECT.validate_python(event["message"]) + return {**event, "message": message if keep else _without_stop_details(message)} + if event["type"] != "message_delta": + return event + delta: Final = JSON_OBJECT.validate_python(event["delta"]) + replaced: Final = {**delta, "stop_details": stop_details} if keep else _without_stop_details(delta) + return {**event, "delta": replaced} + + +def _captured(stop_details: JsonValue, *, keep: bool = True) -> Fixture: + body: Final = {**CAPTURED_TURN, "stop_details": stop_details} if keep else _without_stop_details(CAPTURED_TURN) + return Fixture( + 200, + json.dumps(body).encode(), + tuple(_captured_event(event, stop_details, keep=keep) for event in CAPTURED_EVENTS), + ) + + +def _duplicated_stop_details() -> bytes: + pair: Final = f'"stop_details": {json.dumps(DOCS_STOP_DETAILS)}' + return json.dumps(DOCS_REFUSAL).replace(pair, f"{pair}, {pair}").encode() + + +def _anthropic_error(kind: str, message: str) -> bytes: + return json.dumps({"type": "error", "error": {"type": kind, "message": message}}).encode() + + +_PARTIAL: Final[Event] = {**DOCS_REFUSAL, **DOCS_PARTIAL_REFUSAL} +_REASONED: Final[Event] = { + **_PARTIAL, + "usage": {"input_tokens": 412, "output_tokens": 10, "output_tokens_details": {"thinking_tokens": 4}}, +} + +JSON_TOOL_NAME: Final = "json_tool_call" +JSON_ANSWER_SCHEMA: Final[Event] = { + "type": "object", + "properties": {"answer": {"type": "string"}}, + "required": ["answer"], + "additionalProperties": False, +} +JSON_ANSWER_FORMAT: Final[Event] = { + "type": "json_schema", + "json_schema": {"name": "answer", "schema": JSON_ANSWER_SCHEMA, "strict": True}, +} +DOCS_PARTIAL_TEXT: Final = "Hello.." +_DECLARED_INPUT: Final[Event] = {"answer": DOCS_PARTIAL_TEXT} +_ENVELOPED_INPUT: Final[Event] = {"parameters": _DECLARED_INPUT} +DECLARED_PARTIAL_JSON: Final = '{"answer": "Hello..' +ENVELOPED_PARTIAL_JSON: Final = '{"parameters": {"answer": "Hello..' +_JSON_TOOL_USE_ID: Final = "toolu_01RefusalFixtureJsonTool" + + +def _json_tool_refusal(tool_input: Event, partial_json: str) -> Fixture: + message: Final[Event] = { + **DOCS_REFUSAL, + "content": [{"type": "tool_use", "id": _JSON_TOOL_USE_ID, "name": JSON_TOOL_NAME, "input": tool_input}], + } + start, delta, stop = refusal_events(DOCS_REFUSAL) + tool_block: Final[tuple[Event, ...]] = ( + { + "type": "content_block_start", + "index": 0, + "content_block": {"type": "tool_use", "id": _JSON_TOOL_USE_ID, "name": JSON_TOOL_NAME, "input": {}}, + }, + { + "type": "content_block_delta", + "index": 0, + "delta": {"type": "input_json_delta", "partial_json": partial_json}, + }, + {"type": "content_block_stop", "index": 0}, + ) + return Fixture(200, json.dumps(message).encode(), (start, *tool_block, delta, stop)) + + +FIXTURES: Final[Mapping[str, Fixture]] = MappingProxyType( + { + "refusal": Fixture(200, json.dumps(DOCS_REFUSAL).encode(), refusal_events(DOCS_REFUSAL)), + "partial": Fixture(200, json.dumps(_PARTIAL).encode(), refusal_events(_PARTIAL)), + "reasoned": Fixture(200, json.dumps(_REASONED).encode(), refusal_events(_REASONED)), + "jsontool": _json_tool_refusal(_DECLARED_INPUT, DECLARED_PARTIAL_JSON), + "jsonheld": _json_tool_refusal(_ENVELOPED_INPUT, ENVELOPED_PARTIAL_JSON), + "duplicate": Fixture(200, _duplicated_stop_details()), + "null": _captured(None), + "missing": _captured(None, keep=False), + **{name: _captured(value) for name, value in MALFORMED_STOP_DETAILS.items()}, + "nondict": Fixture(200, b"[]"), + "unauthorized": Fixture(401, _anthropic_error("authentication_error", "invalid x-api-key")), + "invalid": Fixture(400, _anthropic_error("invalid_request_error", "max_tokens: Field required")), + } +) + + +def refusal_prompt(fixture: str, marker: str) -> str: + return f"refusal-fixture-{fixture} marker-{marker}" + + +CITED_FIELDS: Final[Event] = {"citations": ["https://example.com/source"]} +_PLAIN_DELTA: Final[Event] = {"role": "assistant", "content": "ready"} +_CITED_DELTA: Final[Event] = {**_PLAIN_DELTA, "provider_specific_fields": CITED_FIELDS} +_OPENAI_USAGE: Final[Event] = { + "prompt_tokens": 5, + "completion_tokens": 7, + "total_tokens": 12, + "completion_tokens_details": {"reasoning_tokens": 4, "text_tokens": 3}, +} +UNVALIDATED_USAGE_DETAILS: Final[Mapping[str, JsonValue]] = MappingProxyType( + { + "text": {"reasoning_tokens": "abc"}, + "fraction": {"reasoning_tokens": 1.5}, + "object": {"reasoning_tokens": {"a": 1}}, + } +) + + +@dataclass(frozen=True, slots=True) +class OpenAIFixture: + first_delta: Event + usage: tuple[Event, ...] = () + + +_OPENAI_FIXTURES: Final[Mapping[str, OpenAIFixture]] = MappingProxyType( + { + "plain": OpenAIFixture(_PLAIN_DELTA), + "cited": OpenAIFixture(_CITED_DELTA), + "plainusage": OpenAIFixture(_PLAIN_DELTA, (_OPENAI_USAGE,)), + "citedusage": OpenAIFixture(_CITED_DELTA, (_OPENAI_USAGE,)), + } +) + + +def openai_id(marker: str) -> str: + return f"chatcmpl-{marker}" + + +def _openai_frame(marker: str, body: Event) -> bytes: + chunk: Final[Event] = { + "id": openai_id(marker), + "object": "chat.completion.chunk", + "created": 1760000000, + "model": "gpt-4o-mini", + **body, + } + return b"data: " + json.dumps(chunk).encode() + b"\n\n" + + +def _openai_choice(delta: Event, finish_reason: str | None) -> Event: + return {"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}]} + + +def _openai_stream(name: str, request: Request) -> Reply: + assert streams(request), request.body + marker: Final = marker_of(request) + fixture: Final = _OPENAI_FIXTURES[name] + return Reply( + content_type="text/event-stream", + chunks=( + _openai_frame(marker, _openai_choice(fixture.first_delta, None)), + _openai_frame(marker, _openai_choice({"content": " now"}, "stop")), + *(_openai_frame(marker, {"choices": [], "usage": usage}) for usage in fixture.usage), + b"data: [DONE]\n\n", + ), + ) + + +def respond(request: Request) -> Reply: + found: Final[Sequence[str]] = _FIXTURE.findall(request.body.decode()) + assert found, request.body + if request.target.endswith("/chat/completions"): + return _openai_stream(found[-1], request) + fixture: Final = FIXTURES[found[-1]] + if fixture.status != 200 or not fixture.events or not streams(request): + return Reply(status=fixture.status, body=fixture.body) + return stream_reply(request, fixture.events) + + +def _stop_fields(fields: JsonValue) -> Event: + if not isinstance(fields, dict): + return {} + return {name: fields[name] for name in STOP_FIELDS if name in fields} + + +def relayed(value: JsonValue, location: Location = ()) -> tuple[tuple[Location, Event], ...]: + if isinstance(value, list): + return tuple(chain.from_iterable(relayed(item, (*location, index)) for index, item in enumerate(value))) + if not isinstance(value, dict): + return () + found: Final = _stop_fields(value.get("provider_specific_fields")) + here: Final = (((*location, "provider_specific_fields"), found),) if found else () + return (*here, *chain.from_iterable(relayed(item, (*location, name)) for name, item in value.items())) + + +_CACHE_KEY: Final = re.compile(r"[0-9a-f]{64}") +_STORED: Final = TypeAdapter(bytes | None) +STALE_ID_SUFFIX: Final = "-stale" + + +def _stored(store: Redis, key: str) -> bytes: + if not _CACHE_KEY.fullmatch(key) or store.type(key) != b"string": + return b"" + return _STORED.validate_python(store.get(key)) or b"" + + +def _entry_key(store: Redis, needle: str) -> str | None: + keys: Final = (raw.decode() for raw in store.scan_iter(count=1000)) + return next((key for key in keys if needle.encode() in _stored(store, key)), None) + + +def _with_message_fields(response: Event, fields: JsonValue) -> Event: + choice: Final = JSON_OBJECT.validate_python(JSON_LIST.validate_python(response["choices"])[0]) + message: Final = JSON_OBJECT.validate_python(choice["message"]) + return {**response, "choices": [{**choice, "message": {**message, "provider_specific_fields": fields}}]} + + +def _with_usage_details(response: Event, details: JsonValue) -> Event: + usage: Final = JSON_OBJECT.validate_python(response["usage"]) + return {**response, "usage": {**usage, "completion_tokens_details": details}} + + +def _rewrite_cached_response(needle: str, rewrite: Callable[[Event], Event]) -> str: + store: Final = Redis(host=os.environ["REDIS_HOST"], port=int(os.environ["REDIS_PORT"])) + key: Final = eventually(lambda: _entry_key(store, needle), lambda found: found is not None) + assert key is not None, needle + entry: Final = JSON_OBJECT.validate_json(_stored(store, key)) + stored: Final = entry["response"] + encoded: Final = isinstance(stored, str) + response: Final = ( + JSON_OBJECT.validate_json(stored) if isinstance(stored, str) else JSON_OBJECT.validate_python(stored) + ) + rewritten: Final = {**rewrite(response), "id": f"{response['id']}{STALE_ID_SUFFIX}"} + store.set(key, json.dumps({**entry, "response": json.dumps(rewritten) if encoded else rewritten}), ex=60) + return str(rewritten["id"]) + + +def rewrite_cached_message_fields(needle: str, fields: JsonValue) -> str: + return _rewrite_cached_response(needle, lambda response: _with_message_fields(response, fields)) + + +def rewrite_cached_usage_details(needle: str, details: JsonValue) -> str: + return _rewrite_cached_response(needle, lambda response: _with_usage_details(response, details)) + + +def key_hash(key: str) -> str: + return hashlib.sha256(key.encode()).hexdigest() + + +def _key_hash_of(event: Mapping[str, JsonValue]) -> JsonValue: + return JSON_OBJECT.validate_python(event.get("metadata") or {}).get("user_api_key_hash") + + +class Ledger: + def __init__(self, wire: Wire) -> None: + self.wire: Final = wire + self.batches: tuple[Request, ...] = () + + def events(self) -> tuple[Event, ...]: + self.batches = (*self.batches, *self.wire.drain()) + decoded: Final = chain.from_iterable(JSON_LIST.validate_json(batch.body) for batch in self.batches) + return tuple(JSON_OBJECT.validate_python(event) for event in decoded) + + def for_key(self, key: str) -> tuple[Event, ...]: + hashed: Final = key_hash(key) + return tuple(event for event in self.events() if _key_hash_of(event) == hashed) + + def await_key(self, key: str, count: int = 1, seconds: float = 30) -> tuple[Event, ...]: + return eventually(lambda: self.for_key(key), lambda found: len(found) >= count, seconds=seconds) + + +_HIDDEN_PARAMS_PROBE: Final = """ +import httpx +from litellm.integrations.custom_logger import CustomLogger + + +class HiddenParamsProbe(CustomLogger): + async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + payload = kwargs.get("standard_logging_object") or {{}} + hidden = getattr(response_obj, "_hidden_params", None) or {{}} + event = {{ + "metadata": {{"user_api_key_hash": (payload.get("metadata") or {{}}).get("user_api_key_hash")}}, + "response_id": getattr(response_obj, "id", None), + "received_model_id": hidden.get("received_model_id"), + }} + async with httpx.AsyncClient(trust_env=False) as client: + await client.post({url!r}, json=[event]) + + +instance = HiddenParamsProbe() +""" + + +def hidden_params_probe(directory: Path, url: str) -> str: + (directory / "hidden_params_probe.py").write_text(_HIDDEN_PARAMS_PROBE.format(url=url)) + return "hidden_params_probe.instance" + + +def sink_config( + directory: Path, + sinks: Mapping[str, str], + *, + retries: int = 0, + extra_callbacks: Sequence[str] = (), + store_prompts: bool = False, +) -> Path: + shared: Final = JSON_OBJECT.validate_python(yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text())) + settings: Final = { + **JSON_OBJECT.validate_python(shared["litellm_settings"]), + "callbacks": [*sinks, *extra_callbacks], + "DEFAULT_FLUSH_INTERVAL_SECONDS": 1, + } + callback_settings: Final = { + name: { + "callback_type": "generic_api", + "endpoint": url, + "headers": {"x-refusal-sink": name}, + "max_retries": retries, + "retry_delay": 0.5, + } + for name, url in sinks.items() + } + general_settings: Final = { + **JSON_OBJECT.validate_python(shared["general_settings"]), + "store_prompts_in_spend_logs": store_prompts, + } + path: Final = directory / "anthropic-refusal-sinks.yaml" + path.write_text( + yaml.safe_dump( + { + **shared, + "general_settings": general_settings, + "litellm_settings": settings, + "callback_settings": callback_settings, + } + ) + ) + return path + + +def frames(text: str) -> tuple[Event, ...]: + return tuple( + JSON_OBJECT.validate_json(line.removeprefix("data: ")) + for line in text.splitlines() + if line.startswith("data: {") + ) + + +def frame_types(events: Sequence[Mapping[str, JsonValue]]) -> tuple[JsonValue, ...]: + return tuple(event.get("type") for event in events) diff --git a/tests/integration/caching/response_cache_case.py b/tests/integration/caching/response_cache_case.py index 9df34721202..8dfb6a0a479 100644 --- a/tests/integration/caching/response_cache_case.py +++ b/tests/integration/caching/response_cache_case.py @@ -336,6 +336,29 @@ def flip_entry_to_empty(store: Redis, key: str, field: str) -> str: return stale_id +def rewrite_usage_details(store: Redis, key: str, details: JsonValue) -> str: + """Rewrite the stored usage's ``completion_tokens_details`` to ``details`` and stamp the id ``-stale``, keeping the + encoding, and return the stale id.""" + raw: Final = store.get(key) + assert raw is not None, key + entry, is_json = _decode_entry(raw) + response: Final = entry["response"] + stored: Final = object_value(json.loads(response) if isinstance(response, str) else response) + stale_id: Final = f"{string_value(stored['id'])}{STALE_ID_SUFFIX}" + usage: Final = object_value(stored["usage"]) + rewritten_response: Final[dict[str, JsonValue]] = { + **stored, + "usage": {**usage, "completion_tokens_details": details}, + "id": stale_id, + } + rewritten: Final[dict[str, JsonValue]] = { + **entry, + "response": json.dumps(rewritten_response) if isinstance(response, str) else rewritten_response, + } + store.set(key, json.dumps(rewritten) if is_json else str(rewritten), ex=STALE_ENTRY_TTL_SECONDS) + return stale_id + + def chat_body(model: str, text: str, **extra: JsonValue) -> dict[str, JsonValue]: return {"model": model, "messages": [{"role": "user", "content": text}], **extra} diff --git a/tests/integration/providers/test_anthropic_refusal_stop_details_chaos.py b/tests/integration/providers/test_anthropic_refusal_stop_details_chaos.py new file mode 100644 index 00000000000..dd4b7602b96 --- /dev/null +++ b/tests/integration/providers/test_anthropic_refusal_stop_details_chaos.py @@ -0,0 +1,337 @@ +import asyncio +import json +import re +import threading +import time +import uuid +from collections import Counter +from collections.abc import Callable, Mapping, Sequence +from contextlib import ExitStack +from dataclasses import dataclass +from itertools import chain +from pathlib import Path +from queue import SimpleQueue +from types import MappingProxyType +from typing import Final, Literal, TypeAlias +from urllib.parse import urlsplit + +import httpx +import psutil +import pytest +import yaml +from integration._support.anthropic_refusal import ( + REFUSAL_FIELDS, + Ledger, + Location, + refusal_prompt, + relayed, + respond, + sink_config, +) +from integration._support.anthropic_thinking import JSON_OBJECT, MODEL, Event, chunks_of +from integration._support.client import Gateway, eventually, held +from integration._support.process import OwnedProxy, graceful_stop_seconds, owned_proxy_process +from integration._support.timing import assert_not_delayed_by +from integration._support.wire import Reply, Request, Wire, wire_server + +pytestmark: Final = [ + pytest.mark.timeout(2 * graceful_stop_seconds() + 120), + pytest.mark.xdist_group("anthropic_refusal_stop_details_chaos"), +] + +_MODEL_NAME: Final = "anthropic-refusal-chaos" +_SLOW_SINK_SECONDS: Final = 4 +_SINK: Final = "refusal_chaos_sink" +_STARTED_WORKER: Final = re.compile(r"Started server process \[(\d+)\]") +_MARKER: Final = re.compile(r"marker-([0-9a-f]{32})") +_CHAT_FIELDS: Final[Location] = ("choices", 0, "message", "provider_specific_fields") +_DELTA_FIELDS: Final[Location] = ("choices", 0, "delta", "provider_specific_fields") +_PATHS: Final[Mapping[str, str]] = MappingProxyType( + {"chat": "/v1/chat/completions", "messages": "/v1/messages", "responses": "/v1/responses"} +) + +Surface: TypeAlias = Literal["chat", "messages", "responses"] +_RecordProperty: TypeAlias = Callable[[str, object], None] + + +@dataclass(frozen=True, slots=True) +class _Call: + surface: Surface + stream: bool + marker: str + + +@dataclass(frozen=True, slots=True) +class _Served: + call: _Call + status: int + text: str + + +@dataclass(frozen=True, slots=True) +class _Held: + release: threading.Event + markers: SimpleQueue[str] + + def respond(self, request: Request) -> Reply: + self.markers.put(_markers_in(request.body.decode())[0]) + assert self.release.wait(timeout=90), "The burst was never released" + return respond(request) + + +def _markers_in(text: str) -> tuple[str, ...]: + return tuple(dict.fromkeys(_MARKER.findall(text))) + + +def _calls(count: int) -> tuple[_Call, ...]: + surfaces: Final[tuple[Surface, ...]] = ("chat", "messages", "responses") + return tuple( + _Call(surfaces[index % 3], stream=(index // 3) % 2 == 0, marker=uuid.uuid4().hex) for index in range(count) + ) + + +def _body(call: _Call) -> Event: + text: Final = refusal_prompt("refusal", call.marker) + shared: Final[Event] = {"model": _MODEL_NAME, "stream": call.stream, "cache": {"no-cache": True}} + match call.surface: + case "chat": + return {**shared, "messages": [{"role": "user", "content": text}], "max_tokens": 64} + case "messages": + return {**shared, "messages": [{"role": "user", "content": text}], "max_tokens": 64} + case "responses": + return {**shared, "input": text, "max_output_tokens": 64} + + +async def _send(client: httpx.AsyncClient, key: str, call: _Call) -> _Served: + try: + response: Final = await client.post( + _PATHS[call.surface], json=_body(call), headers={"Authorization": f"Bearer {key}"} + ) + except httpx.TransportError as error: + return _Served(call, 0, repr(error)) + return _Served(call, response.status_code, response.text) + + +async def _burst(gateway: Gateway, calls: Sequence[_Call]) -> tuple[_Served, ...]: + async with httpx.AsyncClient(base_url=str(gateway.client.base_url), timeout=120, trust_env=False) as client: + return tuple(await asyncio.gather(*(_send(client, gateway.key, call) for call in calls))) + + +def _chaos_config(directory: Path, upstream: Wire, sink: str, *, retries: int) -> Path: + base: Final = sink_config(directory, {_SINK: sink}, retries=retries) + config: Final = JSON_OBJECT.validate_python(yaml.safe_load(base.read_text())) + model_list: Final = [ + { + "model_name": _MODEL_NAME, + "litellm_params": {"model": f"anthropic/{MODEL}", "api_base": upstream.url, "api_key": "scripted-key"}, + } + ] + path: Final = directory / "anthropic-refusal-chaos.yaml" + path.write_text(yaml.safe_dump({**config, "model_list": model_list})) + return path + + +def _ledger_events(ledgers: Sequence[Ledger]) -> tuple[Event, ...]: + return tuple(chain.from_iterable(ledger.events() for ledger in ledgers)) + + +def _marker_counts(ledgers: Sequence[Ledger], markers: frozenset[str]) -> Mapping[str, int]: + found: Final = chain.from_iterable(_markers_in(json.dumps(event)) for event in _ledger_events(ledgers)) + return MappingProxyType(Counter(marker for marker in found if marker in markers)) + + +def _events_for(ledgers: Sequence[Ledger], marker: str) -> tuple[Event, ...]: + return tuple(event for event in _ledger_events(ledgers) if marker in json.dumps(event)) + + +def _assert_answered(item: _Served) -> None: + assert item.status == 200, (item.call, item.text) + if item.call.surface != "chat": + return + if item.call.stream: + found: Final = tuple(chain.from_iterable(relayed(chunk) for chunk in chunks_of(item.text))) + assert found == ((_DELTA_FIELDS, REFUSAL_FIELDS),), item.text + return + assert relayed(JSON_OBJECT.validate_json(item.text)) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), item.text + + +def _assert_logged_chat_relays(ledgers: Sequence[Ledger], calls: Sequence[_Call]) -> None: + for call in calls: + if call.surface != "chat": + continue + (event,) = _events_for(ledgers, call.marker) + assert relayed(event["response"]) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), event + + +def _worker_pids(owned: OwnedProxy) -> tuple[int, ...]: + return eventually( + lambda: tuple(int(pid) for pid in _STARTED_WORKER.findall(owned.log.read_text())), + lambda pids: len(pids) == 2, + seconds=30, + ) + + +def _open_upstream_connections(pid: int, upstream: str) -> int: + port: Final = urlsplit(upstream).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 + ) + + +def _port(wire: Wire) -> int: + port: Final = urlsplit(wire.url).port + assert port is not None, wire.url + return port + + +def _accept(_request: Request) -> Reply: + return Reply() + + +async def _timed_chat(gateway: Gateway) -> float: + started: Final = time.monotonic() + (served,) = await _burst(gateway, (_Call("chat", stream=False, marker=uuid.uuid4().hex),)) + _assert_answered(served) + return time.monotonic() - started + + +async def test_sink_outage_mid_burst_delivers_every_refusal_event_once(gateway: Gateway, tmp_path: Path) -> None: + calls: Final = _calls(30) + markers: Final = frozenset(call.marker for call in calls) + with wire_server(respond) as upstream, ExitStack() as first_sink: + sink: Final = first_sink.enter_context(wire_server(_accept)) + first: Final = Ledger(sink) + config: Final = _chaos_config(tmp_path, upstream, sink.url, retries=6) + with owned_proxy_process(gateway, tmp_path, {}, config=config, workers=2) as owned: + _worker_pids(owned) + served_first: Final = await _burst(owned.gateway, calls[:10]) + await asyncio.to_thread( + eventually, lambda: sum(_marker_counts((first,), markers).values()), lambda total: total >= 1, 30 + ) + first.events() + first_sink.close() + served_during: Final = await _burst(owned.gateway, calls[10:20]) + with wire_server(_accept, port=_port(sink)) as restarted: + second: Final = Ledger(restarted) + served_after: Final = await _burst(owned.gateway, calls[20:]) + for item in (*served_first, *served_during, *served_after): + _assert_answered(item) + counts: Final = await asyncio.to_thread( + held, + lambda: _marker_counts((first, second), markers), + lambda found: set(found) == markers and set(found.values()) == {1}, + holding=3, + seconds=60, + ) + assert sum(counts.values()) == 30, counts + _assert_logged_chat_relays((first, second), calls) + assert len(upstream.drain()) == 30 + + +async def test_slow_sink_neither_deadlocks_nor_uploads_a_refusal_event_twice(gateway: Gateway, tmp_path: Path) -> None: + calls: Final = _calls(30) + markers: Final = frozenset(call.marker for call in calls) + + def slow(_request: Request) -> Reply: + time.sleep(_SLOW_SINK_SECONDS) + return Reply() + + with wire_server(respond) as upstream, wire_server(slow) as sink: + ledger: Final = Ledger(sink) + config: Final = _chaos_config(tmp_path, upstream, sink.url, retries=0) + with owned_proxy_process(gateway, tmp_path, {}, config=config, workers=2) as owned: + _worker_pids(owned) + control: Final = await _timed_chat(owned.gateway) + served: Final = await _burst(owned.gateway, calls) + for item in served: + _assert_answered(item) + follow_up: Final = await _timed_chat(owned.gateway) + assert_not_delayed_by((follow_up,), control=(control,), injected_delay=_SLOW_SINK_SECONDS) + counts: Final = await asyncio.to_thread( + held, + lambda: _marker_counts((ledger,), markers), + lambda found: set(found) == markers and set(found.values()) == {1}, + holding=5, + seconds=90, + ) + assert sum(counts.values()) == 30, counts + _assert_logged_chat_relays((ledger,), calls) + + +async def test_worker_sigkill_mid_burst_leaves_the_sibling_relaying_and_logging_once( + gateway: Gateway, tmp_path: Path +) -> None: + calls: Final = _calls(20) + hold: Final = _Held(threading.Event(), SimpleQueue()) + with wire_server(hold.respond) as upstream, wire_server(_accept) as sink: + ledger: Final = Ledger(sink) + config: Final = _chaos_config(tmp_path, upstream, sink.url, retries=0) + with owned_proxy_process(gateway, tmp_path, {}, config=config, workers=2) as owned: + workers: Final = _worker_pids(owned) + burst: Final = asyncio.create_task(_burst(owned.gateway, calls)) + await asyncio.to_thread(eventually, hold.markers.qsize, lambda size: size == 20, 60) + held_by: Final = MappingProxyType({pid: _open_upstream_connections(pid, upstream.url) for pid in workers}) + assert sum(held_by.values()) == 20, held_by + victim_pid, survivor_pid = sorted(workers, key=held_by.__getitem__) + with owned.replacing_worker(victim_pid) as kill: + kill.freeze() + await asyncio.to_thread(kill) + hold.release.set() + served: Final = await burst + assert held_by[survivor_pid] >= 10, held_by + completed: Final = tuple(item for item in served if item.status == 200) + assert len(completed) == held_by[survivor_pid], (held_by, [item.status for item in served]) + for item in completed: + _assert_answered(item) + follow_up: Final = _Call("chat", stream=True, marker=uuid.uuid4().hex) + (answered,) = await _burst(owned.gateway, (follow_up,)) + _assert_answered(answered) + survivors: Final = frozenset((*(item.call.marker for item in completed), follow_up.marker)) + counts: Final = await asyncio.to_thread( + held, + lambda: _marker_counts((ledger,), frozenset(call.marker for call in (*calls, follow_up))), + lambda found: set(found) == survivors and set(found.values()) == {1}, + holding=3, + seconds=60, + ) + assert sum(counts.values()) == len(survivors), counts + _assert_logged_chat_relays((ledger,), tuple(item.call for item in completed)) + + +async def test_proxy_restart_mid_burst_never_logs_a_refusal_twice( + gateway: Gateway, tmp_path: Path, record_property: _RecordProperty +) -> None: + calls: Final = _calls(20) + markers: Final = frozenset(call.marker for call in calls) + hold: Final = _Held(threading.Event(), SimpleQueue()) + with wire_server(hold.respond) as upstream, wire_server(_accept) as sink: + ledger: Final = Ledger(sink) + config: Final = _chaos_config(tmp_path, upstream, sink.url, retries=0) + with owned_proxy_process(gateway, tmp_path, {}, config=config, workers=2) as first: + _worker_pids(first) + burst: Final = asyncio.create_task(_burst(first.gateway, calls)) + await asyncio.to_thread(eventually, hold.markers.qsize, lambda size: size == 20, 60) + first.begin_shutdown() + hold.release.set() + served: Final = await burst + for item in served: + _assert_answered(item) + second_directory: Final = tmp_path / "second" + second_directory.mkdir() + with owned_proxy_process(gateway, second_directory, {}, config=config, workers=2) as second: + follow_up: Final = _Call("chat", stream=False, marker=uuid.uuid4().hex) + (answered,) = await _burst(second.gateway, (follow_up,)) + _assert_answered(answered) + await asyncio.to_thread( + eventually, + lambda: _marker_counts((ledger,), frozenset({follow_up.marker})), + lambda found: found[follow_up.marker] == 1, + 30, + ) + counts: Final = _marker_counts((ledger,), markers) + assert set(counts.values()) <= {1}, counts + _assert_logged_chat_relays((ledger,), tuple(call for call in calls if call.marker in counts)) + record_property("served", len(served)) + record_property("landed", sum(counts.values())) diff --git a/tests/integration/providers/test_anthropic_refusal_stop_details_wire.py b/tests/integration/providers/test_anthropic_refusal_stop_details_wire.py new file mode 100644 index 00000000000..461839ccbcc --- /dev/null +++ b/tests/integration/providers/test_anthropic_refusal_stop_details_wire.py @@ -0,0 +1,1356 @@ +import asyncio +import json +import uuid +from collections.abc import Iterator, Mapping, Sequence +from dataclasses import dataclass +from itertools import chain +from typing import Final, Literal, TypeAlias +from urllib.parse import unquote, urlsplit + +import anthropic +import httpx +import openai +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from integration._support.anthropic_refusal import ( + CITED_FIELDS, + DECLARED_PARTIAL_JSON, + DOCS_PARTIAL_TEXT, + DOCS_REFUSAL, + DOCS_STOP_DETAILS, + ENVELOPED_PARTIAL_JSON, + FIXTURES, + JSON_ANSWER_FORMAT, + JSON_TOOL_NAME, + MALFORMED_STOP_DETAILS, + REFUSAL_FIELDS, + UNVALIDATED_USAGE_DETAILS, + Ledger, + Location, + frame_types, + frames, + hidden_params_probe, + openai_id, + refusal_events, + refusal_prompt, + relayed, + respond, + rewrite_cached_message_fields, + rewrite_cached_usage_details, + sink_config, +) +from integration._support.anthropic_thinking import ( + BEDROCK_MODEL, + JSON_LIST, + JSON_OBJECT, + MODEL, + NO_CACHE, + Event, + aws_chunks, + chunks_of, + sse_chunks, +) +from integration._support.client import Gateway, Scenario, eventually, gateway_from_environment, held +from integration._support.forward_proxy import tunnelling_forward_proxy +from integration._support.process import graceful_stop_seconds, owned_proxy +from integration._support.tls import server_context, write_self_signed_cert +from integration._support.wire import Reply, Wire, wire_server +from pydantic import BaseModel, JsonValue + +pytestmark: Final = [ + pytest.mark.timeout(2 * graceful_stop_seconds() + 120), + pytest.mark.xdist_group("anthropic_refusal_stop_details_wire"), +] + +_ANTHROPIC_BASE: Final = "http://api.anthropic.com" +_ANTHROPIC_KEY: Final = "scripted-anthropic-key" +_VERTEX_PROJECT: Final = "scripted-project" +_VERTEX_LOCATION: Final = "us-east5" +_VERTEX_HOST: Final = f"{_VERTEX_LOCATION}-aiplatform.googleapis.com" +_VERTEX_MODEL_PATH: Final = ( + f"/v1/projects/{_VERTEX_PROJECT}/locations/{_VERTEX_LOCATION}/publishers/anthropic/models/{MODEL}" +) +_CHAT_FIELDS: Final[Location] = ("choices", 0, "message", "provider_specific_fields") +_DELTA_FIELDS: Final[Location] = ("choices", 0, "delta", "provider_specific_fields") +_RESPONSE_FIELDS: Final[Location] = ("provider_specific_fields",) +_STREAMED_RESPONSE_FIELDS: Final[Location] = ("response", "provider_specific_fields") + +Client: TypeAlias = Literal["sdk", "async_sdk", "httpx"] +Provider: TypeAlias = Literal[ + "anthropic", + "bedrock_invoke", + "claude_platform", + "vertex_ai", + "azure_ai", + "bedrock_mantle", + "snowflake", + "openai_compatible", +] +Surface: TypeAlias = Literal["chat", "chat_stream", "messages", "messages_stream", "responses", "responses_stream"] + +CLIENTS: Final[tuple[Client, ...]] = ("sdk", "async_sdk", "httpx") +FAMILY: Final[tuple[Provider, ...]] = ( + "bedrock_invoke", + "claude_platform", + "vertex_ai", + "azure_ai", + "bedrock_mantle", + "snowflake", +) +_STREAM_TARGETS: Final[Mapping[Provider, tuple[str, str]]] = { + "anthropic": ("/v1/messages", "/v1/messages"), + "bedrock_invoke": (f"/model/{BEDROCK_MODEL}/invoke", f"/model/{BEDROCK_MODEL}/invoke-with-response-stream"), + "claude_platform": ("/v1/messages", "/v1/messages"), + "vertex_ai": (f"{_VERTEX_MODEL_PATH}:rawPredict", f"{_VERTEX_MODEL_PATH}:streamRawPredict"), + "azure_ai": ("/anthropic/v1/messages", "/anthropic/v1/messages"), + "bedrock_mantle": ("/anthropic/v1/messages", "/anthropic/v1/messages"), + "snowflake": ("/api/v2/cortex/v1/messages", "/api/v2/cortex/v1/messages"), +} + + +@dataclass(frozen=True, slots=True) +class Rig: + gateway: Gateway + wire: Wire + vertex: Wire + sink: Ledger + forbidden: Ledger + absent: Ledger + hidden: Ledger + credentials: str + + +def _service_account_json(token_url: str) -> str: + private_key: Final = ( + rsa.generate_private_key(public_exponent=65537, key_size=2048) + .private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()) + .decode() + ) + return json.dumps( + { + "type": "service_account", + "project_id": _VERTEX_PROJECT, + "private_key_id": "scripted", + "private_key": private_key, + "client_email": f"scripted@{_VERTEX_PROJECT}.iam.gserviceaccount.com", + "client_id": "0", + "auth_uri": f"{token_url}/_oauth/authorize", + "token_uri": f"{token_url}/_oauth/token", + } + ) + + +def _port(wire: Wire) -> int: + port: Final = urlsplit(wire.url).port + assert port is not None, wire.url + return port + + +@pytest.fixture(scope="module") +def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Rig]: + directory: Final = tmp_path_factory.mktemp("anthropic-refusal-stop-details") + certificate: Final = write_self_signed_cert(directory, (_VERTEX_HOST,)) + with ( + gateway_from_environment() as parent, + wire_server(respond) as wire, + wire_server(respond, tls=server_context(*certificate)) as vertex, + tunnelling_forward_proxy(_port(vertex)) as tunnel, + wire_server(lambda _request: Reply()) as sink, + wire_server(lambda _request: Reply(status=403)) as forbidden, + wire_server(lambda _request: Reply(status=404)) as absent, + wire_server(lambda _request: Reply()) as hidden, + ): + credentials: Final = _service_account_json(parent.upstream_url.rstrip("/")) + credentials_path: Final = directory / "vertex-service-account.json" + credentials_path.write_text(credentials) + config: Final = sink_config( + directory, + {"refusal_sink": sink.url, "refusal_sink_403": forbidden.url, "refusal_sink_404": absent.url}, + extra_callbacks=(hidden_params_probe(directory, hidden.url),), + store_prompts=True, + ) + overrides: Final = { + "ANTHROPIC_API_BASE": _ANTHROPIC_BASE, + "ANTHROPIC_API_KEY": _ANTHROPIC_KEY, + "AIOHTTP_TRUST_ENV": "True", + "HTTP_PROXY": wire.url, + "http_proxy": wire.url, + "HTTPS_PROXY": tunnel.url, + "https_proxy": tunnel.url, + "NO_PROXY": "127.0.0.1,localhost", + "no_proxy": "127.0.0.1,localhost", + "SSL_CERT_FILE": str(certificate[0]), + "DEFAULT_VERTEXAI_PROJECT": _VERTEX_PROJECT, + "DEFAULT_VERTEXAI_LOCATION": _VERTEX_LOCATION, + "DEFAULT_GOOGLE_APPLICATION_CREDENTIALS": str(credentials_path), + } + with owned_proxy(parent, directory, overrides, config=config, workers=2) as owned: + yield Rig(owned, wire, vertex, Ledger(sink), Ledger(forbidden), Ledger(absent), Ledger(hidden), credentials) + + +@pytest.fixture(autouse=True) +def _drained_upstreams(rig: Rig) -> None: + rig.wire.drain() + rig.vertex.drain() + + +def _marker() -> str: + return uuid.uuid4().hex + + +def _deployment(scenario: Scenario, provider: Provider, rig: Rig) -> str: + wire_url: Final = rig.wire.url + match provider: + case "anthropic": + return scenario.model(model=f"anthropic/{MODEL}", api_base=wire_url, api_key=_ANTHROPIC_KEY) + case "bedrock_invoke": + return scenario.model( + model=f"bedrock/invoke/{BEDROCK_MODEL}", + api_base=wire_url, + aws_access_key_id="AKIASCRIPTEDPROVIDER", + aws_secret_access_key="scripted-secret", + aws_region_name="us-east-1", + aws_bedrock_runtime_endpoint=wire_url, + ) + case "claude_platform": + return scenario.model( + model=f"bedrock/claude_platform/{MODEL}", + api_base=wire_url, + api_key="scripted-platform-key", + aws_region_name="us-east-1", + workspace_id="scripted-workspace", + ) + case "vertex_ai": + return scenario.model( + model=f"vertex_ai/{MODEL}", + api_base=f"{wire_url}{_VERTEX_MODEL_PATH}", + api_key=None, + vertex_project=_VERTEX_PROJECT, + vertex_location=_VERTEX_LOCATION, + vertex_credentials=rig.credentials, + ) + case "azure_ai": + return scenario.model(model=f"azure_ai/{MODEL}", api_base=wire_url, api_key="scripted-azure-key") + case "bedrock_mantle": + return scenario.model( + model=f"bedrock_mantle/{BEDROCK_MODEL}", + api_base=wire_url, + api_key="scripted-mantle-key", + aws_region_name="us-east-1", + ) + case "snowflake": + return scenario.model(model=f"snowflake/{MODEL}", api_base=wire_url, api_key="scripted-snowflake-key") + case "openai_compatible": + return scenario.model(model="openai/gpt-4o-mini", api_base=f"{wire_url}/v1", api_key="scripted-openai-key") + + +def _base_url(rig: Rig) -> str: + return str(rig.gateway.client.base_url).rstrip("/") + + +def _dump(model: BaseModel) -> Event: + return JSON_OBJECT.validate_python(model.model_dump()) + + +def _post(rig: Rig, path: str, body: Mapping[str, JsonValue], key: str) -> bytes: + response: Final = rig.gateway.request("POST", path, body, key=key) + assert response.status_code == 200, response.text + return response.content + + +def _chat_request(model: str, text: str, *, stream: bool, cached: bool) -> Event: + request: Final[Event] = { + "model": model, + "messages": [{"role": "user", "content": text}], + "stream": stream, + "max_tokens": 64, + } + return request if cached else {**request, **NO_CACHE} + + +def _chat(rig: Rig, key: str, model: str, text: str, client: Client = "httpx", *, cached: bool = False) -> Event: + match client: + case "httpx": + return JSON_OBJECT.validate_json( + _post(rig, "/v1/chat/completions", _chat_request(model, text, stream=False, cached=cached), key) + ) + case "sdk": + with openai.OpenAI(base_url=f"{_base_url(rig)}/v1", api_key=key, max_retries=0) as sdk: + return _dump( + sdk.chat.completions.create( + model=model, messages=[{"role": "user", "content": text}], max_tokens=64, extra_body=NO_CACHE + ) + ) + case "async_sdk": + + async def call() -> Event: + async with openai.AsyncOpenAI(base_url=f"{_base_url(rig)}/v1", api_key=key, max_retries=0) as sdk: + return _dump( + await sdk.chat.completions.create( + model=model, + messages=[{"role": "user", "content": text}], + max_tokens=64, + extra_body=NO_CACHE, + ) + ) + + return asyncio.run(call()) + + +def _chat_stream( + rig: Rig, key: str, model: str, text: str, client: Client = "httpx", *, cached: bool = False +) -> tuple[Event, ...]: + match client: + case "httpx": + return chunks_of( + _post(rig, "/v1/chat/completions", _chat_request(model, text, stream=True, cached=cached), key).decode() + ) + case "sdk": + with openai.OpenAI(base_url=f"{_base_url(rig)}/v1", api_key=key, max_retries=0) as sdk: + return tuple( + _dump(chunk) + for chunk in sdk.chat.completions.create( + model=model, + messages=[{"role": "user", "content": text}], + max_tokens=64, + stream=True, + extra_body=NO_CACHE, + ) + ) + case "async_sdk": + + async def call() -> tuple[Event, ...]: + async with openai.AsyncOpenAI(base_url=f"{_base_url(rig)}/v1", api_key=key, max_retries=0) as sdk: + stream: Final = await sdk.chat.completions.create( + model=model, + messages=[{"role": "user", "content": text}], + max_tokens=64, + stream=True, + extra_body=NO_CACHE, + ) + return tuple([_dump(chunk) async for chunk in stream]) + + return asyncio.run(call()) + + +def _messages_request(model: str, text: str, *, stream: bool, cached: bool) -> Event: + request: Final[Event] = { + "model": model, + "max_tokens": 64, + "stream": stream, + "messages": [{"role": "user", "content": text}], + } + return request if cached else {**request, **NO_CACHE} + + +def _messages(rig: Rig, key: str, model: str, text: str, client: Client = "httpx", *, cached: bool = False) -> Event: + match client: + case "httpx": + return JSON_OBJECT.validate_json( + _post(rig, "/v1/messages", _messages_request(model, text, stream=False, cached=cached), key) + ) + case "sdk": + with anthropic.Anthropic(base_url=_base_url(rig), api_key=key, max_retries=0) as sdk: + return _dump( + sdk.messages.create( + model=model, max_tokens=64, messages=[{"role": "user", "content": text}], extra_body=NO_CACHE + ) + ) + case "async_sdk": + + async def call() -> Event: + async with anthropic.AsyncAnthropic(base_url=_base_url(rig), api_key=key, max_retries=0) as sdk: + return _dump( + await sdk.messages.create( + model=model, + max_tokens=64, + messages=[{"role": "user", "content": text}], + extra_body=NO_CACHE, + ) + ) + + return asyncio.run(call()) + + +def _messages_stream( + rig: Rig, key: str, model: str, text: str, client: Client = "httpx", *, cached: bool = False +) -> tuple[Event, ...]: + match client: + case "httpx": + return frames( + _post(rig, "/v1/messages", _messages_request(model, text, stream=True, cached=cached), key).decode() + ) + case "sdk": + with anthropic.Anthropic(base_url=_base_url(rig), api_key=key, max_retries=0) as sdk: + return tuple( + _dump(event) + for event in sdk.messages.create( + model=model, + max_tokens=64, + messages=[{"role": "user", "content": text}], + stream=True, + extra_body=NO_CACHE, + ) + ) + case "async_sdk": + + async def call() -> tuple[Event, ...]: + async with anthropic.AsyncAnthropic(base_url=_base_url(rig), api_key=key, max_retries=0) as sdk: + stream: Final = await sdk.messages.create( + model=model, + max_tokens=64, + messages=[{"role": "user", "content": text}], + stream=True, + extra_body=NO_CACHE, + ) + return tuple([_dump(event) async for event in stream]) + + return asyncio.run(call()) + + +def _responses_request(model: str, text: str, *, stream: bool, cached: bool) -> Event: + request: Final[Event] = {"model": model, "input": text, "max_output_tokens": 64, "stream": stream} + return request if cached else {**request, **NO_CACHE} + + +def _responses(rig: Rig, key: str, model: str, text: str, client: Client = "httpx", *, cached: bool = False) -> Event: + match client: + case "httpx": + return JSON_OBJECT.validate_json( + _post(rig, "/v1/responses", _responses_request(model, text, stream=False, cached=cached), key) + ) + case "sdk": + with openai.OpenAI(base_url=f"{_base_url(rig)}/v1", api_key=key, max_retries=0) as sdk: + return _dump(sdk.responses.create(model=model, input=text, max_output_tokens=64, extra_body=NO_CACHE)) + case "async_sdk": + + async def call() -> Event: + async with openai.AsyncOpenAI(base_url=f"{_base_url(rig)}/v1", api_key=key, max_retries=0) as sdk: + return _dump( + await sdk.responses.create(model=model, input=text, max_output_tokens=64, extra_body=NO_CACHE) + ) + + return asyncio.run(call()) + + +def _responses_stream(rig: Rig, key: str, model: str, text: str, client: Client = "httpx") -> tuple[Event, ...]: + match client: + case "httpx": + return frames( + _post(rig, "/v1/responses", _responses_request(model, text, stream=True, cached=False), key).decode() + ) + case "sdk": + with openai.OpenAI(base_url=f"{_base_url(rig)}/v1", api_key=key, max_retries=0) as sdk: + return tuple( + _dump(event) + for event in sdk.responses.create( + model=model, input=text, max_output_tokens=64, stream=True, extra_body=NO_CACHE + ) + ) + case "async_sdk": + + async def call() -> tuple[Event, ...]: + async with openai.AsyncOpenAI(base_url=f"{_base_url(rig)}/v1", api_key=key, max_retries=0) as sdk: + stream: Final = await sdk.responses.create( + model=model, input=text, max_output_tokens=64, stream=True, extra_body=NO_CACHE + ) + return tuple([_dump(event) async for event in stream]) + + return asyncio.run(call()) + + +def _relayed_in(events: Sequence[JsonValue]) -> tuple[tuple[Location, Event], ...]: + return tuple(chain.from_iterable(relayed(event) for event in events)) + + +def _choice(chunk: Mapping[str, JsonValue]) -> Event: + choices: Final = JSON_LIST.validate_python(chunk.get("choices") or []) + return JSON_OBJECT.validate_python(choices[0]) if choices else {} + + +def _finish_reasons(chunks: Sequence[Mapping[str, JsonValue]]) -> tuple[JsonValue, ...]: + return tuple(_choice(chunk)["finish_reason"] for chunk in chunks if _choice(chunk).get("finish_reason")) + + +def _streamed_text(chunks: Sequence[Mapping[str, JsonValue]]) -> str: + return "".join( + str(JSON_OBJECT.validate_python(_choice(chunk).get("delta") or {}).get("content") or "") for chunk in chunks + ) + + +def _message_text(body: Mapping[str, JsonValue]) -> JsonValue: + return JSON_OBJECT.validate_python(_choice(body)["message"]).get("content") + + +def _native_stops(events: Sequence[Mapping[str, JsonValue]]) -> tuple[Event, ...]: + deltas: Final = tuple( + JSON_OBJECT.validate_python(event["delta"]) for event in events if event.get("type") == "message_delta" + ) + return tuple({name: delta.get(name) for name in ("stop_reason", "stop_details")} for delta in deltas) + + +def _native_deltas(events: Sequence[Mapping[str, JsonValue]]) -> tuple[Event, ...]: + deltas: Final = tuple( + JSON_OBJECT.validate_python(event["delta"]) for event in events if event.get("type") == "message_delta" + ) + return tuple({name: delta[name] for name in ("stop_reason", "stop_details") if name in delta} for delta in deltas) + + +def _targets(wire: Wire) -> tuple[str, ...]: + return tuple(unquote(request.target) for request in wire.drain()) + + +def _paths(wire: Wire) -> tuple[str, ...]: + return tuple(urlsplit(target).path for target in _targets(wire)) + + +def _logged(rig: Rig, key: str, count: int = 1) -> tuple[Event, ...]: + events: Final = rig.sink.await_key(key, count) + assert len(events) == count, events + return events + + +def _logged_response(rig: Rig, key: str) -> JsonValue: + return _logged(rig, key)[0]["response"] + + +@pytest.mark.parametrize("client", CLIENTS) +def test_chat_refusal_relays_the_docs_stop_details_to_the_caller_and_the_log(rig: Rig, client: Client) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + key: Final = scenario.key() + body: Final = _chat(rig, key, model, refusal_prompt("refusal", _marker()), client) + assert relayed(body) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), body + assert _choice(body)["finish_reason"] == "content_filter", body + assert _paths(rig.wire) == ("/v1/messages",) + logged: Final = _logged_response(rig, key) + assert relayed(logged) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), logged + + +@pytest.mark.parametrize("client", CLIENTS) +def test_chat_stream_refusal_relays_the_docs_stop_details_in_one_chunk_and_the_log(rig: Rig, client: Client) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + key: Final = scenario.key() + chunks: Final = _chat_stream(rig, key, model, refusal_prompt("refusal", _marker()), client) + assert _relayed_in(chunks) == ((_DELTA_FIELDS, REFUSAL_FIELDS),), chunks + assert _finish_reasons(chunks) == ("content_filter",), chunks + assert _streamed_text(chunks) == "", chunks + assert _paths(rig.wire) == ("/v1/messages",) + logged: Final = _logged_response(rig, key) + assert relayed(logged) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), logged + + +@pytest.mark.parametrize("client", CLIENTS) +def test_messages_refusal_returns_the_docs_body_and_logs_the_stop_details(rig: Rig, client: Client) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + key: Final = scenario.key() + body: Final = _messages(rig, key, model, refusal_prompt("refusal", _marker()), client) + assert (body["stop_reason"], body["stop_details"], body["content"]) == ("refusal", DOCS_STOP_DETAILS, []), body + assert relayed(body) == (), body + assert _paths(rig.wire) == ("/v1/messages",) + logged: Final = _logged_response(rig, key) + assert relayed(logged) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), logged + + +@pytest.mark.parametrize("client", CLIENTS) +def test_messages_stream_refusal_keeps_the_client_frames_and_logs_the_stop_details(rig: Rig, client: Client) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + key: Final = scenario.key() + events: Final = _messages_stream(rig, key, model, refusal_prompt("refusal", _marker()), client) + assert frame_types(events) == frame_types(FIXTURES["refusal"].events), events + assert _native_stops(events) == (REFUSAL_FIELDS,), events + assert _relayed_in(events) == (), events + assert _paths(rig.wire) == ("/v1/messages",) + logged: Final = _logged_response(rig, key) + assert relayed(logged) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), logged + + +@pytest.mark.parametrize("client", CLIENTS) +def test_responses_refusal_relays_the_docs_stop_details_at_the_top_level(rig: Rig, client: Client) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + key: Final = scenario.key() + body: Final = _responses(rig, key, model, refusal_prompt("refusal", _marker()), client) + assert relayed(body) == ((_RESPONSE_FIELDS, REFUSAL_FIELDS),), body + assert (body["status"], body["incomplete_details"]) == ("incomplete", {"reason": "content_filter"}), body + assert _paths(rig.wire) == ("/v1/messages",) + logged: Final = _logged_response(rig, key) + assert relayed(logged) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), logged + + +@pytest.mark.parametrize("client", CLIENTS) +def test_responses_stream_refusal_relays_the_docs_stop_details_on_the_incomplete_event( + rig: Rig, client: Client +) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + key: Final = scenario.key() + events: Final = _responses_stream(rig, key, model, refusal_prompt("refusal", _marker()), client) + assert _relayed_in(events) == ((_STREAMED_RESPONSE_FIELDS, REFUSAL_FIELDS),), events + assert frame_types(events)[-1] == "response.incomplete", events + assert _paths(rig.wire) == ("/v1/messages",) + logged: Final = _logged_response(rig, key) + assert relayed(logged) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), logged + + +@pytest.mark.parametrize("stream", [False, True], ids=["json", "sse"]) +def test_anthropic_passthrough_refusal_relays_upstream_bytes_and_logs_the_stop_details(rig: Rig, stream: bool) -> None: + with rig.gateway.scenario() as scenario: + key: Final = scenario.key() + body: Final = { + "model": MODEL, + "max_tokens": 64, + "stream": stream, + "messages": [{"role": "user", "content": refusal_prompt("refusal", _marker())}], + } + content: Final = _post(rig, "/anthropic/v1/messages", body, key) + if stream: + assert content == b"".join(sse_chunks(refusal_events(DOCS_REFUSAL))), content + else: + assert JSON_OBJECT.validate_json(content) == DOCS_REFUSAL, content + assert _targets(rig.wire) == (f"{_ANTHROPIC_BASE}/v1/messages",) + logged: Final = _logged_response(rig, key) + assert relayed(logged) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), logged + + +@pytest.mark.parametrize("provider", FAMILY) +def test_family_chat_refusal_relays_the_docs_stop_details(rig: Rig, provider: Provider) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, provider, rig) + key: Final = scenario.key() + body: Final = _chat(rig, key, model, refusal_prompt("refusal", _marker())) + expected: Final = () if provider == "snowflake" else ((_CHAT_FIELDS, REFUSAL_FIELDS),) + assert relayed(body) == expected, body + assert _choice(body)["finish_reason"] == ("stop" if provider == "snowflake" else "content_filter"), body + assert _paths(rig.wire) == (_STREAM_TARGETS[provider][0],) + logged: Final = _logged_response(rig, key) + assert relayed(logged) == expected, logged + + +@pytest.mark.parametrize("provider", FAMILY) +def test_family_chat_stream_refusal_relays_the_docs_stop_details(rig: Rig, provider: Provider) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, provider, rig) + key: Final = scenario.key() + chunks: Final = _chat_stream(rig, key, model, refusal_prompt("refusal", _marker())) + assert _relayed_in(chunks) == ((_DELTA_FIELDS, REFUSAL_FIELDS),), chunks + assert _finish_reasons(chunks) == ("content_filter",), chunks + assert _paths(rig.wire) == (_STREAM_TARGETS[provider][1],) + logged: Final = _logged_response(rig, key) + assert relayed(logged) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), logged + + +@pytest.mark.parametrize("provider", FAMILY) +def test_family_messages_stream_refusal_keeps_frames_and_logs_the_stop_details(rig: Rig, provider: Provider) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, provider, rig) + key: Final = scenario.key() + events: Final = _messages_stream(rig, key, model, refusal_prompt("refusal", _marker())) + native: Final = {"stop_reason": "refusal"} if provider == "snowflake" else REFUSAL_FIELDS + assert _native_deltas(events) == (native,), events + assert _relayed_in(events) == (), events + assert _paths(rig.wire) == (_STREAM_TARGETS[provider][1],) + logged: Final = _logged_response(rig, key) + assert relayed(logged) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), logged + + +def _bedrock_passthrough_deployment(scenario: Scenario, rig: Rig) -> str: + return scenario.model( + model=f"bedrock/{BEDROCK_MODEL}", + api_base=rig.wire.url, + aws_access_key_id="AKIASCRIPTEDPROVIDER", + aws_secret_access_key="scripted-secret", + aws_region_name="us-east-1", + aws_bedrock_runtime_endpoint=rig.wire.url, + ) + + +@pytest.mark.parametrize("stream", [False, True], ids=["invoke", "invoke-with-response-stream"]) +def test_bedrock_passthrough_refusal_relays_upstream_bytes_and_logs_the_stop_details(rig: Rig, stream: bool) -> None: + with rig.gateway.scenario() as scenario: + deployment: Final = _bedrock_passthrough_deployment(scenario, rig) + key: Final = scenario.key() + action: Final = "invoke-with-response-stream" if stream else "invoke" + body: Final = { + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 64, + "messages": [{"role": "user", "content": refusal_prompt("refusal", _marker())}], + } + content: Final = _post(rig, f"/bedrock/model/{deployment}/{action}", body, key) + if stream: + assert content == b"".join(aws_chunks(refusal_events(DOCS_REFUSAL))), content + else: + assert JSON_OBJECT.validate_json(content) == DOCS_REFUSAL, content + assert _paths(rig.wire) == (f"/model/{BEDROCK_MODEL}/{action}",) + logged: Final = _logged_response(rig, key) + assert relayed(logged) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), logged + + +@pytest.mark.parametrize("stream", [False, True], ids=["rawPredict", "streamRawPredict"]) +def test_vertex_passthrough_refusal_relays_upstream_bytes_and_logs_the_stop_details(rig: Rig, stream: bool) -> None: + with rig.gateway.scenario() as scenario: + key: Final = scenario.key() + action: Final = "streamRawPredict" if stream else "rawPredict" + body: Final = { + "anthropic_version": "vertex-2023-10-16", + "max_tokens": 64, + "stream": stream, + "messages": [{"role": "user", "content": refusal_prompt("refusal", _marker())}], + } + content: Final = _post(rig, f"/vertex_ai{_VERTEX_MODEL_PATH}:{action}", body, key) + if stream: + assert content == b"".join(sse_chunks(refusal_events(DOCS_REFUSAL))), content + else: + assert JSON_OBJECT.validate_json(content) == DOCS_REFUSAL, content + assert _paths(rig.vertex) == (f"{_VERTEX_MODEL_PATH}:{action}",) + assert _targets(rig.wire) == () + logged: Final = _logged_response(rig, key) + assert relayed(logged) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), logged + + +def _surface_call(rig: Rig, surface: Surface, key: str, model: str, text: str) -> JsonValue: + match surface: + case "chat": + return _chat(rig, key, model, text, cached=True) + case "chat_stream": + return list(_chat_stream(rig, key, model, text, cached=True)) + case "messages": + return _messages(rig, key, model, text, cached=True) + case "messages_stream": + return list(_messages_stream(rig, key, model, text, cached=True)) + case "responses": + return _responses(rig, key, model, text, cached=True) + case "responses_stream": + return list(_responses_stream(rig, key, model, text)) + + +@dataclass(frozen=True, slots=True) +class CacheTwin: + fields: tuple[tuple[Location, Event], ...] + served_from_cache: tuple[bool, bool] + + +_CACHE_TWINS: Final[Mapping[Surface, CacheTwin]] = { + "chat": CacheTwin(((_CHAT_FIELDS, REFUSAL_FIELDS),), (False, True)), + "chat_stream": CacheTwin(((_DELTA_FIELDS, REFUSAL_FIELDS),), (False, True)), + "messages": CacheTwin((), (False, False)), + "messages_stream": CacheTwin((), (False, False)), +} + + +def _relayed_anywhere(value: JsonValue) -> tuple[tuple[Location, Event], ...]: + return _relayed_in(JSON_LIST.validate_python(value)) if isinstance(value, list) else relayed(value) + + +@pytest.mark.parametrize("surface", tuple(_CACHE_TWINS)) +def test_cache_hit_twin_carries_the_same_stop_details_as_the_miss(rig: Rig, surface: Surface) -> None: + twin: Final = _CACHE_TWINS[surface] + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + key: Final = scenario.key() + text: Final = refusal_prompt("refusal", _marker()) + first: Final = _surface_call(rig, surface, key, model, text) + _logged(rig, key) + second: Final = _surface_call(rig, surface, key, model, text) + logged: Final = _logged(rig, key, 2) + upstream: Final = _paths(rig.wire) + served_from_cache: Final = tuple(event.get("cache_hit") is True for event in logged) + evidence: Final = {"cache_hit": served_from_cache, "upstream": upstream, "first": first, "second": second} + assert (served_from_cache, len(upstream)) == (twin.served_from_cache, twin.served_from_cache.count(False)), ( + evidence + ) + assert (_relayed_anywhere(first), _relayed_anywhere(second)) == (twin.fields, twin.fields), evidence + assert tuple(relayed(event["response"]) for event in logged) == (((_CHAT_FIELDS, REFUSAL_FIELDS),),) * 2, ( + served_from_cache, + logged, + ) + + +def test_responses_cache_hit_logs_the_same_stop_details_as_the_miss(rig: Rig) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + key: Final = scenario.key() + text: Final = refusal_prompt("refusal", _marker()) + first: Final = _surface_call(rig, "responses", key, model, text) + _logged(rig, key) + _surface_call(rig, "responses", key, model, text) + logged: Final = _logged(rig, key, 2) + served_from_cache: Final = tuple(event.get("cache_hit") is True for event in logged) + assert (len(_paths(rig.wire)), served_from_cache.count(True)) == (1, 1), (served_from_cache, logged) + assert relayed(first) == ((_RESPONSE_FIELDS, REFUSAL_FIELDS),), first + assert tuple(relayed(event["response"]) for event in logged) == (((_CHAT_FIELDS, REFUSAL_FIELDS),),) * 2, ( + served_from_cache, + logged, + ) + + +def test_passthrough_is_not_served_from_the_response_cache(rig: Rig) -> None: + with rig.gateway.scenario() as scenario: + key: Final = scenario.key() + body: Final = { + "model": MODEL, + "max_tokens": 64, + "messages": [{"role": "user", "content": refusal_prompt("refusal", _marker())}], + } + first: Final = _post(rig, "/anthropic/v1/messages", body, key) + second: Final = _post(rig, "/anthropic/v1/messages", body, key) + assert JSON_OBJECT.validate_json(first) == JSON_OBJECT.validate_json(second) == DOCS_REFUSAL + assert _targets(rig.wire) == (f"{_ANTHROPIC_BASE}/v1/messages",) * 2 + logged: Final = _logged(rig, key, 2) + assert tuple(relayed(event["response"]) for event in logged) == (((_CHAT_FIELDS, REFUSAL_FIELDS),),) * 2 + + +@pytest.mark.parametrize("variant", tuple(MALFORMED_STOP_DETAILS)) +def test_malformed_stop_details_are_relayed_as_sent_without_breaking_the_turn(rig: Rig, variant: str) -> None: + value: Final = MALFORMED_STOP_DETAILS[variant] + expected: Final[Event] = {"stop_reason": "end_turn", "stop_details": value} + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + key: Final = scenario.key() + body: Final = _chat(rig, key, model, refusal_prompt(variant, _marker())) + assert relayed(body) == ((_CHAT_FIELDS, expected),), body + assert (_choice(body)["finish_reason"], _message_text(body)) == ("stop", "ready"), body + chunks: Final = _chat_stream(rig, key, model, refusal_prompt(variant, _marker())) + assert _relayed_in(chunks) == ((_DELTA_FIELDS, expected),), chunks + assert (_finish_reasons(chunks), _streamed_text(chunks)) == (("stop",), "ready"), chunks + assert len(_paths(rig.wire)) == 2 + logged: Final = _logged(rig, key, 2) + assert tuple(relayed(event["response"]) for event in logged) == (((_CHAT_FIELDS, expected),),) * 2, logged + assert rig.gateway.client.get("/health/liveliness").status_code == 200 + + +def test_duplicated_stop_details_key_relays_the_docs_object_once(rig: Rig) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + key: Final = scenario.key() + body: Final = _chat(rig, key, model, refusal_prompt("duplicate", _marker())) + assert relayed(body) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), body + logged: Final = _logged_response(rig, key) + assert relayed(logged) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), logged + + +def test_non_object_upstream_body_fails_the_call_and_leaves_other_keys_serving(rig: Rig) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + broken_key: Final = scenario.key() + healthy_key: Final = scenario.key() + broken: Final = rig.gateway.request( + "POST", + "/v1/chat/completions", + _chat_request(model, refusal_prompt("nondict", _marker()), stream=False, cached=False), + key=broken_key, + ) + assert broken.status_code == 500, broken.text + assert "error" in JSON_OBJECT.validate_json(broken.content), broken.text + healthy: Final = _chat(rig, healthy_key, model, refusal_prompt("refusal", _marker())) + assert relayed(healthy) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), healthy + assert len(_paths(rig.wire)) == 2 + failure: Final = _logged(rig, broken_key)[0] + assert (failure["status"], relayed(failure["response"])) == ("failure", ()), failure + + +@pytest.mark.parametrize( + ("fixture", "status", "message"), + [("unauthorized", 401, "invalid x-api-key"), ("invalid", 400, "max_tokens: Field required")], +) +@pytest.mark.parametrize("stream", [False, True], ids=["json", "sse"]) +def test_provider_errors_reach_the_caller_and_log_a_failure_without_stop_fields( + rig: Rig, fixture: str, status: int, message: str, stream: bool +) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + key: Final = scenario.key() + response: Final = rig.gateway.request( + "POST", + "/v1/chat/completions", + _chat_request(model, refusal_prompt(fixture, _marker()), stream=stream, cached=False), + key=key, + ) + assert response.status_code == status, response.text + assert message in response.text, response.text + assert _paths(rig.wire) == ("/v1/messages",) + failure: Final = _logged(rig, key)[0] + assert (failure["status"], relayed(failure["response"])) == ("failure", ()), failure + + +def test_failing_sinks_get_one_attempt_each_and_the_proxy_stays_healthy(rig: Rig) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + key: Final = scenario.key() + body: Final = _chat(rig, key, model, refusal_prompt("refusal", _marker())) + assert relayed(body) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), body + for ledger in (rig.sink, rig.forbidden, rig.absent): + events = ledger.await_key(key) + assert tuple(relayed(event["response"]) for event in events) == (((_CHAT_FIELDS, REFUSAL_FIELDS),),) + held( + lambda: tuple(len(ledger.for_key(key)) for ledger in (rig.sink, rig.forbidden, rig.absent)), + lambda counts: counts == (1, 1, 1), + holding=3, + ) + readiness: Final = rig.gateway.client.get("/health/readiness") + assert (readiness.status_code, readiness.json()["status"]) == (200, "healthy"), readiness.text + + +def test_unauthenticated_refusal_request_never_reaches_the_upstream_or_the_sink(rig: Rig) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + stranger: Final = "sk-not-a-virtual-key-" + _marker() + response: Final = rig.gateway.request( + "POST", + "/v1/chat/completions", + _chat_request(model, refusal_prompt("refusal", _marker()), stream=False, cached=False), + key=stranger, + ) + assert response.status_code == 401, response.text + assert _targets(rig.wire) == () + assert rig.sink.for_key(stranger) == () + + +@pytest.mark.parametrize("fixture", ["null", "missing"]) +def test_normal_turn_adds_no_stop_fields_on_any_surface(rig: Rig, fixture: str) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + key: Final = scenario.key() + body: Final = _chat(rig, key, model, refusal_prompt(fixture, _marker())) + assert relayed(body) == (), body + assert (_choice(body)["finish_reason"], _message_text(body)) == ("stop", "ready"), body + chunks: Final = _chat_stream(rig, key, model, refusal_prompt(fixture, _marker())) + assert _relayed_in(chunks) == (), chunks + assert (_finish_reasons(chunks), _streamed_text(chunks)) == (("stop",), "ready"), chunks + events: Final = _messages_stream(rig, key, model, refusal_prompt(fixture, _marker())) + expected_native: Final = {"stop_reason": "end_turn", "stop_details": None} + assert _native_stops(events) == (expected_native,), events + assert len(_paths(rig.wire)) == 3 + logged: Final = _logged(rig, key, 3) + assert tuple(relayed(event["response"]) for event in logged) == ((), (), ()), logged + + +def test_refusal_after_partial_text_keeps_the_text_and_relays_the_stop_details(rig: Rig) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + chat_key: Final = scenario.key() + messages_key: Final = scenario.key() + chunks: Final = _chat_stream(rig, chat_key, model, refusal_prompt("partial", _marker())) + assert _streamed_text(chunks) == "Hello..", chunks + assert _relayed_in(chunks) == ((_DELTA_FIELDS, REFUSAL_FIELDS),), chunks + assert _finish_reasons(chunks) == ("content_filter",), chunks + logged_chat: Final = _logged_response(rig, chat_key) + assert relayed(logged_chat) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), logged_chat + assert _message_text(JSON_OBJECT.validate_python(logged_chat)) == "Hello..", logged_chat + events: Final = _messages_stream(rig, messages_key, model, refusal_prompt("partial", _marker())) + assert _native_stops(events) == (REFUSAL_FIELDS,), events + assert frame_types(events) == frame_types(FIXTURES["partial"].events), events + logged_messages: Final = _logged_response(rig, messages_key) + assert relayed(logged_messages) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), logged_messages + assert len(_paths(rig.wire)) == 2 + + +def test_repeated_identical_refusals_log_once_per_request(rig: Rig) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + key: Final = scenario.key() + text: Final = refusal_prompt("refusal", _marker()) + bodies: Final = (_chat(rig, key, model, text), _chat(rig, key, model, text)) + assert tuple(relayed(body) for body in bodies) == (((_CHAT_FIELDS, REFUSAL_FIELDS),),) * 2, bodies + assert len(_paths(rig.wire)) == 2 + logged: Final = _logged(rig, key, 2) + assert len({event["litellm_call_id"] for event in logged}) == 2, logged + assert tuple(relayed(event["response"]) for event in logged) == (((_CHAT_FIELDS, REFUSAL_FIELDS),),) * 2 + + +@pytest.mark.parametrize("fixture", ["refusal", "int", "null"]) +def test_chat_stream_logged_response_keeps_the_received_model_id(rig: Rig, fixture: str) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + key: Final = scenario.key() + chunks: Final = _chat_stream(rig, key, model, refusal_prompt(fixture, _marker())) + assert len({chunk["id"] for chunk in chunks}) == 1, chunks + probed: Final = rig.hidden.await_key(key) + assert len(probed) == 1, probed + assert (probed[0]["response_id"], probed[0]["received_model_id"]) == (chunks[0]["id"], chunks[0]["id"]), probed + + +def _delta_fields(chunks: Sequence[Mapping[str, JsonValue]]) -> tuple[JsonValue, ...]: + deltas: Final = tuple(JSON_OBJECT.validate_python(_choice(chunk).get("delta") or {}) for chunk in chunks) + return tuple( + delta["provider_specific_fields"] for delta in deltas if delta.get("provider_specific_fields") is not None + ) + + +def _message_fields(response: JsonValue) -> tuple[JsonValue, ...]: + message: Final = JSON_OBJECT.validate_python(_choice(JSON_OBJECT.validate_python(response))["message"]) + fields: Final = message.get("provider_specific_fields") + return () if fields is None else (fields,) + + +def test_openai_compatible_stream_logged_response_keeps_the_received_model_id(rig: Rig) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "openai_compatible", rig) + key: Final = scenario.key() + marker: Final = _marker() + chunks: Final = _chat_stream(rig, key, model, refusal_prompt("plain", marker)) + assert (_streamed_text(chunks), _finish_reasons(chunks), _delta_fields(chunks)) == ("ready now", ("stop",), ()) + assert {chunk["id"] for chunk in chunks} == {openai_id(marker)}, chunks + assert _paths(rig.wire) == ("/v1/chat/completions",) + probed: Final = rig.hidden.await_key(key) + assert len(probed) == 1, probed + assert (probed[0]["response_id"], probed[0]["received_model_id"]) == (openai_id(marker),) * 2, probed + + +@pytest.mark.parametrize(("fixture", "fields"), [("plain", ()), ("cited", (CITED_FIELDS,))], ids=["plain", "cited"]) +def test_openai_compatible_stream_cache_hit_replays_the_provider_fields_of_the_miss( + rig: Rig, fixture: str, fields: tuple[JsonValue, ...] +) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "openai_compatible", rig) + key: Final = scenario.key() + text: Final = refusal_prompt(fixture, _marker()) + first: Final = _chat_stream(rig, key, model, text, cached=True) + _logged(rig, key) + second: Final = _chat_stream(rig, key, model, text, cached=True) + logged: Final = _logged(rig, key, 2) + served_from_cache: Final = tuple(event.get("cache_hit") is True for event in logged) + evidence: Final = {"cache_hit": served_from_cache, "first": first, "second": second, "logged": logged} + assert (served_from_cache, _paths(rig.wire)) == ((False, True), ("/v1/chat/completions",)), evidence + streamed: Final = tuple( + (_streamed_text(chunks), _finish_reasons(chunks), _delta_fields(chunks)) for chunks in (first, second) + ) + assert streamed == (("ready now", ("stop",), fields),) * 2, evidence + assert tuple(_message_fields(event["response"]) for event in logged) == (fields, fields), evidence + + +def _spend_rows(rig: Rig, key: str) -> tuple[Event, ...]: + response: Final = rig.gateway.request("GET", "/spend/logs", params={"api_key": key}) + assert response.status_code == 200, response.text + return tuple(JSON_OBJECT.validate_python(row) for row in JSON_LIST.validate_json(response.content)) + + +def test_spend_logs_endpoint_reports_the_stop_details_of_a_refusal(rig: Rig) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + key: Final = scenario.key() + body: Final = _chat(rig, key, model, refusal_prompt("refusal", _marker())) + rows: Final = eventually(lambda: _spend_rows(rig, key), lambda found: len(found) >= 1, seconds=70) + assert len(rows) == 1, rows + reported: Final = (rows[0]["request_id"], relayed(rows[0]["response"])) + expected: Final = (body["id"], ((_CHAT_FIELDS, REFUSAL_FIELDS),)) + assert ((body["id"], relayed(body)), reported) == (expected, expected), rows + + +def _usage_counts(usage: JsonValue, total: str, details: str) -> tuple[JsonValue, JsonValue]: + counted: Final = JSON_OBJECT.validate_python(usage) + return (counted[total], JSON_OBJECT.validate_python(counted[details])["reasoning_tokens"]) + + +def _chat_usage_counts(chunks: Sequence[Mapping[str, JsonValue]]) -> tuple[tuple[JsonValue, JsonValue], ...]: + return tuple( + _usage_counts(chunk["usage"], "completion_tokens", "completion_tokens_details") + for chunk in chunks + if chunk.get("usage") + ) + + +def _cached_stream_usage_counts( + rig: Rig, surface: Literal["chat_stream", "responses_stream"], key: str, model: str, text: str +) -> tuple[tuple[JsonValue, JsonValue], ...]: + match surface: + case "chat_stream": + request: Final = { + **_chat_request(model, text, stream=True, cached=True), + "stream_options": {"include_usage": True}, + } + return _chat_usage_counts(chunks_of(_post(rig, "/v1/chat/completions", request, key).decode())) + case "responses_stream": + events: Final = frames( + _post(rig, "/v1/responses", _responses_request(model, text, stream=True, cached=True), key).decode() + ) + responses: Final = tuple( + JSON_OBJECT.validate_python(event["response"]) + for event in events + if isinstance(event.get("response"), dict) + ) + return tuple( + _usage_counts(response["usage"], "output_tokens", "output_tokens_details") + for response in responses + if response.get("usage") + ) + + +def _logged_usage_counts(logged: Sequence[Mapping[str, JsonValue]]) -> tuple[tuple[JsonValue, JsonValue], ...]: + return tuple( + _usage_counts( + JSON_OBJECT.validate_python(event["response"])["usage"], "completion_tokens", "completion_tokens_details" + ) + for event in logged + ) + + +@pytest.mark.parametrize("fixture", ["plainusage", "citedusage"], ids=["plain", "cited"]) +def test_openai_compatible_stream_cache_hit_replays_the_usage_details_of_the_miss(rig: Rig, fixture: str) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "openai_compatible", rig) + key: Final = scenario.key() + text: Final = refusal_prompt(fixture, _marker()) + first: Final = _cached_stream_usage_counts(rig, "chat_stream", key, model, text) + _logged(rig, key) + second: Final = _cached_stream_usage_counts(rig, "chat_stream", key, model, text) + logged: Final = _logged(rig, key, 2) + served_from_cache: Final = tuple(event.get("cache_hit") is True for event in logged) + evidence: Final = {"cache_hit": served_from_cache, "first": first, "second": second, "logged": logged} + assert (served_from_cache, _paths(rig.wire)) == ((False, True), ("/v1/chat/completions",)), evidence + assert (first, second, _logged_usage_counts(logged)) == (((7, 4),), ((7, 4),), ((7, 4), (7, 4))), evidence + + +def test_text_completion_stream_cache_hit_replays_the_usage_details_of_the_miss(rig: Rig) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "openai_compatible", rig) + key: Final = scenario.key() + request: Final[Event] = { + "model": model, + "prompt": refusal_prompt("plainusage", _marker()), + "max_tokens": 64, + "stream": True, + "stream_options": {"include_usage": True}, + } + first: Final = _chat_usage_counts(chunks_of(_post(rig, "/v1/completions", request, key).decode())) + _logged(rig, key) + second: Final = _chat_usage_counts(chunks_of(_post(rig, "/v1/completions", request, key).decode())) + logged: Final = _logged(rig, key, 2) + served_from_cache: Final = tuple(event.get("cache_hit") is True for event in logged) + evidence: Final = {"cache_hit": served_from_cache, "first": first, "second": second, "logged": logged} + assert (served_from_cache, _paths(rig.wire)) == ((False, True), ("/v1/chat/completions",)), evidence + assert (first, second, _logged_usage_counts(logged)) == (((7, 4),), ((7, 4),), ((7, 4), (7, 4))), evidence + + +@pytest.mark.parametrize("surface", ["chat_stream", "responses_stream"]) +def test_anthropic_stream_cache_hit_keeps_the_usage_details_of_the_miss( + rig: Rig, surface: Literal["chat_stream", "responses_stream"] +) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + key: Final = scenario.key() + text: Final = refusal_prompt("reasoned", _marker()) + first: Final = _cached_stream_usage_counts(rig, surface, key, model, text) + _logged(rig, key) + second: Final = _cached_stream_usage_counts(rig, surface, key, model, text) + logged: Final = _logged(rig, key, 2) + served_from_cache: Final = tuple(event.get("cache_hit") is True for event in logged) + evidence: Final = {"cache_hit": served_from_cache, "first": first, "second": second, "logged": logged} + assert (served_from_cache, _paths(rig.wire)) == ((False, True), ("/v1/messages",)), evidence + assert (first, second, _logged_usage_counts(logged)) == (((10, 4),), ((10, 4),), ((10, 4), (10, 4))), evidence + + +def _response_id(event: Mapping[str, JsonValue]) -> JsonValue: + return JSON_OBJECT.validate_python(event["response"]).get("id") + + +@pytest.mark.parametrize("variant", tuple(MALFORMED_STOP_DETAILS)) +def test_stream_cache_hit_skips_cached_provider_fields_that_are_not_an_object(rig: Rig, variant: str) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "openai_compatible", rig) + key: Final = scenario.key() + marker: Final = _marker() + text: Final = refusal_prompt("plain", marker) + filled: Final = chunks_of( + _post( + rig, + "/v1/chat/completions", + {**_chat_request(model, text, stream=True, cached=True), "cache": {"ttl": 2}}, + key, + ).decode() + ) + assert _streamed_text(filled) == "ready now", filled + _logged(rig, key) + stale_id: Final = rewrite_cached_message_fields(openai_id(marker), MALFORMED_STOP_DETAILS[variant]) + replayed: Final = eventually( + lambda: _chat_stream(rig, key, model, text, cached=True), + lambda chunks: {chunk["id"] for chunk in chunks} == {stale_id}, + seconds=30, + ) + streamed: Final = (_streamed_text(replayed), _finish_reasons(replayed), _delta_fields(replayed)) + assert streamed == ("ready now", ("stop",), ()), replayed + assert _paths(rig.wire) == ("/v1/chat/completions",) + stale_logs: Final = eventually( + lambda: tuple(event for event in rig.sink.for_key(key) if _response_id(event) == stale_id), + lambda found: len(found) >= 1, + ) + assert tuple(_message_fields(event["response"]) for event in stale_logs) == ((),) * len(stale_logs), stale_logs + + +JsonModeRoute: TypeAlias = Literal["anthropic_native", "anthropic_tool", "bedrock_invoke"] +_UNMAPPED_MODEL: Final = "claude-scripted-9" +_JSON_ANSWER: Final = json.dumps({"answer": DOCS_PARTIAL_TEXT}) + + +@dataclass(frozen=True, slots=True) +class JsonModeCase: + route: JsonModeRoute + fixture: str + streamed_text: str + message_text: str + streamed_finish_reason: str + message_finish_reason: str + requested: Literal["json_tool", "native"] + + +_JSON_MODE_CASES: Final[Mapping[str, JsonModeCase]] = { + "anthropic_native": JsonModeCase( + "anthropic_native", + "partial", + DOCS_PARTIAL_TEXT, + DOCS_PARTIAL_TEXT, + "content_filter", + "content_filter", + "native", + ), + "anthropic_tool_declared": JsonModeCase( + "anthropic_tool", "jsontool", DECLARED_PARTIAL_JSON, _JSON_ANSWER, "content_filter", "stop", "json_tool" + ), + "anthropic_tool_enveloped": JsonModeCase( + "anthropic_tool", "jsonheld", ENVELOPED_PARTIAL_JSON, _JSON_ANSWER, "content_filter", "stop", "json_tool" + ), + "bedrock_invoke_declared": JsonModeCase( + "bedrock_invoke", "jsontool", DECLARED_PARTIAL_JSON, _JSON_ANSWER, "content_filter", "stop", "json_tool" + ), + "bedrock_invoke_enveloped": JsonModeCase( + "bedrock_invoke", "jsonheld", ENVELOPED_PARTIAL_JSON, _JSON_ANSWER, "content_filter", "stop", "json_tool" + ), +} + + +def _json_mode_deployment(scenario: Scenario, route: JsonModeRoute, rig: Rig) -> tuple[str, Provider]: + match route: + case "anthropic_native": + return _deployment(scenario, "anthropic", rig), "anthropic" + case "anthropic_tool": + return ( + scenario.model(model=f"anthropic/{_UNMAPPED_MODEL}", api_base=rig.wire.url, api_key=_ANTHROPIC_KEY), + "anthropic", + ) + case "bedrock_invoke": + return _deployment(scenario, "bedrock_invoke", rig), "bedrock_invoke" + + +def _requested_format(body: Mapping[str, JsonValue]) -> tuple[bool, bool]: + tools: Final = JSON_LIST.validate_python(body.get("tools") or []) + output_config: Final = JSON_OBJECT.validate_python(body.get("output_config") or {}) + json_tool: Final = any(JSON_OBJECT.validate_python(tool).get("name") == JSON_TOOL_NAME for tool in tools) + return json_tool, "output_format" in body or "format" in output_config + + +def _received(wire: Wire) -> tuple[tuple[str, tuple[bool, bool]], ...]: + return tuple( + (urlsplit(unquote(request.target)).path, _requested_format(JSON_OBJECT.validate_json(request.body))) + for request in wire.drain() + ) + + +@pytest.mark.parametrize("stream", [False, True], ids=["json", "sse"]) +@pytest.mark.parametrize("case", tuple(_JSON_MODE_CASES)) +def test_json_mode_refusal_relays_the_docs_stop_details_through_the_response_format_path( + rig: Rig, case: str, stream: bool +) -> None: + expected: Final = _JSON_MODE_CASES[case] + with rig.gateway.scenario() as scenario: + model, provider = _json_mode_deployment(scenario, expected.route, rig) + key: Final = scenario.key() + request: Final = { + **_chat_request(model, refusal_prompt(expected.fixture, _marker()), stream=stream, cached=False), + "response_format": JSON_ANSWER_FORMAT, + } + content: Final = _post(rig, "/v1/chat/completions", request, key) + requested: Final = (expected.requested == "json_tool", expected.requested == "native") + assert _received(rig.wire) == ((_STREAM_TARGETS[provider][int(stream)], requested),) + if stream: + chunks: Final = chunks_of(content.decode()) + streamed: Final = (_streamed_text(chunks), _finish_reasons(chunks), _relayed_in(chunks)) + assert streamed == ( + expected.streamed_text, + (expected.streamed_finish_reason,), + ((_DELTA_FIELDS, REFUSAL_FIELDS),), + ), chunks + else: + body: Final = JSON_OBJECT.validate_json(content) + answered: Final = (_message_text(body), _choice(body)["finish_reason"], relayed(body)) + assert answered == ( + expected.message_text, + expected.message_finish_reason, + ((_CHAT_FIELDS, REFUSAL_FIELDS),), + ), body + logged: Final = _logged_response(rig, key) + assert relayed(logged) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), logged + + +def _serialized_choice(body: Mapping[str, JsonValue]) -> Event: + choice: Final = _choice(body) + return {**choice, "message": JSON_OBJECT.validate_python(choice["message"])} + + +_ANTHROPIC_MESSAGE_FIELDS: Final[Event] = {"citations": None, "thinking_blocks": None} + + +@pytest.mark.parametrize(("fixture", "content"), [("refusal", None), ("partial", DOCS_PARTIAL_TEXT)]) +def test_chat_refusal_body_serializes_the_stop_details_beside_the_existing_message_fields( + rig: Rig, fixture: str, content: str | None +) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "anthropic", rig) + key: Final = scenario.key() + body: Final = _chat(rig, key, model, refusal_prompt(fixture, _marker())) + expected: Final[Event] = { + "finish_reason": "content_filter", + "index": 0, + "message": { + "content": content, + "role": "assistant", + "provider_specific_fields": {**_ANTHROPIC_MESSAGE_FIELDS, **REFUSAL_FIELDS}, + }, + } + assert _serialized_choice(body) == expected, body + assert _paths(rig.wire) == ("/v1/messages",) + logged: Final = _logged_response(rig, key) + assert relayed(logged) == ((_CHAT_FIELDS, REFUSAL_FIELDS),), logged + + +def _usage_of(chunks: Sequence[Mapping[str, JsonValue]]) -> tuple[tuple[JsonValue, JsonValue], ...]: + usages: Final = tuple(JSON_OBJECT.validate_python(chunk["usage"]) for chunk in chunks if chunk.get("usage")) + return tuple((usage["completion_tokens"], usage.get("completion_tokens_details")) for usage in usages) + + +def _served_ids(response: httpx.Response) -> frozenset[JsonValue]: + return frozenset(chunk["id"] for chunk in chunks_of(response.text)) if response.status_code == 200 else frozenset() + + +@pytest.mark.parametrize("variant", tuple(UNVALIDATED_USAGE_DETAILS)) +def test_stream_cache_hit_skips_cached_usage_details_that_fail_validation(rig: Rig, variant: str) -> None: + with rig.gateway.scenario() as scenario: + model: Final = _deployment(scenario, "openai_compatible", rig) + key: Final = scenario.key() + marker: Final = _marker() + request: Final = { + **_chat_request(model, refusal_prompt("plainusage", marker), stream=True, cached=True), + "stream_options": {"include_usage": True}, + } + filled: Final = chunks_of(_post(rig, "/v1/chat/completions", {**request, "cache": {"ttl": 2}}, key).decode()) + assert (_streamed_text(filled), _chat_usage_counts(filled)) == ("ready now", ((7, 4),)), filled + _logged(rig, key) + stale_id: Final = rewrite_cached_usage_details(openai_id(marker), UNVALIDATED_USAGE_DETAILS[variant]) + replayed: Final = eventually( + lambda: rig.gateway.request("POST", "/v1/chat/completions", request, key=key), + lambda response: response.status_code != 200 or _served_ids(response) == {stale_id}, + seconds=30, + ) + assert replayed.status_code == 200, replayed.text + chunks: Final = chunks_of(replayed.text) + streamed: Final = (_streamed_text(chunks), _finish_reasons(chunks), _usage_of(chunks)) + assert streamed == ("ready now", ("stop",), ((7, {"reasoning_tokens": 0, "text_tokens": 7}),)), chunks + assert _paths(rig.wire) == ("/v1/chat/completions",) + stale_logs: Final = eventually( + lambda: tuple(event for event in rig.sink.for_key(key) if _response_id(event) == stale_id), + lambda found: len(found) >= 1, + ) + assert tuple(event["status"] for event in stale_logs) == ("success",) * len(stale_logs), stale_logs diff --git a/tests/integration/sdk/test_response_cache_sync_sdk.py b/tests/integration/sdk/test_response_cache_sync_sdk.py index 54d78adbaba..1fe73ec9012 100644 --- a/tests/integration/sdk/test_response_cache_sync_sdk.py +++ b/tests/integration/sdk/test_response_cache_sync_sdk.py @@ -9,17 +9,23 @@ from __future__ import annotations import os from collections.abc import Iterator +from itertools import chain from typing import Final import pytest +from pydantic import JsonValue, TypeAdapter import litellm from litellm.caching.caching import Cache +from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper from litellm.types.caching import LiteLLMCacheType from litellm.types.utils import ModelResponse, TextCompletionResponse +from tests.integration._support.client import JSON_OBJECT from tests.integration.caching.response_cache_case import ( CHAT, + CHAT_CHUNK, CHAT_MODEL, + CHAT_STREAM, PROVIDER_KEY, TEXT, TEXT_MODEL, @@ -31,8 +37,10 @@ from tests.integration.caching.response_cache_case import ( prompt, redis_store, rescript, + rewrite_usage_details, scripted_in_process, ) +from tests.integration.cost_calculation.cost_tracking_case import SseResponse @pytest.fixture @@ -123,3 +131,77 @@ def test_sync_text_completion_with_empty_choices_reaches_upstream_again() -> Non hit: Final = _text_completion(handle.api_base(), text) assert hit.id == refilled.id, hit.model_dump_json() assert upstream.calls(handle.scenario_id) == 2 + + +_REASONING_USAGE_FRAME: Final = ( + CHAT_CHUNK + '[],"usage":{"prompt_tokens":5,"completion_tokens":7,"total_tokens":12,' + '"completion_tokens_details":{"reasoning_tokens":4}}}' +) +_REASONING_STREAM: Final = SseResponse( + content_type="text/event-stream", + frames=(*CHAT_STREAM.frames[:-1], _REASONING_USAGE_FRAME, CHAT_STREAM.frames[-1]), +) +_JSON_LIST: Final = TypeAdapter(list[JsonValue]) +_UNVALIDATED_USAGE_DETAILS: Final[dict[str, JsonValue]] = { + "text": {"reasoning_tokens": "abc"}, + "fraction": {"reasoning_tokens": 1.5}, + "object": {"reasoning_tokens": {"a": 1}}, +} + + +def _stream(api_base: str, text: str) -> tuple[dict[str, JsonValue], ...]: + stream: Final = litellm.completion( + model=CHAT_MODEL, + messages=[{"role": "user", "content": text}], + api_base=api_base, + api_key=PROVIDER_KEY, + stream=True, + stream_options={"include_usage": True}, + ) + assert isinstance(stream, CustomStreamWrapper), type(stream) + return tuple(JSON_OBJECT.validate_python(chunk.model_dump(mode="json")) for chunk in stream) + + +def _replayed(chunks: tuple[dict[str, JsonValue], ...]) -> tuple[frozenset[JsonValue], str, tuple[JsonValue, ...]]: + choices: Final = tuple( + JSON_OBJECT.validate_python(choice) + for choice in chain.from_iterable(_JSON_LIST.validate_python(chunk.get("choices") or []) for chunk in chunks) + ) + text: Final = "".join(str(JSON_OBJECT.validate_python(choice["delta"]).get("content") or "") for choice in choices) + usages: Final = tuple(JSON_OBJECT.validate_python(chunk["usage"]) for chunk in chunks if chunk.get("usage")) + reasoning: Final = tuple( + JSON_OBJECT.validate_python(usage.get("completion_tokens_details") or {}).get("reasoning_tokens") + for usage in usages + ) + return frozenset(chunk["id"] for chunk in chunks), text, reasoning + + +@pytest.mark.usefixtures("sdk_cache") +def test_sync_stream_cache_hit_replays_the_usage_details_of_the_miss() -> None: + upstream: Final = Upstream() + text: Final = prompt() + with scripted_in_process(_REASONING_STREAM) as handle, redis_store() as store: + first: Final = _replayed(_stream(handle.api_base(), text)) + (first_id,) = first[0] + assert first[1:] == ("streamed response", (4,)), first + await_entry(store, str(first_id)) + hit: Final = _replayed(_stream(handle.api_base(), text)) + assert hit == first, hit + assert upstream.calls(handle.scenario_id) == 1 + + +@pytest.mark.usefixtures("sdk_cache") +@pytest.mark.parametrize("variant", tuple(_UNVALIDATED_USAGE_DETAILS)) +def test_sync_stream_cache_hit_skips_cached_usage_details_that_fail_validation(variant: str) -> None: + upstream: Final = Upstream() + text: Final = prompt() + with scripted_in_process(_REASONING_STREAM) as handle, redis_store() as store: + first: Final = _replayed(_stream(handle.api_base(), text)) + (first_id,) = first[0] + assert first[1:] == ("streamed response", (4,)), first + stale_id: Final = rewrite_usage_details( + store, await_entry(store, str(first_id)), _UNVALIDATED_USAGE_DETAILS[variant] + ) + hit: Final = _replayed(_stream(handle.api_base(), text)) + assert hit == (frozenset({stale_id}), "streamed response", (0,)), hit + assert upstream.calls(handle.scenario_id) == 1 diff --git a/tests/unit/litellm_core_utils/llm_response_utils/test_convert_to_streaming_response.py b/tests/unit/litellm_core_utils/llm_response_utils/test_convert_to_streaming_response.py index 2c3dae21058..e9d96f057ae 100644 --- a/tests/unit/litellm_core_utils/llm_response_utils/test_convert_to_streaming_response.py +++ b/tests/unit/litellm_core_utils/llm_response_utils/test_convert_to_streaming_response.py @@ -11,6 +11,7 @@ content frame, restoring per-token cadence on cache hits. import pytest +import litellm from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import ( _split_assembled_content_for_replay, convert_to_streaming_response, @@ -87,6 +88,71 @@ async def test_async_yields_multiple_content_chunks_with_lossless_join(): assert reassembled == text +@pytest.mark.parametrize("content", ["", "Hello world! How are you?"], ids=["single_frame", "sliced"]) +@pytest.mark.asyncio +async def test_async_and_sync_replays_carry_the_cached_provider_specific_fields(content): + provider_specific_fields = {"stop_reason": "refusal", "stop_details": {"category": "cyber"}} + + def payload(): + cached = _async_payload(content=content) + cached["choices"][0]["message"]["provider_specific_fields"] = provider_specific_fields + return cached + + async_chunks = await _collect_async(payload()) + sync_chunks = list(convert_to_streaming_response(payload())) + + assert [c.choices[0].delta.provider_specific_fields for c in async_chunks] == [ + c.choices[0].delta.provider_specific_fields for c in sync_chunks + ] + assert async_chunks[0].choices[0].delta.provider_specific_fields == provider_specific_fields + + +@pytest.mark.parametrize("provider_fields", [{"thought_signatures": ["sig"]}, None], ids=["provider_fields", "text"]) +@pytest.mark.parametrize("replay", ["async", "sync"]) +@pytest.mark.asyncio +async def test_a_replayed_stream_keeps_the_cached_completion_tokens_details(replay, provider_fields): + cached = _async_payload() + cached["choices"][0]["message"]["provider_specific_fields"] = provider_fields + cached["usage"]["completion_tokens_details"] = {"reasoning_tokens": 4, "text_tokens": 3} + + chunks = await _collect_async(cached) if replay == "async" else list(convert_to_streaming_response(cached)) + rebuilt = litellm.stream_chunk_builder(chunks=chunks) + + assert rebuilt.choices[0].message.get("provider_specific_fields") == provider_fields + assert rebuilt.usage.completion_tokens_details.reasoning_tokens == 4 + assert rebuilt.usage.completion_tokens_details.text_tokens == 3 + + +@pytest.mark.parametrize("cached_fields", [7, [1, 2], "", "x" * 5000], ids=["int", "list", "empty", "long"]) +@pytest.mark.asyncio +async def test_the_async_replay_ignores_cached_provider_fields_that_are_not_an_object(cached_fields): + cached = _async_payload() + cached["choices"][0]["message"]["provider_specific_fields"] = cached_fields + + chunks = await _collect_async(cached) + + assert "".join(c.choices[0].delta.content or "" for c in chunks) == "Hello world! How are you?" + assert all(c.choices[0].delta.provider_specific_fields is None for c in chunks) + + +@pytest.mark.parametrize( + "details", + [{"reasoning_tokens": "abc"}, {"reasoning_tokens": 1.5}, {"reasoning_tokens": {"a": 1}}], + ids=["text", "fraction", "object"], +) +@pytest.mark.parametrize("replay", ["async", "sync"]) +@pytest.mark.asyncio +async def test_a_replayed_stream_skips_cached_completion_tokens_details_that_do_not_validate(replay, details): + cached = _async_payload() + cached["usage"]["completion_tokens_details"] = details + + chunks = await _collect_async(cached) if replay == "async" else list(convert_to_streaming_response(cached)) + rebuilt = litellm.stream_chunk_builder(chunks=chunks) + + assert rebuilt.choices[0].message.content == "Hello world! How are you?" + assert rebuilt.usage.completion_tokens == 7 + + @pytest.mark.asyncio async def test_async_finish_reason_only_on_last_chunk(): chunks = await _collect_async(_async_payload()) @@ -204,9 +270,7 @@ def test_sync_multi_chunk_and_lossless_join(): def test_sync_delta_and_choice_metadata_only_on_first_chunk(): - thinking_blocks = [ - {"type": "thinking", "thinking": "cached thinking", "signature": "sig"} - ] + thinking_blocks = [{"type": "thinking", "thinking": "cached thinking", "signature": "sig"}] payload = { "id": "chatcmpl-test-sync", "object": "chat.completion", diff --git a/tests/unit/litellm_core_utils/test_streaming_handler.py b/tests/unit/litellm_core_utils/test_streaming_handler.py index 0172f0e9aeb..a604e61b9c2 100644 --- a/tests/unit/litellm_core_utils/test_streaming_handler.py +++ b/tests/unit/litellm_core_utils/test_streaming_handler.py @@ -732,6 +732,42 @@ def test_set_response_id_propagation_valid_to_invalid( assert initialized_custom_stream_wrapper.response_id == "first-valid-id" +@pytest.mark.parametrize( + "last_delta", + [ + Delta(content="Hello"), + Delta(provider_specific_fields={"stop_reason": "refusal"}), + ], + ids=["content", "provider_specific_fields"], +) +def test_logged_response_keeps_received_model_id_when_the_finish_chunk_is_synthesized( + logging_obj: Logging, last_delta: Delta +): + upstream_id: Final = "chatcmpl-upstream-id" + wrapper: Final = CustomStreamWrapper( + completion_stream=ModelResponseListIterator( + model_responses=[ + ModelResponseStream( + id=upstream_id, + model="gpt-4o-mini", + choices=[StreamingChoices(index=0, delta=last_delta, finish_reason="content_filter")], + ) + ] + ), + model="gpt-4o-mini", + logging_obj=logging_obj, + custom_llm_provider="openai", + ) + + emitted: Final = list(wrapper) + logged: Final = litellm.stream_chunk_builder(chunks=wrapper.chunks) + + assert len(emitted) == 2 + assert [chunk._hidden_params.get("received_model_id") for chunk in emitted] == [upstream_id, upstream_id] + assert logged is not None + assert logged._hidden_params.get("received_model_id") == upstream_id + + @pytest.mark.asyncio async def test_streaming_completion_start_time(logging_obj: Logging): """Test that the start time is set correctly""" diff --git a/tests/unit/llms/anthropic/anthropic_docs_refusal.py b/tests/unit/llms/anthropic/anthropic_docs_refusal.py new file mode 100644 index 00000000000..2bd75b4aab5 --- /dev/null +++ b/tests/unit/llms/anthropic/anthropic_docs_refusal.py @@ -0,0 +1,19 @@ +from typing import Final + +ANTHROPIC_DOCS_REFUSAL_URL: Final = "https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback#refusal-response" + +# Copied verbatim from ANTHROPIC_DOCS_REFUSAL_URL on 2026-10-12. Streams carry the same stop_details on message_delta. +ANTHROPIC_DOCS_REFUSAL_MESSAGE: Final = { + "id": "msg_01XFUDYJgAACzvnptvVoYEL", + "type": "message", + "role": "assistant", + "model": "claude-fable-5", + "content": [], + "stop_reason": "refusal", + "stop_details": { + "type": "refusal", + "category": "cyber", + "explanation": "This request was declined because it could enable cyber harm.", + }, + "usage": {"input_tokens": 412, "output_tokens": 0}, +} diff --git a/tests/unit/llms/anthropic/chat/test_anthropic_chat_handler.py b/tests/unit/llms/anthropic/chat/test_anthropic_chat_handler.py index 7d1d2a3c434..a072010bfa9 100644 --- a/tests/unit/llms/anthropic/chat/test_anthropic_chat_handler.py +++ b/tests/unit/llms/anthropic/chat/test_anthropic_chat_handler.py @@ -22,6 +22,7 @@ from litellm.types.llms.openai import ( ) from litellm.types.responses.main import OutputCodeInterpreterCall from litellm.types.utils import ChatCompletionDeltaToolCall, ModelResponseStream +from tests.unit.llms.anthropic.anthropic_docs_refusal import ANTHROPIC_DOCS_REFUSAL_MESSAGE @pytest.mark.asyncio @@ -3366,3 +3367,50 @@ def test_anthropic_streaming_invalid_key_maps_to_authentication_error() -> None: assert exc_info.value.status_code == 401 assert exc_info.value.llm_provider == "anthropic" assert route.calls.last.request.headers["x-api-key"] == "bad-anthropic-key" + + +def _message_stream_events(stop_reason: str, stop_details: dict | None) -> tuple[dict, ...]: + return ( + { + "type": "message_start", + "message": { + "id": ANTHROPIC_DOCS_REFUSAL_MESSAGE["id"], + "type": "message", + "role": "assistant", + "model": ANTHROPIC_DOCS_REFUSAL_MESSAGE["model"], + "content": [], + "usage": {"input_tokens": 412, "output_tokens": 0}, + }, + }, + { + "type": "message_delta", + "delta": {"stop_reason": stop_reason, "stop_sequence": None, "stop_details": stop_details}, + "usage": {"output_tokens": 0}, + }, + {"type": "message_stop"}, + ) + + +def _assemble_stream(events: tuple[dict, ...]) -> litellm.ModelResponse: + iterator: Final = ModelResponseIterator(streaming_response=MagicMock(), sync_stream=True, json_mode=False) + chunks: Final = [iterator.chunk_parser(event) for event in events] + return litellm.stream_chunk_builder(chunks=chunks, messages=[{"role": "user", "content": "hi"}]) + + +def test_streamed_refusal_stop_details_reach_the_assembled_message(): + stop_details: Final = ANTHROPIC_DOCS_REFUSAL_MESSAGE["stop_details"] + assembled: Final = _assemble_stream(_message_stream_events("refusal", stop_details)) + + assert assembled.choices[0].finish_reason == "content_filter" + provider_fields: Final = assembled.choices[0].message.provider_specific_fields + assert provider_fields["stop_reason"] == "refusal" + assert provider_fields["stop_details"] == stop_details + + +def test_streamed_end_turn_carries_no_stop_details(): + assembled: Final = _assemble_stream(_message_stream_events("end_turn", None)) + + assert assembled.choices[0].finish_reason == "stop" + provider_fields: Final = assembled.choices[0].message.provider_specific_fields or {} + assert "stop_details" not in provider_fields + assert "stop_reason" not in provider_fields diff --git a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py index bbcc8629a10..5f4ecc4440b 100644 --- a/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/unit/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -39,6 +39,7 @@ from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.transformation im from litellm.types.llms.anthropic import ANTHROPIC_BETA_HEADER_VALUES from litellm.types.llms.openai import ChatCompletionToolCallFunctionChunk from litellm.types.utils import ChatCompletionToolCallChunk, ModelResponse, ServerToolUse, Usage +from tests.unit.llms.anthropic.anthropic_docs_refusal import ANTHROPIC_DOCS_REFUSAL_MESSAGE def test_response_format_transformation_unit_test(): @@ -9082,3 +9083,93 @@ def test_completion_surfaces_response_citations_in_provider_specific_fields(resp assert response.choices[0].message.provider_specific_fields["citations"] == [ [{**citation, "supported_text": "the grass is green"}] ] + + +def _refusal_message_body(stop_reason: str, stop_details: dict | None) -> dict: + return {**ANTHROPIC_DOCS_REFUSAL_MESSAGE, "stop_reason": stop_reason, "stop_details": stop_details} + + +def _transform_message_body(body: dict) -> litellm.ModelResponse: + return AnthropicConfig().transform_parsed_response( + completion_response=body, + raw_response=httpx.Response(status_code=200, headers={}), + model_response=litellm.ModelResponse(), + json_mode=False, + prefix_prompt=None, + ) + + +def test_refusal_stop_details_kept_in_provider_specific_fields(): + stop_details: Final = ANTHROPIC_DOCS_REFUSAL_MESSAGE["stop_details"] + result: Final = _transform_message_body(ANTHROPIC_DOCS_REFUSAL_MESSAGE) + + assert result.choices[0].finish_reason == "content_filter" + provider_fields: Final = result.choices[0].message.provider_specific_fields + assert provider_fields["stop_reason"] == "refusal" + assert provider_fields["stop_details"] == stop_details + assert result._hidden_params["provider_specific_fields"]["stop_details"] == stop_details + + +def test_refusal_without_stop_details_keeps_the_native_stop_reason(): + result: Final = _transform_message_body(_refusal_message_body("refusal", None)) + + provider_fields: Final = result.choices[0].message.provider_specific_fields + assert provider_fields["stop_reason"] == "refusal" + assert "stop_details" not in provider_fields + + +def test_end_turn_response_carries_no_stop_details(): + result: Final = _transform_message_body(_refusal_message_body("end_turn", None)) + + assert result.choices[0].finish_reason == "stop" + provider_fields: Final = result.choices[0].message.provider_specific_fields + assert "stop_details" not in provider_fields + assert "stop_reason" not in provider_fields + + +def _json_mode_message_body(stop_reason: str, stop_details: dict | None) -> dict: + return { + **_refusal_message_body(stop_reason, stop_details), + "content": [ + {"type": "tool_use", "id": "toolu_json", "name": RESPONSE_FORMAT_TOOL_NAME, "input": {"answer": "Hello"}} + ], + } + + +def _transform_json_mode_body(body: dict) -> litellm.ModelResponse: + return AnthropicConfig().transform_parsed_response( + completion_response=body, + raw_response=httpx.Response(status_code=200, headers={}), + model_response=litellm.ModelResponse(), + json_mode=True, + prefix_prompt=None, + ) + + +def test_json_mode_refusal_keeps_stop_details_on_the_message(): + stop_details: Final = ANTHROPIC_DOCS_REFUSAL_MESSAGE["stop_details"] + result: Final = _transform_json_mode_body(_json_mode_message_body("refusal", stop_details)) + + message: Final = result.choices[0].message + assert json.loads(message.content) == {"answer": "Hello"} + assert message.provider_specific_fields == {"stop_reason": "refusal", "stop_details": stop_details} + + +def test_json_mode_tool_use_turn_adds_no_stop_fields_to_the_message(): + result: Final = _transform_json_mode_body(_json_mode_message_body("tool_use", None)) + + message: Final = result.choices[0].message + assert json.loads(message.content) == {"answer": "Hello"} + assert not message.provider_specific_fields + + +def test_non_dict_upstream_body_still_transforms_without_refusal_fields() -> None: + result = AnthropicConfig().transform_parsed_response( + completion_response=MagicMock(), + raw_response=httpx.Response(200, headers={}), + model_response=litellm.ModelResponse(), + json_mode=False, + prefix_prompt=None, + ) + provider_specific_fields = result.choices[0].message.provider_specific_fields or {} + assert "stop_details" not in provider_specific_fields diff --git a/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py b/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py index 859b57c7cf3..8cb9ad9b73c 100644 --- a/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py +++ b/tests/unit/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py @@ -4,7 +4,7 @@ import json import uuid from types import SimpleNamespace from typing import Final -from unittest.mock import patch +from unittest.mock import MagicMock, patch import httpx import pytest @@ -17,6 +17,7 @@ from litellm.llms.bedrock.chat.invoke_transformations.anthropic_claude3_transfor ) from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome +from tests.unit.llms.anthropic.anthropic_docs_refusal import ANTHROPIC_DOCS_REFUSAL_MESSAGE ONE_PIXEL_PNG = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" @@ -1689,3 +1690,23 @@ class TestBedrockAnthropicCombinedRegressions: user_msg = result["messages"][0] has_cache_control = any(isinstance(c, dict) and "cache_control" in c for c in user_msg["content"]) assert has_cache_control + + +def test_refusal_stop_details_kept_through_bedrock_invoke_transform(): + body: Final = ANTHROPIC_DOCS_REFUSAL_MESSAGE + result: Final = AmazonAnthropicClaudeConfig().transform_response( + model="anthropic.claude-sonnet-5-5-v1:0", + raw_response=httpx.Response(status_code=200, headers={}, json=body), + model_response=litellm.ModelResponse(), + logging_obj=MagicMock(), + request_data={}, + messages=[{"role": "user", "content": "hi"}], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + assert result.choices[0].finish_reason == "content_filter" + provider_fields: Final = result.choices[0].message.provider_specific_fields + assert provider_fields["stop_reason"] == "refusal" + assert provider_fields["stop_details"] == ANTHROPIC_DOCS_REFUSAL_MESSAGE["stop_details"] diff --git a/tests/unit/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py b/tests/unit/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py index 56e76df25a4..ace24c8a3f4 100644 --- a/tests/unit/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py +++ b/tests/unit/llms/vertex_ai/vertex_ai_partner_models/anthropic/test_vertex_ai_partner_models_anthropic_transformation.py @@ -3,6 +3,7 @@ import copy import json from collections.abc import Mapping from typing import Final +from unittest.mock import MagicMock import httpx import pytest @@ -19,6 +20,7 @@ from litellm.llms.vertex_ai.vertex_ai_partner_models.anthropic.transformation im ) from litellm.llms.vertex_ai.vertex_llm_base import VertexBase from litellm.types.llms.vertex_ai import VertexPartnerProvider +from tests.unit.llms.anthropic.anthropic_docs_refusal import ANTHROPIC_DOCS_REFUSAL_MESSAGE @pytest.mark.parametrize( @@ -1125,3 +1127,23 @@ def test_chat_flagged_model_replays_a_byte_identical_prefix_around_a_mid_convers _assert_prefix_stable(requests) assert [m["role"] for m in requests[1]["messages"]] == ["user", "assistant", "user", "system"] assert [m["role"] for m in requests[2]["messages"]] == ["user", "assistant", "user", "system", "assistant", "user"] + + +def test_refusal_stop_details_kept_through_vertex_anthropic_transform(): + body: Final = ANTHROPIC_DOCS_REFUSAL_MESSAGE + result: Final = VertexAIAnthropicConfig().transform_response( + model="claude-sonnet-5-5", + raw_response=httpx.Response(status_code=200, headers={}, json=body), + model_response=litellm.ModelResponse(), + logging_obj=MagicMock(), + request_data={}, + messages=[{"role": "user", "content": "hi"}], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + assert result.choices[0].finish_reason == "content_filter" + provider_fields: Final = result.choices[0].message.provider_specific_fields + assert provider_fields["stop_reason"] == "refusal" + assert provider_fields["stop_details"] == ANTHROPIC_DOCS_REFUSAL_MESSAGE["stop_details"] diff --git a/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_anthropic_passthrough_logging_handler.py b/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_anthropic_passthrough_logging_handler.py index 973d6bc9f7b..88c5e2d2c1b 100644 --- a/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_anthropic_passthrough_logging_handler.py +++ b/tests/unit/proxy/pass_through_endpoints/llm_provider_handlers/test_anthropic_passthrough_logging_handler.py @@ -16,6 +16,7 @@ from litellm.llms.anthropic.pass_through.stream_assembly import extract_sse_data from litellm.proxy.pass_through_endpoints.llm_provider_handlers.anthropic_passthrough_logging_handler import ( AnthropicPassthroughLoggingHandler, ) +from tests.unit.llms.anthropic.anthropic_docs_refusal import ANTHROPIC_DOCS_REFUSAL_MESSAGE async def _drain_tasks(): @@ -2951,3 +2952,58 @@ def test_get_user_from_metadata(end_user_id): ) assert response == "test" + + +def _anthropic_sse_stream(stop_reason: str, stop_details: dict | None) -> list[str]: + events: Final = ( + ( + "message_start", + { + "type": "message_start", + "message": { + "id": ANTHROPIC_DOCS_REFUSAL_MESSAGE["id"], + "type": "message", + "role": "assistant", + "model": ANTHROPIC_DOCS_REFUSAL_MESSAGE["model"], + "content": [], + "usage": {"input_tokens": 412, "output_tokens": 0}, + }, + }, + ), + ( + "message_delta", + { + "type": "message_delta", + "delta": {"stop_reason": stop_reason, "stop_sequence": None, "stop_details": stop_details}, + "usage": {"output_tokens": 0}, + }, + ), + ("message_stop", {"type": "message_stop"}), + ) + return [f"event: {name}\ndata: {json.dumps(data)}\n\n" for name, data in events] + + +def test_streamed_messages_refusal_stop_details_on_the_logged_response(mock_logging_obj): + stop_details: Final = ANTHROPIC_DOCS_REFUSAL_MESSAGE["stop_details"] + logged: Final = AnthropicPassthroughLoggingHandler.build_complete_streaming_response( + all_chunks=_anthropic_sse_stream("refusal", stop_details), + litellm_logging_obj=mock_logging_obj, + model="claude-sonnet-5-5", + ) + + assert logged.choices[0].finish_reason == "content_filter" + provider_fields: Final = logged.choices[0].message.provider_specific_fields + assert provider_fields["stop_reason"] == "refusal" + assert provider_fields["stop_details"] == stop_details + + +def test_streamed_messages_end_turn_logs_no_stop_details(mock_logging_obj): + logged: Final = AnthropicPassthroughLoggingHandler.build_complete_streaming_response( + all_chunks=_anthropic_sse_stream("end_turn", None), + litellm_logging_obj=mock_logging_obj, + model="claude-sonnet-5-5", + ) + + assert logged.choices[0].finish_reason == "stop" + provider_fields: Final = logged.choices[0].message.provider_specific_fields or {} + assert "stop_details" not in provider_fields From b9849f35881782f137b750340963ceb1e64cb27d Mon Sep 17 00:00:00 2001 From: Kerry Lu Date: Wed, 14 Oct 2026 14:43:01 -0700 Subject: [PATCH 06/25] fix(router): add fallback_on_unsupported_params so param rejections can raise instead of falling back (#46350) * fix(router): add fallback_on_unsupported_params so client-side param rejections can raise instead of falling back Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * style(router): wrap the fallback gate so ruff format passes Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(router): rename the flag to fallback_on_bad_request and cover provider 400s too Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(router): narrow the wire test responses so the type gate count does not grow Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(openai): swap the model map with setattr so the type gate count does not grow Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(router): import the exception classes directly so the type gate count does not grow Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(router): type the fallbacks list in fallback_event_handlers to pay for the new Router kwarg in the type gate Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(router): keep the LIT001 count flat on the typed fallbacks parameters Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(router): type the fallback stream wrapper weakref so the type gate stays level Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(router): make fallback_on_bad_request updatable at runtime so DB-stored router_settings apply it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore(ui): regenerate schema.d.ts for UpdateRouterConfig.fallback_on_bad_request Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(router): rename the flag back to fallback_on_unsupported_params Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * revert(openai): drop the reasoning_effort=none client-side check, the Router flag covers the provider 400 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * ci: rerun docs checks now that the router_settings row is on litellm-docs main Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(router): only stop fallbacks on real 400s and cover the context window and content policy exclusions Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(router): move the no-fallback 400 check into is_bad_request_without_fallback and pin the 402 and Vertex 403 cases Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(router): keep falling back on router-raised 400s when fallback_on_unsupported_params is off Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(router): read llm_provider off the exception with getattr so the type gate stays flat Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/constants.py | 1 + litellm/router.py | 18 +- .../router_utils/fallback_event_handlers.py | 19 +- litellm/types/router.py | 1 + ...router_unsupported_params_fallback_wire.py | 213 ++++++++++++++++++ .../test_fallback_event_handlers.py | 138 ++++++++++-- tests/unit/test_router_retry_policy_update.py | 15 ++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 + 8 files changed, 380 insertions(+), 27 deletions(-) create mode 100644 tests/integration/sdk/test_router_unsupported_params_fallback_wire.py diff --git a/litellm/constants.py b/litellm/constants.py index 4ac6adaba73..bbf6b8a4f04 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -60,6 +60,7 @@ RUNTIME_UPDATABLE_ROUTER_SETTINGS: Final[frozenset[str]] = frozenset( "enable_tag_filtering", "tag_routing_prefix", "optional_pre_call_checks", + "fallback_on_unsupported_params", } ) ROUTER_SETTINGS_MANAGED_OUTSIDE_CONFIG: Final[frozenset[str]] = frozenset( diff --git a/litellm/router.py b/litellm/router.py index 16257775d3f..b38b78b60d9 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -230,6 +230,7 @@ from litellm.router_utils.fallback_event_handlers import ( get_fallback_model_group_for_lookup_groups, get_pre_routing_selection, has_unattempted_fallback_target, + is_bad_request_without_fallback, mid_stream_fallback_hop_kwargs, mid_stream_fallback_snapshot_kwargs, mid_stream_retry_kwargs, @@ -1022,6 +1023,7 @@ class Router: allowed_fails_policy: AllowedFailsPolicy | None = None, # set custom allowed fails policy cooldown_time: float | None = None, # (seconds) time to cooldown a deployment after failure disable_cooldowns: bool | None = None, + fallback_on_unsupported_params: bool = True, routing_strategy: RoutingStrategyName = "simple-shuffle", optional_pre_call_checks: OptionalPreCallChecks | None = None, routing_strategy_args: dict = {}, # just for latency-based @@ -1264,6 +1266,7 @@ class Router: self.cooldown_time = cooldown_time or DEFAULT_COOLDOWN_TIME_SECONDS self.cooldown_cache = CooldownCache(cache=self.cache, default_cooldown_time=self.cooldown_time) self.disable_cooldowns = disable_cooldowns + self.fallback_on_unsupported_params = fallback_on_unsupported_params self.enable_health_check_routing = enable_health_check_routing self.enable_weighted_failover = enable_weighted_failover self.health_check_ignore_transient_errors = health_check_ignore_transient_errors @@ -3270,7 +3273,7 @@ class Router: wrapped_response: Final = FallbackStreamWrapper(stream_with_fallbacks()) # weak, so the generator closing over it does not keep the wrapper out of # refcount teardown and delay the `finally` that releases the deployment slot - wrapper_ref: Final = weakref.ref(wrapped_response) + wrapper_ref: Final[weakref.ref[FallbackAwareStreamWrapper]] = weakref.ref(wrapped_response) return wrapped_response @staticmethod @@ -3875,7 +3878,7 @@ class Router: wrapped_response: Final = SyncFallbackStreamWrapper(stream_with_fallbacks()) # weak, for the same reason as the async twin - wrapper_ref: Final = weakref.ref(wrapped_response) + wrapper_ref: Final[weakref.ref[FallbackAwareStreamWrapper]] = weakref.ref(wrapped_response) return wrapped_response async def _silent_experiment_acompletion(self, silent_model: str, messages: Sequence[Mapping[str, str]], **kwargs): @@ -7728,7 +7731,15 @@ class Router: hop_depth: Final = kwargs.get("fallback_depth") nested_fallback_hop: Final = isinstance(hop_depth, int) and hop_depth > 0 - if disable_fallbacks is True or original_model_group is None or is_guardrail_intervention(e): + unsupported_params_without_fallback: Final = is_bad_request_without_fallback( + e, self.fallback_on_unsupported_params + ) + if ( + disable_fallbacks is True + or original_model_group is None + or is_guardrail_intervention(e) + or unsupported_params_without_fallback + ): raise e input_kwargs: Final = { @@ -12802,6 +12813,7 @@ class Router: "enable_weighted_failover", "enable_tag_filtering", "tag_routing_prefix", + "fallback_on_unsupported_params", ] for var in vars_to_include: diff --git a/litellm/router_utils/fallback_event_handlers.py b/litellm/router_utils/fallback_event_handlers.py index e1de57c91a0..4c393a7a93b 100644 --- a/litellm/router_utils/fallback_event_handlers.py +++ b/litellm/router_utils/fallback_event_handlers.py @@ -222,6 +222,16 @@ def has_unattempted_fallback_target( return any((key := fallback_attempt_key(target)) is None or key not in attempted for target in fallback_model_group) +def is_bad_request_without_fallback(exception: Exception, fallback_on_unsupported_params: bool) -> bool: + if fallback_on_unsupported_params or not isinstance(exception, litellm.BadRequestError): + return False + if isinstance(exception, litellm.UnsupportedParamsError): + return True + if isinstance(exception, (litellm.ContextWindowExceededError, litellm.ContentPolicyViolationError)): + return False + return exception.status_code == 400 and bool(getattr(exception, "llm_provider", "")) + + def _check_stripped_model_group(model_group: str, fallback_key: str) -> bool: """ Handles wildcard routing scenario @@ -514,7 +524,7 @@ def fallback_lookup_groups(kwargs: Mapping[str, object], model_group: str | None def _resolved_a_specific_chain( - fallbacks: list[Any], # mutable-ok: mirrors get_fallback_model_group's contract + fallbacks: Sequence[Mapping[str, list[str]]], # mutable-ok: mirrors the fallbacks config shape result: tuple[list[str] | None, int | None], # mutable-ok: mirrors get_fallback_model_group's contract ) -> bool: resolved, generic_idx = result @@ -524,7 +534,7 @@ def _resolved_a_specific_chain( def get_fallback_model_group_for_lookup_groups( - fallbacks: list[Any], # mutable-ok: mirrors get_fallback_model_group's contract + fallbacks: Sequence[Mapping[str, list[str]]], # mutable-ok: mirrors the fallbacks config shape lookup_groups: tuple[str, ...], ) -> tuple[list[str] | None, int | None]: # mutable-ok: mirrors get_fallback_model_group's contract """ @@ -538,7 +548,10 @@ def get_fallback_model_group_for_lookup_groups( return next((result for result in results if result[0] is not None), (None, None)) -def get_fallback_model_group(fallbacks: list[Any], model_group: str) -> tuple[list[str] | None, int | None]: +def get_fallback_model_group( + fallbacks: Sequence[Mapping[str, list[str]]], # mutable-ok: mirrors the fallbacks config shape + model_group: str, +) -> tuple[list[str] | None, int | None]: # mutable-ok: mirrors get_fallback_model_group's contract """ Returns: - fallback_model_group: List[str] of fallback model groups. example: ["gpt-4", "gpt-3.5-turbo"] diff --git a/litellm/types/router.py b/litellm/types/router.py index 273edf0f913..59b6798bfdc 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -188,6 +188,7 @@ class UpdateRouterConfig(LiteLLMBaseModel): weights: RouterWeights | None = None tag_routing_prefix: str | None = None optional_pre_call_checks: OptionalPreCallChecks | None = None + fallback_on_unsupported_params: bool | None = None model_config = ConfigDict(protected_namespaces=()) diff --git a/tests/integration/sdk/test_router_unsupported_params_fallback_wire.py b/tests/integration/sdk/test_router_unsupported_params_fallback_wire.py new file mode 100644 index 00000000000..e7675fb64ee --- /dev/null +++ b/tests/integration/sdk/test_router_unsupported_params_fallback_wire.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +import asyncio +import json +from collections.abc import Callable, Mapping +from typing import Final + +import pytest +from integration._support.wire import Reply, Request, Wire, wire_server + +import litellm +from litellm import Router + +_MODEL: Final = "gpt-5-wire-unmapped" +_API_KEY: Final = "synthetic-unsupported-params-key" +_PROMPT: Final = "which deployment answers when the requested model rejects the request?" +_CLIENTS: Final = ("sync", "async") +_PRIMARY_TO_BACKUP: Final = [{"primary": ["backup"]}] +_UNSUPPORTED_BY_PRIMARY: Final = {"reasoning_effort": "xhigh"} + + +def _answers(text: str) -> Reply: + body: Final = { + "id": "chatcmpl-bad-request-wire", + "object": "chat.completion", + "created": 1700000000, + "model": _MODEL, + "choices": [{"index": 0, "message": {"role": "assistant", "content": text}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + return Reply(body=json.dumps(body).encode()) + + +_UPSTREAM_500: Final = Reply(status=500, body=json.dumps({"error": {"message": "overloaded"}}).encode()) +_UPSTREAM_400: Final = Reply( + status=400, + body=json.dumps({"error": {"message": "Unsupported value: temperature", "type": "invalid_request_error"}}).encode(), +) +_UPSTREAM_CONTEXT_WINDOW: Final = Reply( + status=400, + body=json.dumps( + { + "error": { + "message": "This model's maximum context length is 8192 tokens", + "type": "invalid_request_error", + "code": "context_length_exceeded", + } + } + ).encode(), +) +_UPSTREAM_CONTENT_POLICY: Final = Reply( + status=400, + body=json.dumps( + { + "error": { + "message": "Your request was rejected", + "type": "invalid_request_error", + "code": "content_policy_violation", + } + } + ).encode(), +) +_BACKUP_ANSWERS: Final = _answers("answered by the backup") + + +def _peer(replies: Mapping[str, Reply]) -> Callable[[Request], Reply]: + def respond(request: Request) -> Reply: + assert request.method == "POST", request.method + deployment, _, route = request.target.lstrip("/").partition("/") + assert route == "chat/completions", request.target + return replies[deployment] + + return respond + + +def _deployments_hit(wire: Wire) -> tuple[str, ...]: + return tuple(request.target.lstrip("/").partition("/")[0] for request in wire.drain()) + + +_ALL_DAY_FOR_TEAM_A: Final = ( + {"start": "00:00", "end": "12:00", "timezone": "UTC", "team_ids": ["team-a"]}, + {"start": "12:00", "end": "00:00", "timezone": "UTC", "team_ids": ["team-a"]}, +) + + +def _router(wire: Wire, primary_model_info: Mapping[str, object] | None = None, **settings: object) -> Router: + return Router( + model_list=[ + { + "model_name": "primary", + "litellm_params": {"model": f"openai/{_MODEL}", "api_base": f"{wire.url}/primary", "api_key": _API_KEY}, + "model_info": dict(primary_model_info or {}), + }, + { + "model_name": "backup", + "litellm_params": { + "model": f"openai/{_MODEL}", + "api_base": f"{wire.url}/backup", + "api_key": _API_KEY, + "drop_params": True, + }, + }, + ], + num_retries=0, + disable_cooldowns=True, + **{"fallbacks": _PRIMARY_TO_BACKUP, **settings}, + ) + + +_MESSAGES: Final = [{"role": "user", "content": _PROMPT}] + + +def _content(response: litellm.ModelResponse) -> str: + choice: Final = response.choices[0] + assert isinstance(choice, litellm.Choices), choice + return str(choice.message.content) + + +async def _acomplete(router: Router, **request: object) -> str: + response: Final = await router.acompletion(model="primary", messages=_MESSAGES, **request) + assert isinstance(response, litellm.ModelResponse), response + return _content(response) + + +def _complete(client: str, router: Router, **request: object) -> str: + if client == "async": + return asyncio.run(_acomplete(router, **request)) + response: Final = router.completion(model="primary", messages=_MESSAGES, **request) + assert isinstance(response, litellm.ModelResponse), response + return _content(response) + + +@pytest.mark.parametrize("client", _CLIENTS) +def test_client_side_param_rejection_reaches_the_caller_without_trying_fallbacks(client: str) -> None: + with wire_server(_peer({"backup": _BACKUP_ANSWERS})) as wire: + router: Final = _router(wire, fallback_on_unsupported_params=False) + with pytest.raises(litellm.UnsupportedParamsError, match="reasoning_effort"): + _complete(client, router, **_UNSUPPORTED_BY_PRIMARY) + assert _deployments_hit(wire) == () + + +@pytest.mark.parametrize("client", _CLIENTS) +def test_client_side_param_rejection_still_falls_back_by_default(client: str) -> None: + with wire_server(_peer({"backup": _BACKUP_ANSWERS})) as wire: + router: Final = _router(wire) + assert _complete(client, router, **_UNSUPPORTED_BY_PRIMARY) == "answered by the backup" + assert _deployments_hit(wire) == ("backup",) + + +@pytest.mark.parametrize("client", _CLIENTS) +def test_upstream_500_still_falls_back_when_bad_requests_raise(client: str) -> None: + with wire_server(_peer({"primary": _UPSTREAM_500, "backup": _BACKUP_ANSWERS})) as wire: + router: Final = _router(wire, fallback_on_unsupported_params=False) + assert _complete(client, router) == "answered by the backup" + assert _deployments_hit(wire) == ("primary", "backup") + + +@pytest.mark.parametrize("client", _CLIENTS) +def test_router_raised_400_still_falls_back_when_bad_requests_raise(client: str) -> None: + with wire_server(_peer({"backup": _BACKUP_ANSWERS})) as wire: + router: Final = _router( + wire, primary_model_info={"access_windows": _ALL_DAY_FOR_TEAM_A}, fallback_on_unsupported_params=False + ) + answer: Final = _complete(client, router, metadata={"user_api_key_team_id": "team-b"}) + assert answer == "answered by the backup" + assert _deployments_hit(wire) == ("backup",) + + +@pytest.mark.parametrize("client", _CLIENTS) +def test_context_window_error_still_uses_its_own_fallbacks_when_bad_requests_raise(client: str) -> None: + with wire_server(_peer({"primary": _UPSTREAM_CONTEXT_WINDOW, "backup": _BACKUP_ANSWERS})) as wire: + router: Final = _router( + wire, fallback_on_unsupported_params=False, fallbacks=None, context_window_fallbacks=_PRIMARY_TO_BACKUP + ) + assert _complete(client, router) == "answered by the backup" + assert _deployments_hit(wire) == ("primary", "backup") + + +@pytest.mark.parametrize("client", _CLIENTS) +def test_content_policy_error_still_uses_its_own_fallbacks_when_bad_requests_raise(client: str) -> None: + with wire_server(_peer({"primary": _UPSTREAM_CONTENT_POLICY, "backup": _BACKUP_ANSWERS})) as wire: + router: Final = _router( + wire, fallback_on_unsupported_params=False, fallbacks=None, content_policy_fallbacks=_PRIMARY_TO_BACKUP + ) + assert _complete(client, router) == "answered by the backup" + assert _deployments_hit(wire) == ("primary", "backup") + + +@pytest.mark.parametrize("client", _CLIENTS) +def test_upstream_400_reaches_the_caller_without_trying_fallbacks(client: str) -> None: + with wire_server(_peer({"primary": _UPSTREAM_400, "backup": _BACKUP_ANSWERS})) as wire: + router: Final = _router(wire, fallback_on_unsupported_params=False) + with pytest.raises(litellm.BadRequestError, match="Unsupported value: temperature"): + _complete(client, router) + assert _deployments_hit(wire) == ("primary",) + + +@pytest.mark.parametrize("client", _CLIENTS) +def test_flag_applied_through_update_settings_stops_fallbacks_on_upstream_400(client: str) -> None: + with wire_server(_peer({"primary": _UPSTREAM_400, "backup": _BACKUP_ANSWERS})) as wire: + router: Final = _router(wire) + router.update_settings(fallback_on_unsupported_params=False) + with pytest.raises(litellm.BadRequestError, match="Unsupported value: temperature"): + _complete(client, router) + assert _deployments_hit(wire) == ("primary",) + + +@pytest.mark.parametrize("client", _CLIENTS) +def test_upstream_400_still_falls_back_by_default(client: str) -> None: + with wire_server(_peer({"primary": _UPSTREAM_400, "backup": _BACKUP_ANSWERS})) as wire: + router: Final = _router(wire) + assert _complete(client, router) == "answered by the backup" + assert _deployments_hit(wire) == ("primary", "backup") diff --git a/tests/unit/router_utils/test_fallback_event_handlers.py b/tests/unit/router_utils/test_fallback_event_handlers.py index febb33aab7d..b353967df3a 100644 --- a/tests/unit/router_utils/test_fallback_event_handlers.py +++ b/tests/unit/router_utils/test_fallback_event_handlers.py @@ -29,6 +29,7 @@ from litellm.router_utils.fallback_event_handlers import ( fallback_attempt_key, get_fallback_model_group, get_pre_routing_selection, + is_bad_request_without_fallback, log_failure_fallback_event, log_success_fallback_event, mid_stream_fallback_snapshot_kwargs, @@ -1378,9 +1379,7 @@ class TestOrderedFallbackLookupGroups: "smart-router", "requested-model", ) - assert fallback_lookup_groups({"metadata": {"model_group": []}}, "requested-model") == ( - "requested-model", - ) + assert fallback_lookup_groups({"metadata": {"model_group": []}}, "requested-model") == ("requested-model",) def test_fallback_hop_resumes_the_original_groups_chain_last(self): from litellm.router_utils.fallback_event_handlers import fallback_lookup_groups @@ -1539,7 +1538,11 @@ def test_mid_stream_retry_kwargs_strips_what_the_retry_wrapper_pops_and_keeps_th @pytest.mark.parametrize( "kwargs,expected", [ - pytest.param({"litellm_metadata": {"attempted_retries": 2}, "metadata": {"attempted_retries": 5}}, 2, id="litellm_metadata-wins"), + pytest.param( + {"litellm_metadata": {"attempted_retries": 2}, "metadata": {"attempted_retries": 5}}, + 2, + id="litellm_metadata-wins", + ), pytest.param({"metadata": {"attempted_retries": 1}}, 1, id="metadata-bucket"), pytest.param({"litellm_metadata": {"attempted_retries": "2"}}, 0, id="string-is-not-a-count"), pytest.param({"litellm_metadata": {"attempted_retries": -1}}, 0, id="negative-is-not-a-count"), @@ -1566,8 +1569,12 @@ def test_record_retry_attempt_stamps_the_bucket_the_retry_wrapper_reads(): "kwargs,expected", [ pytest.param({"litellm_metadata": {"attempted_retries": 1, "max_retries": 3}}, 3, id="committed-by-a-retry"), - pytest.param({"litellm_metadata": {"attempted_retries": 0, "max_retries": 3}}, None, id="stamped-before-any-retry"), - pytest.param({"litellm_metadata": {"attempted_retries": 1, "max_retries": "3"}}, None, id="string-is-not-a-budget"), + pytest.param( + {"litellm_metadata": {"attempted_retries": 0, "max_retries": 3}}, None, id="stamped-before-any-retry" + ), + pytest.param( + {"litellm_metadata": {"attempted_retries": 1, "max_retries": "3"}}, None, id="string-is-not-a-budget" + ), pytest.param({"litellm_metadata": {"attempted_retries": 1}}, None, id="no-budget"), pytest.param({}, None, id="no-bucket"), ], @@ -1602,6 +1609,7 @@ def _vcr_outcome_gate(request, vcr): yield record_vcr_outcome(request, vcr) + @pytest.fixture(scope="function") def setup_and_teardown(): """ @@ -1622,6 +1630,7 @@ def setup_and_teardown(): loop.close() asyncio.set_event_loop(None) + REFUSAL_RESPONSE: dict[str, Any] = { "id": "msg_refusal", "type": "message", @@ -1647,9 +1656,11 @@ OK_RESPONSE: dict[str, Any] = { "usage": {"input_tokens": 25, "output_tokens": 2}, } + def _sse(event: str, data: dict[str, Any]) -> bytes: return f"event: {event}\ndata: {json.dumps(data)}\n\n".encode() + REFUSAL_STREAM_FRAMES: tuple[bytes, ...] = ( _sse("message_start", {"type": "message_start", "message": {**REFUSAL_RESPONSE, "stop_reason": None}}), _sse( @@ -1672,10 +1683,12 @@ OK_STREAM_FRAMES: tuple[bytes, ...] = ( _sse("message_stop", {"type": "message_stop"}), ) + def _split_frames_mid_data_line(frames: tuple[bytes, ...]) -> tuple[bytes, ...]: """Split each frame's data line in half, modeling a transport chunk boundary.""" return tuple(part for frame in frames for part in (frame[: len(frame) // 2], frame[len(frame) // 2 :])) + class _FrameStream(httpx.AsyncByteStream): def __init__(self, frames: tuple[bytes, ...]) -> None: self._frames = frames @@ -1687,6 +1700,7 @@ class _FrameStream(httpx.AsyncByteStream): async def aclose(self) -> None: return None + class FakeAnthropicUpstream: """Intercepts the third-party transport (httpx.AsyncClient.send): refuses on fable models, answers on others. The router deliberately does not forward caller-injected @@ -1724,6 +1738,7 @@ class FakeAnthropicUpstream: return patch("httpx.AsyncClient.send", new=_send) + FABLE_TIER = { "model_name": "fable-tier", "litellm_params": {"model": "anthropic/claude-fable-5", "api_key": "sk-test"}, @@ -1734,12 +1749,15 @@ OPUS_TARGET = { "litellm_params": {"model": "anthropic/claude-opus-5", "api_key": "sk-test"}, } + def _router(content_policy_fallbacks: list | None) -> Router: return Router(model_list=[FABLE_TIER, OPUS_TARGET], content_policy_fallbacks=content_policy_fallbacks) + async def _collect(stream: AsyncIterator[bytes]) -> bytes: return b"".join([chunk async for chunk in stream]) + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio async def test_non_streaming_refusal_with_fallback_row_returns_fallback_response(): @@ -1756,6 +1774,7 @@ async def test_non_streaming_refusal_with_fallback_row_returns_fallback_response assert len(fake.calls) == 2 assert "claude-opus-5" in fake.calls[1] + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio @pytest.mark.parametrize( @@ -1780,6 +1799,7 @@ async def test_non_streaming_refusal_passes_through_untouched(content_policy_fal assert response.get("stop_details") == upstream_body.get("stop_details") assert len(fake.calls) == 1 + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio async def test_streaming_refusal_with_fallback_row_streams_fallback_frames(): @@ -1796,6 +1816,7 @@ async def test_streaming_refusal_with_fallback_row_streams_fallback_frames(): assert b"text_delta" in body assert len(fake.calls) == 2 + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio async def test_streaming_refusal_split_across_chunks_still_falls_back(): @@ -1812,6 +1833,7 @@ async def test_streaming_refusal_split_across_chunks_still_falls_back(): assert b"text_delta" in body assert len(fake.calls) == 2 + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio async def test_streaming_refusal_without_fallback_row_passes_frames_through(): @@ -1828,6 +1850,7 @@ async def test_streaming_refusal_without_fallback_row_passes_frames_through(): assert b"stop_details" in body assert len(fake.calls) == 1 + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio async def test_streaming_refusal_on_routed_tier_matches_tier_keyed_row_without_inbound_metadata(): @@ -1861,6 +1884,7 @@ async def test_streaming_refusal_on_routed_tier_matches_tier_keyed_row_without_i assert b"text_delta" in body assert len(fake.calls) == 2 + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio async def test_caller_forged_tier_stamp_cannot_pick_the_streaming_fallback_chain(): @@ -1880,6 +1904,7 @@ async def test_caller_forged_tier_stamp_cannot_pick_the_streaming_fallback_chain assert b'"stop_reason": "refusal"' in body assert len(fake.calls) == 1 + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio async def test_tier_stamp_never_reaches_provider_bound_metadata(): @@ -1916,6 +1941,7 @@ async def test_tier_stamp_never_reaches_provider_bound_metadata(): for body in fake.bodies: assert body.get("metadata") == {"user_id": "u1"} + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") def test_record_pre_routing_selection_writes_only_the_internal_bucket(): """The Anthropic request's own metadata field must never carry the tier stamp.""" @@ -1926,6 +1952,7 @@ def test_record_pre_routing_selection_writes_only_the_internal_bucket(): assert kwargs["litellm_metadata"] == {PRE_ROUTING_SELECTED_MODEL_KEY: "tier-x"} assert kwargs["metadata"] == {"user_id": "u1"} + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio @pytest.mark.parametrize("stream", [False, True], ids=["non-streaming", "streaming"]) @@ -1949,6 +1976,7 @@ async def test_generic_only_row_recovers_safeguard_refusal(stream): assert len(fake.calls) == 2 assert "claude-opus-5" in fake.calls[1] + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio async def test_configured_content_policy_list_stays_authoritative_over_generic_rows(): @@ -1967,6 +1995,7 @@ async def test_configured_content_policy_list_stays_authoritative_over_generic_r assert response["stop_reason"] == "refusal" assert len(fake.calls) == 1 + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") def test_refusal_fallback_available_arms_on_generic_rows_only_without_content_policy(): router = Router(model_list=[FABLE_TIER, OPUS_TARGET], fallbacks=[{"tier-group": ["opus-target"]}]) @@ -1976,6 +2005,7 @@ def test_refusal_fallback_available_arms_on_generic_rows_only_without_content_po assert router._refusal_fallback_available("router-group", {}) is False assert router._refusal_fallback_available("router-group", {"content_policy_fallbacks": [{"other": ["x"]}]}) is False + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") def test_chat_content_filter_gate_unchanged_by_generic_rows(): """The generic-row arming is scoped to /v1/messages safeguard refusals; the chat surface's @@ -1987,6 +2017,7 @@ def test_chat_content_filter_gate_unchanged_by_generic_rows(): assert router._should_raise_content_policy_error(model="fable-tier", response=response, kwargs={}) is False + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio @pytest.mark.parametrize("stream", [False, True], ids=["non-streaming", "streaming"]) @@ -2012,6 +2043,7 @@ async def test_disable_fallbacks_returns_the_refusal_instead_of_raising(stream): assert body["stop_reason"] == "refusal" assert len(fake.calls) == 1 + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") @pytest.mark.asyncio async def test_disable_fallbacks_beats_a_content_policy_row_too(): @@ -2032,6 +2064,7 @@ async def test_disable_fallbacks_beats_a_content_policy_row_too(): assert response["stop_reason"] == "refusal" assert len(fake.calls) == 1 + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") def test_refusal_gate_keys_on_pre_routing_tier_stamp(): router = _router(content_policy_fallbacks=[{"tier-group": ["opus-target"]}]) @@ -2059,6 +2092,7 @@ def test_refusal_gate_keys_on_pre_routing_tier_stamp(): is False ) + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") def test_has_content_policy_fallback_default_fallbacks_arm(): router = Router(model_list=[OPUS_TARGET], fallbacks=[{"*": ["opus-target"]}]) @@ -2066,6 +2100,7 @@ def test_has_content_policy_fallback_default_fallbacks_arm(): assert router._has_content_policy_fallback("any-group", {}) is True assert router._has_content_policy_fallback("any-group", {"content_policy_fallbacks": [{"other": ["x"]}]}) is False + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") def test_get_fallback_model_group_for_lookup_groups_orders_tier_before_requested(): router = _router(content_policy_fallbacks=None) @@ -2079,6 +2114,7 @@ def test_get_fallback_model_group_for_lookup_groups_orders_tier_before_requested ) == ["backup-b"] assert router._get_fallback_model_group_for_lookup_groups(fallbacks=fallbacks, lookup_groups=()) is None + @pytest.mark.usefixtures("_vcr_outcome_gate", "setup_and_teardown") def test_refusal_gate_ignores_other_generic_call_types(): router = _router(content_policy_fallbacks=[{"fable-tier": ["opus-target"]}]) @@ -2269,9 +2305,7 @@ async def test_failed_fallbacks_raise_most_recent_exception(function_name): model="gpt-3.5-turbo", ) - request_kwargs: Dict[str, Any] = { - "metadata": {"previous_models": ["gpt-3.5-turbo"]} - } + request_kwargs: Dict[str, Any] = {"metadata": {"previous_models": ["gpt-3.5-turbo"]}} if function_name == "_aembedding": request_kwargs["input"] = "hello this is a test for run_async_fallback" @@ -2323,27 +2357,19 @@ class CustomTestLogger(CustomLogger): self.success_fallback_events = [] self.failure_fallback_events = [] - async def log_success_fallback_event( - self, original_model_group, kwargs, original_exception - ): + async def log_success_fallback_event(self, original_model_group, kwargs, original_exception): print( "in log_success_fallback_event for original_model_group: ", original_model_group, ) - self.success_fallback_events.append( - (original_model_group, kwargs, original_exception) - ) + self.success_fallback_events.append((original_model_group, kwargs, original_exception)) - async def log_failure_fallback_event( - self, original_model_group, kwargs, original_exception - ): + async def log_failure_fallback_event(self, original_model_group, kwargs, original_exception): print( "in log_failure_fallback_event for original_model_group: ", original_model_group, ) - self.failure_fallback_events.append( - (original_model_group, kwargs, original_exception) - ) + self.failure_fallback_events.append((original_model_group, kwargs, original_exception)) def create_test_router_2(): @@ -2458,3 +2484,73 @@ async def test_multiple_fallbacks(function_name, respx_mock: respx.MockRouter, m print(result._hidden_params) assert result._hidden_params["api_base"] == FAKE_OPENAI_API_BASE + + +def _provider_error(status_code: int) -> httpx.Response: + return httpx.Response(status_code=status_code, request=httpx.Request(method="POST", url="https://provider.test")) + + +@pytest.mark.parametrize( + ("exception", "stops_fallbacks"), + [ + pytest.param( + litellm.BadRequestError(message="Unsupported value: temperature", model="m", llm_provider="openai"), + True, + id="provider 400", + ), + pytest.param( + litellm.UnsupportedParamsError( + message="reasoning_effort is not supported", model="m", llm_provider="openai" + ), + True, + id="client-side rejection", + ), + pytest.param( + litellm.UnsupportedParamsError(message="stop is not supported"), + True, + id="client-side rejection without a provider", + ), + pytest.param( + litellm.BadRequestError( + message="Deployment ptu is reserved for another team", model="ptu", llm_provider="" + ), + False, + id="router-raised 400 keeps falling back", + ), + pytest.param( + litellm.BadRequestError( + message="VertexAIException BadRequestError - 403", + model="m", + llm_provider="vertex_ai", + response=_provider_error(403), + ), + False, + id="vertex 403 wrapped as BadRequestError", + ), + pytest.param( + litellm.PaymentRequiredError(message="insufficient credits", model="m", llm_provider="openrouter"), + False, + id="402", + ), + pytest.param( + litellm.ContextWindowExceededError(message="maximum context length", model="m", llm_provider="openai"), + False, + id="context window keeps its own fallbacks", + ), + pytest.param( + litellm.ContentPolicyViolationError(message="content_policy_violation", model="m", llm_provider="openai"), + False, + id="content policy keeps its own fallbacks", + ), + pytest.param( + litellm.InternalServerError(message="overloaded", model="m", llm_provider="openai"), + False, + id="500 still falls back", + ), + ], +) +def test_is_bad_request_without_fallback_only_stops_on_a_real_400_when_the_flag_is_off( + exception: Exception, stops_fallbacks: bool +) -> None: + assert is_bad_request_without_fallback(exception, fallback_on_unsupported_params=False) is stops_fallbacks + assert is_bad_request_without_fallback(exception, fallback_on_unsupported_params=True) is False diff --git a/tests/unit/test_router_retry_policy_update.py b/tests/unit/test_router_retry_policy_update.py index 48b5194a66e..918d215c89f 100644 --- a/tests/unit/test_router_retry_policy_update.py +++ b/tests/unit/test_router_retry_policy_update.py @@ -300,6 +300,21 @@ def test_update_settings_persists_retry_policy_dict(): assert router.retry_policy.TimeoutErrorRetries == 3 +def test_update_settings_fallback_on_unsupported_params_round_trips_through_get_settings(): + router = _build_router() + assert router.get_settings()["fallback_on_unsupported_params"] is True + + router.update_settings(fallback_on_unsupported_params=False) + + assert router.fallback_on_unsupported_params is False + assert router.get_settings()["fallback_on_unsupported_params"] is False + + +def test_update_router_config_keeps_fallback_on_unsupported_params_payload(): + payload = UpdateRouterConfig(fallback_on_unsupported_params=False).model_dump(exclude_none=True) + assert payload["fallback_on_unsupported_params"] is False + + def test_update_settings_accepts_retry_policy_object_unchanged(): """A pre-built ``RetryPolicy`` instance must pass through verbatim so callers that already constructed one (e.g. tests or programmatic diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 29d659ec1d9..1fd554e37a1 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -48111,6 +48111,8 @@ export interface components { cooldown_time?: number | null; /** Enable Tag Filtering */ enable_tag_filtering?: boolean | null; + /** Fallback On Unsupported Params */ + fallback_on_unsupported_params?: boolean | null; /** Fallbacks */ fallbacks?: { [key: string]: unknown; From 9f6379223c1d55b98f607b07683c2e731cda7587 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Wed, 14 Oct 2026 14:50:55 -0700 Subject: [PATCH 07/25] feat(access-groups): per-server MCP tool permissions on access groups (#46300) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../migration.sql | 4 + .../litellm_proxy_extras/schema.prisma | 1 + litellm/models/access_group.py | 2 + .../mcp_server/auth/user_api_key_auth_mcp.py | 293 +++++++- litellm/proxy/_lazy_openapi_snapshot.json | 51 ++ .../auth/agent_access_groups.py | 42 +- litellm/proxy/auth/auth_checks.py | 141 ++-- .../access_group_endpoints.py | 19 +- litellm/proxy/schema.prisma | 1 + litellm/types/access_group.py | 4 + schema.prisma | 1 + .../auth/test_managed_agent_access.py | 74 +- .../auth/test_user_api_key_auth_mcp.py | 693 ++++++++++++++++-- .../auth/test_agent_access_groups.py | 85 ++- ...st_auth_checks_object_access_and_lookup.py | 24 + .../test_access_group_endpoints.py | 62 ++ .../AccessGroupsDetailsPage.test.tsx | 30 +- .../_components/AccessGroupsDetailsPage.tsx | 32 + .../AccessGroupsModal/AccessGroupBaseForm.tsx | 41 ++ .../AccessGroupEditModal.integration.test.tsx | 30 + .../AccessGroupEditModal.tsx | 11 +- .../AccessGroupCreateDialog.tsx | 6 + .../access-group-create/mapper.test.ts | 18 + .../_components/access-group-create/mapper.ts | 3 + .../_components/access-group-create/schema.ts | 1 + .../MCPToolPermissions.test.tsx | 44 ++ .../MCPToolPermissions.tsx | 21 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 12 + 28 files changed, 1587 insertions(+), 159 deletions(-) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20261013000000_access_group_mcp_tool_permissions/migration.sql diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20261013000000_access_group_mcp_tool_permissions/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261013000000_access_group_mcp_tool_permissions/migration.sql new file mode 100644 index 00000000000..35197a9e63c --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20261013000000_access_group_mcp_tool_permissions/migration.sql @@ -0,0 +1,4 @@ +SET LOCAL lock_timeout = '10s'; + +-- AlterTable +ALTER TABLE "LiteLLM_AccessGroupTable" ADD COLUMN IF NOT EXISTS "mcp_tool_permissions" JSONB; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index ca58247a2c9..dcae56b5e71 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -1588,6 +1588,7 @@ model LiteLLM_AccessGroupTable { // Resource memberships - explicit arrays per type access_model_names String[] @default([]) access_mcp_server_ids String[] @default([]) + mcp_tool_permissions Json? access_agent_ids String[] @default([]) assigned_team_ids String[] @default([]) diff --git a/litellm/models/access_group.py b/litellm/models/access_group.py index 513a60aa9ba..58b70c39661 100644 --- a/litellm/models/access_group.py +++ b/litellm/models/access_group.py @@ -5,6 +5,7 @@ Canonical definition for ``litellm_accessgrouptable``. Re-exported from ``litellm.proxy._types`` for backwards compatibility. """ +from collections.abc import Mapping, Sequence from datetime import datetime from litellm.types.llms.base import LiteLLMPydanticObjectBase @@ -16,6 +17,7 @@ class LiteLLM_AccessGroupTable(LiteLLMPydanticObjectBase): description: str | None = None access_model_names: list[str] = [] access_mcp_server_ids: list[str] = [] + mcp_tool_permissions: Mapping[str, Sequence[str]] | None = None access_agent_ids: list[str] = [] assigned_team_ids: list[str] = [] assigned_key_ids: list[str] = [] diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index 18b7c279606..43537d48f5e 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -1,3 +1,4 @@ +import functools import re import secrets from collections.abc import Awaitable, Callable, Mapping, Sequence @@ -42,6 +43,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.session_credent ) from litellm.proxy._types import ( UI_TEAM_ID, + LiteLLM_AccessGroupTable, LiteLLM_ObjectPermissionTable, LiteLLM_TeamTable, ProxyException, @@ -53,8 +55,10 @@ from litellm.proxy._types import ( user_api_key_has_admin_view, ) from litellm.proxy.agent_endpoints.auth.agent_access_groups import ( + AgentAccessGroupCeiling, CeilingResolver, resolve_agent_access_group_ceiling, + resolve_managed_agent_ceilings, ) from litellm.proxy.agent_endpoints.auth.agent_caller import agent_caller_auth from litellm.proxy.agent_endpoints.auth.managed_authorization import managed_agent_policy @@ -126,6 +130,22 @@ class UnloadableEntitlementError(Exception): it places no ceiling; denying there would refuse MCP to every caller during a cold-cache fault.""" +@dataclass(frozen=True, slots=True) +class KeyGroupTools: + """``owned``: tools the key's owned groups grant on a server, ``None`` when they place no bound. + ``owns_server``: whether an owned group grants the server at all, so an unbounded owned grant is told + apart from no owned grant. ``attached``: the bound every attached group, owned or not, places there, + applied only when the key holds no grant of its own on the server.""" + + owned: Sequence[str] | None + owns_server: bool + attached: Sequence[str] | None + + +_NO_KEY_GROUP_TOOLS: Final = KeyGroupTools(owned=None, owns_server=False, attached=None) +_UNREADABLE_KEY_GROUP_TOOLS: Final = KeyGroupTools(owned=(), owns_server=False, attached=()) + + def _parse_mcp_server_names_from_path(path: str, mcp_servers_header: list[str] | None = None) -> list[str] | None: """Resolve the single MCP server name a cold-start passthrough bypass may target. Delegates parsing to @@ -251,6 +271,15 @@ def _agent_capped_servers( ) +def _intersect_tool_ceiling(own: Sequence[str] | None, ceiling: Sequence[str] | None) -> Sequence[str] | None: + """The agent's own tool grants capped by its access-group ceiling, ``None`` only when neither bounds.""" + if ceiling is None: + return own + if own is None: + return ceiling + return tuple(set(own) & set(ceiling)) + + def is_mcp_admitted_user_subject(user_api_key_auth: UserAPIKeyAuth | None) -> bool: """True when this auth is a keyless subject admitted by the gateway session / bridge user path, as opposed to a JWT or other keyless auth that merely lacks a ``team_id``. @@ -2363,16 +2392,177 @@ class MCPRequestHandler: def _union_tool_grants( direct: Sequence[str] | None, via_toolsets: Sequence[str] | None, + via_access_groups: Sequence[str] | None = None, ) -> Sequence[str] | None: - """Union of one level's direct tool grants and its toolset-granted tools on one server, - ``None`` when neither source restricts (allow-all from this level). A direct grant - containing ``MCP_ALL_TOOLS_WILDCARD`` makes the level unrestricted, so it returns - ``None`` whatever the toolsets name.""" + """Union of one level's direct tool grants, its toolset-granted tools and its unified + access-group tools on one server, ``None`` when no source restricts (allow-all from this + level). A direct grant containing ``MCP_ALL_TOOLS_WILDCARD`` makes the level unrestricted, + so it returns ``None`` whatever the toolsets or groups name.""" if direct is not None and MCP_ALL_TOOLS_WILDCARD in direct: return None - if direct is None and via_toolsets is None: + if direct is None and via_toolsets is None and via_access_groups is None: return None - return tuple({*(direct or ()), *(via_toolsets or ())}) + return tuple({*(direct or ()), *(via_toolsets or ()), *(via_access_groups or ())}) + + @staticmethod + def access_group_tools_for_server( + groups: Sequence[LiteLLM_AccessGroupTable], server_id: str + ) -> Sequence[str] | None: + """Tools the unified access groups grant on ``server_id``: the union of each granting group's + ``mcp_tool_permissions`` entry for it. A granting group with no entry grants every tool, so + ``None`` wins. A group that does not grant the server contributes nothing, and an explicit + ``[]`` entry on a granting group grants no tools. ``None`` when no group grants the server.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + def granted_tools(group: LiteLLM_AccessGroupTable) -> Sequence[str] | None: + if not MCPRequestHandler._group_grants_server(group, server_id): + return None + permissions: Final = { + server: list(allowed) for server, allowed in (group.mcp_tool_permissions or {}).items() + } + tools: Final = global_mcp_server_manager.expand_tool_permissions(permissions) + return tools.get(server_id, (MCP_ALL_TOOLS_WILDCARD,)) + + grants: Final = tuple(granted_tools(group) for group in groups) + named: Final = tuple(grant for grant in grants if grant is not None) + if not named or any(MCP_ALL_TOOLS_WILDCARD in grant for grant in named): + return None + return tuple({tool for grant in named for tool in grant}) # comprehension-ok: flatten + + @staticmethod + def _group_grants_server(group: LiteLLM_AccessGroupTable, server_id: str) -> bool: + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + return server_id in global_mcp_server_manager.expand_permission_list(list(group.access_mcp_server_ids)) + + @staticmethod + def _key_tool_scope( + direct: Sequence[str] | None, + via_toolsets: Sequence[str] | None, + groups: KeyGroupTools, + grants_server: bool, + ) -> Sequence[str] | None: + """The key's own tool scope on a server when it holds any grant there (a direct server grant with + or without a tool entry, a toolset or an owned group), so an unrestricted grant stays unrestricted. + Only a key with no grant of its own takes the bound its attached groups place.""" + if grants_server or direct is not None or via_toolsets is not None or groups.owns_server: + return MCPRequestHandler._union_tool_grants(direct, via_toolsets, groups.owned) + return groups.attached + + @staticmethod + def _group_authorizes_key(group: LiteLLM_AccessGroupTable, team_id: str | None, token: str | None) -> bool: + """Mirror of ``get_authorized_resources_from_key_access_groups``: a group a key merely names + binds only when the group lists the key's team or the key itself as an owner.""" + return bool(team_id and team_id in group.assigned_team_ids) or bool(token and token in group.assigned_key_ids) + + @staticmethod + def _loaded_group_tools_for_server( + groups: Sequence[LiteLLM_AccessGroupTable], requested: Sequence[str], server_id: str + ) -> Sequence[str] | None: + """Tool grants of the agent groups that loaded. A named agent group that did not load exists but + could not be read (a group that does not exist never reaches the ceiling), so the level grants no + tools on ``server_id`` rather than shedding a restriction while the fault lasts.""" + if {group.access_group_id for group in groups} != set(requested): + return () + return MCPRequestHandler.access_group_tools_for_server(groups, server_id) + + @staticmethod + async def _key_direct_servers( + key_obj_perm: LiteLLM_ObjectPermissionTable | None, *, requires_fresh_policy: bool + ) -> AbstractSet[str]: + """Servers the key's object permission grants on its own: ``mcp_servers`` by id, name or alias and + legacy ``mcp_access_groups`` by the tag the server carries, exactly as server admission resolves + them. A tag never doubles as a server name, so a tag that collides with one grants nothing by itself.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager + + if key_obj_perm is None: + return frozenset() + try: + tagged: Final = ( + await MCPRequestHandler.get_mcp_servers_from_access_groups( + key_obj_perm.mcp_access_groups, requires_fresh_policy=requires_fresh_policy + ) + if key_obj_perm.mcp_access_groups + else () + ) + except Exception as e: # noqa: BLE001 # a key naming tags we cannot resolve must deny, not widen + raise UnloadableEntitlementError(f"key access group tags unreadable: {e}") from e + return frozenset(global_mcp_server_manager.expand_permission_list(key_obj_perm.mcp_servers or [])) | frozenset( + tagged + ) + + @staticmethod + async def _key_access_group_tools_for_server(user_api_key_auth: UserAPIKeyAuth, server_id: str) -> KeyGroupTools: + """Tool bounds the key's attached groups place on ``server_id``. Attaching a group to a key admits + the group's servers (``_get_key_access_group_mcp_server_extras``), so an attached group the key + does not own still carries its own tool permissions onto the servers it admits, it just cannot + add tools to a grant the key already holds there. Only owned groups are grant sources. A named + group that does not exist grants and bounds nothing, one that exists but cannot be read denies.""" + from litellm.proxy.auth.auth_checks import get_access_group_tables + + if not user_api_key_auth.access_group_ids: + return _NO_KEY_GROUP_TOOLS + try: + groups: Final = await get_access_group_tables( + access_group_ids=user_api_key_auth.access_group_ids, + check_db_only=bool(user_api_key_auth.requires_fresh_policy), + unreadable="raise", + ) + except Exception as e: # noqa: BLE001 # a key naming groups we cannot read must deny, not widen + if user_api_key_auth.requires_fresh_policy: + raise UnloadableEntitlementError(f"key access groups unreadable: {e}") from e + return _UNREADABLE_KEY_GROUP_TOOLS + owned: Final = tuple( + group + for group in groups + if MCPRequestHandler._group_authorizes_key(group, user_api_key_auth.team_id, user_api_key_auth.token) + ) + return KeyGroupTools( + owned=MCPRequestHandler.access_group_tools_for_server(owned, server_id), + owns_server=any(MCPRequestHandler._group_grants_server(group, server_id) for group in owned), + attached=MCPRequestHandler.access_group_tools_for_server(groups, server_id), + ) + + @staticmethod + async def _team_access_group_tools_for_server( + user_api_key_auth: UserAPIKeyAuth, server_id: str + ) -> Sequence[str] | None: + from litellm.proxy.auth.auth_checks import get_access_group_tables, get_team_object + from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache + + if not user_api_key_auth.team_id or user_api_key_auth.team_id == UI_TEAM_ID or prisma_client is None: + return None + try: + team_obj: Final[LiteLLM_TeamTable | None] = await get_team_object( + team_id=user_api_key_auth.team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=user_api_key_auth.parent_otel_span, + proxy_logging_obj=proxy_logging_obj, + check_db_only=bool(user_api_key_auth.requires_fresh_policy), + ) + except Exception as e: # noqa: BLE001 # a team we cannot read must deny, not widen + raise UnloadableEntitlementError(f"team unreadable: {e}") from e + if not team_obj.access_group_ids: + return None + try: + groups: Final = await get_access_group_tables( + access_group_ids=team_obj.access_group_ids, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + check_db_only=bool(user_api_key_auth.requires_fresh_policy), + unreadable="raise", + ) + except Exception as e: # noqa: BLE001 # team groups we cannot read grant no tools, they never widen + if user_api_key_auth.requires_fresh_policy: + raise UnloadableEntitlementError(f"team access groups unreadable: {e}") from e + return () + return MCPRequestHandler.access_group_tools_for_server(groups, server_id) @staticmethod async def _key_object_permission_hydrated( @@ -2493,7 +2683,17 @@ class MCPRequestHandler: else None ) - key_tools: Final = _as_list(MCPRequestHandler._union_tool_grants(key_direct_tools, key_toolset_tools)) + key_group_tools: Final = await MCPRequestHandler._key_access_group_tools_for_server( + user_api_key_auth, server_id + ) + key_direct_servers: Final = await MCPRequestHandler._key_direct_servers( + key_obj_perm, requires_fresh_policy=bool(user_api_key_auth.requires_fresh_policy) + ) + key_tools: Final = _as_list( + MCPRequestHandler._key_tool_scope( + key_direct_tools, key_toolset_tools, key_group_tools, server_id in key_direct_servers + ) + ) team_direct_tools: Final = ( global_mcp_server_manager.expand_tool_permissions(team_obj_perm.mcp_tool_permissions).get(server_id) if team_obj_perm @@ -2505,11 +2705,17 @@ class MCPRequestHandler: team_toolset_tools: Final = await MCPRequestHandler._toolset_tools_for_server( team_obj_perm, server_id, requires_fresh_policy=user_api_key_auth.requires_fresh_policy ) - team_tools: Final = MCPRequestHandler._union_tool_grants(team_direct_tools, team_toolset_tools) + team_group_tools: Final = await MCPRequestHandler._team_access_group_tools_for_server( + user_api_key_auth, server_id + ) + team_tools: Final = MCPRequestHandler._union_tool_grants( + team_direct_tools, team_toolset_tools, team_group_tools + ) - # Apply same inheritance logic as get_allowed_mcp_servers - if team_tools: - if key_tools: + # Apply same inheritance logic as get_allowed_mcp_servers. ``is not None`` so an explicit + # empty allowlist stays a deny instead of reading as "no restriction". + if team_tools is not None: + if key_tools is not None: # Both have restrictions → intersection allowed_tools = list(set(team_tools) & set(key_tools)) else: @@ -3525,6 +3731,9 @@ class MCPRequestHandler: team_toolset_tools: Final = await MCPRequestHandler._toolset_tools_for_server( team_obj_perm, server_id, requires_fresh_policy=caller_auth.requires_fresh_policy ) + team_group_tools: Final = await MCPRequestHandler._team_access_group_tools_for_server( + caller_auth, server_id + ) except Exception as e: # noqa: BLE001 # an unresolved caller team must deny, not widen verbose_logger.warning( "MCP agent caller team tool ceiling unresolvable, denying tools on %r: %s", server_id, e @@ -3535,7 +3744,9 @@ class MCPRequestHandler: if team_obj_perm else None ) - team_tools: Final = MCPRequestHandler._union_tool_grants(team_direct_tools, team_toolset_tools) + team_tools: Final = MCPRequestHandler._union_tool_grants( + team_direct_tools, team_toolset_tools, team_group_tools + ) team_capped: Final = ( allowed_tools if team_tools is None @@ -3729,12 +3940,50 @@ class MCPRequestHandler: global_mcp_server_manager, ) + ceilings: Final = await MCPRequestHandler._agent_access_group_ceilings(user_api_key_auth, resolve_ceiling) + if not ceilings: + return None + expanded: Final[tuple[frozenset[str], ...]] = tuple( + frozenset(global_mcp_server_manager.expand_permission_list(sorted(ceiling.mcp_server_ids))) + for ceiling in ceilings + ) + return functools.reduce(lambda acc, servers: acc & servers, expanded[1:], expanded[0]) + + @staticmethod + async def _get_agent_access_group_tool_ceiling( + user_api_key_auth: UserAPIKeyAuth, + server_id: str, + resolve_ceiling: CeilingResolver = resolve_agent_access_group_ceiling, + ) -> Sequence[str] | None: + """Ceiling the agent's attached unified access groups place on ``server_id`` tools, the tools axis + twin of ``_get_agent_access_group_server_ceiling``. None when the agent has no groups attached or + no attached group bounds tools on the server. The agent's own grants are intersected with it.""" + ceilings: Final = await MCPRequestHandler._agent_access_group_ceilings(user_api_key_auth, resolve_ceiling) + if not ceilings: + return None + bounds: Final = tuple( + MCPRequestHandler._loaded_group_tools_for_server(ceiling.access_groups, ceiling.access_group_ids, server_id) + for ceiling in ceilings + ) + return functools.reduce(_intersect_tool_ceiling, bounds[1:], bounds[0]) + + @staticmethod + async def _agent_access_group_ceilings( + user_api_key_auth: UserAPIKeyAuth, + resolve_ceiling: CeilingResolver, + ) -> tuple[AgentAccessGroupCeiling, ...]: + """A managed agent is bounded by the groups on its admitted policy, the same source server admission + reads, never by the registry row that policy may have superseded. Empty means no groups attached.""" if not user_api_key_auth.agent_id: - return None - ceiling: Final = await resolve_ceiling(user_api_key_auth.agent_id) - if ceiling is None: - return None - return frozenset(global_mcp_server_manager.expand_permission_list(sorted(ceiling.mcp_server_ids))) + return () + managed: Final = managed_agent_policy(user_api_key_auth) + try: + if managed is not None: + return await resolve_managed_agent_ceilings(managed) + ceiling: Final = await resolve_ceiling(user_api_key_auth.agent_id) + except Exception as e: # noqa: BLE001 # agent groups we cannot read must deny, not widen + raise UnloadableEntitlementError(f"agent access groups unreadable: {e}") from e + return (ceiling,) if ceiling is not None else () @staticmethod async def get_agent_tool_permissions_for_server( @@ -3764,14 +4013,17 @@ class MCPRequestHandler: if agent_object_permission is not None else await MCPRequestHandler._get_agent_object_permission(user_api_key_auth) ) - if obj_perm is None: - return None try: from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( global_mcp_server_manager, ) + group_tools: Final = await MCPRequestHandler._get_agent_access_group_tool_ceiling( + user_api_key_auth, server_id + ) + if obj_perm is None: + return _as_list(group_tools) direct_tools: Final = ( global_mcp_server_manager.expand_tool_permissions(obj_perm.mcp_tool_permissions).get(server_id) if obj_perm.mcp_tool_permissions @@ -3780,7 +4032,8 @@ class MCPRequestHandler: toolset_tools: Final = await MCPRequestHandler._toolset_tools_for_server( obj_perm, server_id, requires_fresh_policy=user_api_key_auth.requires_fresh_policy ) - agent_tools: Final = MCPRequestHandler._union_tool_grants(direct_tools, toolset_tools) + own_tools: Final = MCPRequestHandler._union_tool_grants(direct_tools, toolset_tools) + agent_tools: Final = _intersect_tool_ceiling(own_tools, group_tools) return list(agent_tools) if agent_tools is not None else None except Exception as e: if managed_agent_policy(user_api_key_auth) is not None or isinstance(e, UnloadableEntitlementError): diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index effb30fe8cb..225f289748e 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -686,6 +686,23 @@ } ], "title": "Description" + }, + "mcp_tool_permissions": { + "anyOf": [ + { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Mcp Tool Permissions" } }, "required": [ @@ -867,6 +884,23 @@ ], "title": "Description" }, + "mcp_tool_permissions": { + "anyOf": [ + { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Mcp Tool Permissions" + }, "updated_at": { "format": "date-time", "title": "Updated At", @@ -995,6 +1029,23 @@ } ], "title": "Description" + }, + "mcp_tool_permissions": { + "anyOf": [ + { + "additionalProperties": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Mcp Tool Permissions" } }, "title": "AccessGroupUpdateRequest", diff --git a/litellm/proxy/agent_endpoints/auth/agent_access_groups.py b/litellm/proxy/agent_endpoints/auth/agent_access_groups.py index db661fbea30..557d1c7bc0a 100644 --- a/litellm/proxy/agent_endpoints/auth/agent_access_groups.py +++ b/litellm/proxy/agent_endpoints/auth/agent_access_groups.py @@ -25,11 +25,19 @@ class AgentAccessGroupCeiling: models: frozenset[str] mcp_server_ids: frozenset[str] agent_ids: frozenset[str] + access_groups: tuple[LiteLLM_AccessGroupTable, ...] = () CeilingResolver: TypeAlias = Callable[[str], Awaitable[AgentAccessGroupCeiling | None]] +class _MissingGroup: + __slots__ = () + + +_MISSING: Final = _MissingGroup() + + async def _registry_access_group_ids(agent_id: str) -> AccessGroupIds: from litellm.proxy.common_utils.registry_read_through import get_agent_with_read_through @@ -38,7 +46,7 @@ async def _registry_access_group_ids(agent_id: str) -> AccessGroupIds: async def _load_access_group(access_group_id: str, *, check_db_only: bool = False) -> LoadedAccessGroup: - from litellm.proxy.auth.auth_checks import get_access_object + from litellm.proxy.auth.auth_checks import AccessGroupNotFoundError, get_access_object from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache if prisma_client is None: @@ -52,6 +60,8 @@ async def _load_access_group(access_group_id: str, *, check_db_only: bool = Fals proxy_logging_obj=proxy_logging_obj, check_db_only=check_db_only, ) + except AccessGroupNotFoundError: + raise except HTTPException as e: if check_db_only: raise @@ -68,23 +78,35 @@ async def resolve_agent_access_group_ceiling( *, check_db_only: bool = False, ) -> AgentAccessGroupCeiling | None: - """``None`` when the agent has no access groups attached, so nothing is capped.""" - access_group_ids: Final = await load_access_group_ids(agent_id) - if not access_group_ids: + """``None`` when the agent has no access groups attached, so nothing is capped. A named group that + provably does not exist is dropped from the ceiling, it grants and bounds nothing, while a group + that exists but could not be read stays named and loads as ``None``. The ceiling itself stays once + any group is named, so an agent whose every group is missing is still capped to nothing.""" + named_ids: Final = await load_access_group_ids(agent_id) + if not named_ids: return None - loaded: Final = await asyncio.gather( - *( - _load_access_group(group_id, check_db_only=True) if check_db_only else load_access_group(group_id) - for group_id in access_group_ids - ) + from litellm.proxy.auth.auth_checks import AccessGroupNotFoundError + + async def load(group_id: str) -> LoadedAccessGroup | _MissingGroup: + try: + return await ( + _load_access_group(group_id, check_db_only=True) if check_db_only else load_access_group(group_id) + ) + except AccessGroupNotFoundError: + return _MISSING + + results: Final = await asyncio.gather(*(load(group_id) for group_id in named_ids)) + access_group_ids: Final = tuple( + group_id for group_id, result in zip(named_ids, results, strict=True) if not isinstance(result, _MissingGroup) ) - groups: Final = tuple(group for group in loaded if group is not None) + groups: Final = tuple(result for result in results if isinstance(result, LiteLLM_AccessGroupTable)) return AgentAccessGroupCeiling( access_group_ids=access_group_ids, models=frozenset(model for group in groups for model in group.access_model_names), mcp_server_ids=frozenset(server_id for group in groups for server_id in group.access_mcp_server_ids), agent_ids=frozenset(target_id for group in groups for target_id in group.access_agent_ids), + access_groups=groups, ) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 5568ae2995c..1a12e1b21e0 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -3411,6 +3411,19 @@ class TeamNotFoundError(HTTPException): super().__init__(status_code=404, detail=detail) +class AccessGroupNotFoundError(HTTPException): + """The access group row is provably absent, as opposed to merely unreadable. ``get_access_object`` + reports every failure as a 404, so a group that was never created and a database that would not + answer look the same to its callers. Policy code that must not treat a degraded read as a definitive + answer keys on this subclass. It stays a 404 with the same detail, so every other caller is unaffected.""" + + def __init__(self, access_group_id: str) -> None: + super().__init__( + status_code=404, + detail={"error": f"Access group doesn't exist in db. Access group={access_group_id}."}, + ) + + @log_db_metrics async def _get_team_db_check( team_id: str, prisma_client: PrismaClient, team_id_upsert: bool | None = None, *, use_writer: bool = False @@ -3667,10 +3680,7 @@ async def get_access_object( ).find_unique(where={"access_group_id": access_group_id}) if response is None: - raise HTTPException( - status_code=404, - detail={"error": f"Access group doesn't exist in db. Access group={access_group_id}."}, - ) + raise AccessGroupNotFoundError(access_group_id=access_group_id) _response: Final = LiteLLM_AccessGroupTable.model_validate(response.dict()) @@ -4532,6 +4542,56 @@ async def get_org_object_for_request( return org +async def get_access_group_tables( + access_group_ids: Sequence[str], + prisma_client: DatabaseClient | None = None, + user_api_key_cache: UserApiKeyCache | None = None, + proxy_logging_obj: ProxyLogging | None = None, + check_db_only: bool = False, + *, + unreadable: Literal["skip", "raise"] = "skip", +) -> tuple[LiteLLM_AccessGroupTable, ...]: + """Load unified access groups by id (cache first, then DB). A group that provably does not exist is + skipped, it grants nothing. A group that exists but cannot be read is skipped too unless + ``check_db_only`` is set or ``unreadable`` is ``"raise"``, in which case the failure propagates so a + caller enforcing policy can deny instead of shedding a restriction it could not read.""" + if not access_group_ids: + return () + + if prisma_client is None or user_api_key_cache is None: + from litellm.proxy.proxy_server import prisma_client as _prisma_client + from litellm.proxy.proxy_server import proxy_logging_obj as _proxy_logging_obj + from litellm.proxy.proxy_server import user_api_key_cache as _user_api_key_cache + + prisma_client = prisma_client or _prisma_client # rebind-ok: lazy proxy_server fallback + user_api_key_cache = user_api_key_cache or _user_api_key_cache # rebind-ok: lazy proxy_server fallback + proxy_logging_obj = proxy_logging_obj or _proxy_logging_obj # rebind-ok: lazy proxy_server fallback + + if user_api_key_cache is None: + return () + + async def load(ag_id: str) -> LiteLLM_AccessGroupTable | None: + try: + return await get_access_object( + access_group_id=ag_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + check_db_only=check_db_only, + ) + except AccessGroupNotFoundError: + verbose_proxy_logger.debug("One of the requested access groups does not exist, it grants nothing") + return None + except Exception: + if check_db_only or unreadable == "raise": + raise + verbose_proxy_logger.debug("Could not fetch one of the requested access groups") + return None + + loaded: Final = [await load(ag_id) for ag_id in access_group_ids] + return tuple(ag for ag in loaded if ag is not None) + + async def _get_resources_from_access_groups( access_group_ids: Sequence[str], resource_field: Literal["access_model_names", "access_mcp_server_ids", "access_agent_ids"], @@ -4540,59 +4600,30 @@ async def _get_resources_from_access_groups( proxy_logging_obj: ProxyLogging | None = None, check_db_only: bool = False, ) -> list[str]: - """ - Fetch access groups by their IDs (from cache or DB) and collect - the specified resource field across all of them. + """Deduplicated ``resource_field`` values across the resolved access groups.""" + groups: Final = await get_access_group_tables( + access_group_ids=access_group_ids, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + check_db_only=check_db_only, + ) + return list( + {resource for ag in groups for resource in _access_group_field(ag, resource_field)} # comprehension-ok: flatten + ) - Args: - access_group_ids: List of access group IDs to fetch - resource_field: Which resource list to extract from each access group - - "access_model_names": model names (for model access checks) - - "access_mcp_server_ids": MCP server IDs (for MCP access checks) - - "access_agent_ids": agent IDs (for agent access checks) - prisma_client: Optional PrismaClient (lazy-imported from proxy_server if None) - user_api_key_cache: Optional DualCache (lazy-imported from proxy_server if None) - proxy_logging_obj: Optional ProxyLogging (lazy-imported from proxy_server if None) - Returns: - Deduplicated list of resource identifiers from all resolved access groups. - """ - if not access_group_ids: - return [] - - # Lazy import to avoid circular imports - if prisma_client is None or user_api_key_cache is None: - from litellm.proxy.proxy_server import prisma_client as _prisma_client - from litellm.proxy.proxy_server import proxy_logging_obj as _proxy_logging_obj - from litellm.proxy.proxy_server import user_api_key_cache as _user_api_key_cache - - prisma_client = prisma_client or _prisma_client - user_api_key_cache = user_api_key_cache or _user_api_key_cache - proxy_logging_obj = proxy_logging_obj or _proxy_logging_obj - - if user_api_key_cache is None: - return [] - - resources: Final[list[str]] = [] - for ag_id in access_group_ids: - try: - ag = await get_access_object( - access_group_id=ag_id, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - proxy_logging_obj=proxy_logging_obj, - check_db_only=check_db_only, - ) - resources.extend(getattr(ag, resource_field, [])) - except Exception: - if check_db_only: - raise - verbose_proxy_logger.debug( - "Could not fetch access group %s for resource field %s", - ag_id, - resource_field, - ) - return list(set(resources)) +def _access_group_field( + group: LiteLLM_AccessGroupTable, + resource_field: Literal["access_model_names", "access_mcp_server_ids", "access_agent_ids"], +) -> list[str]: + match resource_field: + case "access_model_names": + return group.access_model_names + case "access_mcp_server_ids": + return group.access_mcp_server_ids + case "access_agent_ids": + return group.access_agent_ids async def get_models_from_access_groups( diff --git a/litellm/proxy/management_endpoints/access_group_endpoints.py b/litellm/proxy/management_endpoints/access_group_endpoints.py index 3f05c3a407a..5e0dbb3297f 100644 --- a/litellm/proxy/management_endpoints/access_group_endpoints.py +++ b/litellm/proxy/management_endpoints/access_group_endpoints.py @@ -2,7 +2,7 @@ import asyncio from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from types import MappingProxyType -from typing import Final, Protocol +from typing import TYPE_CHECKING, Final, Protocol from fastapi import APIRouter, Depends, HTTPException, status from typing_extensions import ReadOnly, TypedDict @@ -45,6 +45,9 @@ from litellm.types.access_group import ( AccessGroupUpdateRequest, ) +if TYPE_CHECKING: + from prisma import Json + router: Final = APIRouter( tags=["access group management"], ) @@ -57,6 +60,9 @@ class _AccessGroupRecord(Protocol): @property def access_mcp_server_ids(self) -> Sequence[str] | None: ... + @property + def mcp_tool_permissions(self) -> "Mapping[str, Sequence[str]] | Json | None": ... + @property def access_agent_ids(self) -> Sequence[str] | None: ... @@ -179,6 +185,12 @@ def _require_admin_view(user_api_key_dict: UserAPIKeyAuth) -> None: ) +def _tool_permissions_json(value: Mapping[str, Sequence[str]] | None) -> "Json": + from prisma import Json # noqa: PLC0415 # local import: prisma may be ungenerated at module load in some tools + + return Json(None) if value is None else Json({server: list(tools) for server, tools in value.items()}) + + @dataclass(frozen=True, slots=True) class _ResourceNames: mcp_servers: Mapping[str, str] @@ -544,6 +556,7 @@ async def create_access_group( "description": data.description, "access_model_names": data.access_model_names or [], "access_mcp_server_ids": data.access_mcp_server_ids or [], + "mcp_tool_permissions": _tool_permissions_json(data.mcp_tool_permissions), "access_agent_ids": data.access_agent_ids or [], "assigned_team_ids": data.assigned_team_ids or [], "assigned_key_ids": data.assigned_key_ids or [], @@ -648,7 +661,9 @@ async def update_access_group( and value is None ): value = [] - update_data[field] = value + update_data[field] = ( + _tool_permissions_json(data.mcp_tool_permissions) if field == "mcp_tool_permissions" else value + ) # Initialize delta lists before the try block so they remain accessible # for cache updates after the transaction, even if an error path is added later. diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index ca58247a2c9..dcae56b5e71 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -1588,6 +1588,7 @@ model LiteLLM_AccessGroupTable { // Resource memberships - explicit arrays per type access_model_names String[] @default([]) access_mcp_server_ids String[] @default([]) + mcp_tool_permissions Json? access_agent_ids String[] @default([]) assigned_team_ids String[] @default([]) diff --git a/litellm/types/access_group.py b/litellm/types/access_group.py index b4bf345ecbe..d6d2a312f45 100644 --- a/litellm/types/access_group.py +++ b/litellm/types/access_group.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping, Sequence from datetime import datetime from litellm.types.llms.base import LiteLLMBaseModel @@ -8,6 +9,7 @@ class AccessGroupCreateRequest(LiteLLMBaseModel): description: str | None = None access_model_names: list[str] | None = None access_mcp_server_ids: list[str] | None = None + mcp_tool_permissions: Mapping[str, Sequence[str]] | None = None access_agent_ids: list[str] | None = None assigned_team_ids: list[str] | None = None assigned_key_ids: list[str] | None = None @@ -18,6 +20,7 @@ class AccessGroupUpdateRequest(LiteLLMBaseModel): description: str | None = None access_model_names: list[str] | None = None access_mcp_server_ids: list[str] | None = None + mcp_tool_permissions: Mapping[str, Sequence[str]] | None = None access_agent_ids: list[str] | None = None assigned_team_ids: list[str] | None = None assigned_key_ids: list[str] | None = None @@ -36,6 +39,7 @@ class AccessGroupResponse(LiteLLMBaseModel): description: str | None = None access_model_names: list[str] access_mcp_server_ids: list[str] + mcp_tool_permissions: Mapping[str, Sequence[str]] | None = None access_agent_ids: list[str] assigned_team_ids: list[str] assigned_key_ids: list[str] diff --git a/schema.prisma b/schema.prisma index ca58247a2c9..dcae56b5e71 100644 --- a/schema.prisma +++ b/schema.prisma @@ -1588,6 +1588,7 @@ model LiteLLM_AccessGroupTable { // Resource memberships - explicit arrays per type access_model_names String[] @default([]) access_mcp_server_ids String[] @default([]) + mcp_tool_permissions Json? access_agent_ids String[] @default([]) assigned_team_ids String[] @default([]) diff --git a/tests/unit/proxy/_experimental/mcp_server/auth/test_managed_agent_access.py b/tests/unit/proxy/_experimental/mcp_server/auth/test_managed_agent_access.py index 90403e5553f..1524aad5b66 100644 --- a/tests/unit/proxy/_experimental/mcp_server/auth/test_managed_agent_access.py +++ b/tests/unit/proxy/_experimental/mcp_server/auth/test_managed_agent_access.py @@ -7,7 +7,7 @@ from fastapi import HTTPException from litellm.proxy import proxy_server from litellm.proxy._experimental.mcp_server import mcp_server_manager from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import MCPRequestHandler -from litellm.proxy._types import LiteLLM_ObjectPermissionTable, LiteLLM_UserTable, UserAPIKeyAuth +from litellm.proxy._types import LiteLLM_ObjectPermissionTable, LiteLLM_TeamTable, LiteLLM_UserTable, UserAPIKeyAuth from litellm.proxy.auth import auth_checks from litellm.types.agents import AgentResponse from litellm.types.proxy.agent_identity import ManagedAgentContext @@ -114,6 +114,41 @@ async def test_access_groups_cap_agent_servers_without_granting_new_ones( assert await MCPRequestHandler.get_allowed_tools_for_server("slack", auth) == [] +@pytest.mark.asyncio +async def test_managed_agent_tool_ceiling_reads_the_admitted_policy_not_the_registry( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from litellm.proxy._types import LiteLLM_AccessGroupTable + from litellm.proxy.common_utils import registry_read_through + + groups: Final = { + "stale": LiteLLM_AccessGroupTable( + access_group_id="stale", access_group_name="Stale", access_mcp_server_ids=["slack"] + ), + "current": LiteLLM_AccessGroupTable( + access_group_id="current", + access_group_name="Current", + access_mcp_server_ids=["slack"], + mcp_tool_permissions={"slack": ["read"]}, + ), + } + + async def load_group(access_group_id: str, **_: object) -> LiteLLM_AccessGroupTable: + return groups[access_group_id] + + monkeypatch.setattr(auth_checks, "get_access_object", load_group) + auth: Final = actor(("read", "write")) + assert auth.managed_agent_policy is not None + stale_registry_row: Final = auth.managed_agent_policy.model_copy(update={"access_group_ids": ["stale"]}) + monkeypatch.setattr( + registry_read_through, "get_agent_with_read_through", AsyncMock(return_value=stale_registry_row) + ) + auth.managed_agent_policy = auth.managed_agent_policy.model_copy(update={"access_group_ids": ["current"]}) + + assert tuple(await MCPRequestHandler.get_allowed_mcp_servers(auth)) == ("slack",) + assert await MCPRequestHandler.get_allowed_tools_for_server("slack", auth) == ["read"] + + @pytest.mark.asyncio @pytest.mark.parametrize("change", ["tools", "servers", "disabled", "outage"]) async def test_delegated_mcp_revokes_warm_human_policy_before_tool_execution( @@ -400,9 +435,9 @@ async def test_delegation_never_borrows_another_teams_server_or_tools( team_id=name, models=[], members_with_roles=[{"user_id": "human", "role": "user"}], - object_permission=permission if name == "selected" else LiteLLM_ObjectPermissionTable( - object_permission_id="other-grant", mcp_servers=["slack", "linear"] - ), + object_permission=permission + if name == "selected" + else LiteLLM_ObjectPermissionTable(object_permission_id="other-grant", mcp_servers=["slack", "linear"]), ) for name in ("selected", "other") } @@ -439,7 +474,9 @@ async def test_managed_mcp_rejects_unavailable_authoritative_entitlements( ) auth: Final = actor(None) assert auth.managed_agent_policy is not None - auth.managed_agent_policy = auth.managed_agent_policy.model_copy(update={"object_permission": permission.model_dump()}) + auth.managed_agent_policy = auth.managed_agent_policy.model_copy( + update={"object_permission": permission.model_dump()} + ) auth.requires_fresh_policy = True with pytest.raises(HTTPException) as failure: await MCPRequestHandler.get_allowed_tools_for_server("slack", auth) @@ -484,6 +521,7 @@ async def test_managed_agent_mcp_access_is_capped_at_the_invoking_callers_grants "_apply_user_tool_ceiling", AsyncMock(side_effect=lambda tools, _server_id, _auth: tools), ) + monkeypatch.setattr(auth_checks, "get_team_object", AsyncMock(return_value=LiteLLM_TeamTable(team_id="callers"))) auth: Final = actor(("read", "write")) auth.agent_caller = AgentCaller(user_id="alice", team_id="callers") @@ -496,25 +534,34 @@ async def test_managed_agent_mcp_access_is_capped_at_the_invoking_callers_grants @pytest.mark.parametrize("fresh", [False, True]) @pytest.mark.parametrize("caller_kind", ["team", "user"]) async def test_caller_mcp_revocation_uses_fresh_policy( - monkeypatch: pytest.MonkeyPatch, fresh: bool, caller_kind: str, + monkeypatch: pytest.MonkeyPatch, + fresh: bool, + caller_kind: str, ) -> None: from litellm.proxy._types import LiteLLM_TeamTable from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache, object_permission_cache_key from litellm.types.agents import AgentCaller cached_permission: Final = LiteLLM_ObjectPermissionTable( - object_permission_id="caller-permission", mcp_servers=["slack", "linear"], + object_permission_id="caller-permission", + mcp_servers=["slack", "linear"], mcp_tool_permissions={"slack": ["read", "write"]}, ) current_permission: Final = LiteLLM_ObjectPermissionTable( - object_permission_id="caller-permission", mcp_servers=["slack"], + object_permission_id="caller-permission", + mcp_servers=["slack"], mcp_tool_permissions={"slack": ["read"]}, ) team: Final = LiteLLM_TeamTable( - team_id="caller", object_permission_id="caller-permission", object_permission=current_permission, + team_id="caller", + object_permission_id="caller-permission", + object_permission=current_permission, ) user: Final = LiteLLM_UserTable( - user_id="caller", teams=[], object_permission_id="caller-permission", object_permission=current_permission, + user_id="caller", + teams=[], + object_permission_id="caller-permission", + object_permission=current_permission, ) database: Final = MagicMock() database.writer_db.litellm_teamtable.find_unique = AsyncMock(return_value=team) @@ -531,13 +578,16 @@ async def test_caller_mcp_revocation_uses_fresh_policy( auth.agent_caller = AgentCaller(team_id="caller") if caller_kind == "team" else AgentCaller(user_id="caller") assert set(await MCPRequestHandler.get_allowed_mcp_servers(auth)) == ({"slack"} if fresh else {"slack", "linear"}) - assert await MCPRequestHandler.get_allowed_tools_for_server("slack", auth) == (["read"] if fresh else ["read", "write"]) + assert await MCPRequestHandler.get_allowed_tools_for_server("slack", auth) == ( + ["read"] if fresh else ["read", "write"] + ) @pytest.mark.asyncio @pytest.mark.parametrize("fresh", [False, True]) async def test_caller_team_outage_cannot_remove_authoritative_server_ceiling( - monkeypatch: pytest.MonkeyPatch, fresh: bool, + monkeypatch: pytest.MonkeyPatch, + fresh: bool, ) -> None: from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache from litellm.types.agents import AgentCaller diff --git a/tests/unit/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/unit/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index 078fff0266b..4e2597508fc 100644 --- a/tests/unit/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/unit/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -1,6 +1,8 @@ import contextlib import json import os +from collections.abc import Sequence +from collections.abc import Set as AbstractSet from datetime import datetime, timedelta, timezone from typing import Final, Literal from unittest.mock import AsyncMock, MagicMock, patch @@ -10,6 +12,8 @@ from fastapi import HTTPException from fastapi.testclient import TestClient from starlette.datastructures import Headers +from litellm.constants import MCP_ALL_TOOLS_WILDCARD +from litellm.models.access_group import LiteLLM_AccessGroupTable from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( MCPRequestHandler, UnloadableEntitlementError, @@ -18,6 +22,7 @@ from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( ) from litellm.proxy._types import ( LiteLLM_ObjectPermissionTable, + LiteLLM_TeamTable, ProxyException, SpecialHeaders, SpecialMCPServerNames, @@ -26,17 +31,21 @@ from litellm.proxy._types import ( from litellm.types.agents import AgentCaller -@pytest.mark.parametrize("headers,expected", [ - ({"Authorization": "Bearer caller.jwt.signature"}, "caller.jwt.signature"), - ({"AUTHORIZATION": "bEaReR caller.jwt.signature"}, "caller.jwt.signature"), - ({"authorization": "Bearer "}, ""), - ({"authorization": "Basic credentials"}, None), - ({"authorization-extra": "Bearer wrong.jwt.signature"}, None), - ({"x-litellm-api-key": "sk-key"}, None), - ({}, None), -]) +@pytest.mark.parametrize( + "headers,expected", + [ + ({"Authorization": "Bearer caller.jwt.signature"}, "caller.jwt.signature"), + ({"AUTHORIZATION": "bEaReR caller.jwt.signature"}, "caller.jwt.signature"), + ({"authorization": "Bearer "}, ""), + ({"authorization": "Basic credentials"}, None), + ({"authorization-extra": "Bearer wrong.jwt.signature"}, None), + ({"x-litellm-api-key": "sk-key"}, None), + ({}, None), + ], +) def test_incoming_guardrail_bearer_requires_exact_authorization_header( - headers: dict[str, str], expected: str | None, + headers: dict[str, str], + expected: str | None, ) -> None: token: Final = MCPRequestHandler.get_incoming_bearer_token(headers) assert (token.get_secret_value() if token is not None else None) == expected @@ -8372,7 +8381,9 @@ class TestUserSubjectTeamUnion: assert servers == ["srv1"] assert tools == ["add"] assert resolve.await_args_list - assert all(call.kwargs == {"toolset_ids": ["ts-1"], "requires_fresh_policy": True} for call in resolve.await_args_list) + assert all( + call.kwargs == {"toolset_ids": ["ts-1"], "requires_fresh_policy": True} for call in resolve.await_args_list + ) async def test_key_based_caller_uses_single_team_only(self): """A key-based caller (api_key set) with a team_id sees ONLY that team, even though the @@ -9564,9 +9575,7 @@ class TestSessionBearerEgressScrub: ) -> None: caller_key: Final = "sk-caller-admission-key-123" upstream_token: Final = "Bearer real-upstream-token" - oauth2_headers: Final = { - "Authorization": caller_value if context == "oauth2_authorization" else upstream_token - } + oauth2_headers: Final = {"Authorization": caller_value if context == "oauth2_authorization" else upstream_token} raw_headers: Final = { "x-litellm-api-key": f"Bearer {caller_key}", "authorization": caller_value if context == "raw_authorization" else upstream_token, @@ -9823,7 +9832,9 @@ class TestSessionBearerEgressScrub: {}, ) - async def test_caller_admission_credential_falls_back_to_standard_headers_when_custom_header_is_absent(self) -> None: + async def test_caller_admission_credential_falls_back_to_standard_headers_when_custom_header_is_absent( + self, + ) -> None: caller_value: Final = "Bearer sk-caller-admission-key-123" headers: Final = Headers( { @@ -10243,7 +10254,11 @@ class TestGetUserObjectPermission: prisma_client = MagicMock() from litellm.proxy._types import LiteLLM_UserTable - row = LiteLLM_UserTable(user_id="human", object_permission_id=user_row.object_permission_id) if user_row is not None else None + row = ( + LiteLLM_UserTable(user_id="human", object_permission_id=user_row.object_permission_id) + if user_row is not None + else None + ) prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=row) return prisma_client @@ -10259,7 +10274,10 @@ class TestGetUserObjectPermission: with ( patch("litellm.proxy.proxy_server.prisma_client", prisma_client), patch("litellm.proxy.proxy_server.user_api_key_cache", DualCache()), - patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock(service_logging_obj=MagicMock(async_service_success_hook=AsyncMock()))), + patch( + "litellm.proxy.proxy_server.proxy_logging_obj", + MagicMock(service_logging_obj=MagicMock(async_service_success_hook=AsyncMock())), + ), patch( "litellm.proxy.auth.auth_checks.get_object_permission", new_callable=AsyncMock, @@ -10286,7 +10304,10 @@ class TestGetUserObjectPermission: with ( patch("litellm.proxy.proxy_server.prisma_client", prisma_client), patch("litellm.proxy.proxy_server.user_api_key_cache", DualCache()), - patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock(service_logging_obj=MagicMock(async_service_success_hook=AsyncMock()))), + patch( + "litellm.proxy.proxy_server.proxy_logging_obj", + MagicMock(service_logging_obj=MagicMock(async_service_success_hook=AsyncMock())), + ), patch("litellm.proxy.auth.auth_checks.get_object_permission", new_callable=AsyncMock) as mock_get_perm, ): assert await MCPRequestHandler._get_user_object_permission(auth) is None @@ -10308,7 +10329,10 @@ class TestGetUserObjectPermission: with ( patch("litellm.proxy.proxy_server.prisma_client", prisma_client), patch("litellm.proxy.proxy_server.user_api_key_cache", DualCache()), - patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock(service_logging_obj=MagicMock(async_service_success_hook=AsyncMock()))), + patch( + "litellm.proxy.proxy_server.proxy_logging_obj", + MagicMock(service_logging_obj=MagicMock(async_service_success_hook=AsyncMock())), + ), ): assert await MCPRequestHandler._get_user_object_permission(auth) is None @@ -10322,7 +10346,10 @@ class TestGetUserObjectPermission: with ( patch("litellm.proxy.proxy_server.prisma_client", prisma_client), patch("litellm.proxy.proxy_server.user_api_key_cache", DualCache()), - patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock(service_logging_obj=MagicMock(async_service_success_hook=AsyncMock()))), + patch( + "litellm.proxy.proxy_server.proxy_logging_obj", + MagicMock(service_logging_obj=MagicMock(async_service_success_hook=AsyncMock())), + ), ): assert await MCPRequestHandler._get_user_object_permission(auth) is None @@ -10339,7 +10366,10 @@ class TestGetUserObjectPermission: with ( patch("litellm.proxy.proxy_server.prisma_client", prisma_client), patch("litellm.proxy.proxy_server.user_api_key_cache", DualCache()), - patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock(service_logging_obj=MagicMock(async_service_success_hook=AsyncMock()))), + patch( + "litellm.proxy.proxy_server.proxy_logging_obj", + MagicMock(service_logging_obj=MagicMock(async_service_success_hook=AsyncMock())), + ), patch( "litellm.proxy.auth.auth_checks.get_object_permission", new_callable=AsyncMock, @@ -10759,7 +10789,9 @@ async def test_catalog_refresh_reads_current_user_org_without_losing_resource_sc from litellm.proxy import proxy_server from litellm.proxy._types import LiteLLM_UserTable - current = LiteLLM_UserTable(user_id="catalog-user", organization_id="current-org", user_role="internal_user", teams=[]) + current = LiteLLM_UserTable( + user_id="catalog-user", organization_id="current-org", user_role="internal_user", teams=[] + ) table = SimpleNamespace(find_unique=AsyncMock(return_value=current)) database = SimpleNamespace(writer_db=SimpleNamespace(litellm_usertable=table)) monkeypatch.setattr(proxy_server, "prisma_client", database) @@ -10779,10 +10811,25 @@ async def test_catalog_refresh_reads_current_user_org_without_losing_resource_sc @pytest.mark.parametrize("current_groups", [[], ["replacement-group"]]) async def test_catalog_refresh_uses_current_virtual_key_policy_and_keeps_session_scope(monkeypatch, current_groups): permission = LiteLLM_ObjectPermissionTable(object_permission_id="current-policy", mcp_servers=["current-server"]) - current = UserAPIKeyAuth(object_permission=permission, object_permission_id="current-policy", team_id="new-team", org_id="new-org", project_id="new-project", user_id="new-owner", access_group_ids=current_groups) + current = UserAPIKeyAuth( + object_permission=permission, + object_permission_id="current-policy", + team_id="new-team", + org_id="new-org", + project_id="new-project", + user_id="new-owner", + access_group_ids=current_groups, + ) reload_key = AsyncMock(return_value=current) monkeypatch.setattr(MCPRequestHandler, "_reload_admitted_key", reload_key) - caller = UserAPIKeyAuth(api_key="owned-key-hash", team_id="old-team", org_id="old-org", project_id="old-project", user_id="old-owner", access_group_ids=["original-group"]) + caller = UserAPIKeyAuth( + api_key="owned-key-hash", + team_id="old-team", + org_id="old-org", + project_id="old-project", + user_id="old-owner", + access_group_ids=["original-group"], + ) caller.via_virtual_key = True caller.mcp_session_resource_server_id = "session-server" caller.mcp_toolset_id = "session-toolset" @@ -10790,7 +10837,12 @@ async def test_catalog_refresh_uses_current_virtual_key_policy_and_keeps_session reload_key.assert_awaited_once_with("owned-key-hash", check_db_only=True) assert refreshed.object_permission == permission assert refreshed.object_permission_id == "current-policy" - assert (refreshed.team_id, refreshed.org_id, refreshed.project_id, refreshed.user_id) == ("new-team", "new-org", "new-project", "new-owner") + assert (refreshed.team_id, refreshed.org_id, refreshed.project_id, refreshed.user_id) == ( + "new-team", + "new-org", + "new-project", + "new-owner", + ) assert refreshed.mcp_session_resource_server_id == "session-server" assert refreshed.mcp_toolset_id == "session-toolset" assert refreshed.via_virtual_key and refreshed.requires_fresh_policy @@ -10820,33 +10872,40 @@ async def test_admission_request_body_serves_stashed_peek_callable(): ) assert await with_peek.body() == admission_body - without_peek: Final = _admission_request( - {"type": "http", "method": "POST", "path": "/mcp", "headers": []} - ) + without_peek: Final = _admission_request({"type": "http", "method": "POST", "path": "/mcp", "headers": []}) assert await without_peek.body() == b"{}" -@pytest.mark.parametrize("bearer,api_key,claims,explicit_key,expected", [ - (None, "sk-key", None, None, None), - ("sk-key", "sk-key", None, None, None), - ("sk-key", "sk-key", {"sub": "previous-jwt"}, "sk-key", None), - ("caller.jwt.assertion", "sk-key", {"sub": "caller"}, None, "caller.jwt.assertion"), - ("caller.jwt.assertion", None, {"sub": "caller"}, None, "caller.jwt.assertion"), - ("upstream", "sk-key", None, "sk-key", "upstream"), - ("llm_session_test", None, {"sub": "caller"}, None, None), - ("llm_srefresh_test", None, {"sub": "caller"}, None, None), - ("opaque-master", "litellm_proxy_master_key", None, None, None), -]) +@pytest.mark.parametrize( + "bearer,api_key,claims,explicit_key,expected", + [ + (None, "sk-key", None, None, None), + ("sk-key", "sk-key", None, None, None), + ("sk-key", "sk-key", {"sub": "previous-jwt"}, "sk-key", None), + ("caller.jwt.assertion", "sk-key", {"sub": "caller"}, None, "caller.jwt.assertion"), + ("caller.jwt.assertion", None, {"sub": "caller"}, None, "caller.jwt.assertion"), + ("upstream", "sk-key", None, "sk-key", "upstream"), + ("llm_session_test", None, {"sub": "caller"}, None, None), + ("llm_srefresh_test", None, {"sub": "caller"}, None, None), + ("opaque-master", "litellm_proxy_master_key", None, None, None), + ], +) def test_guardrail_bearer_preserves_identity_without_exposing_gateway_credentials( - bearer: str | None, api_key: str | None, claims: dict[str, str] | None, - explicit_key: str | None, expected: str | None, + bearer: str | None, + api_key: str | None, + claims: dict[str, str] | None, + explicit_key: str | None, + expected: str | None, ) -> None: - headers: Final = Headers({ - **({"Authorization": f"Bearer {bearer}"} if bearer is not None else {}), - **({"x-litellm-api-key": explicit_key} if explicit_key else {}), - }) + headers: Final = Headers( + { + **({"Authorization": f"Bearer {bearer}"} if bearer is not None else {}), + **({"x-litellm-api-key": explicit_key} if explicit_key else {}), + } + ) token: Final = MCPRequestHandler.get_guardrail_bearer_token( - headers, UserAPIKeyAuth(api_key=api_key, jwt_claims=claims), + headers, + UserAPIKeyAuth(api_key=api_key, jwt_claims=claims), ) assert (token.get_secret_value() if token is not None else None) == expected @@ -10860,3 +10919,545 @@ def test_guardrail_bearer_respects_custom_admission_header(monkeypatch: pytest.M UserAPIKeyAuth(api_key="litellm_proxy_master_key"), ) assert token is None + + +def _access_group(group_id, servers, tool_permissions=None, **owners): + from litellm.models.access_group import LiteLLM_AccessGroupTable + + return LiteLLM_AccessGroupTable( + access_group_id=group_id, + access_group_name=group_id, + access_mcp_server_ids=list(servers), + mcp_tool_permissions=tool_permissions, + **owners, + ) + + +class TestAccessGroupToolsForServer: + """Tool grants of unified access groups on one server, before they meet the key/team ceilings.""" + + def test_no_group_grants_the_server_is_no_restriction(self): + groups = [_access_group("g", ["other"], {"other": ["x"], "srv": ["leak"]})] + assert MCPRequestHandler.access_group_tools_for_server(groups, "srv") is None + + def test_granting_group_without_entry_allows_every_tool(self): + groups = [_access_group("g", ["srv"])] + assert MCPRequestHandler.access_group_tools_for_server(groups, "srv") is None + + def test_granting_group_with_explicit_empty_list_grants_no_tools(self): + groups = [_access_group("g", ["srv"], {"srv": []})] + assert MCPRequestHandler.access_group_tools_for_server(groups, "srv") == () + + def test_tools_union_across_granting_groups(self): + groups = [_access_group("a", ["srv"], {"srv": ["read"]}), _access_group("b", ["srv"], {"srv": ["write"]})] + assert sorted(MCPRequestHandler.access_group_tools_for_server(groups, "srv")) == ["read", "write"] + + def test_unrestricted_granting_group_wins_the_union(self): + groups = [_access_group("a", ["srv"], {"srv": ["read"]}), _access_group("b", ["srv"])] + assert MCPRequestHandler.access_group_tools_for_server(groups, "srv") is None + + def test_non_granting_group_entry_does_not_widen(self): + groups = [_access_group("a", ["srv"], {"srv": []}), _access_group("b", ["other"], {"srv": ["write"]})] + assert MCPRequestHandler.access_group_tools_for_server(groups, "srv") == () + + +@pytest.mark.asyncio +class TestAccessGroupToolPermissionsInheritance: + """Group tool allowlists reach the key, team and agent levels of get_allowed_tools_for_server.""" + + @staticmethod + def _team(access_group_ids): + from litellm.proxy._types import LiteLLM_TeamTable + + return LiteLLM_TeamTable(team_id="team-1", access_group_ids=access_group_ids) + + @staticmethod + def _groups_loader(groups): + by_id = {group.access_group_id: group for group in groups} + + async def load(*, access_group_id, **_): + return by_id[access_group_id] + + return AsyncMock(side_effect=load) + + async def test_key_access_group_tool_allowlist_restricts_the_server(self): + auth = UserAPIKeyAuth(api_key="sk-hash", user_id="u1", access_group_ids=["g"]) + groups = [_access_group("g", ["srv"], {"srv": ["read"]}, assigned_key_ids=[auth.token])] + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._groups_loader(groups)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == ["read"] + + async def test_key_access_group_explicit_empty_list_denies_every_tool(self): + auth = UserAPIKeyAuth(api_key="sk-hash", user_id="u1", access_group_ids=["g"]) + groups = [_access_group("g", ["srv"], {"srv": []}, assigned_key_ids=[auth.token])] + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._groups_loader(groups)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == [] + + async def test_key_server_only_access_group_stays_unrestricted(self): + auth = UserAPIKeyAuth(api_key="sk-hash", user_id="u1", access_group_ids=["g"]) + groups = [_access_group("g", ["srv"], assigned_key_ids=[auth.token])] + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._groups_loader(groups)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) is None + + async def test_key_object_permission_tools_intersect_with_group_tools(self): + auth = _key_auth_reaching("srv", tools=["read", "delete"], access_group_ids=["g"]) + groups = [_access_group("g", ["srv"], {"srv": ["read", "write"]}, assigned_key_ids=[auth.token])] + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._groups_loader(groups)), + ): + allowed = await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) + assert sorted(allowed) == ["delete", "read", "write"] + + async def test_team_access_group_tool_allowlist_caps_the_key(self): + auth = _key_auth_reaching("srv", team_id="team-1") + team = self._team(["g"]) + groups = [_access_group("g", ["srv"], {"srv": ["read"]})] + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_team_object", AsyncMock(return_value=team)), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._groups_loader(groups)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == ["read"] + + async def test_team_access_group_explicit_empty_list_denies_even_with_key_tools(self): + auth = _key_auth_reaching("srv", tools=["read"], team_id="team-1") + team = self._team(["g"]) + groups = [_access_group("g", ["srv"], {"srv": []})] + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_team_object", AsyncMock(return_value=team)), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._groups_loader(groups)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == [] + + async def test_team_and_key_group_tools_intersect(self): + auth = UserAPIKeyAuth(api_key="sk-hash", user_id="u1", team_id="team-1", access_group_ids=["kg"]) + team = self._team(["tg"]) + groups = [ + _access_group("kg", ["srv"], {"srv": ["read", "write"]}, assigned_team_ids=["team-1"]), + _access_group("tg", ["srv"], {"srv": ["write", "delete"]}), + ] + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_team_object", AsyncMock(return_value=team)), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._groups_loader(groups)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == ["write"] + + async def test_agent_access_group_tool_allowlist_caps_an_agent_key(self): + auth = _key_auth_reaching("srv", agent_id="agent-1") + groups = [_access_group("g", ["srv"], {"srv": ["read"]})] + agent = MagicMock(access_group_ids=["g"]) + with ( + _entitlement_fault_globals(_agent_prisma()), + patch( + "litellm.proxy.common_utils.registry_read_through.get_agent_with_read_through", + AsyncMock(return_value=agent), + ), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._groups_loader(groups)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == ["read"] + + +class TestAccessGroupToolOwnershipAndFaults: + """Group tool grants bind only through groups that own the caller, fail closed when a named group + cannot be read, and cap rather than widen an agent's own grants. Every case runs the real + ``get_allowed_tools_for_server`` resolver with ``get_access_object`` as the only patched boundary.""" + + @staticmethod + def _loader( + groups: Sequence[LiteLLM_AccessGroupTable], + unreadable: AbstractSet[str] = frozenset(), + missing: AbstractSet[str] = frozenset(), + ) -> AsyncMock: + from fastapi import HTTPException + + from litellm.proxy.auth.auth_checks import AccessGroupNotFoundError + + by_id = {group.access_group_id: group for group in groups} + + async def load(*, access_group_id: str, **_: object) -> LiteLLM_AccessGroupTable: + if access_group_id in unreadable: + raise HTTPException(status_code=404, detail={"error": "Access group lookup failed. Error: db down"}) + if access_group_id in missing: + raise AccessGroupNotFoundError(access_group_id=access_group_id) + return by_id[access_group_id] + + return AsyncMock(side_effect=load) + + async def test_unowned_key_group_contributes_no_tools(self): + auth = _key_auth_reaching("srv", tools=["read"], access_group_ids=["g"]) + groups = [_access_group("g", ["srv"], {"srv": ["write"]})] + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader(groups)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == ["read"] + + async def test_key_group_owned_through_the_team_binds(self): + auth = _key_auth_reaching("srv", tools=["read"], team_id="team-1", access_group_ids=["g"]) + groups = [_access_group("g", ["srv"], {"srv": ["write"]}, assigned_team_ids=["team-1"])] + team = LiteLLM_TeamTable(team_id="team-1") + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_team_object", AsyncMock(return_value=team)), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader(groups)), + ): + assert sorted(await MCPRequestHandler.get_allowed_tools_for_server("srv", auth)) == ["read", "write"] + + async def test_unreadable_key_group_denies_the_server_tools(self): + auth = UserAPIKeyAuth(api_key="sk-hash", user_id="u1", access_group_ids=["g"]) + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader([], unreadable={"g"})), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == [] + + async def test_unreadable_key_group_denies_even_when_the_team_grants_the_server(self): + auth = UserAPIKeyAuth(api_key="sk-hash", user_id="u1", team_id="team-1", access_group_ids=["g"]) + team = LiteLLM_TeamTable( + team_id="team-1", + object_permission=LiteLLM_ObjectPermissionTable( + object_permission_id="op-team", mcp_servers=["srv"], mcp_tool_permissions={"srv": ["read", "write"]} + ), + ) + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_team_object", AsyncMock(return_value=team)), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader([], unreadable={"g"})), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == [] + + async def test_unreadable_key_group_keeps_the_direct_allowlist(self): + auth = _key_auth_reaching("srv", tools=["read"], access_group_ids=["g"]) + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader([], unreadable={"g"})), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == ["read"] + + async def test_unreadable_team_group_denies_the_server_tools(self): + auth = _key_auth_reaching("srv", team_id="team-1") + team = LiteLLM_TeamTable(team_id="team-1", access_group_ids=["tg"]) + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_team_object", AsyncMock(return_value=team)), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader([], unreadable={"tg"})), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == [] + + async def test_nonexistent_key_group_leaves_the_team_grant_alone(self): + auth = UserAPIKeyAuth(api_key="sk-hash", user_id="u1", team_id="team-1", access_group_ids=["typo-group"]) + team = LiteLLM_TeamTable( + team_id="team-1", + object_permission=LiteLLM_ObjectPermissionTable( + object_permission_id="op-team", mcp_servers=["srv"], mcp_tool_permissions={"srv": ["read", "write"]} + ), + ) + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_team_object", AsyncMock(return_value=team)), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader([], missing={"typo-group"})), + ): + assert sorted(await MCPRequestHandler.get_allowed_tools_for_server("srv", auth)) == ["read", "write"] + + @pytest.mark.parametrize("requires_fresh_policy", [False, True]) + async def test_nonexistent_key_group_resolves_like_no_group_at_all(self, requires_fresh_policy): + without_group = _key_auth_reaching("srv", requires_fresh_policy=requires_fresh_policy) + with_typo = _key_auth_reaching( + "srv", requires_fresh_policy=requires_fresh_policy, access_group_ids=["typo-group"] + ) + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader([], missing={"typo-group"})), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server( + "srv", with_typo + ) == await MCPRequestHandler.get_allowed_tools_for_server("srv", without_group) + + async def test_nonexistent_key_group_does_not_lift_a_real_attached_bound(self): + auth = UserAPIKeyAuth(api_key="sk-hash", user_id="u1", access_group_ids=["typo-group", "g"]) + groups = [_access_group("g", ["srv"], {"srv": ["read"]})] + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader(groups, missing={"typo-group"})), + patch( + "litellm.proxy.auth.auth_checks.get_mcp_server_ids_from_access_groups", AsyncMock(return_value=["srv"]) + ), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == ["read"] + + async def test_nonexistent_team_group_resolves_like_a_team_without_groups(self): + auth = _key_auth_reaching("srv", team_id="team-1") + with_typo = LiteLLM_TeamTable(team_id="team-1", access_group_ids=["typo-group"]) + without_group = LiteLLM_TeamTable(team_id="team-1") + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader([], missing={"typo-group"})), + ): + with patch("litellm.proxy.auth.auth_checks.get_team_object", AsyncMock(return_value=with_typo)): + typo_tools = await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) + with patch("litellm.proxy.auth.auth_checks.get_team_object", AsyncMock(return_value=without_group)): + assert typo_tools == await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) + + @pytest.mark.parametrize("group_tools, expected", [(["read"], ["read"]), ([], [])]) + async def test_unowned_group_only_key_is_bounded_by_the_group_it_is_admitted_through(self, group_tools, expected): + auth = UserAPIKeyAuth(api_key="sk-hash", user_id="u1", access_group_ids=["g"]) + groups = [_access_group("g", ["srv"], {"srv": group_tools})] + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader(groups)), + patch( + "litellm.proxy.auth.auth_checks.get_mcp_server_ids_from_access_groups", AsyncMock(return_value=["srv"]) + ), + ): + assert await MCPRequestHandler.get_allowed_mcp_servers(auth) == ["srv"] + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == expected + + @pytest.mark.parametrize("key_owns_group", [True, False], ids=["owned-group", "attached-only-group"]) + async def test_explicit_key_wildcard_is_not_narrowed_by_a_group_bound(self, key_owns_group): + auth = _key_auth_reaching("srv", tools=[MCP_ALL_TOOLS_WILDCARD], access_group_ids=["g"]) + owners = {"assigned_key_ids": [auth.token]} if key_owns_group else {} + groups = [_access_group("g", ["srv"], {"srv": ["read"]}, **owners)] + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader(groups)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) is None + + async def test_owned_group_without_tool_entry_keeps_the_key_unrestricted(self): + auth = UserAPIKeyAuth(api_key="sk-hash", user_id="u1", access_group_ids=["g", "h"]) + groups = [ + _access_group("g", ["srv"], assigned_key_ids=[auth.token]), + _access_group("h", ["srv"], {"srv": ["read"]}), + ] + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader(groups)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) is None + + async def test_direct_server_grant_without_tool_entry_is_not_narrowed_by_an_attached_group(self): + auth = _key_auth_reaching("srv", access_group_ids=["g"]) + groups = [_access_group("g", ["srv"], {"srv": ["read"]})] + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader(groups)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) is None + + async def test_legacy_tag_colliding_with_the_server_name_is_not_a_direct_grant(self): + auth = UserAPIKeyAuth( + api_key="sk-hash", + user_id="u1", + access_group_ids=["g"], + object_permission=LiteLLM_ObjectPermissionTable(object_permission_id="op-key", mcp_access_groups=["srv"]), + ) + groups = [_access_group("g", ["srv"], {"srv": ["read"]})] + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader(groups)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == ["read"] + + async def test_legacy_tag_carried_by_the_server_keeps_the_key_unrestricted(self): + from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager + from litellm.types.mcp import MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + auth = UserAPIKeyAuth( + api_key="sk-hash", + user_id="u1", + access_group_ids=["g"], + object_permission=LiteLLM_ObjectPermissionTable(object_permission_id="op-key", mcp_access_groups=["tag"]), + ) + srv = MCPServer( + server_id="srv", + name="srv", + url="https://srv.example.com", + transport=MCPTransport.http, + access_groups=["tag"], + ) + groups = [_access_group("g", ["srv"], {"srv": ["read"]})] + with ( + _entitlement_fault_globals(), + patch.object(global_mcp_server_manager, "config_mcp_servers", {"srv": srv}), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader(groups)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) is None + + async def test_duplicate_key_group_ids_do_not_deny(self): + auth = _key_auth_reaching("srv", tools=["read"], access_group_ids=["g", "g"]) + groups = [_access_group("g", ["srv"], {"srv": ["write"]}, assigned_key_ids=[auth.token])] + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader(groups)), + ): + assert sorted(await MCPRequestHandler.get_allowed_tools_for_server("srv", auth)) == ["read", "write"] + + async def test_duplicate_team_group_ids_do_not_deny(self): + auth = _key_auth_reaching("srv", team_id="team-1") + team = LiteLLM_TeamTable(team_id="team-1", access_group_ids=["tg", "tg"]) + groups = [_access_group("tg", ["srv"], {"srv": ["read"]})] + with ( + _entitlement_fault_globals(), + patch("litellm.proxy.auth.auth_checks.get_team_object", AsyncMock(return_value=team)), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader(groups)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == ["read"] + + async def test_duplicate_agent_group_ids_do_not_deny(self): + auth = _key_auth_reaching("srv", agent_id="agent-1") + groups = [_access_group("g", ["srv"], {"srv": ["read"]})] + agent = MagicMock(access_group_ids=["g", "g"]) + with ( + _entitlement_fault_globals(self._agent_prisma_with_own_tools(["read", "write"])), + patch( + "litellm.proxy.common_utils.registry_read_through.get_agent_with_read_through", + AsyncMock(return_value=agent), + ), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader(groups)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == ["read"] + + @staticmethod + def _writer_down(*_: object, **__: object) -> None: + raise RuntimeError("writer down") + + async def test_fresh_key_group_fault_denies_the_server_tools(self): + auth = _key_auth_reaching("srv", tools=["read"], access_group_ids=["g"]).model_copy( + update={"requires_fresh_policy": True} + ) + assert auth.requires_fresh_policy is True + with ( + _entitlement_fault_globals(), + patch.object(MCPRequestHandler, "_get_user_object_permission", AsyncMock(return_value=None)), + patch("litellm.proxy.auth.auth_checks.get_access_object", AsyncMock(side_effect=self._writer_down)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == [] + + async def test_fresh_team_group_fault_denies_the_server_tools(self): + auth = _key_auth_reaching("srv", tools=["read"], team_id="team-1").model_copy( + update={"requires_fresh_policy": True} + ) + assert auth.requires_fresh_policy is True + team = LiteLLM_TeamTable(team_id="team-1", access_group_ids=["tg"]) + with ( + _entitlement_fault_globals(), + patch.object(MCPRequestHandler, "_get_user_object_permission", AsyncMock(return_value=None)), + patch("litellm.proxy.auth.auth_checks.get_team_object", AsyncMock(return_value=team)), + patch("litellm.proxy.auth.auth_checks.get_access_object", AsyncMock(side_effect=self._writer_down)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == [] + + async def test_team_lookup_fault_in_group_resolution_denies_the_server_tools(self): + auth = _key_auth_reaching("srv", tools=["read"], team_id="team-1") + with ( + _entitlement_fault_globals(), + patch.object(MCPRequestHandler, "_get_team_object_permission", AsyncMock(return_value=None)), + patch("litellm.proxy.auth.auth_checks.get_team_object", AsyncMock(side_effect=self._writer_down)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == [] + + async def test_agent_group_fault_denies_rather_than_shedding_the_agents_own_allowlist(self): + auth = _key_auth_reaching("srv", agent_id="agent-1") + agent = MagicMock(access_group_ids=["g"]) + with ( + _entitlement_fault_globals(self._agent_prisma_with_own_tools(["read"])), + patch( + "litellm.proxy.common_utils.registry_read_through.get_agent_with_read_through", + AsyncMock(return_value=agent), + ), + patch("litellm.proxy.auth.auth_checks.get_access_object", AsyncMock(side_effect=self._writer_down)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == [] + + async def test_echoed_caller_team_group_tools_cap_the_agent_key(self): + from litellm.types.agents import AgentCaller + + auth = _key_auth_reaching("srv", tools=["read", "write"], agent_id="agent-1") + auth.agent_caller = AgentCaller(team_id="team-1") + team = LiteLLM_TeamTable(team_id="team-1", access_group_ids=["tg"]) + groups = [_access_group("tg", ["srv"], {"srv": ["read"]})] + with ( + _entitlement_fault_globals(), + patch.object(MCPRequestHandler, "_get_team_object_permission", AsyncMock(return_value=None)), + patch("litellm.proxy.auth.auth_checks.get_team_object", AsyncMock(return_value=team)), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader(groups)), + ): + capped = await MCPRequestHandler.apply_agent_caller_tool_ceiling(["read", "write"], "srv", auth) + assert sorted(capped) == ["read"] + + @staticmethod + def _agent_prisma_with_own_tools(tools: Sequence[str]) -> MagicMock: + prisma_client = _agent_prisma(object_permission_id="op-agent") + prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock( + return_value=LiteLLM_ObjectPermissionTable( + object_permission_id="op-agent", mcp_servers=["srv"], mcp_tool_permissions={"srv": tools} + ) + ) + return prisma_client + + async def test_agent_group_ceiling_caps_the_agents_own_tools(self): + auth = _key_auth_reaching("srv", agent_id="agent-1") + groups = [_access_group("g", ["srv"], {"srv": ["read"]})] + agent = MagicMock(access_group_ids=["g"]) + with ( + _entitlement_fault_globals(self._agent_prisma_with_own_tools(["read", "write"])), + patch( + "litellm.proxy.common_utils.registry_read_through.get_agent_with_read_through", + AsyncMock(return_value=agent), + ), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader(groups)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == ["read"] + + async def test_agent_group_tools_outside_the_agents_own_grant_are_denied(self): + auth = _key_auth_reaching("srv", agent_id="agent-1") + groups = [_access_group("g", ["srv"], {"srv": ["write"]})] + agent = MagicMock(access_group_ids=["g"]) + with ( + _entitlement_fault_globals(self._agent_prisma_with_own_tools(["read"])), + patch( + "litellm.proxy.common_utils.registry_read_through.get_agent_with_read_through", + AsyncMock(return_value=agent), + ), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader(groups)), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == [] + + async def test_unreadable_agent_group_denies_the_server_tools(self): + auth = _key_auth_reaching("srv", agent_id="agent-1") + agent = MagicMock(access_group_ids=["g"]) + with ( + _entitlement_fault_globals(_agent_prisma()), + patch( + "litellm.proxy.common_utils.registry_read_through.get_agent_with_read_through", + AsyncMock(return_value=agent), + ), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader([], unreadable={"g"})), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == [] + + async def test_nonexistent_agent_group_leaves_the_real_groups_tools_alone(self): + auth = _key_auth_reaching("srv", agent_id="agent-1") + agent = MagicMock(access_group_ids=["real", "typo-group"]) + groups = [_access_group("real", ["srv"], {"srv": ["read"]})] + with ( + _entitlement_fault_globals(_agent_prisma()), + patch( + "litellm.proxy.common_utils.registry_read_through.get_agent_with_read_through", + AsyncMock(return_value=agent), + ), + patch("litellm.proxy.auth.auth_checks.get_access_object", self._loader(groups, missing={"typo-group"})), + ): + assert await MCPRequestHandler.get_allowed_tools_for_server("srv", auth) == ["read"] diff --git a/tests/unit/proxy/agent_endpoints/auth/test_agent_access_groups.py b/tests/unit/proxy/agent_endpoints/auth/test_agent_access_groups.py index 08cceb0d967..cb9a63512d4 100644 --- a/tests/unit/proxy/agent_endpoints/auth/test_agent_access_groups.py +++ b/tests/unit/proxy/agent_endpoints/auth/test_agent_access_groups.py @@ -61,13 +61,11 @@ async def test_unknown_agent_has_no_ceiling(): @pytest.mark.asyncio async def test_ceiling_is_the_union_of_every_attached_group(): - load_agent, load_group = _loaders( - _agent(["g1", "g2"]), - { - "g1": _group("g1", models=("gpt-5",), mcp_servers=("mcp-a",), agents=("agent-b",)), - "g2": _group("g2", models=("claude-sonnet",), mcp_servers=("mcp-b",), agents=("agent-c",)), - }, - ) + groups: Final = { + "g1": _group("g1", models=("gpt-5",), mcp_servers=("mcp-a",), agents=("agent-b",)), + "g2": _group("g2", models=("claude-sonnet",), mcp_servers=("mcp-b",), agents=("agent-c",)), + } + load_agent, load_group = _loaders(_agent(["g1", "g2"]), groups) ceiling: Final = await resolve_agent_access_group_ceiling("agent-1", load_agent, load_group) @@ -76,12 +74,14 @@ async def test_ceiling_is_the_union_of_every_attached_group(): models=frozenset({"gpt-5", "claude-sonnet"}), mcp_server_ids=frozenset({"mcp-a", "mcp-b"}), agent_ids=frozenset({"agent-b", "agent-c"}), + access_groups=(groups["g1"], groups["g2"]), ) @pytest.mark.asyncio async def test_unloadable_group_contributes_nothing_but_the_ceiling_still_applies(): - load_agent, load_group = _loaders(_agent(["g1", "gone"]), {"g1": _group("g1", models=("gpt-5",))}) + g1: Final = _group("g1", models=("gpt-5",)) + load_agent, load_group = _loaders(_agent(["g1", "gone"]), {"g1": g1}) ceiling: Final = await resolve_agent_access_group_ceiling("agent-1", load_agent, load_group) @@ -90,6 +90,7 @@ async def test_unloadable_group_contributes_nothing_but_the_ceiling_still_applie models=frozenset({"gpt-5"}), mcp_server_ids=frozenset(), agent_ids=frozenset(), + access_groups=(g1,), ) @@ -109,7 +110,8 @@ async def test_only_unloadable_groups_is_an_empty_ceiling_not_unrestricted(): async def test_default_agent_loader_reads_the_attached_groups_from_the_registry(): from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry - _, load_group = _loaders(None, {"g1": _group("g1", models=("gpt-5",))}) + g1: Final = _group("g1", models=("gpt-5",)) + _, load_group = _loaders(None, {"g1": g1}) global_agent_registry.register_agent(_agent(["g1"])) try: ceiling: Final = await resolve_agent_access_group_ceiling("agent-1", load_access_group=load_group) @@ -117,7 +119,11 @@ async def test_default_agent_loader_reads_the_attached_groups_from_the_registry( global_agent_registry.deregister_agent("agent") assert ceiling == AgentAccessGroupCeiling( - access_group_ids=("g1",), models=frozenset({"gpt-5"}), mcp_server_ids=frozenset(), agent_ids=frozenset() + access_group_ids=("g1",), + models=frozenset({"gpt-5"}), + mcp_server_ids=frozenset(), + agent_ids=frozenset(), + access_groups=(g1,), ) @@ -136,6 +142,61 @@ async def test_default_loader_treats_a_missing_group_as_unreadable(monkeypatch: assert await _load_access_group("gone") is None +@pytest.mark.asyncio +async def test_default_loader_lets_a_provably_absent_group_surface(monkeypatch: pytest.MonkeyPatch): + from litellm.proxy import proxy_server + from litellm.proxy.agent_endpoints.auth.agent_access_groups import _load_access_group + from litellm.proxy.auth import auth_checks + from litellm.proxy.auth.auth_checks import AccessGroupNotFoundError + + async def absent_group(*, access_group_id: str, **_: object) -> LiteLLM_AccessGroupTable: + raise AccessGroupNotFoundError(access_group_id=access_group_id) + + monkeypatch.setattr(proxy_server, "prisma_client", object()) + monkeypatch.setattr(auth_checks, "get_access_object", absent_group) + + with pytest.raises(AccessGroupNotFoundError): + await _load_access_group("gone") + + +@pytest.mark.asyncio +async def test_ceiling_drops_a_group_that_does_not_exist_but_keeps_an_unreadable_one(): + from litellm.proxy.agent_endpoints.auth.agent_access_groups import resolve_agent_access_group_ceiling + from litellm.proxy.auth.auth_checks import AccessGroupNotFoundError + + real = LiteLLM_AccessGroupTable(access_group_id="real", access_group_name="real", access_mcp_server_ids=["srv"]) + + async def ids(_agent_id: str) -> tuple[str, ...]: + return ("real", "typo-group", "unreadable") + + async def load(group_id: str) -> LiteLLM_AccessGroupTable | None: + if group_id == "typo-group": + raise AccessGroupNotFoundError(access_group_id=group_id) + return real if group_id == "real" else None + + ceiling = await resolve_agent_access_group_ceiling("agent-1", ids, load) + assert ceiling is not None + assert ceiling.access_group_ids == ("real", "unreadable") + assert ceiling.access_groups == (real,) + assert ceiling.mcp_server_ids == frozenset({"srv"}) + + +@pytest.mark.asyncio +async def test_ceiling_stays_and_caps_to_nothing_when_every_named_group_does_not_exist(): + from litellm.proxy.agent_endpoints.auth.agent_access_groups import resolve_agent_access_group_ceiling + from litellm.proxy.auth.auth_checks import AccessGroupNotFoundError + + async def ids(_agent_id: str) -> tuple[str, ...]: + return ("typo-group",) + + async def load(group_id: str) -> LiteLLM_AccessGroupTable | None: + raise AccessGroupNotFoundError(access_group_id=group_id) + + assert await resolve_agent_access_group_ceiling("agent-1", ids, load) == AgentAccessGroupCeiling( + access_group_ids=(), models=frozenset(), mcp_server_ids=frozenset(), agent_ids=frozenset(), access_groups=() + ) + + @pytest.mark.asyncio async def test_default_loader_returns_nothing_without_a_db(monkeypatch: pytest.MonkeyPatch): from litellm.proxy import proxy_server @@ -159,7 +220,9 @@ async def test_authoritative_group_ceiling_propagates_policy_outages( database: Final = MagicMock() database.db.litellm_accessgrouptable.find_unique = AsyncMock(side_effect=RuntimeError("database unavailable")) - database.writer_db.litellm_accessgrouptable.find_unique = AsyncMock(side_effect=RuntimeError("database unavailable")) + database.writer_db.litellm_accessgrouptable.find_unique = AsyncMock( + side_effect=RuntimeError("database unavailable") + ) monkeypatch.setattr(proxy_server, "prisma_client", database) monkeypatch.setattr(proxy_server, "user_api_key_cache", UserApiKeyCache()) if strict: diff --git a/tests/unit/proxy/auth/test_auth_checks_object_access_and_lookup.py b/tests/unit/proxy/auth/test_auth_checks_object_access_and_lookup.py index 25022621edc..1f8c85ce337 100644 --- a/tests/unit/proxy/auth/test_auth_checks_object_access_and_lookup.py +++ b/tests/unit/proxy/auth/test_auth_checks_object_access_and_lookup.py @@ -8856,6 +8856,30 @@ async def test_get_access_object_db_fetch_returns_validated_access_group(): assert result.access_model_names == ["gpt-4"] +@pytest.mark.asyncio +async def test_get_access_object_distinguishes_absent_group_from_unreadable_row(): + from fastapi import HTTPException + + from litellm.proxy.auth.auth_checks import AccessGroupNotFoundError, get_access_object + + mock_cache = MagicMock() + mock_cache.async_get_cache = AsyncMock(return_value=None) + + absent_client = MagicMock() + absent_client.db.litellm_accessgrouptable.find_unique = AsyncMock(return_value=None) + with pytest.raises(AccessGroupNotFoundError) as absent_info: + await get_access_object("typo-group", absent_client, mock_cache) + assert absent_info.value.status_code == 404 + assert "Access group doesn't exist in db" in str(absent_info.value.detail) + + unreadable_client = MagicMock() + unreadable_client.db.litellm_accessgrouptable.find_unique = AsyncMock(side_effect=ConnectionError("db unreachable")) + with pytest.raises(HTTPException) as unreadable_info: + await get_access_object("real-group", unreadable_client, mock_cache) + assert unreadable_info.value.status_code == 404 + assert not isinstance(unreadable_info.value, AccessGroupNotFoundError) + + @pytest.mark.asyncio async def test_get_team_object_by_alias_db_fetch_returns_cached_obj(): from litellm.proxy._types import LiteLLM_TeamTableCachedObj diff --git a/tests/unit/proxy/management_endpoints/test_access_group_endpoints.py b/tests/unit/proxy/management_endpoints/test_access_group_endpoints.py index 13e57408bd0..5d7efe66449 100644 --- a/tests/unit/proxy/management_endpoints/test_access_group_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_access_group_endpoints.py @@ -1602,3 +1602,65 @@ def test_update_access_group_response_carries_resolved_names(client_and_mocks): assert body["access_mcp_servers"] == [{"id": "mcp-new", "name": "Linear"}] assert body["access_agents"] == [{"id": "agent-a", "name": "support-bot"}] assert body["access_mcp_server_ids"] == ["mcp-new"] + + +def test_create_access_group_persists_mcp_tool_permissions_as_json(client_and_mocks): + """Tool allowlists are stored on the group as JSON, an explicit [] survives as a deny-all entry.""" + client, _, mock_table, *_ = client_and_mocks + tool_permissions = {"mcp-1": ["read_file"], "mcp-2": []} + + resp = client.post( + "/v1/access_group", + json={ + "access_group_name": "tools", + "access_mcp_server_ids": ["mcp-1", "mcp-2"], + "mcp_tool_permissions": tool_permissions, + }, + ) + + assert resp.status_code == 201, resp.text + written = mock_table.create.await_args.kwargs["data"] + assert written["mcp_tool_permissions"].data == tool_permissions + + +def test_create_access_group_without_tool_permissions_writes_null(client_and_mocks): + client, _, mock_table, *_ = client_and_mocks + + resp = client.post("/v1/access_group", json={"access_group_name": "servers-only", "access_mcp_server_ids": ["m"]}) + + assert resp.status_code == 201, resp.text + assert mock_table.create.await_args.kwargs["data"]["mcp_tool_permissions"].data is None + + +@pytest.mark.parametrize("tool_permissions", [{"mcp-1": ["a", "b"]}, {"mcp-1": []}, None]) +def test_update_access_group_writes_mcp_tool_permissions(client_and_mocks, tool_permissions): + client, _, mock_table, *_ = client_and_mocks + mock_table.find_unique = AsyncMock(return_value=_make_access_group_record(access_group_id="ag-update")) + + resp = client.put("/v1/access_group/ag-update", json={"mcp_tool_permissions": tool_permissions}) + + assert resp.status_code == 200, resp.text + written = mock_table.update.await_args.kwargs["data"]["mcp_tool_permissions"] + assert written.data == tool_permissions + + +def test_update_access_group_leaves_tool_permissions_alone_when_not_sent(client_and_mocks): + client, _, mock_table, *_ = client_and_mocks + mock_table.find_unique = AsyncMock(return_value=_make_access_group_record(access_group_id="ag-update")) + + resp = client.put("/v1/access_group/ag-update", json={"description": "x"}) + + assert resp.status_code == 200, resp.text + assert "mcp_tool_permissions" not in mock_table.update.await_args.kwargs["data"] + + +def test_get_access_group_returns_mcp_tool_permissions(client_and_mocks): + client, _, mock_table, *_ = client_and_mocks + record = _make_access_group_record(access_group_id="ag-tools", access_mcp_server_ids=["mcp-1"]) + record.dict = lambda: {**record.model_dump(), "mcp_tool_permissions": {"mcp-1": []}} + mock_table.find_unique = AsyncMock(return_value=record) + + resp = client.get("/v1/access_group/ag-tools") + + assert resp.status_code == 200, resp.text + assert resp.json()["mcp_tool_permissions"] == {"mcp-1": []} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx index aad63e979ac..91a58cef46e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx @@ -1,6 +1,6 @@ import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails"; import { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups"; -import { screen } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../../../../../tests/test-utils"; @@ -336,6 +336,34 @@ describe("AccessGroupDetail", () => { expect(screen.getByText("No MCP servers assigned to this group")).toBeInTheDocument(); }); + + it("should list the allowed tools per server, with deny-all and unrestricted servers spelled out", async () => { + const user = userEvent.setup(); + renderWith({ + access_mcp_servers: [ + { id: "mcp-1", name: "GitHub MCP" }, + { id: "mcp-2", name: "Jira MCP" }, + { id: "mcp-3", name: "Slack MCP" }, + ], + mcp_tool_permissions: { "mcp-1": ["list_issues", "create_issue"], "mcp-2": [] }, + }); + + await user.click(screen.getByRole("tab", { name: /MCP Servers/i })); + + const list = screen.getByRole("region", { name: "MCP tool permissions" }); + expect(within(list).getByText("list_issues, create_issue")).toBeInTheDocument(); + expect(within(list).getByText("No tools")).toBeInTheDocument(); + expect(within(list).getByText("All tools")).toBeInTheDocument(); + }); + + it("should not render a tool permission list when the group has none", async () => { + const user = userEvent.setup(); + renderWith({ mcp_tool_permissions: null }); + + await user.click(screen.getByRole("tab", { name: /MCP Servers/i })); + + expect(screen.queryByRole("region", { name: "MCP tool permissions" })).not.toBeInTheDocument(); + }); }); describe("Agents tab", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx index 118a8655d5a..4f033a6ca02 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx @@ -25,6 +25,37 @@ const MAX_PREVIEW = 5; const shortId = (id: string) => (id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id); +const describeAllowedTools = (tools: readonly string[] | undefined): string => { + if (tools === undefined) { + return "All tools"; + } + return tools.length === 0 ? "No tools" : tools.join(", "); +}; + +function MCPToolPermissionList({ + servers, + toolPermissions, +}: { + servers: readonly AccessGroupResource[]; + toolPermissions: Readonly> | null; +}) { + if (toolPermissions === null || servers.length === 0) { + return null; + } + return ( +
+ {servers.map(({ id, name }) => { + return ( +
+ {name ?? id} + {describeAllowedTools(toolPermissions[id])} +
+ ); + })} +
+ ); +} + function ResourceList({ items, emptyMessage }: { items: readonly AccessGroupResource[]; emptyMessage: string }) { if (items.length === 0) { return

{emptyMessage}

; @@ -237,6 +268,7 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr + diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx index 91e008de402..c66b7b1f1e3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupBaseForm.tsx @@ -6,6 +6,8 @@ import { z } from "zod"; import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions"; import { ModelSelect } from "@/components/ModelSelect/ModelSelect"; import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; @@ -19,6 +21,7 @@ export const accessGroupFormSchema = z.object({ description: z.string(), modelIds: z.array(z.string()), mcpServerIds: z.array(z.string()), + mcpToolPermissions: z.record(z.string(), z.array(z.string())), agentIds: z.array(z.string()), }); @@ -34,6 +37,37 @@ interface AccessGroupBaseFormProps { isNameDisabled?: boolean; activeTab: string; onTabChange: (tab: string) => void; + initialServerIds?: readonly string[]; +} + +export function AccessGroupMCPToolPermissions({ + selectedServers, + toolPermissions, + onChange, + preserveUnrestrictedServers, +}: { + selectedServers: string[]; + toolPermissions: Record; + onChange: (next: Record) => void; + preserveUnrestrictedServers?: readonly string[]; +}) { + const { accessToken } = useAuthorized(); + + if (selectedServers.length === 0) { + return null; + } + + return ( +
+ +
+ ); } export function AccessGroupBaseForm({ @@ -41,6 +75,7 @@ export function AccessGroupBaseForm({ isNameDisabled = false, activeTab, onTabChange, + initialServerIds, }: AccessGroupBaseFormProps) { const { data: agentsData } = useAgents(); const { data: mcpServersData } = useMCPServers(); @@ -108,6 +143,12 @@ export function AccessGroupBaseForm({ /> )} + form.setValue("mcpToolPermissions", next, { shouldDirty: true })} + preserveUnrestrictedServers={initialServerIds} + />
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.integration.test.tsx index 7c4e218261f..ff21872c163 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.integration.test.tsx @@ -3,6 +3,7 @@ import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event" import { fireEvent, renderWithProviders, screen, waitFor, within } from "../../../../../../tests/test-utils"; import { AccessGroupEditModal } from "./AccessGroupEditModal"; import { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups"; +import * as networking from "@/components/networking"; const mutate = vi.fn(); @@ -31,6 +32,8 @@ vi.mock("@/components/ModelSelect/ModelSelect", () => ({ ), })); +vi.mock("@/components/networking"); + vi.mock("@/lib/toast", () => ({ toast: { success: vi.fn(), fromError: vi.fn(), error: vi.fn() }, })); @@ -44,6 +47,7 @@ const accessGroup: AccessGroupResponse = { description: "Engineers", access_model_names: ["gpt-4"], access_mcp_server_ids: ["srv-1"], + mcp_tool_permissions: { "srv-1": ["read_file"] }, access_agent_ids: ["agent-1"], assigned_team_ids: [], assigned_key_ids: [], @@ -67,6 +71,18 @@ const variables = () => mutate.mock.calls.at(-1)?.[0] as { accessGroupId: string describe("AccessGroupEditModal submit payload", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]); + vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]); + vi.mocked(networking.fetchMCPServers).mockResolvedValue([ + { server_id: "srv-1", server_name: "Files", alias: "Files" }, + ]); + vi.mocked(networking.listMCPTools).mockResolvedValue({ + tools: [ + { name: "read_file", description: "Read a file" }, + { name: "write_file", description: "Write a file" }, + ], + error: false, + }); }); it("sends exactly the antd payload for an untouched save", async () => { @@ -169,6 +185,20 @@ describe("AccessGroupEditModal submit payload", () => { expect(mutate).not.toHaveBeenCalled(); }); + it("sends the edited tool checklist as mcp_tool_permissions, an emptied one as an explicit []", async () => { + const user = setup(); + renderModal(); + await screen.findByDisplayValue("Engineering"); + + await user.click(screen.getByRole("tab", { name: /MCP Servers/ })); + await user.click(await screen.findByRole("radio", { name: "Flat List" })); + await user.click(await screen.findByRole("checkbox", { name: "read_file" })); + await save(user); + + await waitFor(() => expect(mutate).toHaveBeenCalled()); + expect(variables().params.mcp_tool_permissions).toStrictEqual({ "srv-1": [] }); + }); + it("renders each selected MCP server as its own removable chip and drops one on remove", async () => { const user = setup(); renderModal(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx index 7370b3bc195..16bcf32d2ea 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState } from "react"; +import { useFormState } from "react-hook-form"; import { toast } from "@/lib/toast"; import { useZodForm } from "@/lib/forms/useZodForm"; @@ -31,11 +32,13 @@ const toFormValues = (accessGroup: AccessGroupResponse): AccessGroupFormValues = description: accessGroup.description ?? "", modelIds: accessGroup.access_model_names ?? [], mcpServerIds: accessGroup.access_mcp_server_ids ?? [], + mcpToolPermissions: accessGroup.mcp_tool_permissions ?? {}, agentIds: accessGroup.access_agent_ids ?? [], }); function AccessGroupEditForm({ accessGroup, onCancel, onSuccess }: Omit) { const form = useZodForm(accessGroupFormSchema, { defaultValues: toFormValues(accessGroup) }); + const { dirtyFields } = useFormState({ control: form.control }); const editMutation = useEditAccessGroup(); const [activeTab, setActiveTab] = useState(GENERAL_TAB); const [visitedTabs, setVisitedTabs] = useState>(new Set([GENERAL_TAB])); @@ -52,6 +55,7 @@ function AccessGroupEditForm({ accessGroup, onCancel, onSuccess }: Omit event.preventDefault()}> - +
+
+ ), +})); + vi.mock("@/lib/toast", () => ({ toast: { success: vi.fn(), fromError: vi.fn(), error: vi.fn() }, })); @@ -49,6 +60,7 @@ const accessGroup: AccessGroupResponse = { access_mcp_server_ids: ["srv-1"], mcp_tool_permissions: { "srv-1": ["read_file"] }, access_agent_ids: ["agent-1"], + access_skill_names: ["old-plugin"], assigned_team_ids: [], assigned_key_ids: [], access_mcp_servers: [{ id: "srv-1", name: "Server One" }], @@ -102,9 +114,37 @@ describe("AccessGroupEditModal submit payload", () => { access_model_names: undefined, access_mcp_server_ids: undefined, access_agent_ids: undefined, + access_skill_names: undefined, }); }); + it("sends the skills picked on the Skills tab", async () => { + const user = setup(); + renderModal(); + await screen.findByDisplayValue("Engineering"); + + await user.click(screen.getByRole("tab", { name: /Skills/ })); + await user.click(screen.getByLabelText("skill-select")); + await save(user); + + await waitFor(() => expect(mutate).toHaveBeenCalled()); + expect(variables().params.access_skill_names).toStrictEqual(["old-plugin", "private-plugin"]); + expect(variables().params.access_agent_ids).toBeUndefined(); + }); + + it("sends an empty list when every skill is removed on the Skills tab", async () => { + const user = setup(); + renderModal(); + await screen.findByDisplayValue("Engineering"); + + await user.click(screen.getByRole("tab", { name: /Skills/ })); + await user.click(screen.getByLabelText("skill-clear")); + await save(user); + + await waitFor(() => expect(mutate).toHaveBeenCalled()); + expect(variables().params.access_skill_names).toStrictEqual([]); + }); + it("sends a tab's field only once that tab has been visited", async () => { const user = setup(); renderModal(); @@ -122,6 +162,7 @@ describe("AccessGroupEditModal submit payload", () => { access_model_names: undefined, access_mcp_server_ids: ["srv-1"], access_agent_ids: ["agent-1"], + access_skill_names: undefined, }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx index 16bcf32d2ea..9ee2583474b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.tsx @@ -13,6 +13,7 @@ import { AccessGroupBaseForm, accessGroupFormSchema, AGENTS_TAB, + SKILLS_TAB, GENERAL_TAB, MCP_SERVERS_TAB, MODELS_TAB, @@ -34,6 +35,7 @@ const toFormValues = (accessGroup: AccessGroupResponse): AccessGroupFormValues = mcpServerIds: accessGroup.access_mcp_server_ids ?? [], mcpToolPermissions: accessGroup.mcp_tool_permissions ?? {}, agentIds: accessGroup.access_agent_ids ?? [], + skillNames: accessGroup.access_skill_names ?? [], }); function AccessGroupEditForm({ accessGroup, onCancel, onSuccess }: Omit) { @@ -57,6 +59,7 @@ function AccessGroupEditForm({ accessGroup, onCancel, onSuccess }: Omit = { +const RESOURCE_TONES: Record<"models" | "mcpServers" | "agents" | "skills", ResourceTone> = { models: { icon: Layers, className: "bg-info/10 text-info ring-blue-600/20" }, mcpServers: { icon: Server, className: "bg-info/10 text-info ring-cyan-600/20" }, agents: { @@ -29,6 +29,11 @@ const RESOURCE_TONES: Record<"models" | "mcpServers" | "agents", ResourceTone> = className: "bg-purple-50 text-purple-700 ring-purple-600/20 dark:bg-purple-950 dark:text-purple-300 dark:ring-purple-400/30", }, + skills: { + icon: Sparkles, + className: + "bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-950 dark:text-amber-300 dark:ring-amber-400/30", + }, }; function ResourcesCell({ group }: { group: AccessGroup }) { @@ -36,6 +41,7 @@ function ResourcesCell({ group }: { group: AccessGroup }) { { key: "models" as const, label: "Models", count: group.modelIds.length }, { key: "mcpServers" as const, label: "MCP Servers", count: group.mcpServerIds.length }, { key: "agents" as const, label: "Agents", count: group.agentIds.length }, + { key: "skills" as const, label: "Skills", count: group.skillNames.length }, ]; return ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx index 47ad88f2934..f684ba674bf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/AccessGroupCreateDialog.tsx @@ -1,17 +1,19 @@ "use client"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { BotIcon, InfoIcon, LayersIcon, ServerIcon } from "lucide-react"; +import { BotIcon, InfoIcon, LayersIcon, ServerIcon, SparklesIcon } from "lucide-react"; import * as React from "react"; import { accessGroupKeys } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups"; import { useAgents } from "@/app/(dashboard)/hooks/agents/useAgents"; import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { ModelSelect } from "@/components/ModelSelect/ModelSelect"; import { toast } from "@/lib/toast"; import { FieldGroup } from "@/components/ui/field"; import { FormField } from "@/components/shared/form/FormField"; import { MultiSelect } from "@/components/shared/MultiSelect"; +import SkillSelector from "@/components/skills/SkillSelector"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; @@ -46,6 +48,7 @@ export const AccessGroupCreateDialog = ({ const form = useZodForm(accessGroupCreateSchema, { defaultValues: emptyAccessGroupFormValues }); const [activeTab, setActiveTab] = React.useState(GENERAL_TAB); + const { accessToken } = useAuthorized(); const { data: agentsData } = useAgents(); const { data: mcpServersData } = useMCPServers(); @@ -119,6 +122,10 @@ export const AccessGroupCreateDialog = ({ Agents + + + Skills + @@ -177,6 +184,24 @@ export const AccessGroupCreateDialog = ({ )} + + + + {({ value, onChange }) => ( + + )} + + diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/mapper.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/mapper.test.ts index 684192d9520..2dd2c7e3de8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/mapper.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/mapper.test.ts @@ -24,6 +24,7 @@ describe("buildAccessGroupCreateBody", () => { mcpServerIds: ["srv-1", "srv-2"], mcpToolPermissions: { "srv-1": ["read_file"], "srv-2": [] }, agentIds: ["agent-1"], + skillNames: ["private-plugin"], }), ).toStrictEqual({ access_group_name: "g", @@ -32,6 +33,13 @@ describe("buildAccessGroupCreateBody", () => { access_mcp_server_ids: ["srv-1", "srv-2"], mcp_tool_permissions: { "srv-1": ["read_file"], "srv-2": [] }, access_agent_ids: ["agent-1"], + access_skill_names: ["private-plugin"], + }); + }); + + it("omits access_skill_names when no skill is selected", () => { + expect(buildAccessGroupCreateBody({ ...emptyAccessGroupFormValues, name: "g", skillNames: [] })).toStrictEqual({ + access_group_name: "g", }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/mapper.ts b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/mapper.ts index df43e0d241f..14810bf55d5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/mapper.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/mapper.ts @@ -11,6 +11,7 @@ export const emptyAccessGroupFormValues: AccessGroupCreateFormValues = { mcpServerIds: [], mcpToolPermissions: {}, agentIds: [], + skillNames: [], }; export const buildAccessGroupCreateBody = (values: AccessGroupCreateFormValues): AccessGroupCreateBody => ({ @@ -21,4 +22,5 @@ export const buildAccessGroupCreateBody = (values: AccessGroupCreateFormValues): ...(values.mcpServerIds.length > 0 && Object.keys(values.mcpToolPermissions).length > 0 && { mcp_tool_permissions: values.mcpToolPermissions }), ...(values.agentIds.length > 0 && { access_agent_ids: values.agentIds }), + ...(values.skillNames.length > 0 && { access_skill_names: values.skillNames }), }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/schema.ts b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/schema.ts index a1c5aea611d..a0deb4487a0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/schema.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/access-group-create/schema.ts @@ -7,6 +7,7 @@ export const accessGroupCreateSchema = z.object({ mcpServerIds: z.array(z.string()), mcpToolPermissions: z.record(z.string(), z.array(z.string())), agentIds: z.array(z.string()), + skillNames: z.array(z.string()), }); export type AccessGroupCreateFormValues = z.output; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/types.ts b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/types.ts index a9f9f7375ff..56b4e3863e8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/types.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/types.ts @@ -5,6 +5,7 @@ export interface AccessGroup { modelIds: string[]; mcpServerIds: string[]; agentIds: string[]; + skillNames: string[]; keyIds: string[]; teamIds: string[]; createdAt: string; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts index 14cae5b1c1f..8a5a4676611 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts @@ -44,6 +44,7 @@ const mockAccessGroups: AccessGroupResponse[] = [ access_model_names: [], access_mcp_server_ids: [], access_agent_ids: [], + access_skill_names: [], assigned_team_ids: [], assigned_key_ids: [], access_mcp_servers: [], diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts index 7dd85ae93dc..017ad123a0e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useEditAccessGroup.ts @@ -11,6 +11,7 @@ export interface AccessGroupUpdateParams { access_model_names?: string[]; access_mcp_server_ids?: string[]; access_agent_ids?: string[]; + access_skill_names?: string[]; assigned_team_ids?: string[]; assigned_key_ids?: string[]; } diff --git a/ui/litellm-dashboard/src/components/object_permissions_view.test.tsx b/ui/litellm-dashboard/src/components/object_permissions_view.test.tsx new file mode 100644 index 00000000000..85f3ac79c8f --- /dev/null +++ b/ui/litellm-dashboard/src/components/object_permissions_view.test.tsx @@ -0,0 +1,84 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import ObjectPermissionsView from "./object_permissions_view"; + +vi.mock("./permissions/VectorStorePermissions", () => ({ default: () =>
})); +vi.mock("./permissions/MCPServerPermissions", () => ({ default: () =>
})); +vi.mock("./permissions/AgentPermissions", () => ({ default: () =>
})); + +const objectPermission = { object_permission_id: "perm-1", skills: [] as string[] }; + +describe("ObjectPermissionsView skills card", () => { + it("shows plugin names granted only through access groups with their source", () => { + render( + , + ); + + expect(screen.getByText(/private-plugin/)).toBeInTheDocument(); + expect(screen.getByTitle(/skills-group/)).toHaveTextContent("private-plugin (access group)"); + expect(screen.queryByText(/No private skills granted/)).not.toBeInTheDocument(); + }); + + it("lists direct grants once even when an access group grants the same plugin", () => { + render( + , + ); + + expect(screen.getAllByText(/private-plugin/)).toHaveLength(1); + expect(screen.getByTitle(/skills-group/)).toHaveTextContent("team-plugin (access group)"); + }); + + it("keeps the empty state when nothing grants a private plugin", () => { + render(); + + expect( + screen.getByText("No private skills granted. Only enabled (public) Claude Code plugins are visible."), + ).toBeInTheDocument(); + }); + + it("says access-group grants are not listed when the view resolves none (key page)", () => { + render(); + + expect(screen.getByText(/Grants from access groups are not listed on this page/)).toBeInTheDocument(); + }); + + it("treats the API null for unresolved group ids as no missing groups", () => { + render( + , + ); + + expect(screen.getByText("private-plugin")).toBeInTheDocument(); + expect(screen.queryByText(/suspended/)).not.toBeInTheDocument(); + }); + + it("reports suspended private access instead of grants while a listed group has no row", () => { + render( + , + ); + + expect(screen.getByText(/access group deleted-group not found/)).toBeInTheDocument(); + expect(screen.queryByText(/private-plugin/)).not.toBeInTheDocument(); + expect(screen.queryByText(/team-plugin/)).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/object_permissions_view.tsx b/ui/litellm-dashboard/src/components/object_permissions_view.tsx index ae46729222a..84117ccb7c7 100644 --- a/ui/litellm-dashboard/src/components/object_permissions_view.tsx +++ b/ui/litellm-dashboard/src/components/object_permissions_view.tsx @@ -3,12 +3,14 @@ import VectorStorePermissions from "./permissions/VectorStorePermissions"; import MCPServerPermissions from "./permissions/MCPServerPermissions"; import AgentPermissions from "./permissions/AgentPermissions"; import type { ObjectPermission } from "./object_permission_types"; -import type { InheritedGrant } from "./permissions/inheritedGrants"; +import { inheritedGrantTooltip, type InheritedGrant } from "./permissions/inheritedGrants"; interface ObjectPermissionsViewProps { objectPermission?: ObjectPermission | null; inheritedMcpServers?: InheritedGrant[]; inheritedAgents?: InheritedGrant[]; + inheritedSkills?: InheritedGrant[]; + unresolvedAccessGroupIds?: string[] | null; variant?: "card" | "inline"; className?: string; accessToken?: string | null; @@ -18,6 +20,8 @@ export function ObjectPermissionsView({ objectPermission, inheritedMcpServers = [], inheritedAgents = [], + inheritedSkills, + unresolvedAccessGroupIds, variant = "card", className = "", accessToken, @@ -31,6 +35,41 @@ export function ObjectPermissionsView({ const agentAccessGroups = objectPermission?.agent_access_groups || []; const searchTools = objectPermission?.search_tools || []; const skills = objectPermission?.skills || []; + const showsInheritedSkills = inheritedSkills !== undefined; + const missingAccessGroupIds = unresolvedAccessGroupIds ?? []; + const inheritedOnlySkills = (inheritedSkills ?? []).filter((grant) => !skills.includes(grant.id)); + + const skillsContent = (() => { + if (missingAccessGroupIds.length > 0) { + return ( +

+ Private plugin access is suspended: access group {missingAccessGroupIds.join(", ")} not found. Only enabled + (public) Claude Code plugins are visible until the id is removed or the group recreated. +

+ ); + } + if (skills.length === 0 && inheritedOnlySkills.length === 0) { + return ( +

+ {showsInheritedSkills + ? "No private skills granted. Only enabled (public) Claude Code plugins are visible." + : "No private skills granted directly here. Grants from access groups are not listed on this page; only enabled (public) Claude Code plugins are visible by default."} +

+ ); + } + return ( +

+ {skills.join(", ")} + {skills.length > 0 && inheritedOnlySkills.length > 0 ? ", " : ""} + {inheritedOnlySkills.map((grant, index) => ( + + {index > 0 ? ", " : ""} + {grant.id} (access group) + + ))} +

+ ); + })(); const content = (
@@ -61,13 +100,7 @@ export function ObjectPermissionsView({

Skills

- {skills.length === 0 ? ( -

- No private skills granted. Only enabled (public) Claude Code plugins are visible. -

- ) : ( -

{skills.join(", ")}

- )} + {skillsContent}
); diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 0743e73005e..24d102aebf7 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -328,6 +328,8 @@ export interface TeamData { access_group_models?: string[]; access_group_mcp_server_ids?: string[]; access_group_agent_ids?: string[]; + access_group_skill_names?: string[]; + unresolved_access_group_ids?: string[] | null; access_group_details?: TeamAccessGroupModelGrant[]; router_settings?: Record; guardrails?: string[]; @@ -1334,6 +1336,11 @@ const TeamInfoView: React.FC = ({ info.access_group_details, (grant) => grant.agent_ids, ); + const inheritedSkills = computeInheritedGrants( + info.access_group_skill_names, + info.access_group_details, + (grant) => grant.skill_names, + ); const initialKillSwitchOn = info.metadata?.disable_global_guardrails === true; @@ -1444,6 +1451,8 @@ const TeamInfoView: React.FC = ({ objectPermission={info.object_permission} inheritedMcpServers={inheritedMcpServers} inheritedAgents={inheritedAgents} + inheritedSkills={inheritedSkills} + unresolvedAccessGroupIds={info.unresolved_access_group_ids} variant="card" accessToken={accessToken} /> @@ -2450,6 +2459,8 @@ const TeamInfoView: React.FC = ({ objectPermission={info.object_permission} inheritedMcpServers={inheritedMcpServers} inheritedAgents={inheritedAgents} + inheritedSkills={inheritedSkills} + unresolvedAccessGroupIds={info.unresolved_access_group_ids} variant="inline" className="pt-4 border-t border-border" accessToken={accessToken} diff --git a/ui/litellm-dashboard/src/components/team/teamModelAccess.ts b/ui/litellm-dashboard/src/components/team/teamModelAccess.ts index bc8022592bd..d5d9245ed51 100644 --- a/ui/litellm-dashboard/src/components/team/teamModelAccess.ts +++ b/ui/litellm-dashboard/src/components/team/teamModelAccess.ts @@ -7,6 +7,7 @@ export interface TeamAccessGroupModelGrant { models: string[]; mcp_server_ids?: string[]; agent_ids?: string[]; + skill_names?: string[]; } export type TeamModelBadgeKind = "all-proxy" | "no-default" | "direct" | "access-group"; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 3c298825b61..500848e08a4 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -2386,7 +2386,7 @@ export interface paths { * * Without `key` the catalog holds the enabled (public) plugins. With `?key=sk-...` * the key is authenticated and the catalog also holds the disabled plugins granted - * to it through `object_permission.skills` on the key or its team. + * to it through `object_permission.skills` or an access group on the key or its team. * * Returns: * Marketplace catalog with list of available plugins and their git sources. @@ -25105,6 +25105,8 @@ export interface components { access_mcp_server_ids?: string[] | null; /** Access Model Names */ access_model_names?: string[] | null; + /** Access Skill Names */ + access_skill_names?: string[] | null; /** Assigned Key Ids */ assigned_key_ids?: string[] | null; /** Assigned Team Ids */ @@ -25154,6 +25156,11 @@ export interface components { access_mcp_servers: components["schemas"]["AccessGroupResource"][]; /** Access Model Names */ access_model_names: string[]; + /** + * Access Skill Names + * @default [] + */ + access_skill_names: string[]; /** Assigned Key Ids */ assigned_key_ids: string[]; /** Assigned Keys */ @@ -25193,6 +25200,8 @@ export interface components { access_mcp_server_ids?: string[] | null; /** Access Model Names */ access_model_names?: string[] | null; + /** Access Skill Names */ + access_skill_names?: string[] | null; /** Assigned Key Ids */ assigned_key_ids?: string[] | null; /** Assigned Team Ids */ @@ -45869,6 +45878,8 @@ export interface components { access_group_mcp_server_ids?: string[] | null; /** Access Group Models */ access_group_models?: string[] | null; + /** Access Group Skill Names */ + access_group_skill_names?: string[] | null; /** * Admins * @default [] From 7d7e28829f2f6a98aa106e8f9732518bc1524620 Mon Sep 17 00:00:00 2001 From: Kerry Lu Date: Wed, 14 Oct 2026 16:24:08 -0700 Subject: [PATCH 24/25] test(integration): delete flaky team model TPM concurrency test (#46541) Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../routing/test_team_model_tpm_limit.py | 94 ------------------- 1 file changed, 94 deletions(-) delete mode 100644 tests/integration/routing/test_team_model_tpm_limit.py diff --git a/tests/integration/routing/test_team_model_tpm_limit.py b/tests/integration/routing/test_team_model_tpm_limit.py deleted file mode 100644 index 22a4e5f54ba..00000000000 --- a/tests/integration/routing/test_team_model_tpm_limit.py +++ /dev/null @@ -1,94 +0,0 @@ -import json -import threading -import uuid -from concurrent.futures import Future, ThreadPoolExecutor -from typing import Final - -import httpx -import pytest -from integration._support.client import Gateway, eventually -from integration._support.wire import Reply, Request, wire_server - -PROVIDER_MODEL: Final = "gpt-4o-mini" -TEAM_MODEL_TPM: Final = 100 -MAX_TOKENS: Final = 60 -CONCURRENT_REQUESTS: Final = 3 -UPSTREAM_USAGE: Final = {"prompt_tokens": 10, "completion_tokens": 4, "total_tokens": 14} -UPSTREAM_REPLY: Final = json.dumps( - { - "id": "chatcmpl-team-tpm-control", - "object": "chat.completion", - "created": 1, - "model": PROVIDER_MODEL, - "choices": [ - {"index": 0, "message": {"role": "assistant", "content": "team tpm control"}, "finish_reason": "stop"} - ], - "usage": UPSTREAM_USAGE, - } -).encode() - - -@pytest.mark.covers("routing.team_model_tpm.concurrent_requests_over_the_limit_are_rejected_before_the_provider_call") -def test_concurrent_team_model_tpm_requests_reserve_tokens_before_reaching_the_provider(gateway: Gateway) -> None: - probe: Final = "team tpm probe " + uuid.uuid4().hex - release: Final = threading.Event() - - def respond(request: Request) -> Reply: - assert request.method == "POST" and request.target == "/v1/chat/completions" - assert request.headers["authorization"] == "Bearer synthetic-team-tpm-key" - body: Final = json.loads(request.body) - content: Final = body["messages"][0]["content"] - assert body == { - "model": PROVIDER_MODEL, - "messages": [{"role": "user", "content": content}], - "max_tokens": MAX_TOKENS, - } - assert content.startswith(probe), content - release.wait(timeout=10) - return Reply(body=UPSTREAM_REPLY) - - with wire_server(respond) as wire, gateway.scenario() as scenario: - model: Final = scenario.model( - model=f"openai/{PROVIDER_MODEL}", - api_base=wire.url + "/v1", - api_key="synthetic-team-tpm-key", - ) - team: Final = scenario.team(metadata={"model_tpm_limit": {model: TEAM_MODEL_TPM}}) - key: Final = scenario.key(team_id=team) - - def send(index: int) -> httpx.Response: - return gateway.request( - "POST", - "/v1/chat/completions", - { - "model": model, - "max_tokens": MAX_TOKENS, - "messages": [{"role": "user", "content": f"{probe} {index}"}], - }, - key=key, - ) - - with ThreadPoolExecutor(max_workers=CONCURRENT_REQUESTS) as pool: - futures: Final[tuple[Future[httpx.Response], ...]] = tuple( - pool.submit(send, index) for index in range(CONCURRENT_REQUESTS) - ) - eventually( - lambda: sum(future.done() for future in futures) + wire.received.qsize(), - lambda settled: settled >= CONCURRENT_REQUESTS, - seconds=10, - ) - release.set() - responses: Final = tuple(future.result(timeout=15) for future in futures) - served: Final = tuple(response for response in responses if response.status_code == 200) - rejected: Final = tuple(response for response in responses if response.status_code != 200) - assert len(served) <= 1, tuple(response.text for response in responses) - assert len(wire.drain()) == len(served), tuple(response.status_code for response in responses) - for response in rejected: - assert response.status_code == 429, response.text - error: Final = response.json()["error"] - assert (error["type"], error["code"], error["param"]) == ("throttling_error", "429", None), response.text - assert f"Limit type: tokens. Current limit: {TEAM_MODEL_TPM}," in error["message"], response.text - admitted: Final = served[0] if served else send(CONCURRENT_REQUESTS) - assert admitted.status_code == 200, admitted.text - assert admitted.json()["usage"] == UPSTREAM_USAGE, admitted.text - assert len(wire.drain()) == (0 if served else 1) From 01cec8f9381e2afb4f0c957de84d93e2777fa435 Mon Sep 17 00:00:00 2001 From: "berriai-litellm-provider-info-sync[bot]" <328147090+berriai-litellm-provider-info-sync[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:47:00 -0700 Subject: [PATCH 25/25] fix(vertex-ai): add flex prices to gemini-nano-banana-2.1 (#46512) Price-Sync: litellm-providers Co-authored-by: berriai-litellm-provider-info-sync[bot] <328147090+berriai-litellm-provider-info-sync[bot]@users.noreply.github.com> Co-authored-by: Kerry Lu --- litellm/model_prices_and_context_window_backup.json | 6 ++++++ model_prices_and_context_window.json | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 9011e4511d7..04f391d4d20 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -72571,10 +72571,12 @@ "gemini-nano-banana-2.1": { "cache_read_input_token_cost": 1.5e-07, "cache_read_input_token_cost_batches": 7.5e-08, + "cache_read_input_token_cost_flex": 7.5e-08, "cache_read_input_token_cost_priority": 2.7e-07, "input_cost_per_image": 0.00168, "input_cost_per_token": 1.5e-06, "input_cost_per_token_batches": 7.5e-07, + "input_cost_per_token_flex": 7.5e-07, "input_cost_per_token_priority": 2.7e-06, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 131072, @@ -72585,6 +72587,7 @@ "output_cost_per_image_token": 3e-05, "output_cost_per_token": 7.5e-06, "output_cost_per_token_batches": 3.75e-06, + "output_cost_per_token_flex": 3.75e-06, "output_cost_per_token_priority": 1.35e-05, "search_context_cost_per_query": {"search_context_size_low":0.014,"search_context_size_medium":0.014,"search_context_size_high":0.014}, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", @@ -72617,10 +72620,12 @@ "vertex_ai/gemini-nano-banana-2.1": { "cache_read_input_token_cost": 1.5e-07, "cache_read_input_token_cost_batches": 7.5e-08, + "cache_read_input_token_cost_flex": 7.5e-08, "cache_read_input_token_cost_priority": 2.7e-07, "input_cost_per_image": 0.00168, "input_cost_per_token": 1.5e-06, "input_cost_per_token_batches": 7.5e-07, + "input_cost_per_token_flex": 7.5e-07, "input_cost_per_token_priority": 2.7e-06, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 131072, @@ -72631,6 +72636,7 @@ "output_cost_per_image_token": 3e-05, "output_cost_per_token": 7.5e-06, "output_cost_per_token_batches": 3.75e-06, + "output_cost_per_token_flex": 3.75e-06, "output_cost_per_token_priority": 1.35e-05, "search_context_cost_per_query": {"search_context_size_low":0.014,"search_context_size_medium":0.014,"search_context_size_high":0.014}, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 9011e4511d7..04f391d4d20 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -72571,10 +72571,12 @@ "gemini-nano-banana-2.1": { "cache_read_input_token_cost": 1.5e-07, "cache_read_input_token_cost_batches": 7.5e-08, + "cache_read_input_token_cost_flex": 7.5e-08, "cache_read_input_token_cost_priority": 2.7e-07, "input_cost_per_image": 0.00168, "input_cost_per_token": 1.5e-06, "input_cost_per_token_batches": 7.5e-07, + "input_cost_per_token_flex": 7.5e-07, "input_cost_per_token_priority": 2.7e-06, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 131072, @@ -72585,6 +72587,7 @@ "output_cost_per_image_token": 3e-05, "output_cost_per_token": 7.5e-06, "output_cost_per_token_batches": 3.75e-06, + "output_cost_per_token_flex": 3.75e-06, "output_cost_per_token_priority": 1.35e-05, "search_context_cost_per_query": {"search_context_size_low":0.014,"search_context_size_medium":0.014,"search_context_size_high":0.014}, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing", @@ -72617,10 +72620,12 @@ "vertex_ai/gemini-nano-banana-2.1": { "cache_read_input_token_cost": 1.5e-07, "cache_read_input_token_cost_batches": 7.5e-08, + "cache_read_input_token_cost_flex": 7.5e-08, "cache_read_input_token_cost_priority": 2.7e-07, "input_cost_per_image": 0.00168, "input_cost_per_token": 1.5e-06, "input_cost_per_token_batches": 7.5e-07, + "input_cost_per_token_flex": 7.5e-07, "input_cost_per_token_priority": 2.7e-06, "litellm_provider": "vertex_ai-language-models", "max_input_tokens": 131072, @@ -72631,6 +72636,7 @@ "output_cost_per_image_token": 3e-05, "output_cost_per_token": 7.5e-06, "output_cost_per_token_batches": 3.75e-06, + "output_cost_per_token_flex": 3.75e-06, "output_cost_per_token_priority": 1.35e-05, "search_context_cost_per_query": {"search_context_size_low":0.014,"search_context_size_medium":0.014,"search_context_size_high":0.014}, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing",