mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
Merge latest staging into OTEL v2 destinations
This commit is contained in:
commit
4ba3b4fcb3
25 changed files with 1437 additions and 66 deletions
57
.github/workflows/test-litellm-ui-unit.yml
vendored
Normal file
57
.github/workflows/test-litellm-ui-unit.yml
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
name: UI Unit Tests
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- litellm_internal_staging
|
||||
- litellm_oss_staging
|
||||
- "litellm_**"
|
||||
push:
|
||||
branches:
|
||||
- litellm_internal_staging
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ui-unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui/litellm-dashboard
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: ui/litellm-dashboard/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run UI unit tests (Vitest)
|
||||
env:
|
||||
CI: "true"
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
if [ -n "$BASE_SHA" ]; then
|
||||
echo "Pull request: running only tests related to changes since $BASE_SHA"
|
||||
npm run test -- --run --changed "$BASE_SHA" --passWithNoTests \
|
||||
--pool forks --poolOptions.forks.maxForks=4
|
||||
else
|
||||
echo "Push to $GITHUB_REF_NAME: running the full suite"
|
||||
npm run test -- --run --pool forks --poolOptions.forks.maxForks=4
|
||||
fi
|
||||
|
|
@ -4066,6 +4066,7 @@ class JWTAuthBuilderResult(TypedDict):
|
|||
token: str
|
||||
team_id: Optional[str]
|
||||
user_id: Optional[str]
|
||||
user_email: str | None
|
||||
end_user_id: Optional[str]
|
||||
org_id: Optional[str]
|
||||
team_membership: Optional[LiteLLM_TeamMembership]
|
||||
|
|
|
|||
|
|
@ -1155,6 +1155,7 @@ class JWTAuthManager:
|
|||
org_id: Optional[str],
|
||||
api_key: str,
|
||||
jwt_valid_token: Optional[dict] = None,
|
||||
user_email: str | None = None,
|
||||
) -> Optional[JWTAuthBuilderResult]:
|
||||
"""Check admin status and route access permissions"""
|
||||
if not jwt_handler.is_admin(scopes=scopes):
|
||||
|
|
@ -1179,6 +1180,7 @@ class JWTAuthManager:
|
|||
token=api_key,
|
||||
team_id=None,
|
||||
user_id=user_id,
|
||||
user_email=user_email,
|
||||
end_user_id=None,
|
||||
org_id=org_id,
|
||||
team_membership=None,
|
||||
|
|
@ -2068,7 +2070,7 @@ class JWTAuthManager:
|
|||
|
||||
# Check admin access
|
||||
admin_result = await JWTAuthManager.check_admin_access(
|
||||
jwt_handler, scopes, route, user_id, org_id, api_key, jwt_valid_token
|
||||
jwt_handler, scopes, route, user_id, org_id, api_key, jwt_valid_token, user_email=user_email
|
||||
)
|
||||
if admin_result:
|
||||
await JWTAuthManager._attach_team_from_header_for_admin(
|
||||
|
|
@ -2303,6 +2305,7 @@ class JWTAuthManager:
|
|||
team_id=team_id,
|
||||
team_object=team_object,
|
||||
user_id=user_id,
|
||||
user_email=(user_object.user_email if user_object is not None and user_object.user_email else user_email),
|
||||
user_object=user_object,
|
||||
org_id=resolved_org_id, # Use resolved org_id (from alias lookup if applicable)
|
||||
org_object=org_object,
|
||||
|
|
|
|||
|
|
@ -1295,6 +1295,7 @@ async def _user_api_key_auth_builder(
|
|||
team_id = result["team_id"]
|
||||
team_object = result["team_object"]
|
||||
user_id = result["user_id"]
|
||||
user_email = result["user_email"]
|
||||
user_object = result["user_object"]
|
||||
end_user_id = result["end_user_id"]
|
||||
org_id = result["org_id"]
|
||||
|
|
@ -1319,6 +1320,7 @@ async def _user_api_key_auth_builder(
|
|||
api_key=None,
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
user_id=user_id,
|
||||
user_email=user_email,
|
||||
team_id=team_id,
|
||||
team_alias=(team_object.team_alias if team_object is not None else None),
|
||||
team_tpm_limit=(team_object.tpm_limit if team_object is not None else None),
|
||||
|
|
@ -1344,6 +1346,7 @@ async def _user_api_key_auth_builder(
|
|||
else LitellmUserRoles.INTERNAL_USER
|
||||
),
|
||||
user_id=user_id,
|
||||
user_email=user_email,
|
||||
org_id=org_id,
|
||||
parent_otel_span=parent_otel_span,
|
||||
end_user_id=end_user_id,
|
||||
|
|
@ -1385,6 +1388,7 @@ async def _user_api_key_auth_builder(
|
|||
)
|
||||
if auto_registered is not None:
|
||||
auto_registered.jwt_claims = jwt_claims
|
||||
auto_registered.user_email = user_email
|
||||
valid_token = auto_registered
|
||||
api_key = valid_token.token or ""
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ R = TypeVar("R", bound=BaseModel)
|
|||
|
||||
class Success(BaseModel, Generic[R]):
|
||||
kind: Literal["success"] = "success"
|
||||
status_code: int
|
||||
data: R
|
||||
|
||||
|
||||
|
|
@ -159,6 +160,18 @@ def unwrap[R: BaseModel](result: Result[R]) -> R:
|
|||
raise AssertionError(result)
|
||||
|
||||
|
||||
def unwrap_status[R: BaseModel](result: Result[R], expected_status: int) -> R:
|
||||
"""Like unwrap, but also pins the exact HTTP status the success came back on,
|
||||
for routes whose contract is a specific 2xx (e.g. 201 Created on a submission)."""
|
||||
match result:
|
||||
case Success(status_code=status_code, data=data) if status_code == expected_status:
|
||||
return data
|
||||
case Success(status_code=status_code):
|
||||
raise AssertionError(f"expected HTTP {expected_status}, got {status_code}")
|
||||
case _:
|
||||
raise AssertionError(result)
|
||||
|
||||
|
||||
def is_ok[R: BaseModel](result: Result[R]) -> bool:
|
||||
match result:
|
||||
case Success():
|
||||
|
|
@ -199,7 +212,7 @@ def _classify[R: BaseModel](
|
|||
if not resp.ok:
|
||||
return UnknownApiError(status_code=resp.status_code, body=resp.text)
|
||||
try:
|
||||
return Success(data=response_type.model_validate(resp.json()))
|
||||
return Success(status_code=resp.status_code, data=response_type.model_validate(resp.json()))
|
||||
except Exception as exc: # noqa: BLE001 - any parse/validation failure is a value
|
||||
return ValidationError(message=str(exc))
|
||||
|
||||
|
|
@ -286,6 +299,26 @@ def patch[R: BaseModel](
|
|||
return _classify(resp, response_type)
|
||||
|
||||
|
||||
def put[R: BaseModel](
|
||||
url: URL,
|
||||
*,
|
||||
headers: BaseModel,
|
||||
json: BaseModel,
|
||||
response_type: type[R],
|
||||
timeout: float = 30.0,
|
||||
) -> Result[R]:
|
||||
try:
|
||||
resp = requests.put(
|
||||
str(url),
|
||||
headers=_headers(headers),
|
||||
json=json.model_dump(by_alias=True, exclude_none=True),
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
return NetworkError(message=str(exc))
|
||||
return _classify(resp, response_type)
|
||||
|
||||
|
||||
def probe(
|
||||
url: URL, *, headers: BaseModel, params: BaseModel, timeout: float = 30.0
|
||||
) -> ProbeResult:
|
||||
|
|
|
|||
155
tests/e2e/llm_translation/test_messages_azure_foundry_e2e.py
Normal file
155
tests/e2e/llm_translation/test_messages_azure_foundry_e2e.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
"""Live e2e: POST /v1/messages routed to Azure AI Foundry Anthropic deployments.
|
||||
|
||||
Registers `azure_ai/<claude>` deployments at runtime and drives the Messages
|
||||
endpoint through the gateway across the behaviors an Anthropic client relies on:
|
||||
a basic completion, a streamed completion, and tool use (non-streaming and
|
||||
streaming). Auth is the Azure API key (`x-api-key`); the deployment reads
|
||||
`AZURE_AI_API_BASE` / `AZURE_AI_API_KEY` from the proxy env, so no secret is
|
||||
sent in the request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from e2e_config import unique_marker
|
||||
from e2e_http import StreamingResponse, require_successful_call, unwrap
|
||||
from endpoints_client import EndpointsClient
|
||||
from lifecycle import ResourceManager
|
||||
from models import (
|
||||
AnthropicCustomTool,
|
||||
AnthropicMessagesBody,
|
||||
ChatMessage,
|
||||
JsonSchemaProperty,
|
||||
LiteLLMParamsBody,
|
||||
ToolInputSchema,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
AZURE_FOUNDRY_MODEL = "azure_ai/claude-haiku-4-5"
|
||||
|
||||
WEATHER_TOOL = AnthropicCustomTool(
|
||||
name="get_weather",
|
||||
description="Get the current weather for a city.",
|
||||
input_schema=ToolInputSchema(
|
||||
properties={"city": JsonSchemaProperty(type="string")},
|
||||
required=["city"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _assert_streamed_ok(result: StreamingResponse) -> None:
|
||||
require_successful_call(result)
|
||||
assert result.is_streaming, f"response was not streamed: {result.headers}"
|
||||
assert not result.stream_error, f"stream errored: {result.stream_error}"
|
||||
assert result.stream_events, "stream produced no SSE events"
|
||||
assert any("content_block_delta" in event for event in result.stream_events), (
|
||||
"stream carried no content deltas"
|
||||
)
|
||||
assert any("message_stop" in event for event in result.stream_events), (
|
||||
"stream never reached message_stop"
|
||||
)
|
||||
|
||||
|
||||
class TestAzureFoundryMessages:
|
||||
def _register(
|
||||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
) -> tuple[str, str]:
|
||||
model = f"e2e-azure-foundry-messages-{unique_marker()}"
|
||||
model_id = endpoints_client.create_model(
|
||||
model,
|
||||
LiteLLMParamsBody(
|
||||
model=AZURE_FOUNDRY_MODEL,
|
||||
api_base="os.environ/AZURE_AI_API_BASE",
|
||||
api_key="os.environ/AZURE_AI_API_KEY",
|
||||
),
|
||||
)
|
||||
resources.defer(lambda: endpoints_client.delete_model(model_id))
|
||||
return model, resources.key(models=[model])
|
||||
|
||||
@pytest.mark.covers("llm.messages.azure_foundry.basic.nonstream.works")
|
||||
def test_basic_nonstream(
|
||||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
) -> None:
|
||||
model, key = self._register(endpoints_client, resources)
|
||||
response = unwrap(
|
||||
endpoints_client.proxy.messages(
|
||||
key,
|
||||
AnthropicMessagesBody(
|
||||
model=model,
|
||||
max_tokens=64,
|
||||
messages=[ChatMessage(role="user", content="Reply with one word.")],
|
||||
),
|
||||
)
|
||||
)
|
||||
assert response.content, f"no content blocks in response: {response}"
|
||||
text = "".join(block.text or "" for block in response.content if block.type == "text")
|
||||
assert text.strip(), f"/v1/messages returned no text: {response}"
|
||||
|
||||
@pytest.mark.covers("llm.messages.azure_foundry.basic.stream.works")
|
||||
def test_basic_stream(
|
||||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
) -> None:
|
||||
model, key = self._register(endpoints_client, resources)
|
||||
result = endpoints_client.proxy.messages_stream(
|
||||
key,
|
||||
AnthropicMessagesBody(
|
||||
model=model,
|
||||
max_tokens=64,
|
||||
stream=True,
|
||||
messages=[ChatMessage(role="user", content="Count from one to three.")],
|
||||
),
|
||||
)
|
||||
_assert_streamed_ok(result)
|
||||
|
||||
@pytest.mark.covers("llm.messages.azure_foundry.tool_use.nonstream.works")
|
||||
def test_tool_use_nonstream(
|
||||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
) -> None:
|
||||
model, key = self._register(endpoints_client, resources)
|
||||
response = unwrap(
|
||||
endpoints_client.proxy.messages(
|
||||
key,
|
||||
AnthropicMessagesBody(
|
||||
model=model,
|
||||
max_tokens=256,
|
||||
tools=[WEATHER_TOOL],
|
||||
messages=[
|
||||
ChatMessage(role="user", content="What is the weather in Paris? Use the tool.")
|
||||
],
|
||||
),
|
||||
)
|
||||
)
|
||||
assert response.content, f"no content blocks in response: {response}"
|
||||
assert any(block.type == "tool_use" for block in response.content), (
|
||||
f"model did not call the tool: {response}"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("llm.messages.azure_foundry.tool_use.stream.works")
|
||||
def test_tool_use_stream(
|
||||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
) -> None:
|
||||
model, key = self._register(endpoints_client, resources)
|
||||
result = endpoints_client.proxy.messages_stream(
|
||||
key,
|
||||
AnthropicMessagesBody(
|
||||
model=model,
|
||||
max_tokens=256,
|
||||
stream=True,
|
||||
tools=[WEATHER_TOOL],
|
||||
messages=[
|
||||
ChatMessage(role="user", content="What is the weather in Paris? Use the tool.")
|
||||
],
|
||||
),
|
||||
)
|
||||
require_successful_call(result)
|
||||
assert result.is_streaming, f"response was not streamed: {result.headers}"
|
||||
assert not result.stream_error, f"stream errored: {result.stream_error}"
|
||||
assert result.stream_events, "stream produced no SSE events"
|
||||
assert any("tool_use" in event for event in result.stream_events), (
|
||||
"stream carried no tool_use block"
|
||||
)
|
||||
assert any("message_stop" in event for event in result.stream_events), (
|
||||
"stream never reached message_stop"
|
||||
)
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
"""Live e2e: POST /v1/messages (Anthropic Messages API) returns a real completion.
|
||||
|
||||
Registers an Anthropic deployment at runtime, drives the Messages endpoint through
|
||||
the gateway, and asserts an assistant message with text came back. Migrated from
|
||||
the gateway, and asserts an assistant message with text came back, both
|
||||
non-streaming and streamed. Migrated from
|
||||
litellm-regression-tests/tests/test_inference_endpoints.py.
|
||||
"""
|
||||
|
||||
|
|
@ -10,18 +11,34 @@ from __future__ import annotations
|
|||
import pytest
|
||||
|
||||
from e2e_config import unique_marker
|
||||
from e2e_http import require_successful_call
|
||||
from e2e_http import require_successful_call, unwrap
|
||||
from endpoints_client import EndpointsClient, MessagesResult
|
||||
from lifecycle import ResourceManager
|
||||
from models import LiteLLMParamsBody
|
||||
from models import (
|
||||
AnthropicCustomTool,
|
||||
AnthropicMessagesBody,
|
||||
ChatMessage,
|
||||
JsonSchemaProperty,
|
||||
LiteLLMParamsBody,
|
||||
ToolInputSchema,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
WEATHER_TOOL = AnthropicCustomTool(
|
||||
name="get_weather",
|
||||
description="Get the current weather for a city.",
|
||||
input_schema=ToolInputSchema(
|
||||
properties={"city": JsonSchemaProperty(type="string")},
|
||||
required=["city"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestAnthropicMessages:
|
||||
def test_messages_returns_completion(
|
||||
def _register(
|
||||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
) -> None:
|
||||
) -> tuple[str, str]:
|
||||
model = f"e2e-messages-{unique_marker()}"
|
||||
model_id = endpoints_client.create_model(
|
||||
model,
|
||||
|
|
@ -30,10 +47,66 @@ class TestAnthropicMessages:
|
|||
),
|
||||
)
|
||||
resources.defer(lambda: endpoints_client.delete_model(model_id))
|
||||
key = resources.key()
|
||||
return model, resources.key()
|
||||
|
||||
@pytest.mark.covers("llm.messages.anthropic.basic.nonstream.works")
|
||||
def test_messages_returns_completion(
|
||||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
) -> None:
|
||||
model, key = self._register(endpoints_client, resources)
|
||||
|
||||
result = endpoints_client.messages(key, model, "reply with one word")
|
||||
require_successful_call(result)
|
||||
parsed = MessagesResult.model_validate_json(result.body)
|
||||
assert parsed.role == "assistant", f"unexpected role: {result.body[:300]}"
|
||||
assert parsed.text.strip(), f"/v1/messages returned no text: {result.body[:300]}"
|
||||
|
||||
@pytest.mark.covers("llm.messages.anthropic.basic.stream.works")
|
||||
def test_messages_streams_completion(
|
||||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
) -> None:
|
||||
model, key = self._register(endpoints_client, resources)
|
||||
|
||||
result = endpoints_client.proxy.messages_stream(
|
||||
key,
|
||||
AnthropicMessagesBody(
|
||||
model=model,
|
||||
max_tokens=64,
|
||||
stream=True,
|
||||
messages=[ChatMessage(role="user", content="Count from one to three.")],
|
||||
),
|
||||
)
|
||||
require_successful_call(result)
|
||||
assert result.is_streaming, f"response was not streamed: {result.headers}"
|
||||
assert not result.stream_error, f"stream errored: {result.stream_error}"
|
||||
assert result.stream_events, "stream produced no SSE events"
|
||||
assert any("content_block_delta" in event for event in result.stream_events), (
|
||||
"stream carried no content deltas"
|
||||
)
|
||||
assert any("message_stop" in event for event in result.stream_events), (
|
||||
"stream never reached message_stop"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("llm.messages.anthropic.tool_use.nonstream.works")
|
||||
def test_messages_tool_use(
|
||||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
) -> None:
|
||||
model, key = self._register(endpoints_client, resources)
|
||||
|
||||
response = unwrap(
|
||||
endpoints_client.proxy.messages(
|
||||
key,
|
||||
AnthropicMessagesBody(
|
||||
model=model,
|
||||
max_tokens=256,
|
||||
tools=[WEATHER_TOOL],
|
||||
messages=[
|
||||
ChatMessage(role="user", content="What is the weather in Paris? Use the tool.")
|
||||
],
|
||||
),
|
||||
)
|
||||
)
|
||||
assert response.content, f"no content blocks in response: {response}"
|
||||
assert any(block.type == "tool_use" for block in response.content), (
|
||||
f"model did not call the tool: {response}"
|
||||
)
|
||||
|
|
|
|||
698
tests/e2e/management/test_config_misc_endpoints_e2e.py
Normal file
698
tests/e2e/management/test_config_misc_endpoints_e2e.py
Normal file
|
|
@ -0,0 +1,698 @@
|
|||
"""Live e2e: the config and miscellaneous Management/UI routes.
|
||||
|
||||
One method per registry cell, each asserting the real contract against a live
|
||||
proxy: read-only inventory routes return their documented shape, stateless
|
||||
validators compute their verdict from the request, and the write routes persist
|
||||
so a read-back reflects the change. The two routes that mutate global proxy state
|
||||
(cache settings and router settings, both driven from the admin UI) are exercised
|
||||
with a benign, self-restoring change so a shared proxy is left as it was found.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from e2e_config import unique_marker
|
||||
from e2e_http import NoBody, Success, unwrap, unwrap_status
|
||||
from lifecycle import ResourceManager
|
||||
from management_client import ManagementClient
|
||||
from models import KeyGenerateBody, LiteLLMParamsBody, TeamNewBody
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
def _poll[T](client: ManagementClient, attempt: Callable[[], T | None], failure: str) -> T:
|
||||
deadline = time.monotonic() + client.proxy.poll_timeout
|
||||
while time.monotonic() < deadline:
|
||||
found = attempt()
|
||||
if found is not None:
|
||||
return found
|
||||
time.sleep(client.proxy.poll_interval)
|
||||
pytest.fail(failure)
|
||||
|
||||
|
||||
# ---- callbacks -------------------------------------------------------------
|
||||
|
||||
|
||||
class CallbacksListResponse(BaseModel):
|
||||
success: list[str]
|
||||
failure: list[str]
|
||||
success_and_failure: list[str]
|
||||
|
||||
|
||||
# ---- cost estimate ---------------------------------------------------------
|
||||
|
||||
|
||||
class CostEstimateBody(BaseModel):
|
||||
model: str
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
num_requests_per_day: int | None = None
|
||||
|
||||
|
||||
class CostEstimateResponse(BaseModel):
|
||||
model: str
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
cost_per_request: float
|
||||
input_cost_per_request: float
|
||||
output_cost_per_request: float
|
||||
margin_cost_per_request: float
|
||||
daily_cost: float | None = None
|
||||
provider: str | None = None
|
||||
|
||||
|
||||
# ---- credential migration check --------------------------------------------
|
||||
|
||||
|
||||
class MigrationReport(BaseModel):
|
||||
residual_legacy: int
|
||||
total_undecryptable: int
|
||||
|
||||
|
||||
class MigrationCheckResponse(BaseModel):
|
||||
status: str
|
||||
report: MigrationReport
|
||||
|
||||
|
||||
# ---- tool + workflow inventories -------------------------------------------
|
||||
|
||||
|
||||
class ToolListEntry(BaseModel):
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class ToolListResponse(BaseModel):
|
||||
tools: list[ToolListEntry]
|
||||
total: int
|
||||
|
||||
|
||||
class WorkflowRunEntry(BaseModel):
|
||||
workflow_id: str | None = None
|
||||
|
||||
|
||||
class WorkflowRunsResponse(BaseModel):
|
||||
runs: list[WorkflowRunEntry]
|
||||
count: int
|
||||
|
||||
|
||||
# ---- compliance ------------------------------------------------------------
|
||||
|
||||
|
||||
class ComplianceGdprBody(BaseModel):
|
||||
request_id: str
|
||||
user_id: str
|
||||
model: str
|
||||
timestamp: str
|
||||
|
||||
|
||||
class ComplianceCheck(BaseModel):
|
||||
check_name: str
|
||||
article: str
|
||||
passed: bool
|
||||
detail: str
|
||||
|
||||
|
||||
class ComplianceResponse(BaseModel):
|
||||
compliant: bool
|
||||
regulation: str
|
||||
checks: list[ComplianceCheck]
|
||||
|
||||
|
||||
# ---- cache settings --------------------------------------------------------
|
||||
|
||||
|
||||
class CacheSettingsValue(BaseModel):
|
||||
type: str
|
||||
host: str = ""
|
||||
port: str = ""
|
||||
|
||||
|
||||
class CacheSettingsUpdateBody(BaseModel):
|
||||
cache_settings: CacheSettingsValue
|
||||
|
||||
|
||||
class CacheCurrentValues(BaseModel):
|
||||
type: str | None = None
|
||||
host: str | None = None
|
||||
port: str | None = None
|
||||
|
||||
|
||||
class CacheGetResponse(BaseModel):
|
||||
current_values: CacheCurrentValues
|
||||
|
||||
|
||||
class CacheUpdateResponse(BaseModel):
|
||||
status: str
|
||||
settings: CacheSettingsValue
|
||||
|
||||
|
||||
# ---- fallback management ---------------------------------------------------
|
||||
|
||||
|
||||
class FallbackShape(BaseModel):
|
||||
model: str
|
||||
fallback_models: list[str]
|
||||
fallback_type: str
|
||||
|
||||
|
||||
class FallbackCreateBody(FallbackShape):
|
||||
pass
|
||||
|
||||
|
||||
class FallbackResponse(FallbackShape):
|
||||
message: str
|
||||
|
||||
|
||||
class FallbackGetParams(BaseModel):
|
||||
fallback_type: str
|
||||
|
||||
|
||||
class FallbackGetResponse(FallbackShape):
|
||||
pass
|
||||
|
||||
|
||||
# ---- jwt key mapping -------------------------------------------------------
|
||||
|
||||
|
||||
class JwtKeyMappingNewBody(BaseModel):
|
||||
jwt_claim_name: str
|
||||
jwt_claim_value: str
|
||||
key: str
|
||||
description: str
|
||||
|
||||
|
||||
class JwtInfoParams(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
class JwtDeleteBody(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
class JwtKeyMappingResponse(BaseModel):
|
||||
id: str
|
||||
jwt_claim_name: str
|
||||
jwt_claim_value: str
|
||||
is_active: bool
|
||||
description: str | None = None
|
||||
|
||||
|
||||
# ---- router settings via /config/update ------------------------------------
|
||||
|
||||
|
||||
class RouterSettingsPatch(BaseModel):
|
||||
num_retries: int
|
||||
|
||||
|
||||
class ConfigUpdateBody(BaseModel):
|
||||
router_settings: RouterSettingsPatch
|
||||
|
||||
|
||||
class ConfigUpdateResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class RouterCurrentValues(BaseModel):
|
||||
num_retries: int | None = None
|
||||
|
||||
|
||||
class RouterSettingsResponse(BaseModel):
|
||||
current_values: RouterCurrentValues
|
||||
|
||||
|
||||
# ---- mcp server submission -------------------------------------------------
|
||||
|
||||
|
||||
class McpRegisterBody(BaseModel):
|
||||
server_name: str
|
||||
url: str
|
||||
transport: str
|
||||
description: str
|
||||
|
||||
|
||||
class McpServerResponse(BaseModel):
|
||||
server_id: str
|
||||
server_name: str | None = None
|
||||
approval_status: str
|
||||
transport: str
|
||||
url: str | None = None
|
||||
|
||||
|
||||
class TestInventoryRoutes:
|
||||
@pytest.mark.covers("mgmt.callback.list.happy_path")
|
||||
def test_callbacks_list_reports_active_logging_callbacks(self, client: ManagementClient) -> None:
|
||||
listing = unwrap(
|
||||
client.proxy.transport.get(
|
||||
"/callbacks/list",
|
||||
headers=client.proxy.transport.master,
|
||||
params=NoBody(),
|
||||
response_type=CallbacksListResponse,
|
||||
)
|
||||
)
|
||||
every = [*listing.success, *listing.failure, *listing.success_and_failure]
|
||||
assert every, "/callbacks/list reported no active logging callbacks; the proxy always runs the db logger"
|
||||
assert "_ProxyDBLogger" in every, (
|
||||
f"/callbacks/list omitted the always-on _ProxyDBLogger spend logger; got {every}"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("mgmt.tool_management.list.happy_path")
|
||||
def test_tool_list_returns_catalog_with_consistent_total(self, client: ManagementClient) -> None:
|
||||
listing = unwrap(
|
||||
client.proxy.transport.get(
|
||||
"/v1/tool/list",
|
||||
headers=client.proxy.transport.master,
|
||||
params=NoBody(),
|
||||
response_type=ToolListResponse,
|
||||
)
|
||||
)
|
||||
assert listing.total == len(listing.tools), (
|
||||
f"/v1/tool/list total {listing.total} disagrees with the {len(listing.tools)} tools returned"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("mgmt.workflow.list.happy_path")
|
||||
def test_workflow_runs_list_returns_consistent_count(self, client: ManagementClient) -> None:
|
||||
listing = unwrap(
|
||||
client.proxy.transport.get(
|
||||
"/v1/workflows/runs",
|
||||
headers=client.proxy.transport.master,
|
||||
params=NoBody(),
|
||||
response_type=WorkflowRunsResponse,
|
||||
)
|
||||
)
|
||||
assert listing.count == len(listing.runs), (
|
||||
f"/v1/workflows/runs count {listing.count} disagrees with the {len(listing.runs)} runs returned"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("mgmt.credential_migration.check.happy_path")
|
||||
def test_credential_migration_check_reports_residual_scan(self, client: ManagementClient) -> None:
|
||||
report = unwrap(
|
||||
client.proxy.transport.get(
|
||||
"/credentials/migrate-encryption/check",
|
||||
headers=client.proxy.transport.master,
|
||||
params=NoBody(),
|
||||
response_type=MigrationCheckResponse,
|
||||
)
|
||||
)
|
||||
assert report.status == "success", f"migrate-encryption/check status {report.status!r}, expected 'success'"
|
||||
assert report.report.residual_legacy >= 0, (
|
||||
f"residual_legacy count is negative ({report.report.residual_legacy}); the scan is broken"
|
||||
)
|
||||
assert report.report.total_undecryptable >= 0, (
|
||||
f"total_undecryptable count is negative ({report.report.total_undecryptable}); the scan is broken"
|
||||
)
|
||||
|
||||
|
||||
class TestCostEstimate:
|
||||
@pytest.mark.covers("mgmt.cost_tracking.estimate.happy_path")
|
||||
def test_estimate_computes_cost_from_token_counts(self, client: ManagementClient) -> None:
|
||||
estimate = unwrap(
|
||||
client.proxy.transport.post(
|
||||
"/cost/estimate",
|
||||
headers=client.proxy.transport.master,
|
||||
json=CostEstimateBody(
|
||||
model="gpt-4o-mini", input_tokens=1000, output_tokens=500, num_requests_per_day=100
|
||||
),
|
||||
response_type=CostEstimateResponse,
|
||||
)
|
||||
)
|
||||
assert estimate.input_cost_per_request > 0, (
|
||||
f"input cost per request is {estimate.input_cost_per_request}; a priced model must cost more than zero"
|
||||
)
|
||||
assert estimate.output_cost_per_request > 0, (
|
||||
f"output cost per request is {estimate.output_cost_per_request}; a priced model must cost more than zero"
|
||||
)
|
||||
expected_per_request = (
|
||||
estimate.input_cost_per_request + estimate.output_cost_per_request + estimate.margin_cost_per_request
|
||||
)
|
||||
assert math.isclose(estimate.cost_per_request, expected_per_request, rel_tol=1e-9), (
|
||||
f"cost_per_request {estimate.cost_per_request} != input+output+margin {expected_per_request}"
|
||||
)
|
||||
assert estimate.daily_cost is not None and math.isclose(
|
||||
estimate.daily_cost, estimate.cost_per_request * 100, rel_tol=1e-9
|
||||
), f"daily_cost {estimate.daily_cost} != cost_per_request * 100 requests {estimate.cost_per_request * 100}"
|
||||
|
||||
|
||||
class TestComplianceRoutes:
|
||||
@pytest.mark.covers("mgmt.compliance.gdpr.happy_path")
|
||||
def test_gdpr_check_derives_verdict_from_the_request(self, client: ManagementClient) -> None:
|
||||
result = unwrap(
|
||||
client.proxy.transport.post(
|
||||
"/compliance/gdpr",
|
||||
headers=client.proxy.transport.master,
|
||||
json=ComplianceGdprBody(
|
||||
request_id=f"e2e-gdpr-{unique_marker()}",
|
||||
user_id=f"e2e-user-{unique_marker()}",
|
||||
model="gpt-4o-mini",
|
||||
timestamp="2026-07-21T00:00:00Z",
|
||||
),
|
||||
response_type=ComplianceResponse,
|
||||
)
|
||||
)
|
||||
assert result.regulation == "GDPR", (
|
||||
f"/compliance/gdpr reported regulation {result.regulation!r}, expected 'GDPR'"
|
||||
)
|
||||
articles = {check.article for check in result.checks}
|
||||
assert articles == {"Art. 32", "Art. 5(1)(c)", "Art. 30"}, (
|
||||
f"/compliance/gdpr returned articles {articles}, expected the three GDPR articles"
|
||||
)
|
||||
assert result.compliant == all(check.passed for check in result.checks), (
|
||||
"the overall compliant verdict must be the conjunction of the individual checks"
|
||||
)
|
||||
assert all(check.check_name and check.detail for check in result.checks), (
|
||||
"every compliance check must carry a name and a human-readable detail"
|
||||
)
|
||||
|
||||
|
||||
class TestCacheSettings:
|
||||
@pytest.mark.covers("mgmt.cache_settings.update.happy_path")
|
||||
def test_update_persists_cache_backend_to_get(
|
||||
self, client: ManagementClient, resources: ResourceManager
|
||||
) -> None:
|
||||
"""Exercise the update route without changing global state: capture the live
|
||||
cache backend and write exactly that back, so the config the proxy ends on is
|
||||
byte-for-byte the one it started with. A teardown restore of the same captured
|
||||
settings is the safety net if the body fails partway. The update route is only
|
||||
meaningful against a configured cache, so an unconfigured proxy fails loudly
|
||||
here rather than being silently switched to redis."""
|
||||
before = self._read_settings(client)
|
||||
assert before.type is not None, (
|
||||
"GET /cache/settings reported no cache type; refusing to invent one and mutate the shared proxy"
|
||||
)
|
||||
captured = CacheSettingsValue(type=before.type, host=before.host or "", port=before.port or "")
|
||||
resources.defer(lambda: self._write_settings(client, captured))
|
||||
|
||||
updated = unwrap(
|
||||
client.proxy.transport.post(
|
||||
"/cache/settings",
|
||||
headers=client.proxy.transport.master,
|
||||
json=CacheSettingsUpdateBody(cache_settings=captured),
|
||||
response_type=CacheUpdateResponse,
|
||||
)
|
||||
)
|
||||
assert updated.status == "success", f"/cache/settings update status {updated.status!r}, expected 'success'"
|
||||
assert updated.settings.type == captured.type, (
|
||||
f"/cache/settings echoed type {updated.settings.type!r}, wrote {captured.type!r}"
|
||||
)
|
||||
|
||||
def reflected() -> CacheCurrentValues | None:
|
||||
current = self._read_settings(client)
|
||||
return current if current.type == captured.type else None
|
||||
|
||||
after = _poll(client, reflected, f"/cache/settings never reported type {captured.type!r} after the update")
|
||||
assert after.host == captured.host and after.port == captured.port, (
|
||||
f"/cache/settings persisted host/port {after.host!r}/{after.port!r}, "
|
||||
f"wrote {captured.host!r}/{captured.port!r}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _read_settings(client: ManagementClient) -> CacheCurrentValues:
|
||||
return unwrap(
|
||||
client.proxy.transport.get(
|
||||
"/cache/settings",
|
||||
headers=client.proxy.transport.master,
|
||||
params=NoBody(),
|
||||
response_type=CacheGetResponse,
|
||||
)
|
||||
).current_values
|
||||
|
||||
@staticmethod
|
||||
def _write_settings(client: ManagementClient, settings: CacheSettingsValue) -> None:
|
||||
_ = unwrap(
|
||||
client.proxy.transport.post(
|
||||
"/cache/settings",
|
||||
headers=client.proxy.transport.master,
|
||||
json=CacheSettingsUpdateBody(cache_settings=settings),
|
||||
response_type=CacheUpdateResponse,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestFallbackManagement:
|
||||
@pytest.mark.covers("mgmt.fallback_management.update.happy_path")
|
||||
def test_create_persists_and_is_read_back(self, client: ManagementClient, resources: ResourceManager) -> None:
|
||||
primary = f"e2e-fallback-primary-{unique_marker()}"
|
||||
secondary = f"e2e-fallback-secondary-{unique_marker()}"
|
||||
params = LiteLLMParamsBody(model="openai/gpt-5.5", api_key="e2e-dummy-key")
|
||||
primary_id = client.proxy.create_model(primary, params)
|
||||
resources.defer(lambda: client.proxy.delete_model(primary_id))
|
||||
secondary_id = client.proxy.create_model(secondary, params)
|
||||
resources.defer(lambda: client.proxy.delete_model(secondary_id))
|
||||
resources.defer(lambda: self._delete_fallback(client, primary))
|
||||
|
||||
created = unwrap(
|
||||
client.proxy.transport.post(
|
||||
"/fallback",
|
||||
headers=client.proxy.transport.master,
|
||||
json=FallbackCreateBody(model=primary, fallback_models=[secondary], fallback_type="general"),
|
||||
response_type=FallbackResponse,
|
||||
)
|
||||
)
|
||||
assert created.model == primary and created.fallback_models == [secondary], (
|
||||
f"/fallback echoed model={created.model!r} fallbacks={created.fallback_models}, "
|
||||
f"configured {primary!r} -> [{secondary!r}]"
|
||||
)
|
||||
|
||||
def read_back() -> FallbackGetResponse | None:
|
||||
result = client.proxy.transport.get(
|
||||
f"/fallback/{primary}",
|
||||
headers=client.proxy.transport.master,
|
||||
params=FallbackGetParams(fallback_type="general"),
|
||||
response_type=FallbackGetResponse,
|
||||
)
|
||||
match result:
|
||||
case Success(data=data) if secondary in data.fallback_models:
|
||||
return data
|
||||
case _:
|
||||
return None
|
||||
|
||||
got = _poll(client, read_back, f"GET /fallback/{primary} never reported {secondary} after /fallback")
|
||||
assert got.fallback_models == [secondary], (
|
||||
f"GET /fallback/{primary} reports fallbacks {got.fallback_models}, configured [{secondary!r}]"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _delete_fallback(client: ManagementClient, model: str) -> None:
|
||||
_ = client.proxy.transport.delete(
|
||||
f"/fallback/{model}",
|
||||
headers=client.proxy.transport.master,
|
||||
json=NoBody(),
|
||||
params=FallbackGetParams(fallback_type="general"),
|
||||
response_type=NoBody,
|
||||
)
|
||||
|
||||
|
||||
class TestJwtKeyMapping:
|
||||
@pytest.mark.covers("mgmt.jwt_key_mapping.new.happy_path")
|
||||
def test_new_persists_mapping_and_is_read_back(
|
||||
self, client: ManagementClient, resources: ResourceManager
|
||||
) -> None:
|
||||
key = client.proxy.generate_key(KeyGenerateBody())
|
||||
resources.defer(lambda: client.proxy.delete_key(key))
|
||||
claim_value = f"e2e_jwt_{unique_marker()}"
|
||||
|
||||
created = unwrap(
|
||||
client.proxy.transport.post(
|
||||
"/jwt/key/mapping/new",
|
||||
headers=client.proxy.transport.master,
|
||||
json=JwtKeyMappingNewBody(
|
||||
jwt_claim_name="team_id",
|
||||
jwt_claim_value=claim_value,
|
||||
key=key,
|
||||
description="e2e coverage mapping",
|
||||
),
|
||||
response_type=JwtKeyMappingResponse,
|
||||
)
|
||||
)
|
||||
resources.defer(lambda: self._delete_mapping(client, created.id))
|
||||
assert created.jwt_claim_value == claim_value and created.is_active, (
|
||||
f"/jwt/key/mapping/new returned claim_value={created.jwt_claim_value!r} active={created.is_active}, "
|
||||
f"configured {claim_value!r} active=True"
|
||||
)
|
||||
|
||||
info = unwrap(
|
||||
client.proxy.transport.get(
|
||||
"/jwt/key/mapping/info",
|
||||
headers=client.proxy.transport.master,
|
||||
params=JwtInfoParams(id=created.id),
|
||||
response_type=JwtKeyMappingResponse,
|
||||
)
|
||||
)
|
||||
assert info.id == created.id and info.jwt_claim_name == "team_id" and info.jwt_claim_value == claim_value, (
|
||||
f"/jwt/key/mapping/info reports {info.jwt_claim_name!r}={info.jwt_claim_value!r} for id {info.id}, "
|
||||
f"created team_id={claim_value!r}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _delete_mapping(client: ManagementClient, mapping_id: str) -> None:
|
||||
_ = client.proxy.transport.post(
|
||||
"/jwt/key/mapping/delete",
|
||||
headers=client.proxy.transport.master,
|
||||
json=JwtDeleteBody(id=mapping_id),
|
||||
response_type=NoBody,
|
||||
)
|
||||
|
||||
|
||||
class TestRouterSettings:
|
||||
@pytest.mark.covers("mgmt.router_settings.update.happy_path")
|
||||
def test_config_update_persists_router_setting_to_get(
|
||||
self, client: ManagementClient, resources: ResourceManager
|
||||
) -> None:
|
||||
"""/config/update is the only write path for router_settings (there is no
|
||||
dedicated router-settings write route). The change is restored on teardown so
|
||||
the shared proxy keeps its original retry policy."""
|
||||
original = self._read_num_retries(client)
|
||||
assert original is not None, "GET /router/settings did not report num_retries; cannot prove a change"
|
||||
resources.defer(lambda: self._write_num_retries(client, original))
|
||||
|
||||
target = original + 5
|
||||
response = unwrap(
|
||||
client.proxy.transport.post(
|
||||
"/config/update",
|
||||
headers=client.proxy.transport.master,
|
||||
json=ConfigUpdateBody(router_settings=RouterSettingsPatch(num_retries=target)),
|
||||
response_type=ConfigUpdateResponse,
|
||||
)
|
||||
)
|
||||
assert "success" in response.message.lower(), (
|
||||
f"/config/update reported {response.message!r}, expected a success message"
|
||||
)
|
||||
|
||||
_ = _poll(
|
||||
client,
|
||||
lambda: True if self._read_num_retries(client) == target else None,
|
||||
f"GET /router/settings never reported num_retries {target} after /config/update",
|
||||
)
|
||||
|
||||
self._write_num_retries(client, original)
|
||||
restored = _poll(
|
||||
client,
|
||||
lambda: original if self._read_num_retries(client) == original else None,
|
||||
f"GET /router/settings never returned to the original num_retries {original} after the restore",
|
||||
)
|
||||
assert restored == original, f"router num_retries left at {restored}, expected the original {original}"
|
||||
|
||||
@staticmethod
|
||||
def _read_num_retries(client: ManagementClient) -> int | None:
|
||||
return unwrap(
|
||||
client.proxy.transport.get(
|
||||
"/router/settings",
|
||||
headers=client.proxy.transport.master,
|
||||
params=NoBody(),
|
||||
response_type=RouterSettingsResponse,
|
||||
)
|
||||
).current_values.num_retries
|
||||
|
||||
@staticmethod
|
||||
def _write_num_retries(client: ManagementClient, value: int) -> None:
|
||||
_ = unwrap(
|
||||
client.proxy.transport.post(
|
||||
"/config/update",
|
||||
headers=client.proxy.transport.master,
|
||||
json=ConfigUpdateBody(router_settings=RouterSettingsPatch(num_retries=value)),
|
||||
response_type=ConfigUpdateResponse,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestMcpServerSubmission:
|
||||
@pytest.mark.covers("mgmt.mcp_server.register.happy_path")
|
||||
def test_register_submits_pending_server(self, client: ManagementClient, resources: ResourceManager) -> None:
|
||||
"""A non-admin, team-scoped key submits an MCP server for review; the proxy
|
||||
stores it as pending_review without loading it into the runtime registry."""
|
||||
team_id = client.create_team(TeamNewBody(team_alias=f"e2e-mcp-team-{unique_marker()}"))
|
||||
resources.defer(lambda: client.delete_team(team_id))
|
||||
team_key = client.proxy.generate_key(KeyGenerateBody(team_id=team_id))
|
||||
resources.defer(lambda: client.proxy.delete_key(team_key))
|
||||
|
||||
server_name = f"e2e_mcp_{unique_marker()}"
|
||||
submitted = unwrap_status(
|
||||
client.proxy.transport.post(
|
||||
"/v1/mcp/server/register",
|
||||
headers=client.proxy.transport.bearer(team_key),
|
||||
json=McpRegisterBody(
|
||||
server_name=server_name,
|
||||
url="https://example.com/mcp",
|
||||
transport="sse",
|
||||
description="e2e coverage submission",
|
||||
),
|
||||
response_type=McpServerResponse,
|
||||
),
|
||||
201,
|
||||
)
|
||||
resources.defer(lambda: self._delete_server(client, submitted.server_id))
|
||||
assert submitted.approval_status == "pending_review", (
|
||||
f"a user submission must be pending_review, got {submitted.approval_status!r}"
|
||||
)
|
||||
assert submitted.server_name == server_name and submitted.transport == "sse", (
|
||||
f"/v1/mcp/server/register echoed name={submitted.server_name!r} transport={submitted.transport!r}, "
|
||||
f"configured {server_name!r}/sse"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("mgmt.mcp_server.approve.persists")
|
||||
def test_approve_activates_submission_and_persists(
|
||||
self, client: ManagementClient, resources: ResourceManager
|
||||
) -> None:
|
||||
"""An admin approving a pending submission flips it to active, and the change
|
||||
persists to a fresh read of the server."""
|
||||
team_id = client.create_team(TeamNewBody(team_alias=f"e2e-mcp-team-{unique_marker()}"))
|
||||
resources.defer(lambda: client.delete_team(team_id))
|
||||
team_key = client.proxy.generate_key(KeyGenerateBody(team_id=team_id))
|
||||
resources.defer(lambda: client.proxy.delete_key(team_key))
|
||||
|
||||
submitted = unwrap(
|
||||
client.proxy.transport.post(
|
||||
"/v1/mcp/server/register",
|
||||
headers=client.proxy.transport.bearer(team_key),
|
||||
json=McpRegisterBody(
|
||||
server_name=f"e2e_mcp_{unique_marker()}",
|
||||
url="https://example.com/mcp",
|
||||
transport="sse",
|
||||
description="e2e coverage submission",
|
||||
),
|
||||
response_type=McpServerResponse,
|
||||
)
|
||||
)
|
||||
resources.defer(lambda: self._delete_server(client, submitted.server_id))
|
||||
assert submitted.approval_status == "pending_review", (
|
||||
f"a fresh submission must be pending_review before approval, got {submitted.approval_status!r}"
|
||||
)
|
||||
|
||||
approved = unwrap(
|
||||
client.proxy.transport.put(
|
||||
f"/v1/mcp/server/{submitted.server_id}/approve",
|
||||
headers=client.proxy.transport.master,
|
||||
json=NoBody(),
|
||||
response_type=McpServerResponse,
|
||||
)
|
||||
)
|
||||
assert approved.approval_status == "active", (
|
||||
f"approve must flip the submission to active, got {approved.approval_status!r}"
|
||||
)
|
||||
|
||||
fetched = unwrap(
|
||||
client.proxy.transport.get(
|
||||
f"/v1/mcp/server/{submitted.server_id}",
|
||||
headers=client.proxy.transport.master,
|
||||
params=NoBody(),
|
||||
response_type=McpServerResponse,
|
||||
)
|
||||
)
|
||||
assert fetched.server_id == submitted.server_id and fetched.approval_status == "active", (
|
||||
f"GET /v1/mcp/server/{submitted.server_id} reports approval_status {fetched.approval_status!r} "
|
||||
"after approve, expected 'active'"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _delete_server(client: ManagementClient, server_id: str) -> None:
|
||||
_ = client.proxy.transport.delete(
|
||||
f"/v1/mcp/server/{server_id}",
|
||||
headers=client.proxy.transport.master,
|
||||
json=NoBody(),
|
||||
response_type=NoBody,
|
||||
)
|
||||
|
|
@ -286,6 +286,7 @@ class CountTokensBody(BaseModel):
|
|||
|
||||
class AnthropicContentBlock(BaseModel):
|
||||
type: str | None = None
|
||||
text: str | None = None
|
||||
|
||||
|
||||
class AnthropicMessagesResponse(BaseModel):
|
||||
|
|
|
|||
|
|
@ -65,6 +65,10 @@ class Transport(Protocol):
|
|||
self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R]
|
||||
) -> Result[R]: ...
|
||||
|
||||
def put[R: BaseModel](
|
||||
self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R]
|
||||
) -> Result[R]: ...
|
||||
|
||||
def probe(self, path: str, *, params: BaseModel) -> ProbeResult: ...
|
||||
|
||||
def upload[R: BaseModel](
|
||||
|
|
@ -159,6 +163,17 @@ class HttpTransport:
|
|||
timeout=self.request_timeout,
|
||||
)
|
||||
|
||||
def put[R: BaseModel](
|
||||
self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R]
|
||||
) -> Result[R]:
|
||||
return e2e_http.put(
|
||||
self._url(path),
|
||||
headers=headers,
|
||||
json=json,
|
||||
response_type=response_type,
|
||||
timeout=self.request_timeout,
|
||||
)
|
||||
|
||||
def stream(
|
||||
self, path: str, *, headers: BaseModel, json: BaseModel
|
||||
) -> StreamingResponse:
|
||||
|
|
@ -319,6 +334,13 @@ class SplitTransport:
|
|||
path, headers=headers, json=json, response_type=response_type
|
||||
)
|
||||
|
||||
def put[R: BaseModel](
|
||||
self, path: str, *, headers: BaseModel, json: BaseModel, response_type: type[R]
|
||||
) -> Result[R]:
|
||||
return self._route(path).put(
|
||||
path, headers=headers, json=json, response_type=response_type
|
||||
)
|
||||
|
||||
def stream(
|
||||
self, path: str, *, headers: BaseModel, json: BaseModel
|
||||
) -> StreamingResponse:
|
||||
|
|
|
|||
|
|
@ -1115,6 +1115,7 @@ async def test_jwt_non_admin_team_route_access(monkeypatch):
|
|||
"team_id": None,
|
||||
"team_object": None,
|
||||
"user_id": None,
|
||||
"user_email": None,
|
||||
"user_object": None,
|
||||
"org_id": None,
|
||||
"org_object": None,
|
||||
|
|
|
|||
|
|
@ -458,6 +458,118 @@ async def test_auth_builder_non_proxy_admin_user_role():
|
|||
assert result["user_id"] == "test_user_1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"row_email,expected_email",
|
||||
[
|
||||
("row@example.com", "row@example.com"),
|
||||
(None, "claim@example.com"),
|
||||
("", "claim@example.com"),
|
||||
],
|
||||
)
|
||||
async def test_auth_builder_result_includes_user_email(row_email, expected_email):
|
||||
"""LIT-4238: auth_builder must return user_email (user row wins, JWT claim
|
||||
is the fallback) so the auth object and metrics get the email."""
|
||||
api_key = "test_jwt_token"
|
||||
request_data = {"model": "gpt-4"}
|
||||
general_settings = {"enforce_rbac": False}
|
||||
route = "/chat/completions"
|
||||
|
||||
user_object = LiteLLM_UserTable(
|
||||
user_id="test_user_1",
|
||||
user_email=row_email,
|
||||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||||
)
|
||||
|
||||
jwt_handler = JWTHandler()
|
||||
jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth()
|
||||
|
||||
with (
|
||||
patch.object(jwt_handler, "auth_jwt", new_callable=AsyncMock) as mock_auth_jwt,
|
||||
patch.object(JWTAuthManager, "check_rbac_role", new_callable=AsyncMock),
|
||||
patch.object(jwt_handler, "get_rbac_role", return_value=None),
|
||||
patch.object(jwt_handler, "get_scopes", return_value=[]),
|
||||
patch.object(jwt_handler, "get_object_id", return_value=None),
|
||||
patch.object(
|
||||
JWTAuthManager,
|
||||
"get_user_info",
|
||||
new_callable=AsyncMock,
|
||||
return_value=("test_user_1", "claim@example.com", True),
|
||||
),
|
||||
patch.object(jwt_handler, "get_org_id", return_value=None),
|
||||
patch.object(jwt_handler, "get_end_user_id", return_value=None),
|
||||
patch.object(
|
||||
JWTAuthManager,
|
||||
"check_admin_access",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
) as mock_check_admin,
|
||||
patch.object(
|
||||
JWTAuthManager,
|
||||
"find_and_validate_specific_team_id",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(None, None),
|
||||
),
|
||||
patch.object(JWTAuthManager, "get_all_team_ids", return_value=set()),
|
||||
patch.object(
|
||||
JWTAuthManager,
|
||||
"find_team_with_model_access",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(None, None),
|
||||
),
|
||||
patch.object(
|
||||
JWTAuthManager,
|
||||
"get_objects",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(user_object, None, None, None, user_object.user_id),
|
||||
),
|
||||
patch.object(JWTAuthManager, "map_user_to_teams", new_callable=AsyncMock),
|
||||
patch.object(JWTAuthManager, "validate_object_id", return_value=True),
|
||||
):
|
||||
mock_auth_jwt.return_value = {"sub": "test_user_1", "scope": ""}
|
||||
|
||||
result = await JWTAuthManager.auth_builder(
|
||||
api_key=api_key,
|
||||
jwt_handler=jwt_handler,
|
||||
request_data=request_data,
|
||||
general_settings=general_settings,
|
||||
route=route,
|
||||
prisma_client=None,
|
||||
user_api_key_cache=None,
|
||||
parent_otel_span=None,
|
||||
proxy_logging_obj=None,
|
||||
)
|
||||
|
||||
assert result["user_email"] == expected_email
|
||||
assert mock_check_admin.call_args.kwargs["user_email"] == "claim@example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_admin_access_result_includes_user_email():
|
||||
"""LIT-4238: the scope-based admin path has no user row, so the JWT claim
|
||||
email must ride the JWTAuthBuilderResult."""
|
||||
jwt_handler = JWTHandler()
|
||||
jwt_handler.litellm_jwtauth = LiteLLM_JWTAuth(
|
||||
admin_jwt_scope="litellm_proxy_admin",
|
||||
admin_allowed_routes=["/chat/completions"],
|
||||
)
|
||||
|
||||
result = await JWTAuthManager.check_admin_access(
|
||||
jwt_handler=jwt_handler,
|
||||
scopes=["litellm_proxy_admin"],
|
||||
route="/chat/completions",
|
||||
user_id="admin-user",
|
||||
user_email="admin@example.com",
|
||||
org_id=None,
|
||||
api_key="test_jwt_token",
|
||||
jwt_valid_token={"sub": "admin-user"},
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["is_proxy_admin"] is True
|
||||
assert result["user_email"] == "admin@example.com"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_user_role_and_teams():
|
||||
from unittest.mock import MagicMock
|
||||
|
|
|
|||
|
|
@ -1569,6 +1569,7 @@ class TestJWTOAuth2Coexistence:
|
|||
"token": jwt_token,
|
||||
"team_id": "jwt-team",
|
||||
"user_id": "jwt-human-user",
|
||||
"user_email": None,
|
||||
"end_user_id": None,
|
||||
"org_id": None,
|
||||
"team_membership": None,
|
||||
|
|
@ -1643,6 +1644,7 @@ class TestJWTOAuth2Coexistence:
|
|||
"token": jwt_token,
|
||||
"team_id": "validated-team",
|
||||
"user_id": "validated-user",
|
||||
"user_email": "validated@example.com",
|
||||
"end_user_id": "validated-end-user",
|
||||
"org_id": "validated-org",
|
||||
"team_membership": None,
|
||||
|
|
@ -1702,6 +1704,7 @@ class TestJWTOAuth2Coexistence:
|
|||
mock_auto_register.call_args.kwargs["end_user_id"] == "validated-end-user"
|
||||
)
|
||||
assert result.org_id == "validated-org"
|
||||
assert result.user_email == "validated@example.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routing_override_routes_matching_jwt_to_oauth2(self):
|
||||
|
|
@ -1788,6 +1791,7 @@ class TestJWTOAuth2Coexistence:
|
|||
"token": jwt_token,
|
||||
"team_id": "jwt-team",
|
||||
"user_id": "jwt-user-no-override",
|
||||
"user_email": None,
|
||||
"end_user_id": None,
|
||||
"org_id": None,
|
||||
"team_membership": None,
|
||||
|
|
@ -1988,6 +1992,7 @@ class TestJWTOAuth2Coexistence:
|
|||
"token": jwt_token,
|
||||
"team_id": "jwt-team",
|
||||
"user_id": "jwt-user-scope-mismatch",
|
||||
"user_email": None,
|
||||
"end_user_id": None,
|
||||
"org_id": None,
|
||||
"team_membership": None,
|
||||
|
|
@ -2296,6 +2301,7 @@ class TestJWTOAuth2Coexistence:
|
|||
"token": jwt_token,
|
||||
"team_id": None,
|
||||
"user_id": "jwt-admin-user",
|
||||
"user_email": None,
|
||||
"end_user_id": None,
|
||||
"org_id": None,
|
||||
"team_membership": None,
|
||||
|
|
@ -4288,6 +4294,98 @@ async def test_auth_does_not_rewrite_cached_key_object_back_into_cache():
|
|||
setattr(_proxy_server_mod, k, v)
|
||||
|
||||
|
||||
class TestJWTAuthUserEmail:
|
||||
"""JWT auth must populate `UserAPIKeyAuth.user_email` (LIT-4238); it feeds
|
||||
the Prometheus `user_email` label and `user_api_key_user_email` in
|
||||
StandardLogging/SpendLogs metadata, which were always None for JWT traffic."""
|
||||
|
||||
def _jwt_request(self, jwt_token):
|
||||
mock_request = MagicMock()
|
||||
mock_request.url.path = "/v1/chat/completions"
|
||||
mock_request.method = "POST"
|
||||
mock_request.headers = {"authorization": f"Bearer {jwt_token}"}
|
||||
mock_request.query_params = {}
|
||||
return mock_request
|
||||
|
||||
async def _run_jwt_auth(self, mock_jwt_result, jwt_token):
|
||||
with (
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.general_settings",
|
||||
{"enable_jwt_auth": True},
|
||||
),
|
||||
patch("litellm.proxy.proxy_server.premium_user", True),
|
||||
patch("litellm.proxy.proxy_server.master_key", "sk-master"),
|
||||
patch("litellm.proxy.proxy_server.prisma_client", None),
|
||||
patch(
|
||||
"litellm.proxy.auth.user_api_key_auth.JWTAuthManager.auth_builder",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_jwt_result,
|
||||
),
|
||||
):
|
||||
litellm.proxy.proxy_server.jwt_handler.update_environment(
|
||||
prisma_client=None,
|
||||
user_api_key_cache=DualCache(),
|
||||
litellm_jwtauth=LiteLLM_JWTAuth(),
|
||||
)
|
||||
return await user_api_key_auth(
|
||||
request=self._jwt_request(jwt_token),
|
||||
api_key=f"Bearer {jwt_token}",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_auth_populates_user_email_on_valid_token(self):
|
||||
jwt_token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature"
|
||||
mock_jwt_result = {
|
||||
"is_proxy_admin": False,
|
||||
"team_object": None,
|
||||
"user_object": LiteLLM_UserTable(
|
||||
user_id="jwt-human-user",
|
||||
user_email="row@example.com",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
),
|
||||
"end_user_object": None,
|
||||
"org_object": None,
|
||||
"token": jwt_token,
|
||||
"team_id": None,
|
||||
"user_id": "jwt-human-user",
|
||||
"user_email": "resolved@example.com",
|
||||
"end_user_id": None,
|
||||
"org_id": None,
|
||||
"team_membership": None,
|
||||
"jwt_claims": {"sub": "user1"},
|
||||
}
|
||||
|
||||
result = await self._run_jwt_auth(mock_jwt_result, jwt_token)
|
||||
|
||||
assert result.user_id == "jwt-human-user"
|
||||
assert result.user_email == "resolved@example.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_jwt_auth_populates_user_email_on_proxy_admin(self):
|
||||
jwt_token = "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyMSJ9.signature"
|
||||
mock_jwt_result = {
|
||||
"is_proxy_admin": True,
|
||||
"team_object": None,
|
||||
"user_object": None,
|
||||
"end_user_object": None,
|
||||
"org_object": None,
|
||||
"token": jwt_token,
|
||||
"team_id": None,
|
||||
"user_id": "jwt-admin-user",
|
||||
"user_email": "admin@example.com",
|
||||
"end_user_id": None,
|
||||
"org_id": None,
|
||||
"team_membership": None,
|
||||
"jwt_claims": {"sub": "user1"},
|
||||
}
|
||||
|
||||
result = await self._run_jwt_auth(mock_jwt_result, jwt_token)
|
||||
|
||||
assert result.user_role == LitellmUserRoles.PROXY_ADMIN
|
||||
assert result.user_id == "jwt-admin-user"
|
||||
assert result.user_email == "admin@example.com"
|
||||
|
||||
|
||||
class TestCheckKeyModelBudgetWithFallback:
|
||||
"""`_check_key_model_budget_with_fallback` must reroute a request to the
|
||||
first configured `budget_fallbacks` entry still within its own budget,
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ describe("Logo", () => {
|
|||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a dash avatar when neither provider nor src is given", () => {
|
||||
render(<Logo />);
|
||||
it("renders a dash avatar when src is empty and the label has no characters", () => {
|
||||
render(<Logo src={null} label="" />);
|
||||
expect(screen.getByText("-")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,12 +2,10 @@ import React, { useState } from "react";
|
|||
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
|
||||
import { resolveLogoSrc } from "@/lib/assetPaths";
|
||||
|
||||
interface LogoProps {
|
||||
provider?: string;
|
||||
src?: string | null;
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
type LogoProps = { className?: string } & (
|
||||
| { provider: string; src?: never; label?: string }
|
||||
| { provider?: never; src: string | null | undefined; label: string }
|
||||
);
|
||||
|
||||
export const Logo: React.FC<LogoProps> = ({ provider, src, label, className = "w-4 h-4" }) => {
|
||||
const [erroredSrc, setErroredSrc] = useState<string | null>(null);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export const pageDescriptions: Record<string, string> = {
|
|||
"tool-policies": "Configure tool use policies and permissions",
|
||||
"vector-stores": "Manage vector databases for embeddings",
|
||||
new_usage: "View usage analytics and metrics",
|
||||
"cost-optimization": "Track and configure cost-saving features: prompt compression, caching, and auto routing",
|
||||
logs: "Access request and response logs",
|
||||
"guardrails-monitor": "Monitor guardrail performance and view logs",
|
||||
users: "Manage internal user accounts and permissions",
|
||||
|
|
|
|||
|
|
@ -114,13 +114,35 @@ describe("provider_info_helpers", () => {
|
|||
});
|
||||
|
||||
describe("provider logo bundled assets", () => {
|
||||
it("should expose every provider logo as a truthy bundled URL, never a raw /ui/assets path", () => {
|
||||
const logos = Object.values(providerLogoMap);
|
||||
expect(logos.length).toBeGreaterThan(0);
|
||||
logos.forEach((logo) => {
|
||||
expect(typeof logo).toBe("string");
|
||||
expect(logo.length).toBeGreaterThan(0);
|
||||
expect(logo.startsWith("/ui/assets/")).toBe(false);
|
||||
it("should map every provider to a bundled logo except the known logoless set, never a raw /ui/assets path", () => {
|
||||
const knownLogolessProviders = [
|
||||
Providers.AUTO_ROUTER,
|
||||
Providers.BYTEZ,
|
||||
Providers.CLARIFAI,
|
||||
Providers.COMPACTIFAI,
|
||||
Providers.DATAROBOT,
|
||||
Providers.DOCKER_MODEL_RUNNER,
|
||||
Providers.DOTPROMPT,
|
||||
Providers.EMPOWER,
|
||||
Providers.GALADRIEL,
|
||||
Providers.GradientAI,
|
||||
Providers.HEROKU,
|
||||
Providers.LEMONADE,
|
||||
Providers.LLAMAFILE,
|
||||
Providers.MARITALK,
|
||||
Providers.NLP_CLOUD,
|
||||
Providers.NSCALE,
|
||||
Providers.OVHCLOUD,
|
||||
Providers.PETALS,
|
||||
Providers.PG_VECTOR,
|
||||
Providers.PREDIBASE,
|
||||
Providers.WANDB,
|
||||
Providers.ZAI,
|
||||
];
|
||||
const logolessProviders = Object.values(Providers).filter((provider) => !providerLogoMap[provider]);
|
||||
expect([...logolessProviders].sort()).toEqual([...knownLogolessProviders].sort());
|
||||
Object.values(providerLogoMap).forEach((logo) => {
|
||||
expect(logo?.startsWith("/ui/assets/")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -283,7 +283,7 @@ export const provider_map: Record<string, string> = {
|
|||
|
||||
const standaloneSubproviderSlugs = new Set<string>(["bedrock_mantle"]);
|
||||
|
||||
export const providerLogoMap: Record<string, string> = {
|
||||
export const providerLogoMap: Partial<Record<Providers, string>> = {
|
||||
[Providers.A2A_Agent]: a2aAgentLogo.src,
|
||||
[Providers.AI21]: ai21Logo.src,
|
||||
[Providers.AI21_CHAT]: ai21Logo.src,
|
||||
|
|
@ -395,7 +395,7 @@ export const getProviderLogoAndName = (providerValue: string): { logo: string; d
|
|||
|
||||
// Get the display name from Providers enum and logo from map
|
||||
const displayName = Providers[enumKey as keyof typeof Providers];
|
||||
const logo = resolveLogoSrc(providerLogoMap[displayName as keyof typeof providerLogoMap]) ?? "";
|
||||
const logo = resolveLogoSrc(providerLogoMap[displayName]) ?? "";
|
||||
|
||||
return { logo, displayName };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -277,6 +277,13 @@ vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({
|
|||
useOrganizations: vi.fn().mockReturnValue({ data: [], refetch: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/keys/useSetKeyBlockedState", () => ({
|
||||
useSetKeyBlockedState: vi.fn().mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// useQueryClient also needs a provider; the delete-path invalidation is covered in key_info_view.test.tsx
|
||||
vi.mock("@tanstack/react-query", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@tanstack/react-query")>();
|
||||
|
|
|
|||
|
|
@ -662,11 +662,14 @@ describe("KeyEditView", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should resend existing budget windows on submit when they are left untouched", async () => {
|
||||
it("should omit budget_limits when existing windows are left untouched (issue #33246)", async () => {
|
||||
// The backend treats any budget_limits in the payload as an admin-only
|
||||
// budget change, so re-sending untouched windows 403s a non-admin owner.
|
||||
// Leaving the field off keeps the stored windows and passes the gate.
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
const keyDataWithWindow = {
|
||||
...MOCK_KEY_DATA,
|
||||
budget_limits: [{ budget_duration: "30d", max_budget: 100 }],
|
||||
budget_limits: [{ budget_duration: "30d", max_budget: 100, reset_at: "2026-08-01T00:00:00" }],
|
||||
};
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
|
|
@ -686,7 +689,66 @@ describe("KeyEditView", () => {
|
|||
await waitFor(() => {
|
||||
expect(onSubmitMock).toHaveBeenCalled();
|
||||
const callArgs = onSubmitMock.mock.calls[0][0];
|
||||
expect(callArgs.budget_limits).toEqual([{ budget_duration: "30d", max_budget: 100 }]);
|
||||
expect(callArgs.budget_limits).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should omit budget_limits on a key that has no windows (issue #33246 repro)", async () => {
|
||||
// Core repro: a non-admin owner edits a non-budget field on a key with no
|
||||
// budget windows. The form previously always sent budget_limits: [], which
|
||||
// the backend read as a budget change and rejected.
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA} // no budget_limits
|
||||
onCancel={() => {}}
|
||||
onSubmit={onSubmitMock}
|
||||
accessToken={"test-token"}
|
||||
userID={"test-user"}
|
||||
userRole={"admin"}
|
||||
premiumUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const submitButton = await screen.findByRole("button", { name: /save changes/i });
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmitMock).toHaveBeenCalled();
|
||||
const callArgs = onSubmitMock.mock.calls[0][0];
|
||||
expect(callArgs.budget_limits).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should send budget_limits when a window's cap is changed", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
const keyDataWithWindow = {
|
||||
...MOCK_KEY_DATA,
|
||||
budget_limits: [{ budget_duration: "30d", max_budget: 100 }],
|
||||
};
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={keyDataWithWindow}
|
||||
onCancel={() => {}}
|
||||
onSubmit={onSubmitMock}
|
||||
accessToken={"test-token"}
|
||||
userID={"test-user"}
|
||||
userRole={"admin"}
|
||||
premiumUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const maxBudgetInput = await screen.findByPlaceholderText("Max spend ($)");
|
||||
await userEvent.clear(maxBudgetInput);
|
||||
await userEvent.type(maxBudgetInput, "200");
|
||||
|
||||
const submitButton = screen.getByRole("button", { name: /save changes/i });
|
||||
await userEvent.click(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmitMock).toHaveBeenCalled();
|
||||
const callArgs = onSubmitMock.mock.calls[0][0];
|
||||
expect(callArgs.budget_limits).toEqual([{ budget_duration: "30d", max_budget: 200 }]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -311,14 +311,29 @@ export function KeyEditView({
|
|||
}
|
||||
|
||||
// Reconcile multi-window budget limits from the editor state, dropping
|
||||
// incomplete entries (no max_budget). Sending [] tells the backend to clear
|
||||
// all stored windows, so only send it when the user removed every window;
|
||||
// when entries remain but are still incomplete, omit the field so the saved
|
||||
// windows are left untouched (JSON.stringify drops the undefined key).
|
||||
// incomplete entries (no max_budget). The backend treats any budget_limits
|
||||
// in a /key/update request as an admin-only budget change, so re-sending
|
||||
// the stored windows on an unrelated edit 403s a non-admin key owner
|
||||
// (issue #33246). Only send the field when the user actually changed the
|
||||
// windows, mirroring how allowed_routes is dropped above when unchanged:
|
||||
// compare on (duration, cap), ignoring server-owned reset_at and order.
|
||||
// Sending [] clears every window, so send it only when the user removed
|
||||
// the last one; otherwise leave the field off (JSON.stringify drops the
|
||||
// undefined key) so an unchanged or incomplete editor state never touches
|
||||
// storage.
|
||||
const windowSignature = (windows: Array<{ budget_duration: string; max_budget: number | null }> | undefined) =>
|
||||
(windows ?? [])
|
||||
.filter((w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined)
|
||||
.map((w) => `${w.budget_duration}:${w.max_budget}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
const validWindows = budgetLimits.filter(
|
||||
(w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined,
|
||||
);
|
||||
if (validWindows.length > 0) {
|
||||
const budgetLimitsUnchanged = windowSignature(keyData.budget_limits) === windowSignature(validWindows);
|
||||
if (budgetLimitsUnchanged) {
|
||||
// no-op: leave budget_limits off the payload
|
||||
} else if (validWindows.length > 0) {
|
||||
values.budget_limits = validWindows;
|
||||
} else if (budgetLimits.length === 0) {
|
||||
values.budget_limits = [];
|
||||
|
|
|
|||
|
|
@ -147,6 +147,10 @@ describe("KeyInfoView", () => {
|
|||
showSSOBanner: false,
|
||||
};
|
||||
|
||||
const openMoreKeyActions = async () => {
|
||||
await userEvent.click(await screen.findByRole("button", { name: /more key actions/i }));
|
||||
};
|
||||
|
||||
it("should render tags", async () => {
|
||||
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
|
||||
|
||||
|
|
@ -203,8 +207,9 @@ describe("KeyInfoView", () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Regenerate Key")).toBeInTheDocument();
|
||||
expect(screen.getByText("Delete Key")).toBeInTheDocument();
|
||||
});
|
||||
await openMoreKeyActions();
|
||||
expect(await screen.findByRole("menuitem", { name: /delete key/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should allow team admin to modify key", async () => {
|
||||
|
|
@ -248,8 +253,9 @@ describe("KeyInfoView", () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Regenerate Key")).toBeInTheDocument();
|
||||
expect(screen.getByText("Delete Key")).toBeInTheDocument();
|
||||
});
|
||||
await openMoreKeyActions();
|
||||
expect(await screen.findByRole("menuitem", { name: /delete key/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should allow owner to modify their own key", async () => {
|
||||
|
|
@ -272,8 +278,9 @@ describe("KeyInfoView", () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Regenerate Key")).toBeInTheDocument();
|
||||
expect(screen.getByText("Delete Key")).toBeInTheDocument();
|
||||
});
|
||||
await openMoreKeyActions();
|
||||
expect(await screen.findByRole("menuitem", { name: /delete key/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not allow other user to modify key", async () => {
|
||||
|
|
@ -295,7 +302,7 @@ describe("KeyInfoView", () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Regenerate Key")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete Key")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /more key actions/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -319,7 +326,7 @@ describe("KeyInfoView", () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Regenerate Key")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete Key")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /more key actions/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -363,7 +370,7 @@ describe("KeyInfoView", () => {
|
|||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("Regenerate Key")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Delete Key")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /more key actions/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -573,9 +580,8 @@ describe("KeyInfoView", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /reset spend/i })).toBeInTheDocument();
|
||||
});
|
||||
await openMoreKeyActions();
|
||||
expect(await screen.findByRole("menuitem", { name: /reset spend/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Reset Spend button for team admin of key's team", async () => {
|
||||
|
|
@ -614,9 +620,8 @@ describe("KeyInfoView", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /reset spend/i })).toBeInTheDocument();
|
||||
});
|
||||
await openMoreKeyActions();
|
||||
expect(await screen.findByRole("menuitem", { name: /reset spend/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show Reset Spend button for regular key owner", async () => {
|
||||
|
|
@ -638,9 +643,9 @@ describe("KeyInfoView", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: /reset spend/i })).not.toBeInTheDocument();
|
||||
});
|
||||
await openMoreKeyActions();
|
||||
expect(await screen.findByRole("menuitem", { name: /delete key/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("menuitem", { name: /reset spend/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -663,11 +668,8 @@ describe("KeyInfoView", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /reset spend/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /reset spend/i }));
|
||||
await openMoreKeyActions();
|
||||
await userEvent.click(await screen.findByRole("menuitem", { name: /reset spend/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Reset Key Spend")).toBeInTheDocument();
|
||||
|
|
@ -694,11 +696,8 @@ describe("KeyInfoView", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /reset spend/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /reset spend/i }));
|
||||
await openMoreKeyActions();
|
||||
await userEvent.click(await screen.findByRole("menuitem", { name: /reset spend/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Reset Key Spend")).toBeInTheDocument();
|
||||
|
|
@ -808,7 +807,8 @@ describe("KeyInfoView", () => {
|
|||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(await screen.findByRole("button", { name: /delete key/i }));
|
||||
await openMoreKeyActions();
|
||||
await userEvent.click(await screen.findByRole("menuitem", { name: /delete key/i }));
|
||||
|
||||
const confirmInput = await screen.findByPlaceholderText(MOCK_KEY_DATA.key_alias);
|
||||
await userEvent.type(confirmInput, MOCK_KEY_DATA.key_alias);
|
||||
|
|
|
|||
|
|
@ -26,12 +26,12 @@ export const vectorStoreProviderMap: Record<string, string> = {
|
|||
};
|
||||
|
||||
export const vectorStoreProviderLogoMap: Record<string, string> = {
|
||||
[VectorStoreProviders.Bedrock]: providerLogoMap[Providers.Bedrock],
|
||||
[VectorStoreProviders.Bedrock]: providerLogoMap[Providers.Bedrock] ?? "",
|
||||
[VectorStoreProviders.PgVector]: postgresqlLogo.src,
|
||||
[VectorStoreProviders.VertexRagEngine]: providerLogoMap[Providers.Vertex_AI],
|
||||
[VectorStoreProviders.VertexAiSearch]: providerLogoMap[Providers.Vertex_AI],
|
||||
[VectorStoreProviders.OpenAI]: providerLogoMap[Providers.OpenAI],
|
||||
[VectorStoreProviders.Azure]: providerLogoMap[Providers.Azure],
|
||||
[VectorStoreProviders.VertexRagEngine]: providerLogoMap[Providers.Vertex_AI] ?? "",
|
||||
[VectorStoreProviders.VertexAiSearch]: providerLogoMap[Providers.Vertex_AI] ?? "",
|
||||
[VectorStoreProviders.OpenAI]: providerLogoMap[Providers.OpenAI] ?? "",
|
||||
[VectorStoreProviders.Azure]: providerLogoMap[Providers.Azure] ?? "",
|
||||
[VectorStoreProviders.Milvus]: milvusLogo.src,
|
||||
[VectorStoreProviders.S3Vectors]: s3VectorLogo.src,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ describe("resolveLogoSrc", () => {
|
|||
expect(resolveLogoSrc("//cdn.example.com/x.svg")).toBe("//cdn.example.com/x.svg");
|
||||
});
|
||||
|
||||
it("passes bundled /_next/ asset URLs through untouched even under a sub-path mount", async () => {
|
||||
it("passes bundled /_next/static/ asset URLs through untouched even under a sub-path mount", async () => {
|
||||
const { resolveLogoSrc } = await importWithRoot("/litellm");
|
||||
expect(resolveLogoSrc("/_next/static/media/openai_small.abc123.svg")).toBe(
|
||||
"/_next/static/media/openai_small.abc123.svg",
|
||||
|
|
@ -56,6 +56,14 @@ describe("resolveLogoSrc", () => {
|
|||
expect(resolveLogoSrc("/litellm-asset-prefix/_next/static/media/openai_small.abc123.svg")).toBe(
|
||||
"/litellm-asset-prefix/_next/static/media/openai_small.abc123.svg",
|
||||
);
|
||||
expect(resolveLogoSrc("/litellm/_next/static/media/openai_small.abc123.svg")).toBe(
|
||||
"/litellm/_next/static/media/openai_small.abc123.svg",
|
||||
);
|
||||
});
|
||||
|
||||
it("still prefixes a backend path that merely contains a /_next/ lookalike segment", async () => {
|
||||
const { resolveLogoSrc } = await importWithRoot("/litellm");
|
||||
expect(resolveLogoSrc("/ui/assets/logos/_next/logo.svg")).toBe("/litellm/ui/assets/logos/_next/logo.svg");
|
||||
});
|
||||
|
||||
it("roots a local asset path using the live server root path", async () => {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export const withServerRoot = (path: string, root: string): string => {
|
|||
export const resolveLogoSrc = (value: string | null | undefined, root: string = serverRootPath): string | undefined => {
|
||||
if (!value) return undefined;
|
||||
if (EXTERNAL_SRC.test(value)) return value;
|
||||
if (value.includes("/_next/")) return value;
|
||||
if (value.includes("/_next/static/")) return value;
|
||||
const prefix = normalizeRootPath(root);
|
||||
if (prefix && (value === prefix || value.startsWith(`${prefix}/`))) return value;
|
||||
return withServerRoot(value, root);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue