Merge pull request #39917 from BerriAI/litellm_e2e_key_mgmt_route_group_coverage

test(e2e): cover key spend reset, regenerate grace period, and the llm_api_routes grant
This commit is contained in:
yuneng-jiang 2026-09-05 12:30:59 -07:00 committed by GitHub
commit c6399b5728
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 140 additions and 4 deletions

View file

@ -24,7 +24,7 @@ from access_control_client import (
from e2e_config import unique_marker
from e2e_http import Success, UnauthorizedError, UnknownApiError, unwrap
from lifecycle import ResourceManager
from models import ChatBody, ChatMessage, ChatResponse, LiteLLMParamsBody
from models import ChatBody, ChatMessage, ChatResponse, EmbedBody, LiteLLMParamsBody
from proxy_client import ProxyClient
pytestmark = pytest.mark.e2e
@ -32,6 +32,7 @@ pytestmark = pytest.mark.e2e
ALLOWED_MODEL = "gemini-2.5-flash"
DISALLOWED_MODEL = "gpt-5.5"
VIRTUAL_KEY_BACKEND = "anthropic/claude-haiku-4-5-20251001"
EMBEDDING_MODEL = "openai-text-embedding-3-small"
class TestAccessControl:
@ -71,6 +72,31 @@ class TestAccessControl:
f"403 body must be a model-access denial, got: {result.body[:300]}"
)
@pytest.mark.covers("other.auth.virtual_key.route_group_allowed")
def test_llm_api_routes_group_grants_every_llm_endpoint(
self, client: AccessControlClient, resources: ResourceManager
) -> None:
key = client.llm_only_key()
resources.defer(lambda: client.delete_key(key))
chat = client.chat_status(key, ALLOWED_MODEL, f"capital of France? {unique_marker()}")
assert chat.status_code == 200, (
f"llm_api_routes key must reach /chat/completions, got {chat.status_code}: {chat.body[:300]}"
)
assert ChatResponse.model_validate_json(chat.body).choices, (
f"200 must carry a real completion, not an error envelope: {chat.body[:300]}"
)
embedding = unwrap(
client.proxy.embed(key, EmbedBody(model=EMBEDDING_MODEL, input=f"route group {unique_marker()}"))
)
assert embedding.model, f"llm_api_routes key reached /embeddings but got no model back: {embedding}"
denied = client.create_model_status(key, f"e2e-route-group-{unique_marker()}")
assert denied.status_code == 403 and ROUTE_NOT_ALLOWED_MARKER in denied.body, (
f"the same key must still be shut out of /model/new, got {denied.status_code}: {denied.body[:300]}"
)
def test_llm_only_key_forbidden_from_management_route_403(
self, client: AccessControlClient, resources: ResourceManager
) -> None:

View file

@ -40,6 +40,8 @@ from models import (
KeyListParams,
KeyListResponse,
KeyRegenerateBody,
KeyResetSpendBody,
KeyResetSpendResponse,
KeyUpdateBody,
ModelDeleteBody,
OrgDeleteBody,
@ -191,16 +193,26 @@ class ManagementClient:
response_type=NoBody,
)
)
def regenerate_key(self, key: str) -> str:
def regenerate_key(self, key: str, *, grace_period: str | None = None) -> str:
return unwrap(
self.proxy.transport.post(
"/key/regenerate",
headers=self.proxy.transport.master,
json=KeyRegenerateBody(key=key),
json=KeyRegenerateBody(key=key, grace_period=grace_period),
response_type=KeyGenerateResponse,
)
).key
def reset_key_spend(self, key: str, reset_to: float) -> KeyResetSpendResponse:
return unwrap(
self.proxy.transport.post(
f"/key/{key}/reset_spend",
headers=self.proxy.transport.master,
json=KeyResetSpendBody(reset_to=reset_to),
response_type=KeyResetSpendResponse,
)
)
def key_list(self, key_alias: str, *, caller_key: str | None = None) -> Result[KeyListResponse]:
"""GET /key/list, the Virtual Keys page's own inventory call. `caller_key` is
who is asking: the master key by default, or a virtual key."""

View file

@ -18,7 +18,7 @@ from typing import Literal
import pytest
from e2e_config import unique_marker
from e2e_http import NoBody, unwrap
from e2e_http import NoBody, StreamingResponse, unwrap
from lifecycle import ResourceManager
from management_client import ManagementClient
from models import KeyDeleteBody, KeyGenerateBody, KeyUpdateBody
@ -26,6 +26,9 @@ from pydantic import BaseModel
pytestmark = pytest.mark.e2e
TINY_BUDGET = 3e-6
SPEND_MODEL = "claude-haiku-4-5"
class KeyToggleBlockBody(BaseModel):
key: str
@ -82,6 +85,30 @@ def _generate_key(client: ManagementClient, resources: ResourceManager, body: Ke
return key
def _is_budget_block(outcome: StreamingResponse) -> bool:
return not outcome.ok and "budget_exceeded" in outcome.body
def _spend_until_budget_blocks(client: ManagementClient, key: str) -> None:
for _ in range(40):
outcome = client.chat_status(key, SPEND_MODEL, f"spend {unique_marker()}")
if _is_budget_block(outcome):
assert outcome.status_code == 429, (
f"budget refusal must be 429, got {outcome.status_code}: {outcome.body[:200]}"
)
return
assert outcome.ok, f"paid call failed before the budget tripped ({outcome.status_code}): {outcome.body[:300]}"
time.sleep(2)
pytest.fail(f"max_budget={TINY_BUDGET} never blocked a call on the key")
def _settled_spend(client: ManagementClient, key: str) -> float | None:
first = client.proxy.key_info(key).spend or 0.0
time.sleep(client.proxy.poll_interval)
second = client.proxy.key_info(key).spend or 0.0
return second if first > 0 and first == second else None
def _block(client: ManagementClient, key: str) -> None:
_ = unwrap(
client.proxy.transport.post(
@ -197,6 +224,32 @@ class TestKeyManagementRoutes:
"/key/info never reported max_budget 42.0 after /key/bulk_update before the deadline",
)
@pytest.mark.covers("other.key_mgmt.spend_reset.resets_to_value")
def test_reset_spend_zeroes_recorded_spend_and_lifts_the_budget_block(
self, client: ManagementClient, resources: ResourceManager
) -> None:
key = _generate_key(client, resources, KeyGenerateBody(models=[SPEND_MODEL], max_budget=TINY_BUDGET))
_spend_until_budget_blocks(client, key)
recorded = _poll(
client, lambda: _settled_spend(client, key), "key spend never landed in /key/info before the deadline"
)
reset = client.reset_key_spend(key, reset_to=0.0)
assert reset.previous_spend == recorded, (
f"reset_spend reported previous_spend {reset.previous_spend}, /key/info had recorded {recorded}"
)
assert reset.spend == 0.0, f"reset_spend to 0 reported spend {reset.spend}"
assert client.proxy.key_info(key).spend == 0.0, "/key/info still reports spend after the reset to 0"
def call_allowed_again() -> bool | None:
outcome = client.chat_status(key, SPEND_MODEL, f"after reset {unique_marker()}")
if _is_budget_block(outcome):
return None
assert outcome.ok, f"post-reset call failed ({outcome.status_code}): {outcome.body[:300]}"
return True
_ = _poll(client, call_allowed_again, "the key stayed budget-blocked after its spend was reset to 0")
@pytest.mark.covers("mgmt.key.generate.admin_only")
def test_generate_forbidden_for_non_admin_key(
self, client: ManagementClient, resources: ResourceManager

View file

@ -12,6 +12,7 @@ from __future__ import annotations
import math
import time
from collections.abc import Callable
from typing import Final
import pytest
@ -42,6 +43,10 @@ from models import (
pytestmark = pytest.mark.e2e
REGENERATE_GRACE_PERIOD = "15s"
REGENERATE_GRACE_SECONDS = 15.0
def _poll[T](client: ManagementClient, attempt: Callable[[], T | None], failure: str) -> T:
deadline = time.monotonic() + client.proxy.poll_timeout
while time.monotonic() < deadline:
@ -365,6 +370,36 @@ class TestKeyRegeneration:
client, old_rejected, "old key was still accepted after regeneration (never rejected 401) at the deadline"
)
@pytest.mark.covers("other.key_mgmt.regenerate.grace_period_honored")
def test_regenerate_with_grace_period_keeps_old_key_until_revoked(
self, client: ManagementClient, resources: ResourceManager
) -> None:
old_key = _generate_key(client, resources, KeyGenerateBody(models=["gpt-5.5"]))
new_key = client.regenerate_key(old_key, grace_period=REGENERATE_GRACE_PERIOD)
resources.defer(lambda: client.proxy.delete_key(new_key))
revoke_at: Final = time.monotonic() + REGENERATE_GRACE_SECONDS
assert new_key != old_key, "regenerate returned the same key string, so no rotation happened"
def old_accepted() -> bool | None:
outcome = client.chat_status(old_key, "gpt-5.5", f"say hi {unique_marker()}")
return True if outcome.ok else None
_ = _poll(client, old_accepted, "old key was rejected 401 inside its grace period at the deadline")
assert time.monotonic() < revoke_at, (
f"old key was only accepted after its {REGENERATE_GRACE_PERIOD} grace period had elapsed"
)
def old_rejected() -> bool | None:
outcome = client.chat_status(old_key, "gpt-5.5", f"say hi {unique_marker()}")
return True if outcome.status_code == 401 else None
_ = _poll(
client,
old_rejected,
f"old key was still accepted past its {REGENERATE_GRACE_PERIOD} grace period (never 401) at the deadline",
)
class TestTeamRoutes:
@pytest.mark.covers("mgmt.team.new.persists")

View file

@ -83,6 +83,16 @@ class KeyGenerateResponse(BaseModel):
class KeyRegenerateBody(BaseModel):
key: str
grace_period: str | None = None
class KeyResetSpendBody(BaseModel):
reset_to: float
class KeyResetSpendResponse(BaseModel):
spend: float
previous_spend: float
class KeyDeleteBody(BaseModel):