diff --git a/tests/integration/authorization/test_access_group_budget.py b/tests/integration/authorization/test_access_group_budget.py new file mode 100644 index 00000000000..0db05a649e3 --- /dev/null +++ b/tests/integration/authorization/test_access_group_budget.py @@ -0,0 +1,429 @@ +"""A model access group's shared budget: set and read back, enforced on real chat traffic, and cleared. + +The scripted provider bills every call at 10 prompt and 10 completion tokens, so at the rates below one +call costs 0.00011 and exhausts the 0.0001 budget the group is given. +""" + +import json +import threading +import uuid +from collections.abc import Iterator +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from pathlib import Path +from typing import Final, Literal + +import httpx +import pytest +import yaml +from integration._support.client import Gateway, Scenario, eventually, gateway_from_environment +from integration._support.database import read_rows +from integration._support.process import owned_proxy +from integration._support.wire import Reply, Request, Wire, wire_server +from pydantic import BaseModel, TypeAdapter + +_INPUT_RATE: Final = 0.000001 +_OUTPUT_RATE: Final = 0.00001 +_MAX_BUDGET: Final = 0.0001 +_CALL_COST: Final = 10 * _INPUT_RATE + 10 * _OUTPUT_RATE +_PROVIDER_MODEL: Final = "group-budget-probe" +Grant = Literal["key", "team", "organization", "project"] + + +class _Budget(BaseModel): + budget_id: str + max_budget: float | None + soft_budget: float | None + budget_duration: str | None + + +class _GroupBudget(BaseModel): + access_group: str + spend: float + budget: _Budget | None + + +class _DeletedBudget(BaseModel): + access_group: str + budget_deleted: bool + message: str + + +class _Usage(BaseModel): + prompt_tokens: int + completion_tokens: int + total_tokens: int + + +class _Completion(BaseModel): + id: str + usage: _Usage + + +class _StreamDelta(BaseModel): + content: str | None = None + + +class _StreamChoice(BaseModel): + delta: _StreamDelta + + +class _StreamChunk(BaseModel): + choices: list[_StreamChoice] + usage: _Usage | None = None + + +class _ErrorBody(BaseModel): + message: str + type: str + param: str | None + code: str + + +class _Error(BaseModel): + error: _ErrorBody + + +@dataclass(frozen=True, slots=True) +class _Group: + name: str + model: str + key: str + + +@dataclass(frozen=True, slots=True) +class _BudgetProxy: + gateway: Gateway + reserved: bool + + +def _is_model_discovery(request: Request) -> bool: + return (request.method, request.target) == ("GET", "/v1/models") + + +def _provider_calls(wire: Wire) -> tuple[Request, ...]: + return tuple(entry for entry in wire.drain() if not _is_model_discovery(entry)) + + +def _billed(request: Request) -> Reply: + if _is_model_discovery(request): + return Reply(body=b'{"object":"list","data":[]}') + assert (request.method, request.target) == ("POST", "/v1/chat/completions"), request + body: Final = json.loads(request.body) + if body.get("stream") is True: + identity: Final = f"chatcmpl-{uuid.uuid4().hex}" + frames: Final = ( + { + "id": identity, + "object": "chat.completion.chunk", + "created": 0, + "model": _PROVIDER_MODEL, + "choices": [{"index": 0, "delta": {"role": "assistant", "content": "billed"}, "finish_reason": None}], + }, + { + "id": identity, + "object": "chat.completion.chunk", + "created": 0, + "model": _PROVIDER_MODEL, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + }, + { + "id": identity, + "object": "chat.completion.chunk", + "created": 0, + "model": _PROVIDER_MODEL, + "choices": [], + "usage": {"prompt_tokens": 10, "completion_tokens": 10, "total_tokens": 20}, + }, + ) + chunks: Final = tuple(f"data: {json.dumps(frame)}\n\n".encode() for frame in frames) + (b"data: [DONE]\n\n",) + return Reply(content_type="text/event-stream", chunks=chunks) + return Reply( + body=json.dumps( + { + "id": f"chatcmpl-{uuid.uuid4().hex}", + "object": "chat.completion", + "created": 0, + "model": _PROVIDER_MODEL, + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "billed"}, "finish_reason": "stop"} + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 10, "total_tokens": 20}, + } + ).encode() + ) + + +@pytest.fixture( + params=("reserved", "unreserved"), + ids=("reservation", "no-reservation"), + scope="module", +) +def proxy(request: pytest.FixtureRequest, tmp_path_factory: pytest.TempPathFactory) -> Iterator[_BudgetProxy]: + """The shared proxy reserves budget per request; the owned one runs with ``disable_budget_reservation``.""" + with gateway_from_environment() as gateway: + if request.param == "reserved": + yield _BudgetProxy(gateway=gateway, reserved=True) + return + directory: Final = tmp_path_factory.mktemp("access-group-budget-unreserved") + loaded_config: Final = TypeAdapter(dict[str, object]).validate_python( + yaml.safe_load(Path("tests/integration/proxy_config.yaml").read_text()) + ) + general_settings: Final = TypeAdapter(dict[str, object]).validate_python( + loaded_config.get("general_settings", {}) + ) + config: Final = { + **loaded_config, + "general_settings": {**general_settings, "disable_budget_reservation": True}, + } + path: Final = directory / "unreserved.yaml" + path.write_text(yaml.safe_dump(config)) + with owned_proxy(gateway, directory, {}, config=path) as owned: + yield _BudgetProxy(gateway=owned, reserved=False) + + +def _group_key(scenario: Scenario, name: str, grant: Grant) -> str: + match grant: + case "key": + return scenario.key(models=[name]) + case "team": + team_id: Final = scenario.team(models=[name]) + return scenario.key(team_id=team_id, models=[]) + case "organization": + organization: Final = scenario.organization(models=[name]) + return scenario.key(organization_id=organization, models=[]) + case "project": + project_team: Final = scenario.team() + project_id: Final = scenario.project(project_team, models=[name]) + return scenario.key(team_id=project_team, project_id=project_id, models=[]) + + +def _budgeted_group(scenario: Scenario, wire: Wire, grant: Grant = "key") -> _Group: + """A database model in a fresh access group with one grant and a 0.0001 group budget.""" + name: Final = f"integration-group-{uuid.uuid4().hex}" + model: Final = scenario.model( + model=f"openai/{_PROVIDER_MODEL}", + api_base=f"{wire.url}/v1", + input_cost_per_token=_INPUT_RATE, + output_cost_per_token=_OUTPUT_RATE, + model_info={"access_groups": [name]}, + ) + key: Final = _group_key(scenario, name, grant) + gateway: Final = scenario.gateway + created: Final = gateway.request( + "PUT", f"/access_group/{name}/budget", {"max_budget": _MAX_BUDGET, "budget_duration": "30d"} + ) + assert created.status_code == 200, created.text + stored: Final = _GroupBudget.model_validate_json(created.content) + assert stored.budget is not None, created.text + scenario.cleanups.callback(scenario.delete_budget, stored.budget.budget_id) + scenario.cleanups.callback(gateway.request, "DELETE", f"/access_group/{name}/budget") + assert stored == _GroupBudget( + access_group=name, + spend=0.0, + budget=_Budget( + budget_id=stored.budget.budget_id, max_budget=_MAX_BUDGET, soft_budget=None, budget_duration="30d" + ), + ), created.text + read_back: Final = gateway.request("GET", f"/access_group/{name}/budget") + assert read_back.status_code == 200, read_back.text + assert _GroupBudget.model_validate_json(read_back.content) == stored, read_back.text + assert _budget_rows(name) == [ + {"budget_id": stored.budget.budget_id, "spend": 0.0, "max_budget": _MAX_BUDGET, "budget_duration": "30d"} + ] + return _Group(name, model, key) + + +def _budget_rows(group: str) -> list[dict[str, object]]: + return read_rows( + 'SELECT g.budget_id, g.spend, b.max_budget, b.budget_duration FROM "LiteLLM_ModelAccessGroupBudgetTable" g ' + 'LEFT JOIN "LiteLLM_BudgetTable" b ON b.budget_id = g.budget_id WHERE g.access_group_name = %s', + (group,), + ) + + +def _chat(gateway: Gateway, group: _Group, text: str, max_tokens: int, spelling: str = "json") -> httpx.Response: + body: Final = { + "model": group.model, + "messages": [{"role": "user", "content": text}], + "max_tokens": max_tokens, + **({"stream": True, "stream_options": {"include_usage": True}} if spelling == "stream" else {}), + } + return gateway.request( + "POST", + "/v1/chat/completions", + body, + key=group.key, + ) + + +def _sent(text: str, max_tokens: int, spelling: str = "json") -> dict[str, object]: + return { + "model": _PROVIDER_MODEL, + "messages": [{"role": "user", "content": text}], + "max_tokens": max_tokens, + **({"stream": True, "stream_options": {"include_usage": True}} if spelling == "stream" else {}), + } + + +def _assert_served(response: httpx.Response, wire: Wire, text: str, max_tokens: int, spelling: str = "json") -> None: + assert response.status_code == 200, response.text + if spelling == "stream": + lines: Final = tuple(line for line in response.text.splitlines() if line) + frames: Final = tuple( + _StreamChunk.model_validate_json(line.removeprefix("data: ")) + for line in lines + if line.startswith("data: ") and line != "data: [DONE]" + ) + assert "".join(choice.delta.content or "" for frame in frames for choice in frame.choices) == "billed", ( + response.text + ) + assert frames[-1].usage == _Usage(prompt_tokens=10, completion_tokens=10, total_tokens=20), response.text + assert lines[-1] == "data: [DONE]", response.text + else: + assert _Completion.model_validate_json(response.content).usage == _Usage( + prompt_tokens=10, completion_tokens=10, total_tokens=20 + ), response.text + received: Final = _provider_calls(wire) + assert [json.loads(entry.body) for entry in received] == [_sent(text, max_tokens, spelling)], response.text + assert [entry.headers["authorization"] for entry in received] == ["Bearer integration-provider-key"] + + +def _assert_refused_for_budget(response: httpx.Response, wire: Wire, message: str) -> None: + assert response.status_code == 422, response.text + assert _Error.model_validate_json(response.content).error == _ErrorBody( + message=message, type="budget_exceeded", param=None, code="422" + ), response.text + assert _provider_calls(wire) == (), f"A refused request reached the provider: {response.text}" + + +@pytest.mark.timeout(240) +@pytest.mark.parametrize("spelling", ("json", "stream")) +def test_access_group_budget_refuses_traffic_once_spent_and_serves_again_after_delete( + proxy: _BudgetProxy, spelling: str +) -> None: + gateway: Final = proxy.gateway + arrived: Final = threading.Event() + release: Final = threading.Event() + + def held(request: Request) -> Reply: + if proxy.reserved and spelling == "stream" and b"first call under budget" in request.body: + arrived.set() + assert release.wait(timeout=30), "The held call was never released" + return _billed(request) + + with wire_server(held) as wire, gateway.scenario() as scenario: + group: Final = _budgeted_group(scenario, wire) + if proxy.reserved and spelling == "stream": + with ThreadPoolExecutor(max_workers=1) as pool: + in_flight: Final = pool.submit(_chat, gateway, group, "first call under budget", 1000, spelling) + try: + assert arrived.wait(timeout=30), "The held call never reached the provider" + concurrent: Final = _chat(gateway, group, "concurrent call", 1, spelling) + finally: + release.set() + _assert_served(in_flight.result(timeout=30), wire, "first call under budget", 1000, spelling) + _assert_refused_for_budget( + concurrent, + wire, + f"Budget has been exceeded! Model access group={group.name} Current cost: {_MAX_BUDGET}, " + f"Max budget: {_MAX_BUDGET}", + ) + else: + _assert_served( + _chat(gateway, group, "first call under budget", 1, spelling), + wire, + "first call under budget", + 1, + spelling, + ) + eventually( + lambda: _GroupBudget.model_validate_json( + gateway.request("GET", f"/access_group/{group.name}/budget").content + ), + lambda value: value.spend == pytest.approx(_CALL_COST), + seconds=60, + ) + assert _budget_rows(group.name)[0]["spend"] == pytest.approx(_CALL_COST) + + _assert_refused_for_budget( + _chat(gateway, group, "call over budget", 1, spelling), + wire, + f"Budget has been exceeded! Model access group={group.name} Current cost: {_CALL_COST}, Max budget: {_MAX_BUDGET}", + ) + + deleted: Final = gateway.request("DELETE", f"/access_group/{group.name}/budget") + assert deleted.status_code == 200, deleted.text + assert _DeletedBudget.model_validate_json(deleted.content) == _DeletedBudget( + access_group=group.name, + budget_deleted=True, + message=f"Budget for access group '{group.name}' deleted successfully", + ), deleted.text + cleared: Final = gateway.request("GET", f"/access_group/{group.name}/budget") + assert _GroupBudget.model_validate_json(cleared.content) == _GroupBudget( + access_group=group.name, spend=0.0, budget=None + ), cleared.text + assert _budget_rows(group.name) == [] + _assert_served( + _chat(gateway, group, "call after delete", 1, spelling), + wire, + "call after delete", + 1, + spelling, + ) + + +@pytest.mark.timeout(120) +@pytest.mark.parametrize("grant", ("team", "organization", "project")) +def test_access_group_budget_refuses_a_key_granted_the_group_through_its_scope( + proxy: _BudgetProxy, grant: Literal["team", "organization", "project"] +) -> None: + gateway: Final = proxy.gateway + text: Final = f"group granted through {grant}" + with wire_server(_billed) as wire, gateway.scenario() as scenario: + group: Final = _budgeted_group(scenario, wire, grant) + _assert_served(_chat(gateway, group, text, 1), wire, text, 1) + eventually( + lambda: _GroupBudget.model_validate_json( + gateway.request("GET", f"/access_group/{group.name}/budget").content + ), + lambda value: value.spend == pytest.approx(_CALL_COST), + seconds=60, + ) + assert _provider_calls(wire) == (), "The served request was not drained before the refusal" + _assert_refused_for_budget( + _chat(gateway, group, f"{text} over budget", 1), + wire, + f"Budget has been exceeded! Model access group={group.name} Current cost: {_CALL_COST}, " + f"Max budget: {_MAX_BUDGET}", + ) + assert _budget_rows(group.name)[0]["spend"] == pytest.approx(_CALL_COST) + + +@pytest.mark.timeout(120) +def test_reservation_refuses_a_concurrent_call_while_an_in_flight_call_holds_the_group_budget( + gateway: Gateway, +) -> None: + arrived: Final = threading.Event() + release: Final = threading.Event() + + def held(request: Request) -> Reply: + if b"held call" in request.body: + arrived.set() + assert release.wait(timeout=30), "The held call was never released" + return _billed(request) + + with wire_server(held) as wire, gateway.scenario() as scenario, ThreadPoolExecutor(max_workers=1) as pool: + group: Final = _budgeted_group(scenario, wire) + in_flight: Final = pool.submit(_chat, gateway, group, "held call", 1000) + try: + assert arrived.wait(timeout=30), "The held call never reached the provider" + concurrent: Final = _chat(gateway, group, "concurrent call", 1) + finally: + release.set() + _assert_served(in_flight.result(timeout=30), wire, "held call", 1000) + _assert_refused_for_budget( + concurrent, + wire, + f"Budget has been exceeded! Model access group={group.name} Current cost: {_MAX_BUDGET}, Max budget: {_MAX_BUDGET}", + ) diff --git a/tests/integration/authorization/test_team_admin_gate.py b/tests/integration/authorization/test_team_admin_gate.py index 18bfb6ad350..c3172e27c35 100644 --- a/tests/integration/authorization/test_team_admin_gate.py +++ b/tests/integration/authorization/test_team_admin_gate.py @@ -8,13 +8,17 @@ team-admin gate can prove parity. from __future__ import annotations +import json +import re import uuid from collections.abc import Callable, Iterator, Mapping +from contextlib import contextmanager from dataclasses import dataclass, replace from datetime import datetime, timedelta, timezone from types import MappingProxyType from typing import Final, Literal, assert_never +import httpx import pytest from pydantic import JsonValue @@ -29,6 +33,7 @@ from tests.integration._support.client import ( team_admin_permissions, ) from tests.integration._support.database import read_rows +from tests.integration._support.wire import Reply, Request, Wire, wire_server Caller = Literal["proxy_admin", "team_admin", "member", "other_team_admin", "outsider", "org_admin", "other_org_admin"] CALLERS: Final[tuple[Caller, ...]] = ( @@ -156,13 +161,13 @@ def _spend_rows(gateway: Gateway, team_id: str, since: datetime, until: datetime return rows -def _team_model_body(s: TeamScenario, name: str) -> dict[str, JsonValue]: +def _team_model_body(s: TeamScenario, name: str, api_base: str | None = None) -> dict[str, JsonValue]: return { "model_name": name, "litellm_params": { "model": "openai/gpt-4o-mini", "api_key": "integration-provider-key", - "api_base": f"{s.gateway.upstream_url}/v1", + "api_base": api_base or f"{s.gateway.upstream_url}/v1", }, "model_info": {"team_id": s.team_id}, } @@ -440,8 +445,378 @@ def test_status_code(shared: TeamScenario, org_team: TeamScenario, route: Route, f"{caller} {call.method} {call.path}: {response.status_code} {response.text}" ) if route.name in _BUDGET_ROUTES: - assert read_rows( - 'SELECT max_budget FROM "LiteLLM_TeamTable" WHERE team_id = %s', (s.team_id,) - ) == [{"max_budget": _BUDGET_ROUTES[route.name] if response.status_code == 200 else _BUDGET_BEFORE}] + assert read_rows('SELECT max_budget FROM "LiteLLM_TeamTable" WHERE team_id = %s', (s.team_id,)) == [ + {"max_budget": _BUDGET_ROUTES[route.name] if response.status_code == 200 else _BUDGET_BEFORE} + ] if response.status_code == 200 and route.cleanup is not None: route.cleanup(s, object_value(response.json())) + + +ADMIN_ONLY_CALLERS: Final[tuple[tuple[str, Caller], ...]] = ( + ("shared", "team_admin"), + ("shared", "member"), + ("shared", "outsider"), + ("org_team", "org_admin"), +) +ADMIN_ONLY_IDS: Final = tuple(caller for _, caller in ADMIN_ONLY_CALLERS) + + +def _admin_only_team(request: pytest.FixtureRequest, fixture: str, scenario: Scenario) -> TeamScenario: + team: Final = request.getfixturevalue(fixture) + assert isinstance(team, TeamScenario) + return replace(team, scenario=scenario) + + +def _team_model(s: TeamScenario, api_base: str | None = None) -> tuple[str, str]: + name: Final = f"matrix-{uuid.uuid4().hex}" + created: Final = s.gateway.post("/model/new", _team_model_body(s, name, api_base)) + model_id: Final = string_value(object_value(created["model_info"])["id"]) + s.scenario.cleanups.callback(_delete_model_if_present, s.gateway, model_id) + return model_id, name + + +def _blocked(model_id: str) -> list[dict[str, object]]: + return read_rows('SELECT blocked FROM "LiteLLM_ProxyModelTable" WHERE model_id = %s', (model_id,)) + + +def _serve(s: TeamScenario, name: str, key: str) -> httpx.Response: + return s.gateway.request( + "POST", + "/v1/chat/completions", + {"model": name, "messages": [{"role": "user", "content": "serving state"}]}, + key=key, + ) + + +def _is_model_discovery(request: Request) -> bool: + return (request.method, request.target) == ("GET", "/v1/models") + + +def _model_serving_reply(request: Request) -> Reply: + if _is_model_discovery(request): + return Reply(body=b'{"object":"list","data":[]}') + assert (request.method, request.target) == ("POST", "/v1/chat/completions"), request + return Reply( + body=json.dumps( + { + "id": "chatcmpl-model-blocked-control", + "object": "chat.completion", + "created": 0, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "model serving control"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + ).encode() + ) + + +def _provider_calls(wire: Wire) -> tuple[Request, ...]: + return tuple(entry for entry in wire.drain() if not _is_model_discovery(entry)) + + +def _assert_serving_state(s: TeamScenario, name: str, wire: Wire, *, blocked: bool) -> None: + served: Final = _serve(s, name, s.keys["team_admin"]) + if blocked: + assert (served.status_code, served.json()) == _MODEL_BLOCKED_REFUSED, served.text + assert _provider_calls(wire) == (), f"Blocked model reached the provider: {served.text}" + return + + assert served.status_code == 200, served.text + assert served.json()["choices"][0]["message"]["content"] == "model serving control", served.text + received: Final = _provider_calls(wire) + assert [ + (entry.method, entry.target, entry.headers["authorization"], json.loads(entry.body)) for entry in received + ] == [ + ( + "POST", + "/v1/chat/completions", + "Bearer integration-provider-key", + {"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "serving state"}]}, + ) + ], served.text + + +def _refusal(response: httpx.Response) -> JsonValue: + """The error body with the caller's masked user id elided, since it differs per caller.""" + body: Final = response.json() + error: Final = object_value(body).get("error") + if isinstance(error, dict) and isinstance(error.get("message"), str): + return { + **body, + "error": {**error, "message": re.sub(r"Your user_id=\S+", "Your user_id=", error["message"])}, + } + return body + + +def _admin_only_route(route: str) -> tuple[int, JsonValue]: + return 401, { + "error": { + "message": "Authentication Error, Only proxy admin can be used to generate, delete, update info for new " + f"keys/users/teams. Route={route}. Your role=internal_user. Your user_id=", + "type": "auth_error", + "param": "None", + "code": "401", + } + } + + +_BLOCK_REFUSALS: Final[Mapping[tuple[str, str], tuple[int, JsonValue]]] = MappingProxyType( + { + ("POST", "/model/block"): _admin_only_route("/model/block"), + ("POST", "/model/unblock"): _admin_only_route("/model/unblock"), + } +) +_PATCH_BLOCKED_REFUSED: Final[tuple[int, JsonValue]] = ( + 403, + { + "error": { + "message": "Only proxy admins can change a model's blocked flag.", + "type": "auth_error", + "param": "blocked", + "code": "403", + } + }, +) +_MODEL_BLOCKED_REFUSED: Final[tuple[int, JsonValue]] = ( + 403, + { + "error": { + "message": "litellm.PermissionDeniedError: Model is blocked", + "type": "permission_error", + "param": None, + "code": "403", + } + }, +) + + +@pytest.mark.parametrize(("fixture", "caller"), ADMIN_ONLY_CALLERS, ids=ADMIN_ONLY_IDS) +def test_only_proxy_admin_flips_a_models_blocked_flag( + request: pytest.FixtureRequest, fixture: str, caller: Caller +) -> None: + with ( + gateway_from_environment() as gateway, + gateway.scenario() as scenario, + wire_server(_model_serving_reply) as wire, + ): + s: Final = _admin_only_team(request, fixture, scenario) + model_id, name = _team_model(s, f"{wire.url}/v1") + internal_key: Final = ( + s.scenario.key(user_id=s.scenario.user(user_role="internal_user")) if caller == "team_admin" else None + ) + for blocked in (True, False): + s.gateway.post("/model/block" if blocked else "/model/unblock", {"model_id": model_id}) + attempts = ( + Call("POST", "/model/unblock" if blocked else "/model/block", {"model_id": model_id}), + Call("PATCH", f"/model/{model_id}/update", {"blocked": not blocked}), + ) + for call in attempts: + response = s.gateway.request(call.method, call.path, call.body, key=s.keys[caller]) + if call.method == "PATCH" and caller != "team_admin": + assert response.status_code == 403, f"{caller} {call.method} {call.path}: {response.text}" + else: + expected = ( + _PATCH_BLOCKED_REFUSED if call.method == "PATCH" else _BLOCK_REFUSALS[call.method, call.path] + ) + assert (response.status_code, _refusal(response)) == expected, ( + f"{caller} {call.method} {call.path}: {response.text}" + ) + assert _blocked(model_id) == [{"blocked": blocked}], ( + f"{caller} {call.method} {call.path} flipped blocked" + ) + if internal_key is not None: + path = "/model/unblock" if blocked else "/model/block" + internal_response = s.gateway.request("POST", path, {"model_id": model_id}, key=internal_key) + assert (internal_response.status_code, _refusal(internal_response)) == _BLOCK_REFUSALS["POST", path], ( + internal_response.text + ) + assert _blocked(model_id) == [{"blocked": blocked}], internal_response.text + _assert_serving_state(s, name, wire, blocked=blocked) + + +def test_a_key_allowed_the_block_routes_still_cannot_flip_a_models_blocked_flag(shared: TeamScenario) -> None: + with wire_server(_model_serving_reply) as wire: + user: Final = shared.scenario.user(user_role="internal_user") + key: Final = shared.scenario.key(user_id=user, allowed_routes=["/model/block", "/model/unblock"]) + model_id, name = _team_model(shared, f"{wire.url}/v1") + for blocked in (True, False): + shared.gateway.post("/model/block" if blocked else "/model/unblock", {"model_id": model_id}) + call = Call("POST", "/model/unblock" if blocked else "/model/block", {"model_id": model_id}) + response = shared.gateway.request(call.method, call.path, call.body, key=key) + assert (response.status_code, response.json()) == _PATCH_BLOCKED_REFUSED, response.text + assert _blocked(model_id) == [{"blocked": blocked}], response.text + _assert_serving_state(shared, name, wire, blocked=blocked) + + +@contextmanager +def _model_creation_disabled_for_internal_users(gateway: Gateway) -> Iterator[None]: + setting: Final = "disable_model_add_for_internal_users" + original: Final = object_value(gateway.get("/get/ui_settings")["values"]).get(setting, False) + response: Final = gateway.request("PATCH", "/update/ui_settings", {setting: True}) + assert response.status_code == 200, response.text + try: + yield + finally: + restored: Final = gateway.request("PATCH", "/update/ui_settings", {setting: original}) + assert restored.status_code == 200, restored.text + + +def _model_rows(name: str) -> list[dict[str, object]]: + return read_rows( + 'SELECT model_id FROM "LiteLLM_ProxyModelTable" ' + "WHERE model_name = %s OR model_info->>'team_public_model_name' = %s", + (name, name), + ) + + +def _listed_names(gateway: Gateway, key: str) -> tuple[frozenset[str], frozenset[str]]: + info: Final = gateway.get("/model/info")["data"] + models: Final = gateway.request("GET", "/v1/models", key=key) + assert models.status_code == 200, models.text + listed: Final = models.json()["data"] + assert isinstance(info, list) and isinstance(listed, list), models.text + info_names: Final = frozenset( + name + for entry in map(object_value, info) + for name in (entry["model_name"], object_value(entry["model_info"]).get("team_public_model_name")) + if isinstance(name, str) + ) + return info_names, frozenset(string_value(object_value(entry)["id"]) for entry in listed) + + +def test_internal_user_model_creation_prohibition_refuses_team_admin_but_not_proxy_admin( + shared: TeamScenario, peer: Gateway +) -> None: + with shared.gateway.scenario() as scenario: + s: Final = replace(shared, scenario=scenario) + team_admin: Final = s.keys["team_admin"] + refused_name: Final = f"matrix-{uuid.uuid4().hex}" + admin_name: Final = f"matrix-{uuid.uuid4().hex}" + with _model_creation_disabled_for_internal_users(peer): + refused: Final = s.gateway.request("POST", "/model/new", _team_model_body(s, refused_name), key=team_admin) + assert (refused.status_code, refused.json()) == ( + 403, + { + "error": { + "message": "Model creation is disabled for internal users by disable_model_add_for_internal_users.", + "type": "auth_error", + "param": "None", + "code": "403", + } + }, + ), refused.text + assert _model_rows(refused_name) == [] + info_names, listed = _listed_names(s.gateway, team_admin) + assert refused_name not in info_names and refused_name not in listed, (info_names, listed) + created: Final = s.gateway.post("/model/new", _team_model_body(s, admin_name)) + s.scenario.cleanups.callback( + _delete_model_if_present, s.gateway, string_value(object_value(created["model_info"])["id"]) + ) + assert _model_rows(admin_name) == [{"model_id": object_value(created["model_info"])["id"]}] + assert admin_name in _listed_names(s.gateway, team_admin)[1] + allowed_name: Final = f"matrix-{uuid.uuid4().hex}" + allowed: Final = s.gateway.request("POST", "/model/new", _team_model_body(s, allowed_name), key=team_admin) + assert allowed.status_code == 200, allowed.text + s.scenario.cleanups.callback( + _delete_model_if_present, s.gateway, string_value(object_value(allowed.json()["model_info"])["id"]) + ) + assert len(_model_rows(allowed_name)) == 1 + + +def _credential_row(name: str) -> list[dict[str, object]]: + return read_rows( + 'SELECT credential_name, credential_values, credential_info, updated_at::text AS updated_at FROM "LiteLLM_CredentialsTable" ' + "WHERE credential_name = %s", + (name,), + ) + + +@pytest.mark.parametrize("contains_slash", (False, True), ids=("plain-name", "slash-name")) +@pytest.mark.parametrize(("fixture", "caller"), ADMIN_ONLY_CALLERS, ids=ADMIN_ONLY_IDS) +def test_only_proxy_admin_reads_or_changes_credentials( + request: pytest.FixtureRequest, fixture: str, caller: Caller, contains_slash: bool +) -> None: + secret: Final = f"synthetic-shared-credential-{uuid.uuid4().hex}" + with gateway_from_environment() as gateway, gateway.scenario() as scenario: + s: Final = _admin_only_team(request, fixture, scenario) + name: Final = f"credential/{uuid.uuid4().hex}" if contains_slash else f"credential-{uuid.uuid4().hex}" + attempted: Final = f"credential/{uuid.uuid4().hex}" if contains_slash else f"credential-{uuid.uuid4().hex}" + s.gateway.post( + "/credentials", + {"credential_name": name, "credential_values": {"api_key": secret}, "credential_info": {"team": "shared"}}, + ) + s.scenario.cleanups.callback(s.gateway.request, "DELETE", f"/credentials/{name}") + s.scenario.cleanups.callback(s.gateway.request, "DELETE", f"/credentials/{attempted}") + model_name: Final = f"credential-model-{uuid.uuid4().hex}" + created_model: Final = s.gateway.post( + "/model/new", + { + "model_name": model_name, + "litellm_params": { + "model": "openai/gpt-4o-mini", + "api_base": f"{s.gateway.upstream_url}/v1", + "litellm_credential_name": name, + }, + }, + ) + model_id: Final = string_value(object_value(created_model["model_info"])["id"]) + s.scenario.cleanups.callback(_delete_model_if_present, s.gateway, model_id) + credential_for_model: Final = s.gateway.request("GET", f"/credentials/by_model/{model_id}") + assert credential_for_model.status_code == 200, credential_for_model.text + assert credential_for_model.json() == { + "credential_name": f"{model_name}-credential-{model_id}", + "credential_values": {"api_base": f"{s.gateway.upstream_url}/v1"}, + "credential_info": {}, + }, credential_for_model.text + stored: Final = _credential_row(name) + assert len(stored) == 1, stored + attempts: Final = ( + Call( + "POST", + "/credentials", + {"credential_name": attempted, "credential_values": {"api_key": "k"}, "credential_info": {}}, + ), + Call( + "PATCH", + f"/credentials/{name}", + {"credential_name": name, "credential_values": {"api_key": "overwritten"}, "credential_info": {}}, + ), + Call("DELETE", f"/credentials/{name}"), + Call("GET", "/credentials"), + Call("GET", f"/credentials/by_name/{name}"), + Call("GET", f"/credentials/by_model/{model_id}"), + ) + for call in attempts: + response = s.gateway.request(call.method, call.path, call.body, key=s.keys[caller]) + assert (response.status_code, _refusal(response)) == _admin_only_route(call.path), ( + f"{caller} {call.method} {call.path}: {response.text}" + ) + assert secret not in response.text, f"{caller} {call.method} {call.path} exposed the credential" + assert _credential_row(name) == stored, f"{caller} {call.method} {call.path} changed the credential row" + assert _credential_row(attempted) == [], f"{caller} {call.method} {call.path} wrote a credential" + + +@pytest.mark.parametrize( + ("fixture", "caller"), + (("shared", "member"), ("shared", "outsider"), ("org_team", "org_admin")), + ids=("member", "outsider", "org_admin"), +) +def test_non_team_admin_patching_the_blocked_flag_gets_the_blocked_flag_refusal( + request: pytest.FixtureRequest, fixture: str, caller: Caller +) -> None: + pytest.skip( + 'BUG: PATCH /model/{id}/update {"blocked": ...} by a non-team-admin returns the auto-router refusal ' + "instead of the blocked-flag refusal (https://github.com/BerriAI/litellm/issues/44792)" + ) + with gateway_from_environment() as gateway, gateway.scenario() as scenario: + s: Final = _admin_only_team(request, fixture, scenario) + model_id, _ = _team_model(s) + response: Final = s.gateway.request("PATCH", f"/model/{model_id}/update", {"blocked": True}, key=s.keys[caller]) + assert (response.status_code, _refusal(response)) == _PATCH_BLOCKED_REFUSED, response.text + assert _blocked(model_id) == [{"blocked": False}], response.text diff --git a/tests/integration/configuration/test_effective_settings.py b/tests/integration/configuration/test_effective_settings.py index 8e164acbe03..52f010843e3 100644 --- a/tests/integration/configuration/test_effective_settings.py +++ b/tests/integration/configuration/test_effective_settings.py @@ -1,11 +1,14 @@ +import json import uuid from typing import Final import httpx import pytest +from pydantic import BaseModel, JsonValue from tests.integration._support.client import Gateway, object_value, string_value from tests.integration._support.database import read_rows +from tests.integration._support.wire import Reply, Request, Wire, wire_server def model_identity(gateway: Gateway, alias: str) -> str: @@ -24,11 +27,12 @@ def test_model_block_changes_actual_route_and_leaves_other_route_working(gateway gateway.chat(model) gateway.chat(other) gateway.post("/model/block", {"model_id": identity}) - assert read_rows( - 'SELECT blocked FROM "LiteLLM_ProxyModelTable" WHERE model_id = %s', (identity,) - ) == [{"blocked": True}] + assert read_rows('SELECT blocked FROM "LiteLLM_ProxyModelTable" WHERE model_id = %s', (identity,)) == [ + {"blocked": True} + ] response: Final = gateway.request( - "POST", "/v1/chat/completions", + "POST", + "/v1/chat/completions", {"model": model, "messages": [{"role": "user", "content": "blocked deployment"}]}, ) assert response.status_code == 403, response.text @@ -36,15 +40,18 @@ def test_model_block_changes_actual_route_and_leaves_other_route_working(gateway assert response.json()["error"]["message"] == "litellm.PermissionDeniedError: Model is blocked" assert object_value(gateway.chat(other)["usage"])["total_tokens"] == 40 gateway.post("/model/unblock", {"model_id": identity}) - assert read_rows( - 'SELECT blocked FROM "LiteLLM_ProxyModelTable" WHERE model_id = %s', (identity,) - ) == [{"blocked": False}] + assert read_rows('SELECT blocked FROM "LiteLLM_ProxyModelTable" WHERE model_id = %s', (identity,)) == [ + {"blocked": False} + ] assert object_value(gateway.chat(model)["usage"])["total_tokens"] == 40 @pytest.mark.covers("mgmt.router_settings.update.changes_observed_attempt_count") def test_saved_retry_setting_controls_real_attempts_and_restores(gateway: Gateway) -> None: - with httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as upstream, gateway.scenario() as scenario: + with ( + httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as upstream, + gateway.scenario() as scenario, + ): original: Final = object_value(gateway.get("/router/settings")["current_values"])["num_retries"] provider_model: Final = f"retry-{uuid.uuid4().hex}" model: Final = scenario.model(model=f"openai/{provider_model}", input_cost_per_token=0, output_cost_per_token=0) @@ -63,8 +70,12 @@ def test_saved_retry_setting_controls_real_attempts_and_restores(gateway: Gatewa assert configured.status_code == 200, configured.text upstream.get("/__observations").raise_for_status() response: Final = gateway.request( - "POST", "/v1/chat/completions", - {"model": model, "messages": [{"role": "user", "content": f"{provider_model} attempt {generation}"}]}, + "POST", + "/v1/chat/completions", + { + "model": model, + "messages": [{"role": "user", "content": f"{provider_model} attempt {generation}"}], + }, ) observed: Final = upstream.get("/__observations") observed.raise_for_status() @@ -82,28 +93,210 @@ def test_saved_retry_setting_controls_real_attempts_and_restores(gateway: Gatewa assert object_value(gateway.get("/router/settings")["current_values"])["num_retries"] == original +class _MaskedCredential(BaseModel): + credential_name: str + credential_info: dict[str, JsonValue] + credential_values: dict[str, JsonValue] + + +def _openai_reply(request: Request) -> Reply: + assert (request.method, request.target) == ("POST", "/v1/chat/completions"), request + return Reply( + body=json.dumps( + { + "id": "chatcmpl-credential-merge", + "object": "chat.completion", + "created": 0, + "model": "gpt-4o-mini", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, + } + ).encode() + ) + + +def _stored_credential_values(name: str) -> dict[str, object]: + rows: Final = read_rows( + 'SELECT credential_values FROM "LiteLLM_CredentialsTable" WHERE credential_name = %s', (name,) + ) + assert len(rows) == 1, rows + return object_value(rows[0]["credential_values"]) + + +def _masked_credential(gateway: Gateway, name: str) -> _MaskedCredential: + response: Final = gateway.request("GET", f"/credentials/by_name/{name}") + assert response.status_code == 200, response.text + return _MaskedCredential.model_validate_json(response.content) + + +def _patch_credential(gateway: Gateway, name: str, body: dict[str, JsonValue]) -> None: + response: Final = gateway.request("PATCH", f"/credentials/{name}", {"credential_name": name, **body}) + assert response.status_code == 200, response.text + assert response.json() == {"success": True, "message": "Credential updated successfully"}, response.text + + +def _provider_call(gateway: Gateway, model: str, text: str, *wires: Wire) -> tuple[tuple[Request, ...], ...]: + for wire in wires: + wire.drain() + response: Final = gateway.request( + "POST", "/v1/chat/completions", {"model": model, "messages": [{"role": "user", "content": text}]} + ) + assert response.status_code == 200, response.text + assert object_value(response.json()["usage"])["total_tokens"] == 2, response.text + return tuple(wire.drain() for wire in wires) + + +def _sent_headers(request: Request) -> tuple[str, str | None]: + return request.headers["authorization"], request.headers.get("openai-organization") + + +@pytest.mark.parametrize("contains_slash", (False, True), ids=("plain-name", "slash-name")) +def test_partial_credential_patch_merges_values_and_deletes_only_the_named_field( + gateway: Gateway, contains_slash: bool +) -> None: + api_key: Final = f"synthetic-merge-{uuid.uuid4().hex}" + with ( + wire_server(_openai_reply) as original, + wire_server(_openai_reply) as moved, + gateway.scenario() as scenario, + ): + name: Final = ( + f"team-{uuid.uuid4().hex}/cred-{uuid.uuid4().hex}" if contains_slash else f"credential-{uuid.uuid4().hex}" + ) + gateway.post( + "/credentials", + { + "credential_name": name, + "credential_values": { + "api_key": api_key, + "api_base": f"{original.url}/v1", + "organization": "org-merge", + }, + "credential_info": {}, + }, + ) + scenario.cleanups.callback(gateway.request, "DELETE", f"/credentials/{name}") + model: Final = scenario.model(api_key=None, api_base=None, litellm_credential_name=name) + first, untouched = _provider_call(gateway, model, "before patch", original, moved) + assert [_sent_headers(request) for request in first] == [(f"Bearer {api_key}", "org-merge")] + assert untouched == () + stored: Final = _stored_credential_values(name) + assert sorted(stored) == ["api_base", "api_key", "organization"], stored + + _patch_credential(gateway, name, {"credential_values": {"api_base": f"{moved.url}/v1"}, "credential_info": {}}) + merged: Final = _stored_credential_values(name) + assert sorted(merged) == ["api_base", "api_key", "organization"], merged + assert (merged["api_key"], merged["organization"]) == (stored["api_key"], stored["organization"]), merged + assert merged["api_base"] != stored["api_base"], merged + assert _masked_credential(gateway, name) == _MaskedCredential( + credential_name=name, + credential_info={}, + credential_values={ + "api_key": "sy****" + api_key[-2:], + "api_base": f"{moved.url}/v1", + "organization": "org-merge", + }, + ) + old_base, new_base = _provider_call(gateway, model, "after api_base patch", original, moved) + assert old_base == () + assert [(request.method, request.target) for request in new_base] == [("POST", "/v1/chat/completions")] + assert [_sent_headers(request) for request in new_base] == [(f"Bearer {api_key}", "org-merge")] + + _patch_credential(gateway, name, {"credential_values_to_delete": ["organization"], "credential_info": {}}) + pruned: Final = _stored_credential_values(name) + assert pruned == {"api_key": merged["api_key"], "api_base": merged["api_base"]}, pruned + assert _masked_credential(gateway, name) == _MaskedCredential( + credential_name=name, + credential_info={}, + credential_values={"api_key": "sy****" + api_key[-2:], "api_base": f"{moved.url}/v1"}, + ) + old_base_after_delete, after_delete = _provider_call(gateway, model, "after field delete", original, moved) + assert old_base_after_delete == () + assert [_sent_headers(request) for request in after_delete] == [(f"Bearer {api_key}", None)] + + +def test_dashboard_credential_edit_keeps_the_stripped_secret_and_round_trips_credential_info(gateway: Gateway) -> None: + api_key: Final = f"synthetic-dashboard-{uuid.uuid4().hex}" + info: Final[dict[str, JsonValue]] = {"custom_llm_provider": "openai"} + with ( + wire_server(_openai_reply) as original, + wire_server(_openai_reply) as moved, + gateway.scenario() as scenario, + ): + name: Final = f"credential-{uuid.uuid4().hex}" + gateway.post( + "/credentials", + { + "credential_name": name, + "credential_values": {"api_key": api_key, "api_base": f"{original.url}/v1", "organization": "org-dash"}, + "credential_info": info, + }, + ) + scenario.cleanups.callback(gateway.request, "DELETE", f"/credentials/{name}") + model: Final = scenario.model(api_key=None, api_base=None, litellm_credential_name=name) + stored: Final = _stored_credential_values(name) + + _patch_credential( + gateway, + name, + { + "credential_values": {"api_base": f"{moved.url}/v1", "organization": "org-dash"}, + "credential_info": info, + }, + ) + edited: Final = _stored_credential_values(name) + assert sorted(edited) == ["api_base", "api_key", "organization"], edited + assert edited["api_key"] == stored["api_key"], edited + assert read_rows( + 'SELECT credential_info FROM "LiteLLM_CredentialsTable" WHERE credential_name = %s', (name,) + ) == [{"credential_info": info}] + assert _masked_credential(gateway, name) == _MaskedCredential( + credential_name=name, + credential_info=info, + credential_values={ + "api_key": "sy****" + api_key[-2:], + "api_base": f"{moved.url}/v1", + "organization": "org-dash", + }, + ) + old_base, new_base = _provider_call(gateway, model, "after dashboard edit", original, moved) + assert old_base == () + assert [_sent_headers(request) for request in new_base] == [(f"Bearer {api_key}", "org-dash")] + + @pytest.mark.covers("mgmt.credential.update.saved_value_reaches_wire") def test_credential_value_update_and_model_reload_reach_provider(gateway: Gateway) -> None: - with gateway.scenario() as scenario, httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as upstream: + with ( + gateway.scenario() as scenario, + httpx.Client(base_url=gateway.upstream_url, timeout=5, trust_env=False) as upstream, + ): name: Final = f"credential-{uuid.uuid4().hex}" - gateway.post("/credentials", { - "credential_name": name, "credential_values": {"api_key": "synthetic-credential-first"}, "credential_info": {} - }) + gateway.post( + "/credentials", + { + "credential_name": name, + "credential_values": {"api_key": "synthetic-credential-first"}, + "credential_info": {}, + }, + ) def remove_credential() -> None: response: Final = gateway.request("DELETE", f"/credentials/{name}") assert response.status_code == 200, response.text - assert read_rows( - 'SELECT credential_name FROM "LiteLLM_CredentialsTable" WHERE credential_name = %s', (name,) - ) == [] + assert ( + read_rows('SELECT credential_name FROM "LiteLLM_CredentialsTable" WHERE credential_name = %s', (name,)) + == [] + ) scenario.cleanups.callback(remove_credential) model: Final = scenario.model(api_key=None, litellm_credential_name=name) identity: Final = model_identity(gateway, model) for value in ("synthetic-credential-first", "synthetic-credential-second"): - patched: Final = gateway.request("PATCH", f"/credentials/{name}", { - "credential_name": name, "credential_values": {"api_key": value}, "credential_info": {} - }) + patched: Final = gateway.request( + "PATCH", + f"/credentials/{name}", + {"credential_name": name, "credential_values": {"api_key": value}, "credential_info": {}}, + ) assert patched.status_code == 200, patched.text rows: Final = read_rows( 'SELECT credential_values FROM "LiteLLM_CredentialsTable" WHERE credential_name = %s', (name,) @@ -113,10 +306,15 @@ def test_credential_value_update_and_model_reload_reach_provider(gateway: Gatewa assert isinstance(stored["api_key"], str) and stored["api_key"] != value for reload in (False, True): if reload: - response: Final = gateway.request("PATCH", f"/model/{identity}/update", {"model_info": {"description": value}}) + response: Final = gateway.request( + "PATCH", f"/model/{identity}/update", {"model_info": {"description": value}} + ) assert response.status_code == 200, response.text upstream.get("/__observations").raise_for_status() - assert object_value(gateway.chat(model, text=f"{name} {value} reload={reload}")["usage"])["total_tokens"] == 40 + assert ( + object_value(gateway.chat(model, text=f"{name} {value} reload={reload}")["usage"])["total_tokens"] + == 40 + ) observed: Final = upstream.get("/__observations") observed.raise_for_status() assert len(observed.json()["requests"]) == 1, (value, reload, observed.text) diff --git a/tests/integration/management/test_model_rename_propagation.py b/tests/integration/management/test_model_rename_propagation.py new file mode 100644 index 00000000000..8bf1637c420 --- /dev/null +++ b/tests/integration/management/test_model_rename_propagation.py @@ -0,0 +1,326 @@ +"""Renaming a database model carries every ``models`` allowlist that named it over to the new name. + +Keys, teams, organizations, projects and users store public model names, and access groups store them as +``access_model_names``. A caller restricted through any one of them must reach the renamed model under its +new name, lose the old one, and see only the new name in ``/v1/models``. +""" + +import json +import uuid +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from itertools import chain +from typing import Final + +import httpx +import pytest +from pydantic import BaseModel, JsonValue, TypeAdapter + +from integration._support.client import Gateway, Scenario, eventually, object_value, string_value +from integration._support.database import read_rows +from integration._support.wire import Reply, Request, Wire, wire_server + +_PROVIDER_MODEL: Final = "gpt-4o-mini" +_PROVIDER_API_KEY: Final = "integration-provider-key" +_CALLER_KINDS: Final = ("key", "team", "organization", "project", "user", "access_group") +_JSON_BODY: Final = TypeAdapter(dict[str, JsonValue]) + + +@dataclass(frozen=True, slots=True) +class _Holder: + kind: str + table: str + id_column: str + object_id: str + key: str + listing_is_scoped: bool = True + + +class _AccessGroup(BaseModel): + access_group_id: str + access_model_names: list[str] + + +class _Usage(BaseModel): + total_tokens: int + + +class _Completion(BaseModel): + model: str + usage: _Usage + + +class _Error(BaseModel): + message: str + type: str + param: str | None + code: str | None + + +@dataclass(frozen=True, slots=True) +class _RenameCall: + kind: str + status_code: int + error: _Error + requests: tuple[Request, ...] + + +def _model_id(gateway: Gateway, name: str) -> str: + entries: Final = gateway.get("/model/info")["data"] + assert isinstance(entries, list) + return next( + string_value(object_value(entry["model_info"])["id"]) + for entry in map(object_value, entries) + if entry["model_name"] == name + ) + + +@contextmanager +def _access_group(gateway: Gateway, team_id: str, model: str) -> Iterator[str]: + created: Final = gateway.request( + "POST", + "/v1/access_group", + { + "access_group_name": f"integration-{uuid.uuid4().hex}", + "access_model_names": [model], + "assigned_team_ids": [team_id], + }, + ) + assert created.status_code == 201, created.text + identity: Final = string_value(created.json()["access_group_id"]) + try: + yield identity + finally: + deleted: Final = gateway.request("DELETE", f"/v1/access_group/{identity}") + assert deleted.status_code == 204, deleted.text + + +def _holders(scenario: Scenario, old: str) -> tuple[_Holder, ...]: + team: Final = scenario.team(models=[old]) + organization: Final = scenario.organization(models=[old]) + org_team: Final = scenario.team(organization_id=organization, models=[old]) + project_team: Final = scenario.team() + project: Final = scenario.project(project_team, models=[old]) + user: Final = scenario.user(user_role="internal_user", models=[old]) + key: Final = scenario.key(models=[old]) + return ( + _Holder("key", "LiteLLM_VerificationToken", "key_alias", "", key), + _Holder("team", "LiteLLM_TeamTable", "team_id", team, scenario.key(team_id=team)), + _Holder( + "organization", + "LiteLLM_OrganizationTable", + "organization_id", + organization, + scenario.key(team_id=org_team, organization_id=organization), + ), + _Holder( + "project", + "LiteLLM_ProjectTable", + "project_id", + project, + scenario.key(team_id=project_team, project_id=project), + listing_is_scoped=False, + ), + _Holder("user", "LiteLLM_UserTable", "user_id", user, scenario.key(user_id=user), listing_is_scoped=False), + ) + + +def _allowlist(holder: _Holder) -> list[dict[str, object]]: + if holder.kind == "key": + return read_rows( + "SELECT models FROM \"LiteLLM_VerificationToken\" WHERE token = encode(sha256(%s::bytea), 'hex')", + (holder.key,), + ) + return read_rows( + f'SELECT models FROM "{holder.table}" WHERE "{holder.id_column}" = %s', # noqa: S608 # table and column come from the fixed _holders tuple, never from input + (holder.object_id,), + ) + + +def _chat(gateway: Gateway, model: str, caller: _Holder) -> httpx.Response: + return gateway.request( + "POST", + "/v1/chat/completions", + {"model": model, "messages": [{"role": "user", "content": f"after rename {caller.kind}"}]}, + key=caller.key, + ) + + +def _expected_provider_body(text: str) -> dict[str, JsonValue]: + return {"model": _PROVIDER_MODEL, "messages": [{"role": "user", "content": text}]} + + +def _is_model_discovery(request: Request) -> bool: + return (request.method, request.target) == ("GET", "/v1/models") + + +def _provider_calls(wire: Wire) -> tuple[Request, ...]: + return tuple(entry for entry in wire.drain() if not _is_model_discovery(entry)) + + +def _rename_reply(request: Request) -> Reply: + if _is_model_discovery(request): + return Reply(body=json.dumps({"object": "list", "data": []}).encode()) + assert (request.method, request.target) == ("POST", "/v1/chat/completions"), request + assert request.headers["authorization"] == f"Bearer {_PROVIDER_API_KEY}", request + assert _JSON_BODY.validate_json(request.body) in tuple( + _expected_provider_body(f"after rename {kind}") for kind in _CALLER_KINDS + ), request.body + return Reply( + body=json.dumps( + { + "id": "chatcmpl-rename", + "object": "chat.completion", + "created": 1, + "model": _PROVIDER_MODEL, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "renamed"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 20, "completion_tokens": 20, "total_tokens": 40}, + } + ).encode() + ) + + +def _listed(gateway: Gateway, key: str) -> list[str]: + response: Final = gateway.request("GET", "/v1/models", key=key) + assert response.status_code == 200, response.text + entries: Final = response.json()["data"] + assert isinstance(entries, list), response.text + return sorted(string_value(object_value(entry)["id"]) for entry in entries) + + +def _assert_listing(gateway: Gateway, caller: _Holder, *, served: str, gone: str | None) -> None: + def converged(listed: list[str]) -> bool: + if caller.listing_is_scoped: + return listed == [served] + # Project and user allowlists gate calls but not /v1/models, which then lists every proxy model. + return served in listed and gone not in listed + + eventually(lambda: _listed(gateway, caller.key), converged) + + +def _served(gateway: Gateway, model: str, caller: _Holder) -> httpx.Response: + served: Final = eventually( + lambda: _chat(gateway, model, caller), lambda r: r.status_code == 200, return_last_on_timeout=True + ) + assert served.status_code == 200, f"{caller.kind}: {served.text}" + return served + + +def _baseline_call(gateway: Gateway, model: str, caller: _Holder, wire: Wire) -> tuple[Request, ...]: + served: Final = _served(gateway, model, caller) + assert _Completion.model_validate_json(served.content) == _Completion(model=model, usage=_Usage(total_tokens=40)), ( + f"{caller.kind}: {served.text}" + ) + _assert_listing(gateway, caller, served=model, gone=None) + requests: Final = _provider_calls(wire) + assert len(requests) == 1, f"{caller.kind}: expected one provider request, received {requests!r}" + return requests + + +def _renamed_call( + gateway: Gateway, + old: str, + new: str, + caller: _Holder, + wire: Wire, +) -> _RenameCall: + served: Final = _served(gateway, new, caller) + assert _Completion.model_validate_json(served.content) == _Completion(model=new, usage=_Usage(total_tokens=40)), ( + f"{caller.kind}: {served.text}" + ) + requests: Final = _provider_calls(wire) + assert len(requests) == 1, f"{caller.kind}: expected one provider request, received {requests!r}" + refused: Final = _chat(gateway, old, caller) + assert refused.status_code == 403, f"{caller.kind}: {refused.text}" + error: Final = _Error.model_validate(object_value(refused.json())["error"]) + assert _provider_calls(wire) == (), f"{caller.kind}: the old name reached the provider" + _assert_listing(gateway, caller, served=new, gone=old) + return _RenameCall(caller.kind, refused.status_code, error, requests) + + +@pytest.mark.parametrize("rename_style", ("patch", "legacy_post")) +def test_renamed_model_is_reachable_by_its_new_name_through_every_allowlist_that_named_it( + gateway: Gateway, rename_style: str +) -> None: + with wire_server(_rename_reply) as wire, gateway.scenario() as scenario: + old: Final = scenario.model(api_base=f"{wire.url}/v1", api_key=_PROVIDER_API_KEY) + model_id: Final = _model_id(gateway, old) + holders: Final = _holders(scenario, old) + access_team: Final = scenario.team(models=["no-default-models"]) + access_key: Final = scenario.key(team_id=access_team) + with _access_group(gateway, access_team, old) as access_group_id: + callers: Final = (*holders, _Holder("access_group", "", "", access_group_id, access_key)) + baseline_requests: Final = tuple(_baseline_call(gateway, old, caller, wire) for caller in callers) + + new: Final = f"integration-renamed-{uuid.uuid4().hex}" + renamed: Final = ( + gateway.request("PATCH", f"/model/{model_id}/update", {"model_name": new}) + if rename_style == "patch" + else gateway.request( + "POST", + "/model/update", + {"model_name": new, "model_info": {"id": model_id}, "litellm_params": {}}, + ) + ) + assert renamed.status_code == 200, renamed.text + assert read_rows('SELECT model_name FROM "LiteLLM_ProxyModelTable" WHERE model_id = %s', (model_id,)) == [ + {"model_name": new} + ] + + results: Final = tuple(_renamed_call(gateway, old, new, caller, wire) for caller in callers) + for holder in holders: + assert _allowlist(holder) == [{"models": [new]}], holder.kind + group: Final = gateway.request("GET", f"/v1/access_group/{access_group_id}") + assert group.status_code == 200, group.text + assert _AccessGroup.model_validate_json(group.content) == _AccessGroup( + access_group_id=access_group_id, access_model_names=[new] + ), group.text + + denied_message: Final = ( + f"The requested model '{old}' is not available for this API key, or the model name is invalid. " + "Check the models available to you and try again." + ) + expected_denials: Final = { + "key": _Error(message=denied_message, type="key_model_access_denied", param="model", code="403"), + "team": _Error(message=denied_message, type="team_model_access_denied", param="model", code="403"), + "organization": _Error( + message=denied_message, type="team_model_access_denied", param="model", code="403" + ), + "project": _Error(message=denied_message, type="project_model_access_denied", param="model", code="403"), + "user": _Error(message=denied_message, type="user_model_access_denied", param="model", code="403"), + "access_group": _Error( + message=denied_message, type="team_model_access_denied", param="model", code="403" + ), + } + assert tuple(result.status_code for result in results) == (403,) * len(callers) + assert {result.kind: result.error for result in results} == expected_denials, json.dumps( + {result.kind: result.error.model_dump() for result in results} + ) + provider_requests: Final = tuple( + chain.from_iterable(baseline_requests) + ) + tuple(chain.from_iterable(result.requests for result in results)) + expected_request: Final = ( + "POST", + "/v1/chat/completions", + f"Bearer {_PROVIDER_API_KEY}", + ) + expected_requests: Final = tuple( + (*expected_request, _expected_provider_body(f"after rename {caller.kind}")) + for caller in callers + ) * 2 + assert tuple( + ( + request.method, + request.target, + request.headers["authorization"], + _JSON_BODY.validate_json(request.body), + ) + for request in provider_requests + ) == expected_requests diff --git a/tests/integration/security/test_stored_config_slots.py b/tests/integration/security/test_stored_config_slots.py index 8b4d1574eb4..b2386306e42 100644 --- a/tests/integration/security/test_stored_config_slots.py +++ b/tests/integration/security/test_stored_config_slots.py @@ -1,6 +1,7 @@ """Stored-config slots: credentials the proxy holds in its env, config or database reach only their owner. -Slots: A1 (virtual key raw value), A2 (master key), B2 (deployment ``api_key`` via ``/model/new``), +Slots: A1 (virtual key raw value), A2 (master key), B2 (deployment ``api_key`` via ``/model/new``, kept through a +partial ``POST /model/update`` and rotated through ``PATCH /model/{id}/update``), B3 (``/credentials`` entry named by ``litellm_credential_name``), B4 (deployment ``aws_secret_access_key``), B4v and B4t (Vertex service-account JSON and the access token minted for it), B5 (team ``model_config`` credential override), E1 (guardrail ``api_key`` from config), @@ -43,6 +44,7 @@ from integration._support.client import Gateway, Scenario, eventually, object_va from integration._support.database import read_rows from integration._support.sigv4 import encoded_path, signature from integration._support.wire import Reply, Request, wire_server +from pydantic import BaseModel, JsonValue from integration.security._canary import MARKER, Canary, canary, find_canary from integration.security._sinks import ( CONFIG_MODEL, @@ -233,6 +235,27 @@ def _bearer(rig: Rig, marker: Canary, secret: Canary) -> None: ) +def _provider_chat_matches(rig: Rig, marker: Canary, secret: Canary, text: str) -> bool: + delivered: Final = rig.provider.carrying(marker.value) + actual: Final = tuple( + ( + entry.method, + entry.target, + entry.headers.get("authorization"), + json.loads(entry.body), + ) + for entry in delivered + ) + return actual == ( + ( + "POST", + "/v1/chat/completions", + f"Bearer {secret.value}", + {"model": "gpt-4o-mini", "messages": [{"role": "user", "content": text}]}, + ), + ) + + @pytest.mark.timeout(240) @pytest.mark.parametrize("outcome", OUTCOMES) def test_virtual_key_raw_value_authenticates_and_is_stored_only_as_a_hash( @@ -339,6 +362,141 @@ def test_model_api_key_added_through_the_api_reaches_only_the_provider( ) +def _stored_litellm_params(model_id: str) -> dict[str, object]: + rows: Final = read_rows('SELECT litellm_params FROM "LiteLLM_ProxyModelTable" WHERE model_id=%s', (model_id,)) + assert len(rows) == 1, rows + return object_value(rows[0]["litellm_params"]) + + +class _ServedParams(BaseModel): + model_name: str + litellm_params: dict[str, JsonValue] + + +def _served_params(gateway: Gateway, model_id: str) -> _ServedParams: + response: Final = gateway.request("GET", "/model/info", params={"litellm_model_id": model_id}) + assert response.status_code == 200, response.text + entries: Final = response.json()["data"] + assert isinstance(entries, list) and len(entries) == 1, response.text + return _ServedParams.model_validate(entries[0]) + + +def _served_openai_params(api_base: str, **changed: JsonValue) -> dict[str, JsonValue]: + return { + "model": "openai/gpt-4o-mini", + "api_base": api_base, + "allow_client_keepalive_override": False, + "merge_reasoning_content_in_choices": False, + "use_in_pass_through": False, + "use_litellm_proxy": False, + "use_xai_oauth": False, + **changed, + } + + +@pytest.mark.timeout(240) +@pytest.mark.parametrize( + ("partial_params", "expected_params"), + ( + pytest.param({"rpm": 10}, {"rpm": 10}, id="rpm"), + pytest.param({"timeout": 30}, {"timeout": 30.0}, id="timeout"), + ), +) +def test_partial_legacy_model_update_keeps_the_stored_api_key_usable( + rig: Rig, partial_params: dict[str, JsonValue], expected_params: dict[str, JsonValue] +) -> None: + b2: Final = canary("B2") + marker: Final = canary(MARKER) + api_base: Final = rig.provider.url + "/v1" + with rig.proxy.scenario() as scenario: + model, model_id = _model(scenario, {"model": "openai/gpt-4o-mini", "api_base": api_base, "api_key": b2.value}) + stored_before: Final = _stored_litellm_params(model_id) + updated: Final = rig.proxy.request( + "POST", + "/model/update", + {"model_name": model, "model_info": {"id": model_id}, "litellm_params": partial_params}, + ) + assert updated.status_code == 200, updated.text + assert find_canary(updated.text, (b2,)) == (), f"/model/update echoed the stored key: {updated.text}" + stored_after: Final = _stored_litellm_params(model_id) + assert stored_after == {**stored_before, **expected_params}, ( + "A partial update rewrote stored params it was not sent, the api_key ciphertext included" + ) + assert tuple((key, type(stored_after[key]), stored_after[key]) for key in expected_params) == tuple( + (key, type(value), value) for key, value in expected_params.items() + ), stored_after + _assert_stored_without_canary( + """SELECT litellm_params->>'api_key' FROM "LiteLLM_ProxyModelTable" WHERE model_id=%s""", (model_id,), b2 + ) + served_params: Final = _served_params(rig.proxy, model_id) + assert served_params == _ServedParams( + model_name=model, litellm_params=_served_openai_params(api_base, **expected_params) + ) + assert tuple( + (key, type(served_params.litellm_params[key]), served_params.litellm_params[key]) + for key in expected_params + ) == tuple((key, type(value), value) for key, value in expected_params.items()), served_params + caller: Final = _caller(scenario, models=[model]) + _chat(rig.proxy, caller.key, model, "B2", marker, "success") + assert _provider_chat_matches(rig, marker, b2, f"slot B2 {marker.value}"), ( + "The partial legacy update did not preserve the exact provider request" + ) + + +@pytest.mark.timeout(240) +def test_patched_model_api_key_rotates_the_provider_bearer_and_is_never_stored_or_echoed( + rig: Rig, request: pytest.FixtureRequest +) -> None: + started: Final = datetime.now(UTC) + original: Final = canary("B2") + rotated: Final = canary("B2") + marker: Final = canary(MARKER) + api_base: Final = rig.provider.url + "/v1" + with rig.proxy.scenario() as scenario: + model, model_id = _model( + scenario, {"model": "openai/gpt-4o-mini", "api_base": api_base, "api_key": original.value} + ) + stored_before: Final = _stored_litellm_params(model_id) + patched: Final = rig.proxy.request( + "PATCH", f"/model/{model_id}/update", {"litellm_params": {"api_key": rotated.value}} + ) + assert patched.status_code == 200, patched.text + assert find_canary(patched.text, (original, rotated)) == (), f"PATCH echoed a provider key: {patched.text}" + _assert_stored_without_canary( + """SELECT litellm_params->>'api_key' FROM "LiteLLM_ProxyModelTable" WHERE model_id=%s""", + (model_id,), + rotated, + ) + stored_after: Final = _stored_litellm_params(model_id) + assert stored_after["api_key"] != stored_before["api_key"], "The rotated key was never written" + assert {k: v for k, v in stored_after.items() if k != "api_key"} == { + k: v for k, v in stored_before.items() if k != "api_key" + } + assert _served_params(rig.proxy, model_id) == _ServedParams( + model_name=model, litellm_params=_served_openai_params(api_base) + ) + caller: Final = _caller(scenario, models=[model]) + response, request_id = _chat(rig.proxy, caller.key, model, "B2", marker, "success") + assert _provider_chat_matches(rig, marker, rotated, f"slot B2 {marker.value}"), ( + "The rotated key did not produce the exact provider request" + ) + _finish( + rig, + rig.proxy, + request, + secrets=(original, rotated), + marker=marker, + response=response, + request_id=request_id, + caller=caller, + ids={"model_id": model_id, "model": model}, + detail_routes=(f"/credentials/by_model/{model_id}",), + reads=_reads(rig.proxy, {"/model/info": {"litellm_model_id": model_id}}), + context="slot B2 rotated through PATCH", + since=started, + ) + + @pytest.mark.timeout(240) @pytest.mark.parametrize("outcome", OUTCOMES) def test_named_credential_reaches_only_the_provider(rig: Rig, outcome: str, request: pytest.FixtureRequest) -> None: