test(integration): cover model_management endpoint contracts (#44684)

* test(integration): cover model_management endpoint contracts

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): pin the exact model access refusal after a rename

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): wait for allowlist convergence in the rename test

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): give the rename test lint suppression its reason

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): close reviewer gaps on model management contracts

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): share the blocked serving assertions

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): strengthen authorization and configuration regression tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): converge spend readbacks, ignore model discovery, cover slash credential names

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): pin the dashboard credential edit body round-trip

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: mateo <mateo@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-14 03:36:47 +00:00 • committed by GitHub
parent 925c0a9586
commit 013c440597
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 1514 additions and 28 deletions

View file

@ -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}",
)

View file

@ -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=<caller>", 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=<caller>",
"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

View file

@ -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)

View file

@ -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

View file

@ -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: