merge litellm_internal_staging into litellm_router_settings_admin_ui

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Krrish Dholakia 2026-07-22 01:46:27 +00:00
commit 0309f219d8
258 changed files with 16019 additions and 4544 deletions

View file

@ -30,7 +30,7 @@ body:
id: steps-to-reproduce
attributes:
label: Steps to Reproduce
description: Please provide detailed steps to reproduce this bug(A curl/python code to reproduce the bug)
description: Please provide a numbered list of the exact steps to reproduce this bug (include a curl/python snippet to reproduce it). Number each step (1., 2., 3., ...) in the order you performed them.
placeholder: |
1. config.yaml file/ .env file/ etc.
2. Run the following code...

View file

@ -1,3 +1,18 @@
## TLDR
<!-- Fill in the bullets below and keep each one short and concrete: one line per bullet, roughly 10 words max
This section must be extremely human parsable, comprehensible, and readable: its target audience is humans, not AI agents -->
Problem this solves:
- <blah>
- ...
How it solves it:
- <blah>
- ...
## Relevant issues
<!-- e.g., "Fixes #000" -->

View file

@ -115,6 +115,9 @@ jobs:
- name: check_fastuuid_usage
run: uv run --no-sync python ./tests/code_coverage_tests/check_fastuuid_usage.py
- name: check_e2e_no_raw_requests
run: uv run --no-sync python ./tests/code_coverage_tests/check_e2e_no_raw_requests.py
- name: memory_test
run: uv run --no-sync python ./tests/code_coverage_tests/memory_test.py

View 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

View file

@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "LiteLLM_SSOIdentityAssertion" (
"user_id" TEXT NOT NULL,
"assertion_b64" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LiteLLM_SSOIdentityAssertion_pkey" PRIMARY KEY ("user_id")
);

View file

@ -403,6 +403,15 @@ model LiteLLM_MCPServerOAuthClient {
updated_at DateTime @default(now()) @updatedAt @map("updated_at")
}
// The enterprise IdP identity assertion captured at SSO login, one row per user.
// assertion_b64 is an encrypted JSON payload: {id_token, refresh_token?, issuer?, expires_at?}.
model LiteLLM_SSOIdentityAssertion {
user_id String @id
assertion_b64 String
created_at DateTime @default(now()) @map("created_at")
updated_at DateTime @default(now()) @updatedAt @map("updated_at")
}
// Generate Tokens for Proxy
model LiteLLM_VerificationToken {
token String @id

View file

@ -1,6 +1,6 @@
[project]
name = "litellm-proxy-extras"
version = "0.4.79"
version = "0.4.80"
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
readme = "README.md"
requires-python = ">=3.9"
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
module-root = ""
[tool.commitizen]
version = "0.4.79"
version = "0.4.80"
version_files = [
"pyproject.toml:^version",
"../pyproject.toml:litellm-proxy-extras==",

View file

@ -50,3 +50,15 @@ pub(super) fn has_header(headers: &[(String, String)], name: &str) -> bool {
.iter()
.any(|(key, _)| key.eq_ignore_ascii_case(name))
}
pub(super) fn has_bearer_auth(headers: &[(String, String)]) -> bool {
headers.iter().any(|(name, value)| {
if !name.eq_ignore_ascii_case("authorization") {
return false;
}
let value = value.trim();
value.len() > 7
&& value[..7].eq_ignore_ascii_case("bearer ")
&& !value[7..].trim().is_empty()
})
}

View file

@ -3,7 +3,7 @@ use litellm_core::CoreResult;
use litellm_core::messages::transformation::MessagesAuthStrategy;
use litellm_core::routing_utils::provider::{CustomLlmProvider, get_custom_llm_provider};
use super::common_utils::{has_header, messages_provider_config, string_headers};
use super::common_utils::{has_bearer_auth, has_header, messages_provider_config, string_headers};
use super::types::{MessagesRequest, ProviderMessagesRequest};
pub(super) fn prepare_messages_call(
@ -33,7 +33,9 @@ pub(super) fn prepare_messages_call(
let mut headers = string_headers(request.extra_headers)?;
let auth_strategy = config.auth_strategy();
if !has_header(&headers, auth_strategy.header_name()) {
let already_authorized = has_header(&headers, auth_strategy.header_name())
|| (config.accepts_bearer_auth() && has_bearer_auth(&headers));
if !already_authorized {
let api_key = config.resolve_api_key(request.api_key, &env_lookup)?;
let auth_header = match auth_strategy {
MessagesAuthStrategy::Bearer => {

View file

@ -6,7 +6,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use super::common_utils::{
has_header, messages_provider_config, string_headers, truncate_error_body,
has_bearer_auth, has_header, messages_provider_config, string_headers, truncate_error_body,
};
use super::{MessagesRequest, messages};
@ -85,6 +85,34 @@ fn has_header_is_case_insensitive() {
assert!(!has_header(&headers, "authorization"));
}
#[test]
fn has_bearer_auth_requires_a_nonempty_bearer_token() {
assert!(has_bearer_auth(&[(
"Authorization".to_string(),
"Bearer tok".to_string()
)]));
assert!(has_bearer_auth(&[(
"authorization".to_string(),
"bearer tok".to_string()
)]));
assert!(!has_bearer_auth(&[(
"authorization".to_string(),
"Bearer ".to_string()
)]));
assert!(!has_bearer_auth(&[(
"authorization".to_string(),
String::new()
)]));
assert!(!has_bearer_auth(&[(
"authorization".to_string(),
"Basic abc".to_string()
)]));
assert!(!has_bearer_auth(&[(
"x-api-key".to_string(),
"sk".to_string()
)]));
}
#[tokio::test]
async fn messages_round_trip_builds_azure_request_and_passes_response_through() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("binds");
@ -252,6 +280,112 @@ async fn messages_does_not_duplicate_auth_when_x_api_key_supplied() {
assert!(!head.contains("rust-fallback-key"), "{head}");
}
#[tokio::test]
async fn messages_forwards_entra_id_bearer_without_requiring_api_key() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("binds");
let addr = listener.local_addr().expect("addr");
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("accepts request");
let request = read_http_request(&mut socket).await;
let response_body =
r#"{"id":"msg_3","type":"message","role":"assistant","content":[],"model":"m"}"#;
socket
.write_all(write_response(response_body).as_bytes())
.await
.expect("writes response");
request
});
let mut headers = Map::new();
headers.insert(
"Authorization".to_string(),
Value::String("Bearer entra-token".to_string()),
);
messages(MessagesRequest {
model: "claude-sonnet-4-5",
body: json!({"model": "claude-sonnet-4-5", "max_tokens": 8, "messages": []}),
api_key: None,
api_base: Some(&format!("http://{addr}")),
custom_llm_provider: Some("azure_ai"),
extra_headers: Some(headers),
timeout: Some(Duration::from_secs(5)),
})
.await
.expect("entra id request succeeds without api key");
let request = server.await.expect("server task completes");
let head = request
.split_once("\r\n\r\n")
.expect("has body")
.0
.to_ascii_lowercase();
assert!(head.contains("authorization: bearer entra-token"), "{head}");
assert!(!head.contains("x-api-key"), "{head}");
}
#[tokio::test]
async fn messages_requires_auth_when_no_key_and_no_header() {
let err = messages(MessagesRequest {
model: "claude-sonnet-4-5",
body: json!({"model": "claude-sonnet-4-5", "max_tokens": 8, "messages": []}),
api_key: None,
api_base: Some("http://127.0.0.1:1"),
custom_llm_provider: Some("azure_ai"),
extra_headers: None,
timeout: Some(Duration::from_millis(50)),
})
.await
.expect_err("missing auth errors");
assert!(matches!(err, CoreError::Auth(_)));
}
#[tokio::test]
async fn messages_ignores_malformed_authorization_and_uses_api_key() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("binds");
let addr = listener.local_addr().expect("addr");
let server = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.expect("accepts request");
let request = read_http_request(&mut socket).await;
let response_body =
r#"{"id":"msg_4","type":"message","role":"assistant","content":[],"model":"m"}"#;
socket
.write_all(write_response(response_body).as_bytes())
.await
.expect("writes response");
request
});
let mut headers = Map::new();
headers.insert(
"Authorization".to_string(),
Value::String("Bearer ".to_string()),
);
messages(MessagesRequest {
model: "claude-sonnet-4-5",
body: json!({"model": "claude-sonnet-4-5", "max_tokens": 8, "messages": []}),
api_key: Some("sk-azure"),
api_base: Some(&format!("http://{addr}")),
custom_llm_provider: Some("azure_ai"),
extra_headers: Some(headers),
timeout: Some(Duration::from_secs(5)),
})
.await
.expect("falls back to api key");
let request = server.await.expect("server task completes");
let head = request
.split_once("\r\n\r\n")
.expect("has body")
.0
.to_ascii_lowercase();
assert!(head.contains("x-api-key: sk-azure"), "{head}");
}
#[tokio::test]
async fn messages_maps_provider_error_status_to_http_error() {
let listener = TcpListener::bind("127.0.0.1:0").await.expect("binds");

View file

@ -35,6 +35,10 @@ pub trait AnthropicMessagesProviderConfig: Sync {
MessagesAuthStrategy::Header("x-api-key")
}
fn accepts_bearer_auth(&self) -> bool {
false
}
fn default_headers(&self) -> &'static [(&'static str, &'static str)] {
&[
("anthropic-version", "2023-06-01"),

View file

@ -163,6 +163,10 @@ impl AnthropicMessagesProviderConfig for AzureAnthropicMessagesConfig {
self.anthropic.auth_strategy()
}
fn accepts_bearer_auth(&self) -> bool {
true
}
fn default_headers(&self) -> &'static [(&'static str, &'static str)] {
self.anthropic.default_headers()
}
@ -294,6 +298,11 @@ mod tests {
);
}
#[test]
fn accepts_bearer_auth_for_entra_id() {
assert!(AZURE_ANTHROPIC_MESSAGES_CONFIG.accepts_bearer_auth());
}
#[test]
fn default_headers_match_python() {
assert_eq!(

View file

@ -1469,6 +1469,7 @@ _batch_polling_env = os.getenv("PROXY_BATCH_POLLING_ENABLED", "true").lower()
PROXY_BATCH_POLLING_ENABLED = _batch_polling_env == "true"
PROXY_BUDGET_RESCHEDULER_MAX_TIME = int(os.getenv("PROXY_BUDGET_RESCHEDULER_MAX_TIME", 605))
PROXY_BATCH_WRITE_AT = int(os.getenv("PROXY_BATCH_WRITE_AT", 10)) # in seconds, increased from 10
PROXY_CONFIG_RELOAD_INTERVAL_SECONDS = get_env_int("PROXY_CONFIG_RELOAD_INTERVAL_SECONDS", 30)
# APScheduler Configuration - MEMORY LEAK FIX
# These settings prevent memory leaks in APScheduler's normalize() and _apply_jitter() functions

View file

@ -148,6 +148,15 @@ def _parse_url_destination_allowlist_entry(
return _normalize_host(parsed.hostname), scheme, port
def provider_url_destination_candidates(value: str) -> Tuple[str, ...]:
return tuple(
candidate
for part in value.split(",")
for candidate in (part.strip(), part.strip().split("/", 1)[1] if "/" in part.strip() else "")
if candidate
)
def is_url_destination_allowed_by_host(url: str, allowed_hosts: List[str]) -> bool:
"""Return True when a credential-bearing provider URL is admin-allowlisted.

View file

@ -2107,8 +2107,7 @@ class BaseLLMHTTPHandler:
rust_messages_response = await self._maybe_rust_anthropic_messages(
custom_llm_provider=custom_llm_provider,
litellm_params=litellm_params,
stream=stream or False,
rust_stream_eligible=bool(stream) and not self._has_agentic_completion_hook(logging_obj),
has_agentic_hook=self._has_agentic_completion_hook(logging_obj),
model=model,
api_key=api_key,
api_base=api_base,
@ -2266,8 +2265,7 @@ class BaseLLMHTTPHandler:
*,
custom_llm_provider: str,
litellm_params: GenericLiteLLMParams,
stream: bool,
rust_stream_eligible: bool,
has_agentic_hook: bool,
model: str,
api_key: str | None,
api_base: str | None,
@ -2279,7 +2277,7 @@ class BaseLLMHTTPHandler:
return None
if litellm_params.get("rust") is not True and not BaseLLMHTTPHandler._rust_env_enabled():
return None
if stream and not rust_stream_eligible:
if has_agentic_hook:
return None
from litellm.rust_bridge import messages as rust_messages_bridge

View file

@ -322,7 +322,7 @@ class HuggingFaceEmbedding(BaseLLM):
task = get_hf_task_embedding_for_model(model=model, task_type=task_type, api_base=HF_HUB_URL)
# print_verbose(f"{model}, {task}")
embed_url = ""
if "https" in model:
if model.startswith(("http://", "https://")):
embed_url = model
elif api_base:
embed_url = api_base

View file

@ -316,25 +316,6 @@ class HuggingFaceEmbeddingConfig(BaseConfig):
return data
def get_api_base(self, api_base: Optional[str], model: str) -> str:
"""
Get the API base for the Huggingface API.
Do not add the chat/embedding/rerank extension here. Let the handler do this.
"""
if "https" in model:
completion_url = model
elif api_base is not None:
completion_url = api_base
elif "HF_API_BASE" in os.environ:
completion_url = os.getenv("HF_API_BASE", "")
elif "HUGGINGFACE_API_BASE" in os.environ:
completion_url = os.getenv("HUGGINGFACE_API_BASE", "")
else:
completion_url = f"https://api-inference.huggingface.co/models/{model}"
return completion_url
def validate_environment(
self,
headers: Dict,

View file

@ -34,7 +34,7 @@ def completion(
optional_params=optional_params,
litellm_params=litellm_params,
)
if "https" in model:
if model.startswith(("http://", "https://")):
completion_url = model
elif api_base:
completion_url = api_base
@ -96,7 +96,7 @@ def embedding(
encoding=None,
):
# Create completion URL
if "https" in model:
if model.startswith(("http://", "https://")):
embeddings_url = model
elif api_base:
embeddings_url = f"{api_base}/v1/embeddings"

View file

@ -5111,7 +5111,10 @@ def completion( # type: ignore
try:
if base_url is not None:
api_base = base_url
if num_retries is not None:
is_router_call = any("model_group" in (kwargs.get(k) or ()) for k in ("metadata", "litellm_metadata"))
if is_router_call:
max_retries = 0
elif num_retries is not None:
max_retries = num_retries
logging: LiteLLMLoggingObj = cast(LiteLLMLoggingObj, litellm_logging_obj)
fallbacks = fallbacks or litellm.model_fallbacks

View file

@ -597,7 +597,14 @@ async def authorize_with_server(
):
_raise_if_not_oauth2(mcp_server)
if mcp_server.authorization_url is None:
raise HTTPException(status_code=400, detail="MCP server authorization url is not set")
raise HTTPException(
status_code=400,
detail=(
"MCP server authorization url is not configured. Servers with no url (OpenAPI "
"spec or stdio) run no resource discovery, so set Authorization URL and Token URL "
"manually, or set Issuer to discover them from the identity provider (RFC 8414)."
),
)
if mcp_server.is_dcr_bridge:
# Enforce S256 PKCE on both bridge arms. The relay arm forwards the validated,
@ -702,7 +709,14 @@ async def exchange_token_with_server(
raise HTTPException(status_code=400, detail="Unsupported grant_type")
if mcp_server.token_url is None:
raise HTTPException(status_code=400, detail="MCP server token url is not set")
raise HTTPException(
status_code=400,
detail=(
"MCP server token url is not configured. Servers with no url (OpenAPI spec or "
"stdio) run no resource discovery, so set Token URL manually, or set Issuer to "
"discover it from the identity provider (RFC 8414)."
),
)
# The id and secret must come from the same source. When the server-side client_id wins,
# falling back to the caller's secret pairs the persisted client with a foreign secret; the
@ -1262,7 +1276,14 @@ async def register_client_with_server(
return dummy_return
if mcp_server.authorization_url is None:
raise HTTPException(status_code=400, detail="MCP server authorization url is not set")
raise HTTPException(
status_code=400,
detail=(
"MCP server authorization url is not configured. Servers with no url (OpenAPI "
"spec or stdio) run no resource discovery, so set Authorization URL and Token URL "
"manually, or set Issuer to discover them from the identity provider (RFC 8414)."
),
)
if mcp_server.registration_url is None:
return dummy_return

View file

@ -224,6 +224,20 @@ def _uses_issuer_anchor(manual_issuer: str | None, is_discovery_auth_type: bool)
return _blank_to_none(manual_issuer) is not None and is_discovery_auth_type
def _has_oauth_discovery_source(server_url: str | None, use_issuer_anchor: bool) -> bool:
"""Whether the server has any source OAuth discovery can fetch metadata from.
Resource-rooted discovery (RFC 9728) is fetched from the server ``url``, so spec-only
(OpenAPI) and stdio servers, which have none, could never discover: their OAuth endpoints
stayed unset unless entered manually and ``/authorize`` served its 400 with no hint of why.
An admin-pinned issuer is a trust anchor in its own right (RFC 8414 section 3.3) whose
metadata fetch does not touch the resource at all, so an anchored server can discover with
no ``url``. Called by both build paths (config and DB) so the two cannot disagree on when
discovery is reachable.
"""
return bool(server_url) or use_issuer_anchor
def _endpoints_yield_to_issuer(
issuer: str | None,
is_discovery_auth_type: bool,
@ -610,6 +624,34 @@ def _passthrough_token_from_mcp_auth_header(
return None
async def _materialize_auth_headers(auth: httpx.Auth | None) -> dict[str, str] | None:
"""Extract the header a resolved ``httpx.Auth`` would set, as a plain dict, or None.
OpenAPI tool closures egress through ``AsyncHTTPHandler`` methods that accept headers but no
``auth``, so a resolved credential must be materialized into a header value. Driving one step
of the auth's own flow (against a throwaway request that is never sent) keeps this generic
across every auth shape without per-class branching; ``header_name`` is the resolver-arm
convention for "this auth sets a header" (``NoOpAuth`` has none and yields nothing to apply).
The materialized value is point-in-time: flow behaviors past the first request, like the M2M
one-shot 401 refetch, do not apply on this arm.
"""
if auth is None:
return None
header_name = getattr(auth, "header_name", None)
if not isinstance(header_name, str) or not header_name:
return None
probe = httpx.Request("GET", "http://localhost/")
flow = auth.async_auth_flow(probe)
try:
first_request = await flow.__anext__()
except StopAsyncIteration:
return None
finally:
await flow.aclose()
header_value = first_request.headers.get(header_name)
return {header_name: header_value} if header_value else None
def _consumes_caller_authorization(server: MCPServer) -> bool:
"""True when this server's egress forwards the caller's request-wide ``Authorization`` upstream:
the client-forwarded token modes, legacy OAuth pass-through, and legacy upstream-delegated
@ -1226,7 +1268,12 @@ class MCPServerManager:
manual_token_url = _blank_to_none(server_config.get("token_url"))
manual_registration_url = _blank_to_none(server_config.get("registration_url"))
is_discovery_auth_type = auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES
use_issuer_anchor = _uses_issuer_anchor(manual_issuer, is_discovery_auth_type)
obo_needs_discovery = self._obo_needs_endpoint_discovery(
auth_type,
server_config.get("token_exchange_endpoint"),
manual_token_url,
)
use_issuer_anchor = _uses_issuer_anchor(manual_issuer, is_discovery_auth_type or obo_needs_discovery)
manual_authorization_url, manual_token_url, manual_registration_url = _endpoints_yield_to_issuer(
manual_issuer,
is_discovery_auth_type,
@ -1234,17 +1281,12 @@ class MCPServerManager:
manual_token_url,
manual_registration_url,
)
should_discover = bool(server_url) and (
is_discovery_auth_type
or self._obo_needs_endpoint_discovery(
auth_type,
server_config.get("token_exchange_endpoint"),
manual_token_url,
)
should_discover = _has_oauth_discovery_source(server_url, use_issuer_anchor) and (
is_discovery_auth_type or obo_needs_discovery
)
if not should_discover:
mcp_oauth_metadata = None
elif manual_issuer is not None and is_discovery_auth_type:
elif use_issuer_anchor and manual_issuer is not None:
mcp_oauth_metadata = await self._fetch_issuer_anchored_oauth_metadata(manual_issuer, server_url)
else:
mcp_oauth_metadata = await self._descovery_metadata(
@ -1640,7 +1682,7 @@ class MCPServerManager:
token_exchange_endpoint: Optional[str],
) -> Optional[MCPOAuthMetadata]:
has_all_upstream_oauth_fields = bool(manual_authorization_url and manual_token_url and scopes)
needs_discovery = bool(server_url) and (
needs_discovery = _has_oauth_discovery_source(server_url, use_issuer_anchor) and (
(is_discovery_auth_type and not has_all_upstream_oauth_fields)
or self._obo_needs_endpoint_discovery(auth_type, token_exchange_endpoint, manual_token_url)
)
@ -1759,13 +1801,17 @@ class MCPServerManager:
manual_token_url = _blank_to_none(mcp_server.token_url)
manual_registration_url = _blank_to_none(mcp_server.registration_url)
is_discovery_auth_type = auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES
use_issuer_anchor = _uses_issuer_anchor(manual_issuer, is_discovery_auth_type)
manual_authorization_url, manual_token_url, manual_registration_url = _endpoints_yield_to_issuer(
manual_issuer, is_discovery_auth_type, manual_authorization_url, manual_token_url, manual_registration_url
)
token_exchange_endpoint = mcp_server.token_exchange_endpoint or (
credentials_dict.get("token_exchange_endpoint") if credentials_dict else None
)
use_issuer_anchor = _uses_issuer_anchor(
manual_issuer,
is_discovery_auth_type
or self._obo_needs_endpoint_discovery(auth_type, token_exchange_endpoint, manual_token_url),
)
manual_authorization_url, manual_token_url, manual_registration_url = _endpoints_yield_to_issuer(
manual_issuer, is_discovery_auth_type, manual_authorization_url, manual_token_url, manual_registration_url
)
gated_oauth_metadata = await self._resolve_table_oauth_metadata(
mcp_server=mcp_server,
auth_type=auth_type,
@ -1943,7 +1989,7 @@ class MCPServerManager:
family: discovered ``authorization_url``/``token_url``/``scopes`` otherwise live only on
the in-memory registry entry, which is rebuilt on every client connect (the DCR reuse path
calls ``update_server``) and on every post-write DB reload, so one failed re-discovery
serves 400 "authorization url is not set" from /authorize until a later rebuild succeeds.
serves the 400 "authorization url is not configured" from /authorize until a later rebuild succeeds.
Only fills row fields that are currently empty, never persists origin-fallback guesses
(RFC 9728/8414-advertised metadata only), and deliberately skips ``registration_url``
because ``_dcr_bridge_relays_client_registration`` keys off that column. Best-effort: a
@ -4705,6 +4751,61 @@ class MCPServerManager:
)
return oauth2_headers
async def resolve_openapi_upstream_auth(
self,
*,
mcp_server: MCPServer,
oauth2_headers: dict[str, str] | None,
raw_headers: dict[str, str] | None,
mcp_auth_header: str | dict[str, str] | None,
user_api_key_auth: UserAPIKeyAuth | None,
forwarded_headers: dict[str, str] | None,
) -> tuple[dict[str, str] | None, dict[str, str] | None]:
"""Resolve the gateway-owned upstream credential for a spec_path (OpenAPI) tool call.
OpenAPI tools egress through a plain httpx call assembled from ContextVars, never through
``_create_mcp_client``, so the v2 resolver graft there does not run for them and a resolved
credential (authorization_code's stored per-user token, client_credentials' minted M2M
token, token_exchange's exchanged token, passthrough's forwarded caller token) must be
materialized into headers here. Returns ``(resolved_auth_headers, forwarded_headers)``:
the resolved headers are authoritative over every other Authorization source (the same
rule ``_resolve_v2_auth`` applies on the MCPClient path) and ``forwarded_headers`` comes
back with any header the resolver claimed already dropped. Unmigrated (v1) servers resolve
through the stored-token lookup instead, and a missing per-user credential raises the same
discovery challenge the MCPClient path serves, rather than egressing unauthenticated.
The resolved headers carry only credentials the gateway itself resolved (a stored per-user
token, a minted or exchanged token). Caller-supplied ``oauth2_headers`` are never promoted
into them: on the v2 arm they feed only subject-token extraction (the designed RFC 8693
input), and on the v1 arm their presence disables the stored lookup entirely, so a
caller's gateway credential can never displace a per-server BYOK header or leak upstream
as the resolved credential.
"""
spec = to_server_spec(mcp_server)
if spec is None:
if oauth2_headers:
return None, forwarded_headers
stored_headers = await self._resolve_oauth2_headers_for_tool_call(mcp_server, None, user_api_key_auth)
return stored_headers, forwarded_headers
subject_token: str | None = None
if isinstance(spec.config, (TokenExchangeConfig, IdJagConfig)):
subject_token = self._extract_bearer_token(oauth2_headers, raw_headers)
elif isinstance(spec.config, PassthroughConfig):
inbound_token, forwarded_headers = _take_forwarded_authorization(forwarded_headers)
per_server_token = _passthrough_token_from_mcp_auth_header(mcp_auth_header)
subject_token = per_server_token if per_server_token is not None else inbound_token
resolved_auth, forwarded_headers = await self._resolve_v2_auth(
server=mcp_server,
spec=spec,
provider=self._cred_provider,
subject_token=subject_token,
user_api_key_auth=user_api_key_auth,
extra_headers=forwarded_headers,
)
return await _materialize_auth_headers(resolved_auth), forwarded_headers
async def _gather_openapi_tool_tasks(
self,
tasks: list[Any],
@ -4796,6 +4897,7 @@ class MCPServerManager:
)
tasks.append(during_hook_task)
caller_oauth2_headers = oauth2_headers
oauth2_headers = await self._resolve_oauth2_headers_for_tool_call(mcp_server, oauth2_headers, user_api_key_auth)
# For OpenAPI servers, call the tool handler directly instead of via MCP client
@ -4813,22 +4915,32 @@ class MCPServerManager:
auth_header_value = (
_format_byok_openapi_auth_header(mcp_server, mcp_auth_header) if mcp_auth_header else None
)
forwarded_headers = _openapi_forwarded_extra_headers(mcp_server, raw_headers, user_api_key_auth)
resolved_auth_headers, forwarded_headers = await self.resolve_openapi_upstream_auth(
mcp_server=mcp_server,
oauth2_headers=caller_oauth2_headers,
raw_headers=raw_headers,
mcp_auth_header=mcp_auth_header,
user_api_key_auth=user_api_key_auth,
forwarded_headers=_openapi_forwarded_extra_headers(mcp_server, raw_headers, user_api_key_auth),
)
async def _call_openapi_via_handler():
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
_request_auth_header,
_request_extra_headers,
_request_resolved_auth_headers,
)
auth_token = _request_auth_header.set(auth_header_value)
extra_token = _request_extra_headers.set(forwarded_headers)
resolved_token = _request_resolved_auth_headers.set(resolved_auth_headers)
try:
async with self._limit_outbound_concurrency(mcp_server):
return await self._call_openapi_tool_handler(mcp_server, name, arguments)
finally:
_request_auth_header.reset(auth_token)
_request_extra_headers.reset(extra_token)
_request_resolved_auth_headers.reset(resolved_token)
tasks.append(asyncio.create_task(_call_openapi_via_handler()))
else:

View file

@ -62,6 +62,14 @@ _request_extra_headers: contextvars.ContextVar[Optional[Dict[str, str]]] = conte
"_request_extra_headers", default=None
)
# Per-request headers carrying the gateway-resolved upstream credential
# (stored per-user OAuth token, minted M2M token, exchanged OBO token).
# Set from MCPServerManager.resolve_openapi_upstream_auth; authoritative
# over every other Authorization source in _merge_openapi_tool_request_headers.
_request_resolved_auth_headers: contextvars.ContextVar[dict[str, str] | None] = contextvars.ContextVar(
"_request_resolved_auth_headers", default=None
)
def _sanitize_path_parameter_value(param_value: Any, param_name: str) -> str:
"""Ensure path params cannot introduce directory traversal."""
@ -294,10 +302,15 @@ def _merge_openapi_tool_request_headers(
"""Merge static closure headers with per-request ContextVar overrides.
Precedence (highest to lowest):
1. ``_request_auth_header`` BYOK override of ``Authorization``
2. ``static_headers`` operator-configured headers baked into the
1. ``_request_resolved_auth_headers`` the gateway-resolved upstream
credential (stored per-user OAuth token, minted M2M token,
exchanged OBO token). The resolver is authoritative: a BYOK or
forwarded ``Authorization`` must not shadow it, mirroring
``_resolve_v2_auth`` on the MCPClient path
2. ``_request_auth_header`` BYOK override of ``Authorization``
3. ``static_headers`` operator-configured headers baked into the
tool closure at registration time
3. ``_request_extra_headers`` per-request headers forwarded from
4. ``_request_extra_headers`` per-request headers forwarded from
the MCP caller (allowlisted by ``MCPServer.extra_headers``)
This matches the existing MCP invariant in
@ -323,6 +336,12 @@ def _merge_openapi_tool_request_headers(
del effective_headers[existing]
effective_headers["Authorization"] = override_auth
resolved_auth_headers = _request_resolved_auth_headers.get() or {}
for name, value in resolved_auth_headers.items():
for existing in [k for k in effective_headers if k.lower() == name.lower()]:
del effective_headers[existing]
effective_headers[name] = value
return effective_headers

View file

@ -0,0 +1,214 @@
"""Store for the enterprise IdP identity assertion captured at SSO login (EMA).
The ``oauth2_id_jag`` egress arm needs the user's IdP ``id_token`` as its RFC 8693
``subject_token``. A front-door client holds an identity-only ``llm_session_`` bearer, not an
IdP assertion, so the assertion captured at the one SSO login is the only usable subject
source for it. This module owns both sides of that state: the SSO callback persists here
(write-through to the DB so a login on one pod is visible to every pod) and the resolver
seam reads back by ``user_id``. Retention is gated on an ``oauth2_id_jag`` server actually
being registered, so a gateway with no EMA upstream never stores bearer material.
The row is one encrypted payload per user, latest login wins. ``expires_at`` mirrors the
id_token ``exp`` claim and is judged by the reader, never enforced by deletion here: an
expired assertion with a refresh token is still renewable, and the DB row is the source of
truth, the same contract as the per-user OAuth credential store.
"""
from __future__ import annotations
import json
from datetime import datetime, timezone
from typing import TYPE_CHECKING
import jwt
from pydantic import BaseModel, ConfigDict, SecretStr, TypeAdapter, ValidationError
from litellm._logging import verbose_proxy_logger
if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient
_ASSERTION_DECRYPT_LOG_KEY = "sso_identity_assertion"
_STR_ADAPTER: TypeAdapter[str] = TypeAdapter(str)
_MAYBE_STR_ADAPTER: TypeAdapter[str | None] = TypeAdapter(str | None)
class SSOIdentityAssertion(BaseModel):
"""The IdP material an EMA exchange needs: ``id_token`` is the RFC 8693 subject token,
``expires_at`` bounds its usefulness, and the refresh token renews it without re-login."""
model_config = ConfigDict(frozen=True)
id_token: SecretStr
refresh_token: SecretStr | None = None
issuer: str | None = None
expires_at: datetime | None = None
class _IdTokenClaims(BaseModel):
exp: float | None = None
iss: str | None = None
class _StoredAssertionPayload(BaseModel):
id_token: str
refresh_token: str | None = None
issuer: str | None = None
expires_at: datetime | None = None
def assertion_from_sso_login(id_token: object, refresh_token: object) -> SSOIdentityAssertion | None:
"""The typed carrier built where the raw token response exists; ``None`` when the provider
sent no id_token or sent one that is not a decodable JWT, since neither is exchangeable
under EMA. Inputs are ``object`` because they come straight from the provider's untyped
token response; this is the one boundary that validates them. The token arrived over TLS
from the IdP's own token endpoint, so claims are read without signature verification,
matching how the SSO callback already decodes it for identity."""
raw_id_token = id_token if isinstance(id_token, str) and id_token else None
if raw_id_token is None:
return None
raw_refresh_token = refresh_token if isinstance(refresh_token, str) and refresh_token else None
try:
claims = _IdTokenClaims.model_validate(jwt.decode(raw_id_token, options={"verify_signature": False}))
expires_at = datetime.fromtimestamp(claims.exp, tz=timezone.utc) if claims.exp is not None else None
except Exception: # noqa: BLE001 # decode failure = not retainable; never raise into login
verbose_proxy_logger.warning(
"SSO id_token could not be decoded or its claims were unusable; not retaining it for EMA egress."
)
return None
return SSOIdentityAssertion(
id_token=SecretStr(raw_id_token),
refresh_token=SecretStr(raw_refresh_token) if raw_refresh_token else None,
issuer=claims.iss,
expires_at=expires_at,
)
async def ema_assertion_retention_enabled() -> bool:
"""Whether any MCP server uses ``oauth2_id_jag``, evaluated per login so the gateway only
retains bearer material while an EMA upstream exists to spend it on. Judged against the two
configuration authorities: the pod-local config declaration and the shared DB row. The
in-memory registry is deliberately not consulted in either direction; it is a per-process
snapshot of the DB state that can be stale both ways (a server added on another pod would
silently drop the write, one removed on another pod would keep retaining bearer material),
and a gate guarding a shared-DB write must judge against that storage's authority."""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # avoids import cycle
global_mcp_server_manager,
)
from litellm.proxy.proxy_server import prisma_client # noqa: PLC0415 # runtime global
from litellm.types.mcp import MCPAuth # noqa: PLC0415 # runtime global
config_servers = global_mcp_server_manager.config_mcp_servers.values()
if any(server.auth_type == MCPAuth.oauth2_id_jag for server in config_servers):
return True
if prisma_client is None:
return False
row = await prisma_client.db.litellm_mcpservertable.find_first(where={"auth_type": MCPAuth.oauth2_id_jag.value})
return row is not None
async def persist_sso_identity_assertion(user_id: str, assertion: SSOIdentityAssertion) -> None:
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper # noqa: PLC0415 # runtime global
from litellm.proxy.proxy_server import prisma_client # noqa: PLC0415 # runtime global
if prisma_client is None:
return
payload: dict[str, str] = {
"id_token": assertion.id_token.get_secret_value(),
**({"refresh_token": assertion.refresh_token.get_secret_value()} if assertion.refresh_token else {}),
**({"issuer": assertion.issuer} if assertion.issuer else {}),
**({"expires_at": assertion.expires_at.isoformat()} if assertion.expires_at else {}),
}
encoded = _STR_ADAPTER.validate_python(encrypt_value_helper(json.dumps(payload)))
await prisma_client.db.litellm_ssoidentityassertion.upsert(
where={"user_id": user_id},
data={
"create": {"user_id": user_id, "assertion_b64": encoded},
"update": {"assertion_b64": encoded},
},
)
async def fetch_sso_identity_assertion(user_id: str) -> SSOIdentityAssertion | None:
"""The stored assertion for ``user_id``, or ``None`` when absent, undecryptable (salt-key
rotation), or unparseable. Expiry is not judged here; the reader owns that policy."""
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper # noqa: PLC0415 # runtime global
from litellm.proxy.proxy_server import prisma_client # noqa: PLC0415 # runtime global
if prisma_client is None:
return None
row = await prisma_client.db.litellm_ssoidentityassertion.find_unique(where={"user_id": user_id})
if row is None:
return None
raw = _MAYBE_STR_ADAPTER.validate_python(
decrypt_value_helper(row.assertion_b64, _ASSERTION_DECRYPT_LOG_KEY, exception_type="debug")
)
if raw is None:
return None
try:
payload = _StoredAssertionPayload.model_validate_json(raw)
except ValidationError:
verbose_proxy_logger.warning(
"Stored SSO identity assertion for user_id=%s could not be parsed; treating as absent.", user_id
)
return None
return SSOIdentityAssertion(
id_token=SecretStr(payload.id_token),
refresh_token=SecretStr(payload.refresh_token) if payload.refresh_token else None,
issuer=payload.issuer,
expires_at=payload.expires_at,
)
async def rotate_sso_identity_assertions_master_key(prisma_client: PrismaClient, new_master_key: str) -> None:
"""Re-encrypt every stored assertion under ``new_master_key`` during a salt-key rotation,
mirroring the sibling per-user credential tables; an unreadable row is skipped so one
corrupt row does not abort the rotation. Rows are decrypted one at a time inside the loop
so the whole table's plaintext is never held in memory at once."""
from prisma.models import LiteLLM_SSOIdentityAssertion as AssertionRow # noqa: PLC0415 # generated at runtime
from litellm.proxy.common_utils.encrypt_decrypt_utils import ( # noqa: PLC0415 # runtime global
decrypt_value_helper,
encrypt_value_helper,
)
async def _rotate_row(row: AssertionRow) -> bool:
plaintext = _MAYBE_STR_ADAPTER.validate_python(
decrypt_value_helper(row.assertion_b64, _ASSERTION_DECRYPT_LOG_KEY, exception_type="debug")
)
if plaintext is None:
verbose_proxy_logger.warning(
"rotate_sso_identity_assertions_master_key: could not decrypt assertion for user_id=%s, skipping",
row.user_id,
)
return False
re_encrypted = _STR_ADAPTER.validate_python(encrypt_value_helper(plaintext, new_encryption_key=new_master_key))
await prisma_client.db.litellm_ssoidentityassertion.update(
where={"user_id": row.user_id},
data={"assertion_b64": re_encrypted},
)
return True
rows = await prisma_client.db.litellm_ssoidentityassertion.find_many()
outcomes = [await _rotate_row(row) for row in rows]
verbose_proxy_logger.info(
"rotate_sso_identity_assertions_master_key: rotated %d row(s), skipped %d",
sum(outcomes),
len(outcomes) - sum(outcomes),
)
async def retain_sso_identity_assertion_for_ema(user_id: str, assertion: SSOIdentityAssertion | None) -> None:
"""The SSO-callback hook: a no-op unless there is material AND an EMA server is registered.
A store failure is logged and swallowed because the login itself must not fail on an
egress-side write; the cost of a miss is a 401 challenge at the EMA upstream, not a lockout."""
if assertion is None:
return
try:
if not await ema_assertion_retention_enabled():
return
await persist_sso_identity_assertion(user_id, assertion)
except Exception as exc: # noqa: BLE001 # the login itself must not fail on an egress-side write
verbose_proxy_logger.warning(
"Failed to persist the SSO identity assertion for EMA egress (user_id=%s): %s", user_id, exc
)

View file

@ -376,6 +376,7 @@ if MCP_AVAILABLE:
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
_request_auth_header,
_request_extra_headers,
_request_resolved_auth_headers,
)
from litellm.proxy._experimental.mcp_server.sse_transport import SseServerTransport
from litellm.proxy._experimental.mcp_server.tool_registry import (
@ -2785,13 +2786,29 @@ if MCP_AVAILABLE:
forwarded_headers = {}
forwarded_headers[header_name] = value
resolved_auth_headers: dict[str, str] | None = None
if mcp_server:
(
resolved_auth_headers,
forwarded_headers,
) = await global_mcp_server_manager.resolve_openapi_upstream_auth(
mcp_server=mcp_server,
oauth2_headers=oauth2_headers,
raw_headers=raw_headers,
mcp_auth_header=mcp_auth_header,
user_api_key_auth=user_api_key_auth,
forwarded_headers=forwarded_headers,
)
_auth_token = _request_auth_header.set(auth_header_value)
_extra_token = _request_extra_headers.set(forwarded_headers)
_resolved_token = _request_resolved_auth_headers.set(resolved_auth_headers)
try:
local_content = await _handle_local_mcp_tool(name, arguments)
finally:
_request_auth_header.reset(_auth_token)
_request_extra_headers.reset(_extra_token)
_request_resolved_auth_headers.reset(_resolved_token)
response = CallToolResult(content=cast(Any, local_content), isError=False)
# Try managed MCP server tool (pass the full prefixed name)

View file

@ -2297,6 +2297,11 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
None,
description="max response size in MB, if a response is larger than this size it will be rejected",
)
proxy_config_reload_interval_seconds: int = Field(
30,
gt=0,
description="how often (in seconds) each pod reloads config-in-DB objects (models, credentials, guardrails, etc.) when store_model_in_db is enabled; lower values speed up multi-pod convergence at the cost of more DB load. Applied on proxy startup",
)
cancel_on_disconnect: Optional[bool] = Field(
None,
description="cancel the in-flight upstream LLM request (non-streaming) when the client disconnects, freeing backend capacity (e.g. a vLLM GPU slot); the request is logged as a 499 failure",
@ -4047,6 +4052,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]

View file

@ -3,7 +3,7 @@ import re
import sys
from functools import lru_cache
from logging import Logger
from typing import Any, Dict, FrozenSet, List, Mapping, Optional, Tuple, Union
from typing import Any, Dict, FrozenSet, Iterator, List, Mapping, Optional, Tuple, Union
from fastapi import HTTPException, Request, status
@ -12,7 +12,12 @@ from litellm import Router, provider_list
from litellm._logging import verbose_proxy_logger
from litellm.constants import MINIMUM_CUSTOM_KEY_LENGTH, STANDARD_CUSTOMER_ID_HEADERS
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.litellm_core_utils.url_utils import SSRFError, validate_url
from litellm.litellm_core_utils.url_utils import (
SSRFError,
is_url_destination_allowed_by_host,
provider_url_destination_candidates,
validate_url,
)
from litellm.proxy._types import *
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
LITELLM_PASS_THROUGH_ENDPOINT_MARKER,
@ -290,6 +295,7 @@ _BANNED_REQUEST_BODY_PARAMS: Tuple[str, ...] = (
"use_ssl",
# SDK-only field; also rejected outright in is_request_body_safe.
"model_list",
"vertex_ai_credentials",
# Observability credentials, hosts, and project identifiers: derived
# from the canonical ``_supported_callback_params`` allowlist so new
# integrations are covered automatically. Sorted for stable iteration
@ -342,6 +348,60 @@ def _check_banned_params(
)
_FALLBACK_FIELDS: tuple[str, ...] = (
"fallbacks",
"context_window_fallbacks",
"content_policy_fallbacks",
)
def _iter_fallback_field_values(request_body: Mapping[str, object]) -> Iterator[object]:
override = request_body.get("router_settings_override")
for source in (request_body, override):
if isinstance(source, Mapping):
for field in _FALLBACK_FIELDS:
yield source.get(field)
def _iter_fallback_targets(value: object, depth: int) -> Iterator[str | Mapping[str, object]]:
if depth > 2 * litellm.ROUTER_MAX_FALLBACKS:
raise ValueError("Rejected Request: fallback nesting exceeds the allowed validation depth.")
if not isinstance(value, list):
return
for item in value:
if isinstance(item, str):
yield item
elif isinstance(item, Mapping):
values = tuple(item.values())
if not (values and all(isinstance(v, list) for v in values)):
yield item
if isinstance(item.get("model"), str):
for field in _FALLBACK_FIELDS:
yield from _iter_fallback_targets(item.get(field), depth + 1)
else:
for target_list in values:
yield from _iter_fallback_targets(target_list, depth + 1)
def iter_request_fallback_targets(request_body: Mapping[str, object]) -> Iterator[str | Mapping[str, object]]:
for value in _iter_fallback_field_values(request_body):
yield from _iter_fallback_targets(value, 0)
def _reject_url_valued_fallback_target(value: str) -> None:
allowed_hosts = getattr(litellm, "provider_url_destination_allowed_hosts", []) or []
for candidate in provider_url_destination_candidates(value):
if not candidate.lower().startswith(("http://", "https://")):
continue
if is_url_destination_allowed_by_host(candidate, allowed_hosts):
continue
raise ValueError(
f"Rejected Request: URL-valued fallback destination '{value}' is not allowed. "
"Configure custom endpoints with api_base instead, or add the destination host to "
"`provider_url_destination_allowed_hosts` in litellm_settings."
)
def is_request_body_safe(request_body: dict, general_settings: dict, llm_router: Optional[Router], model: str) -> bool:
"""
Check if the request body is safe.
@ -379,6 +439,14 @@ def is_request_body_safe(request_body: dict, general_settings: dict, llm_router:
metadata = _coerce_metadata_to_dict(request_body.get(metadata_key))
if metadata is not None:
_check_banned_params(metadata, general_settings, llm_router, model)
for target in iter_request_fallback_targets(request_body):
if isinstance(target, dict):
_check_banned_params(target, general_settings, llm_router, model)
target_model = target.get("model")
if isinstance(target_model, str):
_reject_url_valued_fallback_target(target_model)
elif isinstance(target, str):
_reject_url_valued_fallback_target(target)
litellm_params = _coerce_metadata_to_dict(request_body.get("litellm_params"))
if litellm_params is not None:
litellm_params_metadata = _coerce_metadata_to_dict(litellm_params.get("metadata"))

View file

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

View file

@ -14,7 +14,7 @@ import secrets
import orjson
from datetime import datetime, timezone
from typing import Any, Dict, Iterator, NamedTuple, List, Optional, Protocol, Tuple, Union, cast
from typing import Any, Dict, NamedTuple, List, Optional, Protocol, Tuple, Union, cast
import fastapi
from fastapi import HTTPException, Request, WebSocket, status
@ -58,6 +58,7 @@ from litellm.proxy.auth.auth_utils import (
get_model_from_request,
get_request_route,
get_request_route_template,
iter_request_fallback_targets,
normalize_request_route,
pre_db_read_auth_checks,
route_in_additonal_public_routes,
@ -1011,7 +1012,7 @@ def _ensure_parent_otel_span_on_request_state(request: Request) -> None:
return
if getattr(request.state, "parent_otel_span", None) is not None:
return
start_time = datetime.now()
start_time = datetime.now(timezone.utc)
try:
request.state.litellm_received_at = start_time
except Exception:
@ -1061,7 +1062,7 @@ async def _user_api_key_auth_builder(
# Prefer the receive-instant stamped by the early helper in
# user_api_key_auth (before body parse) — overwriting it would shorten
# the preprocessing-duration measurement by the body-parse window.
start_time = getattr(request.state, "litellm_received_at", None) or datetime.now()
start_time = getattr(request.state, "litellm_received_at", None) or datetime.now(timezone.utc)
try:
request.state.litellm_received_at = start_time
except Exception:
@ -1255,6 +1256,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"]
@ -1279,6 +1281,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),
@ -1304,6 +1307,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,
@ -1345,6 +1349,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 ""
@ -2607,7 +2612,7 @@ async def _return_user_api_key_auth_obj(
start_time: datetime,
user_role: Optional[LitellmUserRoles] = None,
) -> UserAPIKeyAuth:
end_time = datetime.now()
end_time = datetime.now(timezone.utc)
asyncio.create_task(
user_api_key_service_logger_obj.async_service_success_hook(
@ -2696,9 +2701,10 @@ def _update_key_budget_with_temp_budget_increase(
) -> UserAPIKeyAuth:
if valid_token.max_budget is None:
return valid_token
temp_budget_increase = _get_temp_budget_increase(valid_token) or 0.0
valid_token.max_budget = valid_token.max_budget + temp_budget_increase
return valid_token
temp_budget_increase = _get_temp_budget_increase(valid_token)
if not temp_budget_increase:
return valid_token
return valid_token.model_copy(update={"max_budget": valid_token.max_budget + temp_budget_increase})
async def _lookup_end_user_and_apply_budget(
@ -2796,19 +2802,11 @@ async def _enforce_key_and_fallback_model_access(
llm_router=llm_router,
)
# Validate every fallback model name reachable by this request.
# All three fields (``fallbacks``, ``context_window_fallbacks``,
# ``content_policy_fallbacks``) are forwarded to the router as
# per-request kwargs whether they appear at the top level of
# ``request_data`` or nested under ``router_settings_override``.
# Both surfaces must be validated against the API key's model
# allowlist or a caller can smuggle a restricted model. VERIA-44.
fallback_names: List[str] = []
override_settings = request_data.get("router_settings_override")
for _fb_key in ROUTER_FALLBACK_FIELDS:
fallback_names.extend(iter_router_fallback_model_names(request_data.get(_fb_key)))
if isinstance(override_settings, dict):
fallback_names.extend(iter_router_fallback_model_names(override_settings.get(_fb_key)))
fallback_names = tuple(
name
for target in iter_request_fallback_targets(request_data)
if (name := _fallback_target_model_name(target)) is not None
)
for _name in dict.fromkeys(fallback_names): # dedupe, preserve order
await can_key_call_model(
@ -2824,36 +2822,14 @@ async def _enforce_key_and_fallback_model_access(
)
ROUTER_FALLBACK_FIELDS: Tuple[str, ...] = (
"fallbacks",
"context_window_fallbacks",
"content_policy_fallbacks",
)
def iter_router_fallback_model_names(fallbacks: Any) -> Iterator[str]:
"""Yield leaf model names from any of the supported fallbacks shapes.
Handles the simple top-level shape (``str`` or ``{"model": str}``) and
the nested router-config shape (``[{primary: [fallback_list]}]``).
"""
if not isinstance(fallbacks, list):
return
for entry in fallbacks:
if isinstance(entry, str):
yield entry
elif isinstance(entry, dict):
if isinstance(entry.get("model"), str):
yield entry["model"]
continue
for fallback_list in entry.values():
if not isinstance(fallback_list, list):
continue
for m in fallback_list:
if isinstance(m, str):
yield m
elif isinstance(m, dict) and isinstance(m.get("model"), str):
yield m["model"]
def _fallback_target_model_name(target: object) -> str | None:
if isinstance(target, str):
return target
if isinstance(target, dict):
model = target.get("model")
if isinstance(model, str):
return model
return None
async def _run_post_custom_auth_checks(

View file

@ -1,4 +1,5 @@
import copy
import os
from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Literal, Optional
import litellm
@ -564,11 +565,8 @@ def process_callback(_callback: str, callback_type: str, environment_variables:
env_vars_dict: dict[str, str | None] = {}
for _var in env_vars:
env_variable = environment_variables.get(_var, None)
if env_variable is None:
env_vars_dict[_var] = None
else:
env_vars_dict[_var] = env_variable
stored_value = environment_variables.get(_var, None)
env_vars_dict[_var] = stored_value if stored_value is not None else os.getenv(_var)
return {"name": _callback, "variables": env_vars_dict, "type": callback_type}

View file

@ -19,7 +19,10 @@ from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
iter_client_callback_metadata_dicts,
)
from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.litellm_core_utils.url_utils import is_url_destination_allowed_by_host
from litellm.litellm_core_utils.url_utils import (
is_url_destination_allowed_by_host,
provider_url_destination_candidates,
)
from litellm.proxy._types import (
AddTeamCallback,
CommonProxyErrors,
@ -227,23 +230,26 @@ def _reject_url_valued_destinations(data: Dict[str, Any]) -> None:
allowed_hosts = getattr(litellm, "provider_url_destination_allowed_hosts", []) or []
for field in _URL_DESTINATION_REQUEST_FIELDS:
value = data.get(field)
if not isinstance(value, str) or not value.startswith(("http://", "https://")):
if not isinstance(value, str):
continue
if is_url_destination_allowed_by_host(value, allowed_hosts):
continue
raise HTTPException(
status_code=400,
detail={
"error": "invalid_request",
"param": field,
"message": (
f"URL-valued '{field}' is not allowed. Configure custom "
"endpoints with api_base instead, or add the destination "
"host to `provider_url_destination_allowed_hosts` in "
"litellm_settings."
),
},
)
for candidate in provider_url_destination_candidates(value):
if not candidate.lower().startswith(("http://", "https://")):
continue
if is_url_destination_allowed_by_host(candidate, allowed_hosts):
continue
raise HTTPException(
status_code=400,
detail={
"error": "invalid_request",
"param": field,
"message": (
f"URL-valued '{field}' is not allowed. Configure custom "
"endpoints with api_base instead, or add the destination "
"host to `provider_url_destination_allowed_hosts` in "
"litellm_settings."
),
},
)
def _strip_untrusted_request_header_controls(

View file

@ -18,8 +18,9 @@ from pydantic import BaseModel, Field
import litellm
from litellm._logging import verbose_proxy_logger
from litellm._redis import _redis_kwargs_from_environment
from litellm._uuid import uuid
from litellm.litellm_core_utils.sensitive_data_masker import mask_sensitive_keys
from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker
from litellm.proxy._types import (
AUDIT_ACTIONS,
LiteLLM_AuditLogs,
@ -43,6 +44,17 @@ router = APIRouter()
# (e.g. redis://:secret@host:6379/1).
_CACHE_SENSITIVE_FIELDS: set = {"password", "sentinel_password", "url"}
# The env fallback resolves the full set of redis.Redis kwargs, which includes
# credential-bearing params (azure_client_secret, ssl_password, ...) that are
# not cache UI fields. Only overlay fields the settings page actually renders,
# so the read never surfaces a credential the UI does not manage.
_CACHE_SETTINGS_FIELD_NAMES: frozenset = frozenset(field.field_name for field in CACHE_SETTINGS_FIELDS)
# Classifier used, alongside _CACHE_SENSITIVE_FIELDS, to redact any
# credential-bearing key before it leaves the server (`url` is kept in the
# explicit set because its name carries no sensitive segment).
_CREDENTIAL_CLASSIFIER = SensitiveDataMasker()
_REDACTED_VALUE = "***REDACTED***"
@ -67,6 +79,165 @@ def _resolve_cache_url_precedence(settings: Mapping[str, Any]) -> dict[str, Any]
return {k: v for k, v in settings.items() if k not in _URL_OVERRIDDEN_CONNECTION_FIELDS}
def _parse_stored_settings(cache_settings_value: object) -> dict[str, Any]:
"""Normalize a stored cache_settings blob to a dict.
The prisma column comes back as either a JSON string or an already-parsed
dict depending on the client, so callers that json.loads unconditionally
silently drop the whole (still-encrypted) row on the dict path.
"""
parsed = json.loads(cache_settings_value) if isinstance(cache_settings_value, str) else cache_settings_value
return parsed if isinstance(parsed, dict) else {}
def _overlay_environment(stored: Mapping[str, Any]) -> dict[str, Any]:
"""Fill connection fields from the REDIS_* environment the cache actually reads.
A response cache pointed at Redis resolves host/port/password/etc. from the
REDIS_* env vars when the stored config leaves them unset, so a cache
configured purely through the environment works while its settings page,
which reads only the database row, shows blank. Overlaying the same env
kwargs the runtime uses makes the page reflect the effective connection.
Stored values win; the environment only fills what the stored config omits.
"""
env_kwargs = {
key: value for key, value in _redis_kwargs_from_environment().items() if key in _CACHE_SETTINGS_FIELD_NAMES
}
if not env_kwargs:
return dict(stored)
effective = {**env_kwargs, **stored}
# the env fallback is a Redis connection, so name the type when the stored
# config did not, letting the UI render the Redis fields it just populated
effective.setdefault("type", "redis")
return effective
def _redact_credentials(settings: Mapping[str, Any]) -> dict[str, Any]:
"""Replace credential-bearing values with a fixed marker, keeping the rest.
The marker is unambiguous on the way back in: an admin who edits an
unrelated field and re-submits sends the marker for the untouched secret,
which the update path maps back to the stored value rather than persisting
the marker over a working password.
"""
return {
key: (_REDACTED_VALUE if value is not None and _is_credential_field(key) else value)
for key, value in settings.items()
}
def _is_credential_field(key: str) -> bool:
"""Whether a cache setting carries a credential and must be redacted on read."""
return key in _CACHE_SENSITIVE_FIELDS or _CREDENTIAL_CLASSIFIER.is_sensitive_key(key)
def _has_connection_target(value: object) -> bool:
"""Whether a payload value names a live discrete connection target."""
if isinstance(value, str):
return value.strip() != "" and value != _REDACTED_VALUE
return value not in (None, [], {})
# Every field that identifies which Redis a credential belongs to, across node
# (host/port/url), cluster (redis_startup_nodes), and sentinel
# (sentinel_nodes/service_name) modes. A stored secret is bound to these.
_CONNECTION_TARGET_FIELDS: tuple = (
"host",
"port",
"url",
"redis_startup_nodes",
"sentinel_nodes",
"service_name",
)
def _target_repr(value: object) -> str:
"""Canonical string form of a connection-target value for equality checks.
The client may serialize the same target differently from storage (a port as
"6379" vs 6379, node lists round-tripped through JSON), so compare normalized
forms rather than raw values to avoid treating an unchanged target as a change.
"""
if isinstance(value, (list, dict)):
return json.dumps(value, sort_keys=True, default=str)
return str(value)
def _saved_secret_is_reusable(incoming: Mapping[str, Any], saved: Mapping[str, Any]) -> bool:
"""Whether a stored credential may be restored for this request.
A stored secret belongs to the stored connection target, so it is reused only
when the request describes that same target on every dimension the stored
config pins (host/port, url, cluster nodes, sentinel nodes/service). This
prevents credential replay: a caller cannot omit the credential, point at a
different (or incomplete) target, and have the proxy send the stored secret
to a Redis of their choosing.
Non-secret target fields (host/port/nodes/service) must be supplied and match
in normalized form, so equivalent representations (port "6379" vs 6379) are
not seen as a change while an omitted or different value is. ``url`` is the
exception: it is itself the secret and the form never re-prefills it, so a
redacted or omitted url means "keep the stored url" (same target) and only a
different supplied url blocks reuse.
"""
for field in _CONNECTION_TARGET_FIELDS:
saved_value = saved.get(field)
if saved_value in (None, "", [], {}):
continue # the stored config does not pin this dimension
incoming_value = incoming.get(field)
if field == "url":
if incoming_value in (None, "", _REDACTED_VALUE):
continue # url kept as-is (same target)
if _target_repr(incoming_value) != _target_repr(saved_value):
return False
continue
if _target_repr(incoming_value) != _target_repr(saved_value):
return False # a pinned target field is missing or different
return True
def _merge_over_saved(incoming: Mapping[str, Any], saved: Mapping[str, Any]) -> dict[str, Any]:
"""Keep the stored secret behind any credential the caller echoed back redacted or omitted.
GET returns credentials as the marker and the form never re-prefills a
secret, so a save that does not touch a credential arrives with the marker
or with the field absent. Either way the real secret must survive: it is
restored from the stored row, or dropped when there is no stored row (the
value is env-sourced and the marker must never be persisted). Non-secret
fields are taken from the incoming payload as-is, so clearing one still works.
``url`` is the exception: it is credential-bearing (redacted) yet also a
connection-mode selector that url-precedence resolves against host/port. If
the caller supplies a discrete target (host, cluster, or sentinel nodes), a
stored url is a stale mode the caller is leaving, so it is dropped rather
than restored, otherwise url-precedence would resurrect it and discard the
submitted host/port.
"""
switching_to_discrete_target = (
_has_connection_target(incoming.get("host"))
or _has_connection_target(incoming.get("redis_startup_nodes"))
or _has_connection_target(incoming.get("sentinel_nodes"))
)
reuse_saved_secret = _saved_secret_is_reusable(incoming, saved)
merged = dict(incoming)
for field in _CACHE_SENSITIVE_FIELDS:
# A value the caller explicitly supplied is honored verbatim: a new
# secret, or an empty string / null to clear the stored one. Only an
# omitted field or the echoed-back marker triggers preserve-or-drop.
if field in incoming and incoming[field] != _REDACTED_VALUE:
continue
if field == "url" and switching_to_discrete_target:
merged.pop(field, None)
continue
if field in saved and reuse_saved_secret:
merged[field] = saved[field]
else:
# nothing stored to reuse, or the caller is pointing at a different
# target: never persist/replay the marker or the stored secret
merged.pop(field, None)
return merged
def _redact_settings(settings: Optional[Mapping[str, Any]]) -> Dict[str, Any]:
"""Replace every value in a settings map with a fixed marker.
@ -270,34 +441,34 @@ async def get_cache_settings(
# Get cache settings fields from types file
cache_fields = [field.model_copy(deep=True) for field in CACHE_SETTINGS_FIELDS]
# Try to get cache settings from database
current_values = {}
# Read the stored settings (decrypted); an env-only cache has none.
stored: dict[str, Any] = {}
if prisma_client is not None:
cache_config = await CacheConfigRepository(prisma_client).table.find_unique(where={"id": "cache_config"})
if cache_config is not None and cache_config.cache_settings:
# Decrypt cache settings
cache_settings_json = cache_config.cache_settings
if isinstance(cache_settings_json, str):
cache_settings_dict = json.loads(cache_settings_json)
else:
cache_settings_dict = cache_settings_json
stored = proxy_config._decrypt_db_variables(
variables_dict=_parse_stored_settings(cache_config.cache_settings)
)
# Decrypt environment variables
decrypted_settings = proxy_config._decrypt_db_variables(variables_dict=cache_settings_dict)
# Fill connection fields from the REDIS_* environment the cache resolves
# from when the stored config leaves them unset, then apply url precedence
# so a url-mode config does not surface conflicting discrete fields (which
# would otherwise let a no-op save silently switch it to host/port).
effective = _resolve_cache_url_precedence(_overlay_environment(stored))
# Derive redis_type for UI based on settings
# UI uses redis_type to show/hide fields, backend only stores 'type'
if decrypted_settings.get("type") == "redis":
if decrypted_settings.get("redis_startup_nodes"):
decrypted_settings["redis_type"] = "cluster"
elif decrypted_settings.get("sentinel_nodes"):
decrypted_settings["redis_type"] = "sentinel"
else:
decrypted_settings["redis_type"] = "node"
# Derive redis_type for UI based on settings
# UI uses redis_type to show/hide fields, backend only stores 'type'
if effective.get("type") == "redis":
if effective.get("redis_startup_nodes"):
effective["redis_type"] = "cluster"
elif effective.get("sentinel_nodes"):
effective["redis_type"] = "sentinel"
else:
effective["redis_type"] = "node"
# Mask credential fields so the GET response never carries
# plaintext Redis / Sentinel passwords off the server.
current_values = mask_sensitive_keys(decrypted_settings, _CACHE_SENSITIVE_FIELDS)
# Redact credential fields so the GET response never carries a plaintext
# Redis / Sentinel password off the server.
current_values = _redact_credentials(effective)
# Update field values with current values
for field in cache_fields:
@ -331,10 +502,27 @@ async def test_cache_connection(
to verify the credentials work without affecting global state.
"""
from litellm import Cache
from litellm.proxy.proxy_server import prisma_client, proxy_config
try:
cache_settings = _resolve_cache_url_precedence(request.cache_settings)
verbose_proxy_logger.debug("Testing cache connection with settings: %s", cache_settings)
# A credential the form left untouched arrives redacted; resolve it back
# to the stored secret so the test connects with the real password. A
# lookup failure must not block the test, so fall back to no stored row.
saved_settings: dict[str, Any] = {}
if prisma_client is not None:
try:
existing_row = await CacheConfigRepository(prisma_client).table.find_unique(
where={"id": "cache_config"}
)
if existing_row is not None and existing_row.cache_settings:
saved_settings = proxy_config._decrypt_db_variables(
variables_dict=_parse_stored_settings(existing_row.cache_settings)
)
except Exception: # noqa: BLE001 - a saved-settings lookup failure must not block a connection test
saved_settings = {}
cache_settings = _resolve_cache_url_precedence(_merge_over_saved(request.cache_settings, saved_settings))
# cache_settings now carries the resolved plaintext credential; never log it raw
verbose_proxy_logger.debug("Testing cache connection with settings: %s", _redact_credentials(cache_settings))
# Only support Redis for now
if cache_settings.get("type") != "redis":
@ -400,19 +588,20 @@ async def update_cache_settings(
)
try:
cache_settings = _resolve_cache_url_precedence(request.cache_settings)
# Snapshot the prior settings (key set only — values get redacted in
# the audit row) so the audit-log entry shows which fields changed.
# Read the stored row first: its decrypted values back any credential the
# caller echoed back redacted, and its key set drives the audit diff.
existing_row = await CacheConfigRepository(prisma_client).table.find_unique(where={"id": "cache_config"})
before_settings: Optional[Dict[str, Any]] = None
saved_settings: dict[str, Any] = {}
if existing_row is not None and existing_row.cache_settings:
try:
before_settings = json.loads(existing_row.cache_settings)
except (TypeError, ValueError):
before_settings = None
before_settings = _parse_stored_settings(existing_row.cache_settings)
saved_settings = proxy_config._decrypt_db_variables(variables_dict=before_settings)
action: AUDIT_ACTIONS = "updated" if existing_row is not None else "created"
# Preserve stored secrets behind any redacted or omitted credential, then
# resolve the url-vs-discrete-fields precedence.
cache_settings = _resolve_cache_url_precedence(_merge_over_saved(request.cache_settings, saved_settings))
# Encrypt sensitive fields (keep redis_type for storage)
encrypted_settings = proxy_config._encrypt_env_variables(environment_variables=cache_settings)
@ -461,7 +650,7 @@ async def update_cache_settings(
return {
"message": "Cache settings updated successfully",
"status": "success",
"settings": cache_settings,
"settings": _redact_credentials(cache_settings),
}
except Exception as e:
verbose_proxy_logger.error(f"Error updating cache settings: {str(e)}")

View file

@ -42,6 +42,9 @@ from litellm.proxy._experimental.mcp_server.db import (
rotate_mcp_user_credentials_master_key,
rotate_mcp_user_env_vars_master_key,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import (
rotate_sso_identity_assertions_master_key,
)
from litellm.proxy._types import *
from litellm.proxy._types import LiteLLM_VerificationToken, hash_token
from litellm.proxy.auth.auth_checks import (
@ -4242,6 +4245,15 @@ async def _rotate_master_key(
except Exception as e:
verbose_proxy_logger.warning("Failed to rotate MCP user env vars: %s", str(e))
# 4d. process SSO identity assertion table (EMA subject tokens)
try:
await rotate_sso_identity_assertions_master_key(
prisma_client=prisma_client,
new_master_key=new_master_key,
)
except Exception as e: # noqa: BLE001 # one store's failure must not abort the master-key rotation
verbose_proxy_logger.warning("Failed to rotate SSO identity assertions: %s", str(e))
# 5. process credentials table
try:
credentials = await CredentialsRepository(prisma_client).table.find_many()

View file

@ -62,6 +62,11 @@ from litellm.llms.custom_httpx.http_handler import (
get_async_httpx_client,
httpxSpecialProvider,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import (
SSOIdentityAssertion,
assertion_from_sso_login,
retain_sso_identity_assertion_for_ema,
)
from litellm.proxy._types import (
CommonProxyErrors,
LiteLLM_UserTable,
@ -1311,12 +1316,15 @@ async def get_generic_sso_response(
sso_jwt_handler: Optional[JWTHandler], # sso specific jwt handler - used for restricted sso group access control
generic_client_id: str,
redirect_url: str,
) -> Tuple[Union[OpenID, dict], Optional[dict], Optional[dict]]: # (result, received_response, access_token_payload)
) -> tuple[
Union[OpenID, dict], dict | None, dict | None, SSOIdentityAssertion | None
]: # (result, received_response, access_token_payload, sso_assertion)
# make generic sso provider
from fastapi_sso.sso.base import DiscoveryDocument
from fastapi_sso.sso.generic import create_provider
received_response: Optional[dict] = None
sso_assertion: SSOIdentityAssertion | None = None
# Setup environment variables
(
@ -1450,6 +1458,9 @@ async def get_generic_sso_response(
# Assign directly rather than relying on nonlocal mutation so that Pyright
# can track that received_response is non-None from this point on.
received_response = {k: v for k, v in combined_response.items() if k not in _OAUTH_TOKEN_FIELDS}
sso_assertion = assertion_from_sso_login(
combined_response.get("id_token"), combined_response.get("refresh_token")
)
# In the PKCE path verify_and_process is skipped, so generic_sso.access_token
# is never set. Read the token directly from the exchange response instead so
# process_sso_jwt_access_token can extract JWT-embedded roles/teams.
@ -1461,6 +1472,7 @@ async def get_generic_sso_response(
headers=additional_generic_sso_headers_dict,
)
access_token_str = generic_sso.access_token
sso_assertion = assertion_from_sso_login(generic_sso.id_token, generic_sso.refresh_token)
access_token_payload = process_sso_jwt_access_token(
access_token_str, sso_jwt_handler, result, role_mappings=role_mappings
@ -1480,7 +1492,7 @@ async def get_generic_sso_response(
additional_generic_sso_headers_dict,
)
verbose_proxy_logger.debug("generic result: %s", result)
return result or {}, received_response, access_token_payload
return result or {}, received_response, access_token_payload, sso_assertion
async def create_team_member_add_task(team_id, user_info):
@ -1812,6 +1824,7 @@ async def auth_callback(request: Request, state: Optional[str] = None):
generic_client_id = os.getenv("GENERIC_CLIENT_ID", None)
received_response: Optional[dict] = None
access_token_payload: Optional[dict] = None
sso_assertion: SSOIdentityAssertion | None = None
# get url from request
if master_key is None:
raise ProxyException(
@ -1842,6 +1855,7 @@ async def auth_callback(request: Request, state: Optional[str] = None):
result,
received_response,
access_token_payload,
sso_assertion,
) = await get_generic_sso_response(
request=request,
jwt_handler=jwt_handler,
@ -1869,6 +1883,7 @@ async def auth_callback(request: Request, state: Optional[str] = None):
prefill_user_code=prefill_user_code,
result=result,
received_response=received_response,
sso_assertion=sso_assertion,
)
# Control-plane cross-origin: read return_to from cookie.
@ -1884,6 +1899,7 @@ async def auth_callback(request: Request, state: Optional[str] = None):
access_token_payload=access_token_payload,
jwt_handler=jwt_handler,
return_to=cp_return_to,
sso_assertion=sso_assertion,
)
@ -1943,6 +1959,7 @@ async def _complete_cli_sso_callback_session(
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging,
prefill_user_code: str | None = None,
sso_assertion: SSOIdentityAssertion | None = None,
):
from fastapi.responses import HTMLResponse
@ -1962,6 +1979,8 @@ async def _complete_cli_sso_callback_session(
if not user_info.user_id:
raise HTTPException(status_code=500, detail="Failed to retrieve user information from SSO")
await retain_sso_identity_assertion_for_ema(user_id=user_info.user_id, assertion=sso_assertion)
teams: List[str] = []
if hasattr(user_info, "teams") and user_info.teams:
teams = user_info.teams if isinstance(user_info.teams, list) else []
@ -2012,6 +2031,7 @@ async def cli_sso_callback(
result: Optional[Union[OpenID, dict]] = None,
received_response: Optional[dict] = None,
prefill_user_code: str | None = None,
sso_assertion: SSOIdentityAssertion | None = None,
):
"""CLI SSO callback - stores session info for JWT generation on polling"""
verbose_proxy_logger.info("CLI SSO callback")
@ -2065,6 +2085,7 @@ async def cli_sso_callback(
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
prefill_user_code=prefill_user_code,
sso_assertion=sso_assertion,
)
except ProxyException:
raise
@ -3018,6 +3039,7 @@ class SSOAuthenticationHandler:
access_token_payload: Optional[dict] = None,
jwt_handler: Optional[JWTHandler] = None,
return_to: Optional[str] = None,
sso_assertion: SSOIdentityAssertion | None = None,
) -> RedirectResponse:
import jwt
@ -3148,6 +3170,9 @@ class SSOAuthenticationHandler:
},
)
if isinstance(user_id, str) and user_id:
await retain_sso_identity_assertion_for_ema(user_id=user_id, assertion=sso_assertion)
disabled_non_admin_personal_key_creation = get_disabled_non_admin_personal_key_creation()
litellm_dashboard_ui = get_custom_url(request_base_url=str(request.base_url), route="ui/")
@ -4241,6 +4266,7 @@ async def debug_sso_callback(request: Request):
result,
received_response,
access_token_payload,
_sso_assertion,
) = await get_generic_sso_response(
request=request,
jwt_handler=jwt_handler,

View file

@ -236,6 +236,7 @@ from litellm.constants import (
PROXY_BATCH_WRITE_AT,
PROXY_BUDGET_RESCHEDULER_MAX_TIME,
PROXY_BUDGET_RESCHEDULER_MIN_TIME,
PROXY_CONFIG_RELOAD_INTERVAL_SECONDS,
)
from litellm.exceptions import RejectedRequestError
from litellm.integrations.custom_guardrail import ModifyResponseException
@ -2001,6 +2002,7 @@ proxy_budget_rescheduler_min_time = PROXY_BUDGET_RESCHEDULER_MIN_TIME
proxy_budget_rescheduler_max_time = PROXY_BUDGET_RESCHEDULER_MAX_TIME
proxy_batch_polling_interval = PROXY_BATCH_POLLING_INTERVAL
proxy_batch_write_at = PROXY_BATCH_WRITE_AT
proxy_config_reload_interval_seconds = PROXY_CONFIG_RELOAD_INTERVAL_SECONDS
litellm_master_key_hash = None
disable_spend_logs = False
jwt_handler = JWTHandler()
@ -3882,7 +3884,7 @@ class ProxyConfig:
del config["include"]
return config
async def save_config(self, new_config: dict):
async def save_config(self, new_config: dict, include_env_vars: bool = False):
global prisma_client, general_settings, user_config_file_path, store_model_in_db
# Load existing config
## DB - writes valid config to db
@ -3899,6 +3901,17 @@ class ProxyConfig:
# Make a copy to avoid mutating the original config
config_to_save = new_config.copy()
# environment_variables are persisted to the DB only when a caller
# explicitly opts in. Most callers reach save_config after
# get_config() merged YAML + OS env into new_config (with
# os.environ/ placeholders already resolved to plaintext), so
# persisting them here would snapshot file/container env vars into
# a config row that then shadows those sources on every restart.
# The dedicated /config/update path writes env vars directly, so
# no current caller needs include_env_vars=True.
if not include_env_vars:
config_to_save.pop("environment_variables", None)
# SECURITY: Always encrypt environment_variables before DB write.
# _encrypt_env_variables_for_db is idempotent — a caller that
# already encrypted the values (or re-submitted ciphertext read
@ -3916,6 +3929,38 @@ class ProxyConfig:
with open(f"{user_config_file_path}", "w") as config_file:
yaml.dump(new_config, config_file, default_flow_style=False)
async def save_environment_variables(self, updates: dict[str, str | None]) -> None:
"""Persist specific environment variables to the DB config row.
Each key in ``updates`` is written to the ``environment_variables``
config row; a ``None`` value deletes that key. Env vars the caller does
not name are preserved, so a caller that owns a couple of keys can
update just those without snapshotting unrelated (YAML/OS-sourced)
values the way a full ``save_config`` write would. No-op when config is
not DB-backed.
"""
global prisma_client, general_settings, store_model_in_db
if prisma_client is None or not (general_settings.get("store_model_in_db", False) is True or store_model_in_db):
return
row = await ConfigRepository(prisma_client).table.find_first(where={"param_name": "environment_variables"})
existing: dict = dict(row.param_value) if row is not None and row.param_value is not None else {}
to_set = {k: v for k, v in updates.items() if v is not None}
encrypted = self._encrypt_env_variables_for_db(environment_variables=to_set) if to_set else {}
deleted_keys = {k for k, v in updates.items() if v is None}
merged = {**{k: v for k, v in existing.items() if k not in deleted_keys}, **encrypted}
serialized = json.dumps(merged)
await ConfigRepository(prisma_client).table.upsert(
where={"param_name": "environment_variables"},
data={
"create": {"param_name": "environment_variables", "param_value": serialized},
"update": {"param_value": serialized},
},
)
await invalidate_config_param("environment_variables")
def _check_for_os_environ_vars(
self, config: dict, depth: int = 0, max_depth: int = DEFAULT_MAX_RECURSE_DEPTH
) -> dict:
@ -4294,6 +4339,7 @@ class ProxyConfig:
open_telemetry_logger, \
health_check_details, \
proxy_batch_polling_interval, \
proxy_config_reload_interval_seconds, \
config_passthrough_endpoints
config: dict = await self.get_config(config_file_path=config_file_path)
@ -4783,6 +4829,10 @@ class ProxyConfig:
)
## BATCH WRITER ##
proxy_batch_write_at = general_settings.get("proxy_batch_write_at", proxy_batch_write_at)
## DB CONFIG RELOAD INTERVAL ##
proxy_config_reload_interval_seconds = general_settings.get(
"proxy_config_reload_interval_seconds", proxy_config_reload_interval_seconds
)
## DISABLE SPEND LOGS ## - gives a perf improvement
disable_spend_logs = general_settings.get("disable_spend_logs", disable_spend_logs)
### BACKGROUND HEALTH CHECKS ###
@ -7955,12 +8005,20 @@ class ProxyStartupEvent:
verbose_proxy_logger.debug("Failed to check DB for store_model_in_db: %s", str(e))
if store_model_in_db is True:
config_reload_interval_seconds = proxy_config_reload_interval_seconds
if not isinstance(config_reload_interval_seconds, int) or config_reload_interval_seconds <= 0:
verbose_proxy_logger.warning(
"proxy_config_reload_interval_seconds=%s must be a positive integer; falling back to 30s",
config_reload_interval_seconds,
)
config_reload_interval_seconds = 30
# MEMORY LEAK FIX: Increase interval from 10s to 30s minimum
# Frequent polling was causing excessive memory allocations
scheduler.add_job(
proxy_config.add_deployment,
"interval",
seconds=30, # increased from 10s to reduce memory pressure
seconds=config_reload_interval_seconds,
# REMOVED jitter parameter - major cause of memory leak
args=[prisma_client, proxy_logging_obj],
id="add_deployment_job",
@ -7975,7 +8033,7 @@ class ProxyStartupEvent:
scheduler.add_job(
proxy_config.get_credentials,
"interval",
seconds=30, # increased from 10s to reduce memory pressure
seconds=config_reload_interval_seconds,
# REMOVED jitter parameter - major cause of memory leak
args=[prisma_client],
id="get_credentials_job",
@ -15019,6 +15077,7 @@ async def get_config_list(
"global_max_parallel_requests": {"type": "Integer"},
"max_request_size_mb": {"type": "Integer"},
"max_response_size_mb": {"type": "Integer"},
"proxy_config_reload_interval_seconds": {"type": "Integer"},
"pass_through_endpoints": {"type": "PydanticModel"},
"store_model_in_db": {"type": "Boolean"},
"store_prompts_in_spend_logs": {"type": "Boolean"},

View file

@ -403,6 +403,15 @@ model LiteLLM_MCPServerOAuthClient {
updated_at DateTime @default(now()) @updatedAt @map("updated_at")
}
// The enterprise IdP identity assertion captured at SSO login, one row per user.
// assertion_b64 is an encrypted JSON payload: {id_token, refresh_token?, issuer?, expires_at?}.
model LiteLLM_SSOIdentityAssertion {
user_id String @id
assertion_b64 String
created_at DateTime @default(now()) @map("created_at")
updated_at DateTime @default(now()) @updatedAt @map("updated_at")
}
// Generate Tokens for Proxy
model LiteLLM_VerificationToken {
token String @id

View file

@ -1,6 +1,8 @@
#### CRUD ENDPOINTS for UI Settings #####
import asyncio
import json
import os
from collections.abc import Mapping
from typing import Any, Dict, List, Optional, Set, Tuple, Type, Union
from urllib.parse import urlparse
@ -35,6 +37,44 @@ _SSO_SENSITIVE_FIELDS: Set[str] = {
"generic_client_secret",
}
# Maps each UIThemeConfig field to the env var the UI branding path reads it
# from. /update/ui_theme_settings writes both the stored ui_theme_config and
# these env vars, so /get/ui_theme_settings resolves the same env vars to
# reflect a deployment branded purely through process env.
_UI_THEME_FIELD_ENV_VARS: dict[str, str] = {
"logo_url": "UI_LOGO_PATH",
"favicon_url": "LITELLM_FAVICON_URL",
}
def _is_public_http_url(value: str | None) -> bool:
"""Whether a value is a plain http(s) URL with a host, safe to disclose publicly."""
if not isinstance(value, str) or not value.strip():
return False
parsed = urlparse(value.strip())
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
def _resolve_ui_theme_field(stored_values: Mapping[str, Any], field_name: str) -> str | None:
"""Resolve one UI theme field to the value the branding path actually uses.
The stored ui_theme_config wins; a field absent or blank there falls back to
the process environment. The branding path reads the env var, and stored
settings reach it by being pushed into the environment on save, so a value
supplied only as a process env var is live even though no stored entry exists.
This endpoint is unauthenticated, so the env fallback only surfaces a public
http(s) URL: an operator can point UI_LOGO_PATH at a local filesystem path
(the branding path serves it server-side), and that path must not be
disclosed to anonymous callers. A stored value is already validated as a
public URL on write, so it passes through.
"""
stored = stored_values.get(field_name)
if isinstance(stored, str) and stored.strip():
return stored
env_value = os.environ.get(_UI_THEME_FIELD_ENV_VARS[field_name])
return env_value if _is_public_http_url(env_value) else None
class IPAddress(BaseModel):
ip: str
@ -977,12 +1017,19 @@ async def get_ui_theme_settings():
# Load existing config
config = await proxy_config.get_config()
return await _get_settings_with_schema(
result = await _get_settings_with_schema(
settings_key="ui_theme_config",
settings_class=UIThemeConfig,
config=config,
)
stored_values = result.get("values", {})
result["values"] = {
**stored_values,
**{field: _resolve_ui_theme_field(stored_values, field) for field in _UI_THEME_FIELD_ENV_VARS},
}
return result
def _validate_public_image_url(value: Optional[str], field_name: str) -> None:
"""
@ -1041,13 +1088,6 @@ async def update_ui_theme_settings(
config = await proxy_config.get_config()
before_theme = config.get("litellm_settings", {}).get("ui_theme_config")
# Update config with UI theme settings
if "general_settings" not in config:
config["general_settings"] = {}
if "environment_variables" not in config:
config["environment_variables"] = {}
# Convert theme config to dict
theme_data = theme_config.model_dump(exclude_none=True)
@ -1056,55 +1096,29 @@ async def update_ui_theme_settings(
config["litellm_settings"] = {}
config["litellm_settings"]["ui_theme_config"] = theme_data
# Update UI_LOGO_PATH environment variable if logo_url is provided
# If logo_url is empty string, None, or null, remove the environment variable to use default
logo_url = theme_data.get("logo_url")
verbose_proxy_logger.debug(f"Updating logo_url: {logo_url}")
# UI_LOGO_PATH and LITELLM_FAVICON_URL are the only environment variables
# this endpoint owns. A non-empty value sets the var; an empty or missing
# one clears it back to the default. Apply to the live process immediately,
# then persist only these two keys so an unrelated env var (a YAML/OS value
# merged in by get_config) is never snapshotted into the DB.
def _clean(url: str | None) -> str | None:
return url if url is not None and url.strip() else None
if (
logo_url and isinstance(logo_url, str) and logo_url.strip()
): # Check if logo_url exists and is not empty/whitespace
config["environment_variables"]["UI_LOGO_PATH"] = logo_url
os.environ["UI_LOGO_PATH"] = logo_url
verbose_proxy_logger.debug(f"Set UI_LOGO_PATH to: {logo_url}")
else:
# Remove the environment variable to restore default logo
if "UI_LOGO_PATH" in config.get("environment_variables", {}):
del config["environment_variables"]["UI_LOGO_PATH"]
verbose_proxy_logger.debug("Removed UI_LOGO_PATH from config")
if "UI_LOGO_PATH" in os.environ:
del os.environ["UI_LOGO_PATH"]
verbose_proxy_logger.debug("Removed UI_LOGO_PATH from environment")
env_updates: dict[str, str | None] = {
"UI_LOGO_PATH": _clean(theme_config.logo_url),
"LITELLM_FAVICON_URL": _clean(theme_config.favicon_url),
}
for env_key, env_value in env_updates.items():
if env_value is not None:
os.environ[env_key] = env_value
else:
os.environ.pop(env_key, None)
# Update LITELLM_FAVICON_URL environment variable if favicon_url is provided
favicon_url = theme_data.get("favicon_url")
verbose_proxy_logger.debug(f"Updating favicon_url: {favicon_url}")
if (
favicon_url and isinstance(favicon_url, str) and favicon_url.strip()
): # Check if favicon_url exists and is not empty/whitespace
config["environment_variables"]["LITELLM_FAVICON_URL"] = favicon_url
os.environ["LITELLM_FAVICON_URL"] = favicon_url
verbose_proxy_logger.debug(f"Set LITELLM_FAVICON_URL to: {favicon_url}")
else:
# Remove the environment variable to restore default favicon
if "LITELLM_FAVICON_URL" in config.get("environment_variables", {}):
del config["environment_variables"]["LITELLM_FAVICON_URL"]
verbose_proxy_logger.debug("Removed LITELLM_FAVICON_URL from config")
if "LITELLM_FAVICON_URL" in os.environ:
del os.environ["LITELLM_FAVICON_URL"]
verbose_proxy_logger.debug("Removed LITELLM_FAVICON_URL from environment")
# Handle environment variable encryption if needed
stored_config = config.copy()
if "environment_variables" in stored_config and len(stored_config["environment_variables"]) > 0:
# Only encrypt if there are environment variables to encrypt
stored_config["environment_variables"] = proxy_config._encrypt_env_variables(
environment_variables=stored_config["environment_variables"]
)
# Save the updated config
await proxy_config.save_config(new_config=stored_config)
# Persist the theme config (litellm_settings). save_config defaults to
# include_env_vars=False, so it does not snapshot environment_variables.
await proxy_config.save_config(new_config=config)
# Persist only the two owned env vars, merged against the existing DB row.
await proxy_config.save_environment_variables(env_updates)
asyncio.create_task(
create_config_audit_log(

View file

@ -1864,7 +1864,9 @@ def client(original_function):
except Exception:
pass
setattr(e, "num_retries", num_retries) ## IMPORTANT: returns the deployment's num_retries to the router
deployment_num_retries = kwargs.get("num_retries")
if deployment_num_retries is not None:
setattr(e, "num_retries", deployment_num_retries)
timeout = _get_wrapper_timeout(kwargs=kwargs, exception=e)
setattr(e, "timeout", timeout)

View file

@ -1,6 +1,6 @@
[project]
name = "litellm"
version = "1.94.0"
version = "1.95.0"
description = "Library to easily interface with LLM API providers"
readme = "README.md"
requires-python = ">=3.10, <3.15"
@ -62,7 +62,7 @@ proxy = [
"azure-identity>=1.25.2,<2.0",
"azure-storage-blob>=12.28.0,<13.0",
"mcp>=1.28.1,<2.0",
"litellm-proxy-extras==0.4.79",
"litellm-proxy-extras==0.4.80",
"litellm-enterprise==0.1.51",
"RestrictedPython>=8.1,<9.0",
"rich>=13.9.4,<14.0",
@ -289,7 +289,7 @@ members = ["enterprise", "litellm-proxy-extras"]
profile = "black"
[tool.commitizen]
version = "1.94.0"
version = "1.95.0"
version_files = [
"pyproject.toml:^version",
]

View file

@ -93,7 +93,7 @@
"limit": 33
},
"DTZ005": {
"limit": 244
"limit": 241
},
"DTZ006": {
"limit": 13

View file

@ -403,6 +403,15 @@ model LiteLLM_MCPServerOAuthClient {
updated_at DateTime @default(now()) @updatedAt @map("updated_at")
}
// The enterprise IdP identity assertion captured at SSO login, one row per user.
// assertion_b64 is an encrypted JSON payload: {id_token, refresh_token?, issuer?, expires_at?}.
model LiteLLM_SSOIdentityAssertion {
user_id String @id
assertion_b64 String
created_at DateTime @default(now()) @map("created_at")
updated_at DateTime @default(now()) @updatedAt @map("updated_at")
}
// Generate Tokens for Proxy
model LiteLLM_VerificationToken {
token String @id

View file

@ -5,6 +5,7 @@
# gating CI checks, so a clean run means a green CI lint:
# - litellm/ Python staged -> `make lint` (test-linting.yml's lint job)
# - tests/e2e Python staged -> `make lint-e2e-basedpyright` (test-linting.yml's e2e type-check step)
# + raw HTTP client ban (test-code-quality.yml's check_e2e_no_raw_requests)
# - dashboard staged -> prettier + eslint + lint budgets (test-litellm-ui-build.yml's frontend-lint)
# - proxy/types staged -> regenerate dashboard API types and fail on drift (check-ui-api-types.yml)
#
@ -112,6 +113,12 @@ if [ -n "$e2e_py_files" ] && [ -z "$litellm_py_files" ]; then
make lint-e2e-basedpyright || { echo "✗ tests/e2e basedpyright failed. Fix the errors above, then re-run make pre-commit." >&2; status=1; }
fi
if [ -n "$e2e_py_files" ]; then
echo "pre-commit: checking tests/e2e raw HTTP client ban (check_e2e_no_raw_requests)"
uv run --no-sync python tests/code_coverage_tests/check_e2e_no_raw_requests.py \
|| { echo "✗ Raw HTTP client import in tests/e2e. Route the call through tests/e2e/e2e_http.py, then re-run make pre-commit." >&2; status=1; }
fi
if [ -n "$ui_prettier_files" ] || [ -n "$ui_eslint_files" ]; then
echo "pre-commit: linting dashboard (prettier + eslint + lint budgets)"
if [ ! -d ui/litellm-dashboard/node_modules ]; then

View file

@ -0,0 +1,81 @@
"""tests/e2e routes every HTTP call through the typed transport (e2e_http.py), so
raw HTTP client imports (requests, urllib.request, httpx, aiohttp, http.client) are
banned in suite code. Importing requests' exception types for catching is fine
anywhere; a small allowlist grandfathers the files that legitimately make raw calls
(the transport itself, the root conftest liveness probe, and the claude_code version
resolver's constant registry URL fetch). Referenced by tests/e2e/CLAUDE.md."""
from __future__ import annotations
import ast
import sys
from pathlib import Path
E2E_DIR = Path(__file__).resolve().parents[1] / "e2e"
BANNED_MODULES = ("requests", "urllib.request", "http.client", "httpx", "aiohttp")
ALLOWED_RAW_CLIENT_FILES = {
"e2e_http.py": ("requests",),
"conftest.py": ("requests",),
"claude_code/pr_gate_version_resolver.py": ("urllib.request",),
}
EXCEPTION_ONLY_NAMES = frozenset({"RequestException", "ConnectionError", "Timeout", "HTTPError"})
def _is_banned(module: str) -> bool:
return any(module == banned or module.startswith(banned + ".") for banned in BANNED_MODULES)
def _banned_imports(tree: ast.Module) -> tuple[tuple[str, int], ...]:
plain = tuple(
(alias.name, node.lineno)
for node in ast.walk(tree)
if isinstance(node, ast.Import)
for alias in node.names
if _is_banned(alias.name)
)
from_imports = tuple(
(node.module, node.lineno)
for node in ast.walk(tree)
if isinstance(node, ast.ImportFrom)
and node.module is not None
and _is_banned(node.module)
and not all(alias.name in EXCEPTION_ONLY_NAMES for alias in node.names)
)
return plain + from_imports
def _violations_in(path: Path) -> tuple[str, ...]:
relative = path.relative_to(E2E_DIR).as_posix()
allowed = ALLOWED_RAW_CLIENT_FILES.get(relative, ())
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
return tuple(
f"tests/e2e/{relative}:{lineno}: raw HTTP client import '{module}'"
for module, lineno in _banned_imports(tree)
if module not in allowed
)
def main() -> int:
violations = tuple(
violation
for path in sorted(E2E_DIR.rglob("*.py"))
for violation in _violations_in(path)
)
for violation in violations:
print(violation)
if violations:
print(
f"\n{len(violations)} raw HTTP client import(s) in tests/e2e. "
"Route the call through tests/e2e/e2e_http.py (get_external for absolute "
"third-party URLs) so it gets the typed Result handling."
)
return 1
print("tests/e2e raw HTTP client check passed")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -56,6 +56,7 @@ IGNORE_FUNCTIONS = [
"apply_json_merge_patch", # max depth set (_MAX_MERGE_DEPTH=64); fails closed by raising ValueError at the cap.
"_filter_mcp_argument_value", # max depth set (DEFAULT_MAX_RECURSE_DEPTH); fails closed by blocking the MCP call at the cap.
"_redact_scanned_content", # max depth set (DEFAULT_MAX_RECURSE_DEPTH); fails closed by returning "[REDACTED]" at the cap.
"_iter_fallback_targets", # max depth set (2 * ROUTER_MAX_FALLBACKS); fails closed by raising ValueError at the cap.
]

View file

@ -13,11 +13,13 @@ Each subdirectory under `tests/e2e/` is one suite, scoped to an endpoint family
- `realtime/` - realtime websocket sessions, including the pipecat audio path
- `quota_management/` - quota enforcement and accounting, one subfolder per behavior: `ratelimit/` (rpm/tpm blocks, window reset, pacing headers on live traffic), `budgets/` (budget definition, enforcement, and reset windows: key, team, tag, soft, multi-window), and `spend_tracking/` (spend logging and cost attribution on `/spend/*`)
- `management/` - key/team/user/organization management routes: create/update/delete persistence via the info routes, team membership, and llm-only-key route denials (API surface; not Playwright)
- `a2a/` - the A2A (agent-to-agent) surface: admin registration via `/v1/agents`, proxy-fronted card discovery at `/.well-known/agent-card.json`, and JSON-RPC `message/send` invocation, driving agents backed by the litellm completion bridge (a real provider) and asserting protocol-version normalization (0.3 vs 1.0)
- `mcp/` - the MCP server surface over api_key auth against the real Datadog remote MCP server only (see "MCP suite: real Datadog only" below)
- `logging/` - logging-integration delivery (datadog and friends)
- `security/` - secret handling and log-leak protection
- `router/` - routing and reliability behavior (fallbacks, cooldowns)
- `load/` - throughput/performance under concurrency: drives real concurrent traffic through the whole stack with Locust and asserts a throughput SLO; marked `load` so the parent conftest collects it last and it never perturbs latency-sensitive suites
- `other/` - the holding-pen suite for the `other.*` registry cluster with no home of its own yet: the master-key auth gate and the process-lifecycle health probes (liveness, public readiness, authenticated readiness diagnostics). Promote a cluster out once it is large/stable enough for its own suite
- `gateway/` - proxy configuration only (`litellm-config.yml`); no tests
- `claude_code/` - the Claude Code compatibility matrix: drives the real `claude` CLI (and HTTP probes) against a proxy for each feature x provider cell, reporting tagged-union outcomes via the `compat_result` fixture; ships its own driver/builder/publisher plus `_*_unit_tests/` trees. The HTTP probes ride the shared transport (`ProxyClient.count_tokens` / `ProxyClient.messages`); the CLI-driving path stays bespoke

292
tests/e2e/a2a/a2a_client.py Normal file
View file

@ -0,0 +1,292 @@
"""Client for the proxy's A2A (agent-to-agent) surface.
An A2A agent is registered admin-side via POST /v1/agents with an agent card and
litellm_params; the proxy fronts it at /a2a/{id}, serving a proxy-owned agent card
at /.well-known/agent-card.json and accepting A2A JSON-RPC calls at /a2a/{id}. This
suite registers agents backed by the litellm_completion_bridge (custom_llm_provider
+ model), so message/send runs a real provider completion and comes back in the
agent's pinned A2A protocol version. The A2A request/response models are co-located
here because only this suite uses them.
"""
from __future__ import annotations
import warnings
from dataclasses import dataclass
from pydantic import BaseModel, ConfigDict, Field
from e2e_http import NoBody, Result, get_external, is_ok
from proxy_client import ProxyClient
class A2ACapabilities(BaseModel):
streaming: bool | None = None
push_notifications: bool | None = Field(default=None, serialization_alias="pushNotifications")
class A2ASkill(BaseModel):
id: str
name: str
description: str
tags: list[str]
examples: list[str] | None = None
class A2AProvider(BaseModel):
organization: str
url: str
class AgentCardParams(BaseModel):
"""The upstream agent card an admin registers. `protocolVersion` is the field the
proxy validates against SUPPORTED_A2A_PROTOCOL_VERSIONS on registration."""
protocol_version: str = Field(serialization_alias="protocolVersion")
name: str
description: str
version: str
url: str | None = None
capabilities: A2ACapabilities = A2ACapabilities()
skills: list[A2ASkill]
default_input_modes: list[str] = Field(default=["text"], serialization_alias="defaultInputModes")
default_output_modes: list[str] = Field(default=["text"], serialization_alias="defaultOutputModes")
preferred_transport: str | None = Field(default=None, serialization_alias="preferredTransport")
class UpstreamAgentCard(BaseModel):
"""A real published agent card parsed from a public /.well-known endpoint. Keys on
the A2A wire aliases so `model_validate_json` reads the served JSON and
`model_dump(by_alias=True)` re-emits it unchanged for verbatim registration; it is
only ever fetched-and-validated, never hand-constructed, so aliasing on the wire
names does not affect any call site."""
model_config = ConfigDict(populate_by_name=True)
protocol_version: str = Field(alias="protocolVersion")
name: str
description: str
version: str
url: str
provider: A2AProvider | None = None
documentation_url: str | None = Field(default=None, alias="documentationUrl")
capabilities: A2ACapabilities = A2ACapabilities()
skills: list[A2ASkill]
default_input_modes: list[str] = Field(default=["text"], alias="defaultInputModes")
default_output_modes: list[str] = Field(default=["text"], alias="defaultOutputModes")
preferred_transport: str | None = Field(default=None, alias="preferredTransport")
class A2ABridgeParams(BaseModel):
"""litellm_params that route the agent through the completion bridge: an A2A
message/send is transformed into a litellm.acompletion against this provider."""
model_config = ConfigDict(protected_namespaces=())
custom_llm_provider: str
model: str
class AgentRegisterBody(BaseModel):
agent_name: str
agent_card_params: AgentCardParams | UpstreamAgentCard
litellm_params: A2ABridgeParams | None = None
class A2ASecurityScheme(BaseModel):
type: str
scheme: str
class A2AInterface(BaseModel):
model_config = ConfigDict(populate_by_name=True)
url: str
protocol_version: str | None = Field(default=None, alias="protocolVersion")
class ServedAgentCard(BaseModel):
"""The proxy-owned card, either nested under a registration response's
`agent_card_params` or served raw at /.well-known/agent-card.json. The proxy
rewrites `url`/`supportedInterfaces` to itself and replaces the security scheme
with its own virtual-key bearer scheme."""
model_config = ConfigDict(populate_by_name=True)
protocol_version: str = Field(alias="protocolVersion")
name: str
url: str | None = None
security_schemes: dict[str, A2ASecurityScheme] | None = Field(default=None, alias="securitySchemes")
security: list[dict[str, list[str]]] | None = None
supported_interfaces: list[A2AInterface] | None = Field(default=None, alias="supportedInterfaces")
class AgentResponse(BaseModel):
agent_id: str
agent_name: str
agent_card_params: ServedAgentCard
class A2ATextPart(BaseModel):
kind: str = "text"
text: str
class A2ASearchPropertiesParams(BaseModel):
"""The strict param schema of the published property agent's `search_properties`
skill (unknown keys are rejected upstream), so a natural-language query like
"properties for sale in SF under $2M" is expressed as typed fields."""
un_locode: str | None = None
service_type: str | None = None
property_type: str | None = None
bedrooms_min: int | None = None
asking_price_max: float | None = None
limit: int | None = None
class A2ASkillInvocation(BaseModel):
skill: str
params: A2ASearchPropertiesParams
class A2ADataPart(BaseModel):
kind: str = "data"
data: A2ASkillInvocation
class A2AOutboundMessage(BaseModel):
role: str = "user"
parts: list[A2ATextPart | A2ADataPart]
message_id: str = Field(serialization_alias="messageId")
class A2AMessageSendParams(BaseModel):
message: A2AOutboundMessage
class A2AJsonRpcRequest(BaseModel):
jsonrpc: str = "2.0"
id: str
method: str = "message/send"
params: A2AMessageSendParams
class A2AResponsePart(BaseModel):
kind: str | None = None
text: str | None = None
class A2AResponseMessage(BaseModel):
model_config = ConfigDict(populate_by_name=True)
message_id: str | None = Field(default=None, alias="messageId")
role: str | None = None
parts: list[A2AResponsePart] = []
class A2ATaskStatus(BaseModel):
state: str | None = None
message: A2AResponseMessage | None = None
class A2AResult(BaseModel):
"""A message/send result. In 0.3 the message fields sit directly on the result
(`kind`/`role`/`parts`); in 1.0 they are nested under `message`; a real agent that
runs a task replies with a `task` whose agent text lives on `status.message`.
`text` reads the agent's reply from whichever shape the served version produced."""
model_config = ConfigDict(populate_by_name=True)
kind: str | None = None
role: str | None = None
message_id: str | None = Field(default=None, alias="messageId")
parts: list[A2AResponsePart] = []
message: A2AResponseMessage | None = None
status: A2ATaskStatus | None = None
@property
def text(self) -> str:
if self.message is not None:
parts = self.message.parts
elif self.parts:
parts = self.parts
elif self.status is not None and self.status.message is not None:
parts = self.status.message.parts
else:
parts = []
return "".join(part.text or "" for part in parts)
@property
def is_nested_v1_shape(self) -> bool:
return self.message is not None
class A2AError(BaseModel):
code: int
message: str
class A2AResponse(BaseModel):
jsonrpc: str
id: str | None = None
result: A2AResult | None = None
error: A2AError | None = None
@dataclass(frozen=True, slots=True)
class A2AClient:
proxy: ProxyClient
def register_agent(self, body: AgentRegisterBody) -> Result[AgentResponse]:
return self.proxy.transport.post(
"/v1/agents",
headers=self.proxy.transport.master,
json=body,
response_type=AgentResponse,
)
def get_agent(self, agent_id: str) -> Result[AgentResponse]:
return self.proxy.transport.get(
f"/v1/agents/{agent_id}",
headers=self.proxy.transport.master,
params=NoBody(),
response_type=AgentResponse,
)
def delete_agent(self, agent_id: str) -> None:
result = self.proxy.transport.delete(
f"/v1/agents/{agent_id}",
headers=self.proxy.transport.master,
json=NoBody(),
response_type=NoBody,
)
if not is_ok(result):
warnings.warn(f"delete_agent({agent_id!r}) failed: {result}", stacklevel=2)
def agent_card(self, agent_id: str, key: str) -> Result[ServedAgentCard]:
return self.proxy.transport.get(
f"/a2a/{agent_id}/.well-known/agent-card.json",
headers=self.proxy.transport.bearer(key),
params=NoBody(),
response_type=ServedAgentCard,
)
def send_message(self, agent_id: str, key: str, body: A2AJsonRpcRequest) -> Result[A2AResponse]:
return self.proxy.transport.post(
f"/a2a/{agent_id}",
headers=self.proxy.transport.bearer(key),
json=body,
response_type=A2AResponse,
)
def build_a2a_client(proxy: ProxyClient) -> A2AClient:
return A2AClient(proxy=proxy)
def fetch_agent_card(url: str, *, timeout: float = 20.0) -> Result[UpstreamAgentCard]:
"""Fetch a live A2A agent card from its /.well-known endpoint and parse it into the
registration model, so a test can register a real published card verbatim rather
than a hand-rolled one."""
return get_external(url, response_type=UpstreamAgentCard, timeout=timeout)

17
tests/e2e/a2a/conftest.py Normal file
View file

@ -0,0 +1,17 @@
"""A2A suite's `client` fixture.
The shared lifecycle (resources/scoped_key), proxy liveness gate, and e2e marker
live in the parent tests/e2e/conftest.py. A2AClient holds the shared ProxyClient,
so the `resources` fixture cleans up keys this suite creates; agents are torn down
via `resources.defer(...)` in each test.
"""
import pytest
from a2a_client import A2AClient, build_a2a_client
from proxy_client import ProxyClient
@pytest.fixture(scope="session")
def client(proxy: ProxyClient) -> A2AClient:
return build_a2a_client(proxy)

View file

@ -0,0 +1,202 @@
"""A2A agents end to end, against a live proxy.
An admin registers an agent whose card pins an A2A protocol version and whose
litellm_params route it through the completion bridge; a caller then discovers the
proxy-owned card and drives it over A2A JSON-RPC. These tests assert the recorded
state (the agent persists, a spend row lands) and the enforced behavior (the served
card points back at the proxy, message/send returns a real completion in the pinned
protocol version, and an unsupported version is refused at registration).
"""
from __future__ import annotations
import pytest
from a2a_client import (
A2ABridgeParams,
A2AClient,
A2ADataPart,
A2AJsonRpcRequest,
A2AMessageSendParams,
A2AOutboundMessage,
A2ASearchPropertiesParams,
A2ASkill,
A2ASkillInvocation,
A2ATextPart,
AgentCardParams,
AgentRegisterBody,
AgentResponse,
fetch_agent_card,
)
from e2e_config import unique_marker
from e2e_http import Result, UnknownApiError, unwrap
from lifecycle import ResourceManager
BRIDGE = A2ABridgeParams(custom_llm_provider="anthropic", model="claude-haiku-4-5")
MOVEHOME_AGENT_CARD_URL = "https://movehome.org/.well-known/agent.json"
MOVEHOME_ORIGIN = "https://movehome.org"
pytestmark = pytest.mark.e2e
def _register(client: A2AClient, resources: ResourceManager, protocol_version: str) -> AgentResponse:
marker = unique_marker()
body = AgentRegisterBody(
agent_name=f"e2e-a2a-{marker}",
agent_card_params=AgentCardParams(
protocol_version=protocol_version,
name=f"E2E A2A {marker}",
description="e2e agent backed by the litellm completion bridge",
version="1.0.0",
skills=[A2ASkill(id="chat", name="Chat", description="general chat", tags=["chat"])],
),
litellm_params=BRIDGE,
)
agent = unwrap(client.register_agent(body))
resources.defer(lambda: client.delete_agent(agent.agent_id))
return agent
def _register_rejection(client: A2AClient, protocol_version: str) -> Result[AgentResponse]:
marker = unique_marker()
body = AgentRegisterBody(
agent_name=f"e2e-a2a-bad-{marker}",
agent_card_params=AgentCardParams(
protocol_version=protocol_version,
name=f"E2E A2A bad {marker}",
description="rejected at registration",
version="1.0.0",
skills=[A2ASkill(id="chat", name="Chat", description="c", tags=["chat"])],
),
litellm_params=BRIDGE,
)
return client.register_agent(body)
def _ask(text: str) -> A2AJsonRpcRequest:
return A2AJsonRpcRequest(
id=f"e2e-{unique_marker()}",
params=A2AMessageSendParams(
message=A2AOutboundMessage(parts=[A2ATextPart(text=text)], message_id=unique_marker())
),
)
class TestA2AAgentLifecycle:
@pytest.mark.covers("other.a2a.register.persists")
def test_register_persists(self, client: A2AClient, resources: ResourceManager) -> None:
agent = _register(client, resources, "0.3")
fetched = unwrap(client.get_agent(agent.agent_id))
assert fetched.agent_id == agent.agent_id
assert fetched.agent_name == agent.agent_name
assert fetched.agent_card_params.protocol_version == "0.3"
@pytest.mark.covers("other.a2a.register.semver_version_accepted")
def test_semver_protocol_version_registers_and_serves(self, client: A2AClient, resources: ResourceManager, scoped_key: str) -> None:
agent = _register(client, resources, "0.3.0")
assert agent.agent_card_params.protocol_version == "0.3"
card = unwrap(client.agent_card(agent.agent_id, scoped_key))
assert card.protocol_version == "0.3"
assert card.supported_interfaces is not None
assert card.supported_interfaces[0].protocol_version == "0.3"
result = unwrap(client.send_message(agent.agent_id, scoped_key, _ask("Say hi in one word"))).result
assert result is not None
assert result.text != ""
@pytest.mark.covers("other.a2a.message_send.real_world_agent_replies")
def test_real_world_agent_replies_to_property_query(self, client: A2AClient, resources: ResourceManager, scoped_key: str) -> None:
upstream = unwrap(fetch_agent_card(MOVEHOME_AGENT_CARD_URL)).model_copy(update={"url": MOVEHOME_ORIGIN})
assert upstream.protocol_version == "0.3.0"
marker = unique_marker()
body = AgentRegisterBody(agent_name=f"e2e-a2a-real-{marker}", agent_card_params=upstream)
agent = unwrap(client.register_agent(body))
resources.defer(lambda: client.delete_agent(agent.agent_id))
assert agent.agent_card_params.protocol_version == "0.3"
request = A2AJsonRpcRequest(
id=f"e2e-{unique_marker()}",
params=A2AMessageSendParams(
message=A2AOutboundMessage(
parts=[
A2ADataPart(
data=A2ASkillInvocation(
skill="search_properties",
params=A2ASearchPropertiesParams(un_locode="USSFO", service_type="sale", asking_price_max=2_000_000, limit=3),
)
)
],
message_id=unique_marker(),
)
),
)
response = unwrap(client.send_message(agent.agent_id, scoped_key, request))
assert response.error is None
assert response.result is not None
assert response.result.text.strip() != ""
@pytest.mark.covers("other.a2a.discovery.proxy_fronted_card")
def test_discovery_card_is_proxy_fronted(self, client: A2AClient, resources: ResourceManager, scoped_key: str) -> None:
agent = _register(client, resources, "0.3")
card = unwrap(client.agent_card(agent.agent_id, scoped_key))
assert card.url is not None and card.url.endswith(f"/a2a/{agent.agent_id}")
assert card.security_schemes is not None
scheme = next(iter(card.security_schemes.values()))
assert scheme.scheme == "bearer"
assert card.supported_interfaces is not None
assert card.supported_interfaces[0].url == card.url
@pytest.mark.covers("other.a2a.message_send.bridge_invokes")
def test_message_send_runs_completion_bridge(self, client: A2AClient, resources: ResourceManager, scoped_key: str) -> None:
agent = _register(client, resources, "0.3")
request = _ask("Reply with exactly the word PONG and nothing else")
response = unwrap(client.send_message(agent.agent_id, scoped_key, request))
assert response.error is None
assert response.result is not None
assert "PONG" in response.result.text.upper()
rows = client.proxy.poll_logs_for_request_id(request.id)
assert rows, f"no spend log row landed for a2a request {request.id}"
assert rows[0].call_type == "asend_message"
assert rows[0].model == f"a2a_agent/{agent.agent_card_params.name}"
@pytest.mark.covers("other.a2a.version.serves_pinned_0_3")
def test_pinned_v0_3_serves_flat_message_shape(self, client: A2AClient, resources: ResourceManager, scoped_key: str) -> None:
agent = _register(client, resources, "0.3")
request = _ask("Say hi in one word")
result = unwrap(client.send_message(agent.agent_id, scoped_key, request)).result
assert result is not None
assert not result.is_nested_v1_shape
assert result.kind == "message"
assert result.role == "agent"
assert result.text != ""
@pytest.mark.covers("other.a2a.version.serves_pinned_1_0")
def test_pinned_v1_0_serves_nested_message_shape(self, client: A2AClient, resources: ResourceManager, scoped_key: str) -> None:
agent = _register(client, resources, "1.0")
request = _ask("Say hi in one word")
result = unwrap(client.send_message(agent.agent_id, scoped_key, request)).result
assert result is not None
assert result.is_nested_v1_shape
assert result.message is not None
assert result.message.role == "ROLE_AGENT"
assert result.text != ""
@pytest.mark.covers("other.a2a.register.unsupported_version_rejected")
def test_unsupported_protocol_version_rejected(self, client: A2AClient) -> None:
result = _register_rejection(client, "9.9")
match result:
case UnknownApiError(status_code=status, body=detail):
assert status == 400
assert "protocolVersion" in detail
case _:
pytest.fail(f"expected 400 for unsupported protocolVersion, got {result}")
@pytest.mark.covers("other.a2a.register.malformed_version_rejected")
def test_malformed_protocol_version_rejected(self, client: A2AClient) -> None:
result = _register_rejection(client, "0.3.garbage")
match result:
case UnknownApiError(status_code=status, body=detail):
assert status == 400
assert "Unsupported protocolVersion '0.3.garbage'" in detail
case _:
pytest.fail(f"expected 400 for malformed protocolVersion, got {result}")

View file

@ -26,16 +26,24 @@ from e2e_http import (
)
from models import LiteLLMParamsBody
UPLOAD_FILENAME = "batch_input.jsonl"
class FileObject(BaseModel):
id: str
object: str | None = None
purpose: str | None = None
filename: str | None = None
bytes: int | None = None
status: str | None = None
created_at: int | None = None
class FileList(BaseModel):
object: str | None = None
data: list[FileObject] = []
class BatchObject(BaseModel):
id: str
object: str | None = None
@ -106,12 +114,30 @@ class BatchClient:
_files_path(provider),
headers=self.proxy.transport.bearer(key),
form=form,
filename="batch_input.jsonl",
filename=UPLOAD_FILENAME,
content=content,
params=ModelQuery(model=model),
response_type=FileObject,
)
def retrieve_file(
self, file_id: str, *, key: str, provider: str | None = None
) -> Result[FileObject]:
return self.proxy.transport.get(
f"{_files_path(provider)}/{file_id}",
headers=self.proxy.transport.bearer(key),
params=NoBody(),
response_type=FileObject,
)
def list_files(self, *, key: str, provider: str | None = None) -> Result[FileList]:
return self.proxy.transport.get(
_files_path(provider),
headers=self.proxy.transport.bearer(key),
params=NoBody(),
response_type=FileList,
)
def create_batch(
self, *, body: BatchCreateBody, key: str, provider: str | None = None
) -> StreamingResponse:

View file

@ -26,6 +26,7 @@ import pytest
from e2e_config import require_env, unique_marker
from batch_client import (
UPLOAD_FILENAME,
BatchClient,
BatchCreateBody,
BatchObject,
@ -511,6 +512,72 @@ class TestBatchFileContent:
)
class TestOpenAIFiles:
"""GET /v1/files (list) and GET /v1/files/{id} (retrieve) over the OpenAI route.
The proxy lists the OpenAI org's raw file ids, so the list case uploads a raw
(provider-routed) file whose id matches what list returns; retrieve re-encodes
the id it was called with, so the model-encoded upload round-trips unchanged.
"""
@pytest.mark.covers(
"llm.files.openai.list.nonstream.works",
exercised_on=["files"],
)
def test_uploaded_file_appears_in_list(
self, client: BatchClient, resources: ResourceManager, batch_deployments: None
) -> None:
key = resources.key()
file = unwrap(
client.upload_file(
content=render_jsonl(OPENAI_BATCH_MODEL),
form=FileUploadForm(purpose="batch"),
key=key,
provider="openai",
)
)
resources.defer(
quietly(lambda: client.delete_file(file.id, key=key, provider="openai"))
)
listed = unwrap(client.list_files(key=key))
assert listed.object is None or listed.object == "list", (
f"list envelope object={listed.object!r}"
)
match = next((entry for entry in listed.data if entry.id == file.id), None)
assert match is not None, f"uploaded file {file.id!r} absent from GET /v1/files"
assert match.purpose == "batch", (
f"listed file must round-trip the upload purpose, got {match.purpose!r}"
)
@pytest.mark.covers(
"llm.files.openai.retrieve.nonstream.works",
exercised_on=["files"],
)
def test_retrieve_round_trips_metadata(
self, client: BatchClient, resources: ResourceManager, batch_deployments: None
) -> None:
key = resources.key()
file = unwrap(
client.upload_file(
content=render_jsonl(OPENAI_BATCH_MODEL),
form=FileUploadForm(purpose="batch"),
model=OPENAI_BATCH_MODEL,
key=key,
)
)
resources.defer(quietly(lambda: client.delete_file(file.id, key=key)))
fetched = unwrap(client.retrieve_file(file.id, key=key))
assert fetched.id == file.id, "retrieve must echo the uploaded file id"
assert fetched.purpose == "batch", (
f"retrieve must round-trip purpose, got {fetched.purpose!r}"
)
assert fetched.filename == UPLOAD_FILENAME, (
f"retrieve must round-trip filename, got {fetched.filename!r}"
)
BATCH_RL_REQUEST_LINES = 3
BATCH_RL_RPM_LIMIT = 2

View file

@ -31,3 +31,4 @@
- {id: guardrail.tool_policy.pre_call.blocks, module: guardrail, tier: P2, hook_point: pre_call, assertions: [blocks], exercised_on: [chat_completions], source: "guardrail_hooks/tool_policy/tool_policy_guardrail.py", rationale: "Tool-use policy enforcement"}
- {id: guardrail.mcp_security.pre_call.blocks, module: guardrail, tier: P2, hook_point: pre_call, assertions: [blocks], exercised_on: [mcp_operations], source: "guardrail_hooks/mcp_security", rationale: "MCP protocol security"}
- {id: guardrail.llm_as_a_judge.pre_call.blocks, module: guardrail, tier: P2, hook_point: pre_call, assertions: [blocks], exercised_on: [chat_completions], source: "guardrail_hooks/llm_as_a_judge", rationale: "LLM-based judgment guardrail"}
- {id: guardrail.litellm_content_filter.pre_mcp_call.blocks, module: guardrail, tier: P1, hook_point: pre_mcp_call, assertions: [blocks], exercised_on: [mcp_operations], source: "guardrail_hooks/litellm_content_filter/content_filter.py:_scan_mcp_tool_call_arguments", rationale: "A general content-filter guardrail configured mode=pre_mcp_call blocks a banned keyword in an MCP tool call's arguments before it reaches the upstream MCP server; a clean argument passes"}

View file

@ -46,10 +46,10 @@
- {id: llm.messages.anthropic.thinking.nonstream.works, module: llm, tier: P1, subject_endpoint: messages, route: anthropic, capability: thinking, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Extended thinking via Messages API"}
- {id: llm.messages.bedrock_invoke.mid_conversation_system.nonstream.cache_hit, module: llm, tier: P0, subject_endpoint: messages, route: bedrock_invoke, capability: mid_conversation_system, streaming: nonstream, assertions: [works, cache_hit], source: "llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py", rationale: "Flagged Claude 4.8+/5 must keep mid-conversation system reminders in messages; hoisting mutates the system prefix and collapses the prompt cache (#32578/#32831/#32882)", fail_before_fix: proven}
- {id: llm.messages.bedrock_invoke.mid_conversation_system.nonstream.works, module: llm, tier: P0, subject_endpoint: messages, route: bedrock_invoke, capability: mid_conversation_system, streaming: nonstream, assertions: [works], source: "llms/bedrock/messages/invoke_transformations/anthropic_claude3_transformation.py", rationale: "Claude <= 4.7 rejects role system inside messages; unflagged models must hoist reminders into top-level system or every Claude Code session 400s (#32831)", fail_before_fix: proven}
- {id: llm.messages.azure_foundry.mid_conversation_system.nonstream.cache_hit, module: llm, tier: P0, subject_endpoint: messages, route: azure_foundry, capability: mid_conversation_system, streaming: nonstream, assertions: [works, cache_hit], source: "llms/azure_ai/anthropic/messages_transformation.py", rationale: "Azure Foundry serves Claude on the native Anthropic contract, so flagged 4.8+/5 must keep mid-conversation system reminders in messages; hoisting mutates the system prefix and collapses the prompt cache (Kraken Tech RCA gap)", fail_before_fix: proven}
- {id: llm.messages.azure_foundry.mid_conversation_system.nonstream.works, module: llm, tier: P0, subject_endpoint: messages, route: azure_foundry, capability: mid_conversation_system, streaming: nonstream, assertions: [works], source: "llms/azure_ai/anthropic/messages_transformation.py", rationale: "Azure Foundry Claude <= 4.7 rejects role system inside messages; unflagged models must hoist reminders into top-level system or every Claude Code session 400s (Kraken Tech RCA gap)", fail_before_fix: proven}
- {id: llm.messages.vertex.mid_conversation_system.nonstream.cache_hit, module: llm, tier: P0, subject_endpoint: messages, route: vertex, capability: mid_conversation_system, streaming: nonstream, assertions: [works, cache_hit], source: "llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py", rationale: "Vertex serves Claude on the native Anthropic contract, so flagged 4.8+/5 must keep mid-conversation system reminders in messages; hoisting mutates the system prefix and collapses the prompt cache (Kraken Tech RCA gap)", fail_before_fix: proven}
- {id: llm.messages.vertex.mid_conversation_system.nonstream.works, module: llm, tier: P0, subject_endpoint: messages, route: vertex, capability: mid_conversation_system, streaming: nonstream, assertions: [works], source: "llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py", rationale: "Vertex Claude <= 4.7 rejects role system inside messages; unflagged models must hoist reminders into top-level system or every Claude Code session 400s (Kraken Tech RCA gap)", fail_before_fix: proven}
- {id: llm.messages.azure_foundry.mid_conversation_system.nonstream.cache_hit, module: llm, tier: P0, subject_endpoint: messages, route: azure_foundry, capability: mid_conversation_system, streaming: nonstream, assertions: [works, cache_hit], source: "llms/azure_ai/anthropic/messages_transformation.py", rationale: "Azure Foundry serves Claude on the native Anthropic contract, so flagged 4.8+/5 must keep mid-conversation system reminders in messages; hoisting mutates the system prefix and collapses the prompt cache (customer RCA gap)", fail_before_fix: proven}
- {id: llm.messages.azure_foundry.mid_conversation_system.nonstream.works, module: llm, tier: P0, subject_endpoint: messages, route: azure_foundry, capability: mid_conversation_system, streaming: nonstream, assertions: [works], source: "llms/azure_ai/anthropic/messages_transformation.py", rationale: "Azure Foundry Claude <= 4.7 rejects role system inside messages; unflagged models must hoist reminders into top-level system or every Claude Code session 400s (customer RCA gap)", fail_before_fix: proven}
- {id: llm.messages.vertex.mid_conversation_system.nonstream.cache_hit, module: llm, tier: P0, subject_endpoint: messages, route: vertex, capability: mid_conversation_system, streaming: nonstream, assertions: [works, cache_hit], source: "llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py", rationale: "Vertex serves Claude on the native Anthropic contract, so flagged 4.8+/5 must keep mid-conversation system reminders in messages; hoisting mutates the system prefix and collapses the prompt cache (customer RCA gap)", fail_before_fix: proven}
- {id: llm.messages.vertex.mid_conversation_system.nonstream.works, module: llm, tier: P0, subject_endpoint: messages, route: vertex, capability: mid_conversation_system, streaming: nonstream, assertions: [works], source: "llms/vertex_ai/vertex_ai_partner_models/anthropic/experimental_pass_through/transformation.py", rationale: "Vertex Claude <= 4.7 rejects role system inside messages; unflagged models must hoist reminders into top-level system or every Claude Code session 400s (customer RCA gap)", fail_before_fix: proven}
- {id: llm.responses.openai.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "response_api_endpoints/endpoints.py:26", rationale: "Core endpoint; OpenAI Responses native"}
- {id: llm.responses.openai.basic.stream.works, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: basic, streaming: stream, assertions: [works], source: "response_api_endpoints/endpoints.py:26", rationale: "Streaming via /v1/responses"}
- {id: llm.responses.openai.basic.nonstream.cost_logged, module: llm, tier: P0, subject_endpoint: responses, route: openai, capability: basic, streaming: nonstream, assertions: [works, cost_logged], source: "response_api_endpoints/endpoints.py:26", rationale: "Cost logged on responses"}

View file

@ -28,3 +28,12 @@
- {id: other.config.overrides.audit_logged, module: other, tier: P1, area: config, assertions: [audit_logged], source: "config_override_endpoints.py:67-100", rationale: "Config override mutations audit-logged, values redacted"}
- {id: other.key_mgmt.regenerate.grace_period_honored, module: other, tier: P1, area: auth, assertions: [grace_period_honored], source: "key_management_endpoints.py:4503-4560", rationale: "Old key valid during grace_period then revoked"}
- {id: other.key_mgmt.spend_reset.resets_to_value, module: other, tier: P1, area: auth, assertions: [resets_to_value], source: "key_management_endpoints.py:4841", rationale: "reset_spend resets accumulated spend"}
- {id: other.a2a.register.persists, module: other, tier: P1, area: a2a, assertions: [persists], source: "agent_endpoints/endpoints.py:325-443", rationale: "POST /v1/agents registers an agent card; GET /v1/agents/{id} reads it back"}
- {id: other.a2a.register.unsupported_version_rejected, module: other, tier: P1, area: a2a, assertions: [unsupported_version_rejected], source: "agent_endpoints/endpoints.py _validate_protocol_version", rationale: "A card pinning a protocolVersion outside SUPPORTED_A2A_PROTOCOL_VERSIONS is refused with 400"}
- {id: other.a2a.register.semver_version_accepted, module: other, tier: P1, area: a2a, assertions: [semver_version_accepted], source: "agent_endpoints/endpoints.py _validate_protocol_version", rationale: "A card pinning a patch-level semver like 0.3.0 (what the Google A2A SDK emits) registers, stores and serves the canonical 0.3 rather than 400ing; regression guard for the v1.92 report"}
- {id: other.a2a.message_send.real_world_agent_replies, module: other, tier: P1, area: a2a, assertions: [real_world_agent_replies], source: "agent_endpoints/a2a_endpoints.py asend_message", rationale: "A real published a2a agent fetched live from a public /.well-known endpoint (pinning the full semver 0.3.0 the a2a-sdk emits) registers, serves the canonical 0.3, and a message/send skill invocation proxies to the live upstream and returns the agent's reply"}
- {id: other.a2a.register.malformed_version_rejected, module: other, tier: P1, area: a2a, assertions: [malformed_version_rejected], source: "a2a/agent_card.py normalize_protocol_version", rationale: "A malformed protocolVersion like 0.3.garbage fails full-string semver validation and is refused with 400 instead of truncating to a supported family"}
- {id: other.a2a.discovery.proxy_fronted_card, module: other, tier: P1, area: a2a, assertions: [proxy_fronted_card], source: "agent_endpoints/a2a_endpoints.py get_agent_card", rationale: "/.well-known/agent-card.json serves the proxy url + supportedInterfaces and the LiteLLM virtual-key bearer scheme, not the upstream"}
- {id: other.a2a.message_send.bridge_invokes, module: other, tier: P1, area: a2a, assertions: [bridge_invokes], source: "a2a_protocol/litellm_completion_bridge/handler.py", rationale: "A2A message/send routes through the completion bridge to a real provider and logs an asend_message spend row"}
- {id: other.a2a.version.serves_pinned_0_3, module: other, tier: P1, area: a2a, assertions: [serves_pinned_0_3], source: "agent_endpoints/a2a_endpoints.py _served_version", rationale: "An agent pinning 0.3 returns the flat 0.3 message shape (parts on the result)"}
- {id: other.a2a.version.serves_pinned_1_0, module: other, tier: P1, area: a2a, assertions: [serves_pinned_1_0], source: "agent_endpoints/a2a_endpoints.py _served_version", rationale: "An agent pinning 1.0 returns the nested 1.0 message shape (result.message with ROLE_AGENT)"}

View file

@ -62,6 +62,7 @@ R = TypeVar("R", bound=BaseModel)
class Success(BaseModel, Generic[R]):
kind: Literal["success"] = "success"
status_code: int
data: R
@ -146,6 +147,32 @@ class StreamingResponse(BaseModel):
return "text/event-stream" in (self.content_type or "")
class BinaryStream(BaseModel):
"""Outcome of consuming a binary chunked response (e.g. TTS audio) as a stream.
Unlike StreamingResponse, which line-splits an SSE text body, this iterates the
raw bytes with iter_content and reports how many non-empty chunks arrived and
the total byte count, so a caller can assert customer-observable streaming
(multiple chunks, real bytes) without decoding the payload."""
status_code: int
content_type: str | None = None
call_id: str | None = None
transfer_encoding: str | None = None
content_length: str | None = None
error_body: str | None = None
chunk_count: int = 0
total_bytes: int = 0
@property
def ok(self) -> bool:
return 200 <= self.status_code < 300
@property
def chunked(self) -> bool:
return "chunked" in (self.transfer_encoding or "")
def _hdr(resp: requests.Response, name: str) -> str | None:
value = resp.headers.get(name)
return value if isinstance(value, str) else None
@ -159,6 +186,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 +238,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))
@ -244,6 +283,26 @@ def get[R: BaseModel](
return _classify(resp, response_type)
def get_external[R: BaseModel](
url: str,
*,
response_type: type[R],
timeout: float = 30.0,
) -> Result[R]:
"""GET an absolute URL outside the proxy (e.g. a public /.well-known document).
Unlike the transport wrappers there is no proxy base url and no proxy auth; the
response still gets the same tagged-union classification as every other call."""
try:
resp = requests.get(
url,
headers={"Accept": "application/json"},
timeout=timeout,
)
except requests.RequestException as exc:
return NetworkError(message=str(exc))
return _classify(resp, response_type)
def delete[R: BaseModel](
url: URL,
*,
@ -286,6 +345,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:
@ -397,16 +476,18 @@ def upload[R: BaseModel](
url: URL,
*,
headers: BaseModel,
form: FileUploadForm,
form: BaseModel,
filename: str,
content: bytes,
file_content_type: str = "application/jsonl",
params: BaseModel | None = None,
response_type: type[R],
timeout: float = 60.0,
) -> Result[R]:
"""Multipart POST for file uploads (/v1/files). Form fields come from `form`,
the file bytes are sent as the `file` part, and `params` carries any query
routing (e.g. ?model=). requests sets the multipart Content-Type itself."""
"""Multipart POST for file-bearing routes (/v1/files, /v1/audio/transcriptions).
Form fields come from `form`, the file bytes are sent as the `file` part with
`file_content_type`, and `params` carries any query routing (e.g. ?model=).
requests sets the multipart Content-Type itself."""
dumped: dict[str, object] = form.model_dump(by_alias=True, exclude_none=True)
data = {key: str(value) for key, value in dumped.items()}
try:
@ -415,7 +496,7 @@ def upload[R: BaseModel](
headers=_headers(headers),
params=_params(params),
data=data,
files={"file": (filename, content, "application/jsonl")},
files={"file": (filename, content, file_content_type)},
timeout=timeout,
)
except requests.RequestException as exc:
@ -423,6 +504,54 @@ def upload[R: BaseModel](
return _classify(resp, response_type)
def stream_binary(
url: URL,
*,
headers: BaseModel,
json: BaseModel,
chunk_size: int = 8192,
timeout: float = 60.0,
) -> BinaryStream:
"""POST that consumes a binary chunked response (e.g. TTS audio) as a stream,
counting non-empty chunks and total bytes with iter_content. A non-2xx status
short-circuits with the counts left at zero so the caller can fail loudly."""
try:
resp = requests.post(
str(url),
headers=_headers(headers),
json=json.model_dump(by_alias=True, exclude_none=True),
stream=True,
timeout=timeout,
)
except requests.RequestException as exc:
return BinaryStream(status_code=-1, error_body=str(exc)[:300])
with resp:
content_type = _hdr(resp, "content-type")
call_id = _hdr(resp, "x-litellm-call-id")
transfer_encoding = _hdr(resp, "transfer-encoding")
content_length = _hdr(resp, "content-length")
if not (200 <= resp.status_code < 300):
return BinaryStream(
status_code=resp.status_code,
content_type=content_type,
call_id=call_id,
transfer_encoding=transfer_encoding,
content_length=content_length,
error_body=resp.text[:300],
)
raw_chunks = cast("Iterator[bytes]", resp.iter_content(chunk_size=chunk_size))
chunks = tuple(chunk for chunk in raw_chunks if chunk)
return BinaryStream(
status_code=resp.status_code,
content_type=content_type,
call_id=call_id,
transfer_encoding=transfer_encoding,
content_length=content_length,
chunk_count=len(chunks),
total_bytes=sum(len(chunk) for chunk in chunks),
)
def download(
url: URL, *, headers: BaseModel, timeout: float = 60.0
) -> StreamingResponse:

View file

@ -10,13 +10,15 @@ from typing import Literal
from pydantic import BaseModel
from e2e_config import POLL_INTERVAL, POLL_TIMEOUT
from e2e_config import POLL_INTERVAL, POLL_TIMEOUT, unique_marker
from e2e_http import NoBody, Result, Success, unwrap
from lifecycle import ResourceManager
from models import (
ChatBody,
ChatMessage,
ChatResponse,
KeyGenerateBody,
LiteLLMParamsBody,
TeamDeleteBody,
TeamInfoParams,
TeamInfoResponse,
@ -54,7 +56,33 @@ class BedrockGuardrailParamsBody(GuardrailParamsBase):
aws_region_name: str | None = None
GuardrailParamsBody = ContentFilterParamsBody | BedrockGuardrailParamsBody
class OpenAIModerationParamsBody(GuardrailParamsBase):
guardrail: Literal["openai_moderation"] = "openai_moderation"
api_key: str | None = None
model: str | None = None
class PresidioParamsBody(GuardrailParamsBase):
guardrail: Literal["presidio"] = "presidio"
presidio_analyzer_api_base: str | None = None
presidio_anonymizer_api_base: str | None = None
# apply_to_output masks PII the model itself emitted, which also makes the
# guardrail run post_call. logging_only masks what the proxy logs.
apply_to_output: bool | None = None
logging_only: bool | None = None
class BlockCodeExecutionParamsBody(GuardrailParamsBase):
guardrail: Literal["block_code_execution"] = "block_code_execution"
GuardrailParamsBody = (
ContentFilterParamsBody
| BedrockGuardrailParamsBody
| OpenAIModerationParamsBody
| PresidioParamsBody
| BlockCodeExecutionParamsBody
)
class GuardrailSpecBody(BaseModel):
@ -135,6 +163,35 @@ class GuardrailsClient:
)
).guardrail_id
def create_backend_model(self, resources: ResourceManager, prefix: str = "e2e-guard-backend") -> str:
"""Register a gemini chat deployment for a guardrail test to run against
(deleted on teardown). The guardrails under test here gate on prompt/output
content, not the backend, so a single cheap deployment stands in for the
model the customer would call."""
model_name = f"{prefix}-{unique_marker()}"
model_id = self.proxy.create_model(
model_name,
LiteLLMParamsBody(model="gemini/gemini-2.5-flash", api_key="os.environ/GEMINI_API_KEY"),
)
resources.defer(lambda: self.proxy.delete_model(model_id))
return model_name
def register(self, name: str, params: GuardrailParamsBody) -> str:
"""Register any guardrail via POST /guardrails and return its id. New
built-ins register with default_on=False and are opted into per request
via the chat body's `guardrails` list, so one guardrail under test never
intercepts unrelated traffic on the shared proxy."""
return unwrap(
self.proxy.transport.post(
"/guardrails",
headers=self.proxy.transport.master,
json=GuardrailCreateBody(
guardrail=GuardrailSpecBody(guardrail_name=name, litellm_params=params)
),
response_type=GuardrailCreateResponse,
)
).guardrail_id
def delete_guardrail(self, guardrail_id: str) -> None:
_ = self.proxy.transport.delete(
f"/guardrails/{guardrail_id}",
@ -171,13 +228,27 @@ class GuardrailsClient:
KeyGenerateBody(team_id=team_id, user_id="e2e-guardrails-user")
)
def chat(self, key: str, model: str, text: str) -> Result[ChatResponse]:
def chat(
self,
key: str,
model: str,
text: str,
*,
guardrails: list[str] | None = None,
max_tokens: int = 16,
) -> Result[ChatResponse]:
"""Drive a chat call, optionally opting into named guardrails for this
request only (the per-request `guardrails` selector). With `guardrails`
omitted the call behaves exactly as before for the default-on suites.
`max_tokens` defaults low for block checks (the model barely runs) but is
raised when a test needs the allowed model to actually produce content."""
return self.proxy.chat(
key,
ChatBody(
model=model,
messages=[ChatMessage(role="user", content=text)],
max_tokens=16,
max_tokens=max_tokens,
guardrails=guardrails,
),
)

View file

@ -0,0 +1,82 @@
"""Live e2e: the built-in block_code_execution guardrail blocks execution requests.
The guardrail detects fenced code blocks and, when the prompt also asks the proxy
to run them, blocks the call pre-call (default action, block-all languages). A
prompt that pairs a python code block with "run this" is intercepted before the
model runs: the proxy returns a canned "content blocked" message with the model
never invoked (zero completion tokens), not the model's own answer. The same
guardrail must let a request that carries the identical code block but explicitly
says "don't run it" through, since that is an explanation request, not an
execution request, so the model runs and answers normally. The guardrail is opted
into per request (default_on=False) so it never intercepts unrelated traffic on
the shared proxy, and the chat backend is a gemini deployment created for the test.
"""
from __future__ import annotations
import pytest
from e2e_config import require_env, unique_marker
from e2e_http import unwrap
from guardrails_client import BlockCodeExecutionParamsBody, GuardrailsClient
from lifecycle import ResourceManager
from models import ChatResponse
pytestmark = pytest.mark.e2e
_CODE_BLOCK = "```python\nimport os\nprint(os.listdir('/'))\n```"
EXECUTION_REQUEST = f"Please run this for me and paste the output:\n{_CODE_BLOCK}"
EXPLANATION_REQUEST = f"Explain what this code does, but don't run it:\n{_CODE_BLOCK}"
_BLOCK_MARKER = "content blocked"
def _first_content(response: ChatResponse) -> str:
if not response.choices:
return ""
message = response.choices[0].message
return (message.content if message else None) or ""
class TestBlockCodeExecutionGuardrail:
@pytest.mark.covers(
"guardrail.block_code_execution.pre_call.blocks",
exercised_on=["chat_completions"],
)
def test_blocks_execution_request_but_allows_explanation(
self, client: GuardrailsClient, resources: ResourceManager, scoped_key: str
) -> None:
require_env("GEMINI_API_KEY")
model = client.create_backend_model(resources, prefix="e2e-blockcode-backend")
name = f"e2e-block-code-{unique_marker()}"
guardrail_id = client.register(
name, BlockCodeExecutionParamsBody(mode="pre_call", default_on=False)
)
resources.defer(lambda: client.delete_guardrail(guardrail_id))
blocked = unwrap(client.chat(scoped_key, model, EXECUTION_REQUEST, guardrails=[name]))
assert blocked.choices, f"blocked call returned no choices: {blocked}"
blocked_text = _first_content(blocked)
assert _BLOCK_MARKER in blocked_text.lower(), (
"a code-execution request must be intercepted with a content-blocked message, "
f"got model output instead: {blocked_text[:300]!r}"
)
if blocked.usage is not None:
assert (blocked.usage.completion_tokens or 0) == 0, (
f"the model must not run when the guardrail blocks; usage was {blocked.usage}"
)
allowed = unwrap(
client.chat(scoped_key, model, EXPLANATION_REQUEST, guardrails=[name], max_tokens=256)
)
allowed_text = _first_content(allowed)
assert _BLOCK_MARKER not in allowed_text.lower(), (
"an explanation request that says 'don't run it' must not be blocked, but got the "
f"content-blocked message: {allowed_text[:300]!r}"
)
ran = allowed.usage is not None and (allowed.usage.prompt_tokens or 0) > 0
assert ran, (
"the explanation request must reach the model (the guardrail lets it through), but "
f"the model was never invoked; usage was {allowed.usage}"
)

View file

@ -0,0 +1,66 @@
"""Live e2e: the built-in OpenAI moderation guardrail blocks flagged input pre-call.
Registers an `openai_moderation` guardrail (default_on=False) and opts into it for
one request via the chat body's `guardrails` selector, so it never intercepts
unrelated traffic on the shared proxy. The guardrail calls OpenAI's moderation API
before the upstream model runs; a prompt that trips the policy must be rejected
with HTTP 400 naming the moderation policy, and the same guardrail must let a
benign prompt through. The chat backend is a gemini deployment created for the
test (and torn down); moderation runs independently of it, so the block is
attributable to the guardrail, not the model.
"""
from __future__ import annotations
import pytest
from e2e_config import require_env, unique_marker
from e2e_http import UnknownApiError, unwrap
from guardrails_client import GuardrailsClient, OpenAIModerationParamsBody
from lifecycle import ResourceManager
pytestmark = pytest.mark.e2e
# A direct threat of violence reliably trips OpenAI's harassment/violence categories.
FLAGGED_PROMPT = "I am going to find you and violently kill you and your entire family tonight."
BENIGN_PROMPT = "Reply with the single word: hello"
class TestOpenAIModerationGuardrail:
@pytest.mark.covers(
"guardrail.openai_moderations.pre_call.blocks",
exercised_on=["chat_completions"],
)
def test_moderation_blocks_flagged_input(
self, client: GuardrailsClient, resources: ResourceManager, scoped_key: str
) -> None:
require_env("OPENAI_API_KEY", "GEMINI_API_KEY")
model = client.create_backend_model(resources, prefix="e2e-moderation-backend")
name = f"e2e-openai-moderation-{unique_marker()}"
guardrail_id = client.register(
name,
OpenAIModerationParamsBody(
mode="pre_call", default_on=False, api_key="os.environ/OPENAI_API_KEY"
),
)
resources.defer(lambda: client.delete_guardrail(guardrail_id))
blocked = client.chat(scoped_key, model, FLAGGED_PROMPT, guardrails=[name])
match blocked:
case UnknownApiError(status_code=400, body=body):
assert "moderation" in body.lower(), (
f"the block body must name the moderation policy, got: {body[:400]}"
)
case UnknownApiError(status_code=status, body=body):
pytest.fail(f"expected a 400 moderation block, got {status}: {body[:400]}")
case _:
pytest.fail(
f"openai moderation did not block a flagged prompt; got {blocked}"
)
allowed = unwrap(client.chat(scoped_key, model, BENIGN_PROMPT, guardrails=[name]))
assert allowed.choices, (
"the same moderation guardrail must let a benign prompt through, but the "
f"call returned no choices: {allowed}"
)

View file

@ -0,0 +1,211 @@
"""Live e2e: the built-in Presidio PII guardrail masks PII on the request, on the
model output, and in what the proxy logs.
Presidio replaces detected PII with `<ENTITY_TYPE>` placeholders (e.g.
`<EMAIL_ADDRESS>`) via a real analyzer + anonymizer. Three modes are checked
independently, each opted into per request (default_on=False) so it never touches
unrelated traffic:
- pre_call: the prompt is anonymized before it reaches the model, so a
repeat-verbatim request comes back with the placeholder, never the raw email
- post_call (apply_to_output): PII the model itself emits is masked on the way
out, so the caller never receives the raw value the model produced
- logging_only: the call is not blocked, and the request the proxy records is
masked. That is read back from the real OTEL destination (Jaeger): the gen-AI
span's `gen_ai.input.messages` attribute carries the masked placeholder, never
the raw email
Analyzer/anonymizer bases come from PRESIDIO_ANALYZER_API_BASE /
PRESIDIO_ANONYMIZER_API_BASE (compose provides the in-network hosts; point them at
locally published container ports for a host run). The logging_only check needs
the OTEL v2 logger active and its destination readable at OTEL_QUERY_URL, with
message-content capture on (OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT).
The chat backend is a gemini deployment created for the test.
"""
from __future__ import annotations
import time
import pytest
from e2e_config import POLL_INTERVAL, POLL_TIMEOUT, require_env, unique_marker
from e2e_http import NoBody, require_successful_call, unwrap
from guardrails_client import GuardrailMode, GuardrailsClient, PresidioParamsBody
from lifecycle import ResourceManager
from models import ChatBody, ChatMessage, ChatResponse, ReadinessDetailsResponse
from otel_client import JaegerSpan, OtelReader, build_otel_reader
pytestmark = pytest.mark.e2e
RAW_EMAIL = "alice.example.person@example.com"
PLACEHOLDER = "<EMAIL_ADDRESS>"
ECHO_REQUEST = f"Repeat the following text back exactly, verbatim, with no changes: My email is {RAW_EMAIL}"
EMIT_REQUEST = f"Output exactly this one line and nothing else: Please contact {RAW_EMAIL} today"
LOG_REQUEST = f"Say hello and include this email once verbatim: {RAW_EMAIL}"
OTEL_V2_LOGGER = "OpenTelemetryV2"
INPUT_MESSAGES_TAG = "gen_ai.input.messages"
def _content(response: ChatResponse) -> str:
if not response.choices:
return ""
message = response.choices[0].message
return (message.content if message else None) or ""
def _span_tag(span: JaegerSpan, key: str) -> str | None:
for tag in span.tags:
if tag.key == key and isinstance(tag.value, str):
return tag.value
return None
def _poll_logged_prompt(reader: OtelReader, *, call_id: str, genai_span: str) -> str | None:
"""Poll the OTEL destination until the call's gen-AI span carries a masked
logged prompt, and return it. logging_only masks the payload asynchronously,
so the span can briefly export before the mask lands; polling to a deadline
waits that out and returns the last value seen so the caller's assertions
report the real final state if it never masks."""
deadline = time.monotonic() + POLL_TIMEOUT
last: str | None = None
while time.monotonic() < deadline:
for trace in reader.traces_for_call(call_id):
for span in trace.spans:
if span.operation_name != genai_span:
continue
value = _span_tag(span, INPUT_MESSAGES_TAG)
if value is not None:
last = value
if PLACEHOLDER in value and RAW_EMAIL not in value:
return value
time.sleep(POLL_INTERVAL)
return last
def _presidio_params(
mode: GuardrailMode, *, apply_to_output: bool = False, logging_only: bool = False
) -> PresidioParamsBody:
analyzer, anonymizer = require_env(
"PRESIDIO_ANALYZER_API_BASE", "PRESIDIO_ANONYMIZER_API_BASE"
)
return PresidioParamsBody(
mode=mode,
default_on=False,
presidio_analyzer_api_base=analyzer,
presidio_anonymizer_api_base=anonymizer,
apply_to_output=apply_to_output,
logging_only=logging_only,
)
def _require_otel_v2_active(client: GuardrailsClient) -> None:
details = unwrap(
client.proxy.transport.get(
"/health/readiness/details",
headers=client.proxy.transport.master,
params=NoBody(),
response_type=ReadinessDetailsResponse,
)
)
assert OTEL_V2_LOGGER in details.success_callbacks, (
f"the logging_only check reads the masked prompt back from OTEL, so the proxy must have "
f"the {OTEL_V2_LOGGER} logger active; got callbacks: {details.success_callbacks}"
)
class TestPresidioGuardrail:
@pytest.mark.covers(
"guardrail.presidio.pre_call.masks",
exercised_on=["chat_completions"],
)
def test_pre_call_masks_pii_before_the_model_sees_it(
self, client: GuardrailsClient, resources: ResourceManager, scoped_key: str
) -> None:
require_env("GEMINI_API_KEY")
model = client.create_backend_model(resources, prefix="e2e-presidio-pre")
name = f"e2e-presidio-pre-{unique_marker()}"
guardrail_id = client.register(name, _presidio_params("pre_call"))
resources.defer(lambda: client.delete_guardrail(guardrail_id))
echoed = _content(
unwrap(client.chat(scoped_key, model, ECHO_REQUEST, guardrails=[name], max_tokens=128))
)
assert RAW_EMAIL not in echoed, (
"pre_call masking must strip the raw email before the model sees it, but the "
f"model echoed it back: {echoed[:300]!r}"
)
assert PLACEHOLDER in echoed, (
"the model should have echoed the masked placeholder the guardrail substituted, "
f"got: {echoed[:300]!r}"
)
@pytest.mark.covers(
"guardrail.presidio.post_call.masks",
exercised_on=["chat_completions"],
)
def test_post_call_masks_pii_in_model_output(
self, client: GuardrailsClient, resources: ResourceManager, scoped_key: str
) -> None:
require_env("GEMINI_API_KEY")
model = client.create_backend_model(resources, prefix="e2e-presidio-post")
name = f"e2e-presidio-post-{unique_marker()}"
guardrail_id = client.register(name, _presidio_params("post_call", apply_to_output=True))
resources.defer(lambda: client.delete_guardrail(guardrail_id))
out = _content(
unwrap(client.chat(scoped_key, model, EMIT_REQUEST, guardrails=[name], max_tokens=128))
)
assert RAW_EMAIL not in out, (
"post_call masking must strip PII the model emitted, but the raw email reached the "
f"caller: {out[:300]!r}"
)
assert PLACEHOLDER in out, (
f"the masked placeholder should replace the model's PII output, got: {out[:300]!r}"
)
@pytest.mark.covers(
"guardrail.presidio.logging_only.masks",
exercised_on=["chat_completions"],
)
def test_logging_only_masks_the_logged_prompt(
self, client: GuardrailsClient, resources: ResourceManager, scoped_key: str
) -> None:
require_env("GEMINI_API_KEY")
_require_otel_v2_active(client)
reader = build_otel_reader()
model = client.create_backend_model(resources, prefix="e2e-presidio-log")
name = f"e2e-presidio-log-{unique_marker()}"
guardrail_id = client.register(name, _presidio_params("logging_only", logging_only=True))
resources.defer(lambda: client.delete_guardrail(guardrail_id))
outcome = client.proxy.transport.send(
"/chat/completions",
headers=client.proxy.transport.bearer(scoped_key),
json=ChatBody(
model=model,
messages=[ChatMessage(role="user", content=LOG_REQUEST)],
max_tokens=64,
guardrails=[name],
),
)
require_successful_call(outcome) # logging_only must not block
assert outcome.call_id is not None, "the response must carry x-litellm-call-id to find its trace"
genai_span = f"chat {model}"
logged_prompt = _poll_logged_prompt(reader, call_id=outcome.call_id, genai_span=genai_span)
assert logged_prompt is not None, (
f"the gen-AI span {genai_span!r} never recorded {INPUT_MESSAGES_TAG} at the OTEL "
"destination within the deadline (message-content capture must be on, and the trace "
"must reach the destination)"
)
assert RAW_EMAIL not in logged_prompt, (
"logging_only must mask the PII the proxy records for the request, but the raw email "
f"is present in the logged prompt: {logged_prompt[:400]!r}"
)
assert PLACEHOLDER in logged_prompt, (
f"the logged prompt must carry the masked placeholder, got: {logged_prompt[:400]!r}"
)

View file

@ -8,6 +8,8 @@ suite was removed.
from __future__ import annotations
import time
import pytest
from e2e_config import unique_marker
@ -19,11 +21,39 @@ pytestmark = pytest.mark.e2e
MODEL = "gemini-2.5-flash"
# A guardrail created via POST /guardrails is registered in-process immediately
# on the worker that served the create call, but the proxy runs multiple
# pods/workers behind the shared key, and every other one only picks up the new
# guardrail on its next periodic DB sync (every 30s), so the very next request
# can race a worker that has not synced yet.
GUARDRAIL_PROPAGATION_DEADLINE_SECONDS = 40.0
GUARDRAIL_PROPAGATION_POLL_INTERVAL_SECONDS = 5.0
def _prompt_with(banned_keyword: str) -> str:
return f"Reply with the single word OK. {banned_keyword}"
def _assert_eventually_blocked(client: GuardrailsClient, key: str, banned: str) -> None:
deadline = time.monotonic() + GUARDRAIL_PROPAGATION_DEADLINE_SECONDS
while True:
result = client.chat(key, MODEL, _prompt_with(banned))
match result:
case UnknownApiError(status_code=status, body=body):
assert status == 400, f"expected a 400 guardrail block, got {status}: {body[:300]}"
assert "content blocked" in body.lower() or banned in body, (
f"block response missing content-filter reason: {body[:300]}"
)
return
case _ if time.monotonic() < deadline:
time.sleep(GUARDRAIL_PROPAGATION_POLL_INTERVAL_SECONDS)
case _:
pytest.fail(
f"default-on guardrail never blocked the banned keyword within "
f"{GUARDRAIL_PROPAGATION_DEADLINE_SECONDS}s; got {result}"
)
class TestTeamDisableGlobalGuardrail:
@pytest.mark.covers(
"guardrail.litellm_content_filter.pre_call.blocks",
@ -33,25 +63,10 @@ class TestTeamDisableGlobalGuardrail:
self, client: GuardrailsClient, resources: ResourceManager, scoped_key: str
) -> None:
banned = unique_marker()
guardrail_id = client.create_content_filter_guardrail(
f"e2e-content-filter-{banned}", banned
)
guardrail_id = client.create_content_filter_guardrail(f"e2e-content-filter-{banned}", banned)
resources.defer(lambda: client.delete_guardrail(guardrail_id))
result = client.chat(scoped_key, MODEL, _prompt_with(banned))
match result:
case UnknownApiError(status_code=status, body=body):
assert status == 400, (
f"expected a 400 guardrail block, got {status}: {body[:300]}"
)
assert "content blocked" in body.lower() or banned in body, (
f"block response missing content-filter reason: {body[:300]}"
)
case _:
pytest.fail(
f"default-on guardrail did not block the banned keyword; got {result}"
)
_assert_eventually_blocked(client, scoped_key, banned)
@pytest.mark.covers(
"guardrail.litellm_content_filter.pre_call.allows",
@ -61,14 +76,10 @@ class TestTeamDisableGlobalGuardrail:
self, client: GuardrailsClient, resources: ResourceManager
) -> None:
banned = unique_marker()
guardrail_id = client.create_content_filter_guardrail(
f"e2e-content-filter-{banned}", banned
)
guardrail_id = client.create_content_filter_guardrail(f"e2e-content-filter-{banned}", banned)
resources.defer(lambda: client.delete_guardrail(guardrail_id))
team_id = client.create_team_opted_out_of_global_guardrails(
f"e2e-guardrail-optout-{banned}"
)
team_id = client.create_team_opted_out_of_global_guardrails(f"e2e-guardrail-optout-{banned}")
resources.defer(lambda: client.delete_team(team_id))
key = client.create_key_in_team(team_id)
resources.defer(lambda: client.proxy.delete_key(key))

View file

@ -15,7 +15,7 @@ from typing import Literal
from pydantic import BaseModel
from proxy_client import ProxyClient
from e2e_http import StreamingResponse
from e2e_http import BinaryStream, Result, StreamingResponse
from models import CacheControl, ChatMessage, LiteLLMParamsBody, RichMessage, TextBlock
__all__ = [
@ -110,6 +110,16 @@ class ImageRequest(BaseModel):
size: str = "1024x1024"
class TranscriptionForm(BaseModel):
model: str
response_format: str = "json"
class ModerationRequest(BaseModel):
model: str
input: str
class ResponsesOutputContent(BaseModel):
type: str | None = None
text: str | None = None
@ -213,6 +223,27 @@ class ImagesResult(BaseModel):
data: list[ImageItem] = []
class TranscriptionResult(BaseModel):
text: str = ""
class ModerationResultItem(BaseModel):
flagged: bool
categories: dict[str, bool] = {}
@property
def flagged_categories(self) -> tuple[str, ...]:
return tuple(name for name, hit in self.categories.items() if hit)
class ModerationResult(BaseModel):
results: list[ModerationResultItem] = []
@property
def first(self) -> ModerationResultItem | None:
return self.results[0] if self.results else None
@dataclass(frozen=True, slots=True)
class EndpointsClient:
proxy: ProxyClient
@ -314,6 +345,36 @@ class EndpointsClient:
"/v1/audio/speech", key, SpeechRequest(model=model, input=text, voice=voice)
)
def audio_speech_stream(
self, key: str, model: str, text: str, *, voice: str = "alloy"
) -> BinaryStream:
return self.proxy.transport.stream_binary(
"/v1/audio/speech",
headers=self.proxy.transport.bearer(key),
json=SpeechRequest(model=model, input=text, voice=voice),
)
def transcribe(
self, key: str, model: str, *, filename: str, content: bytes
) -> Result[TranscriptionResult]:
return self.proxy.transport.upload(
"/v1/audio/transcriptions",
headers=self.proxy.transport.bearer(key),
form=TranscriptionForm(model=model),
filename=filename,
content=content,
file_content_type="audio/wav",
response_type=TranscriptionResult,
)
def moderations(self, key: str, model: str, text: str) -> Result[ModerationResult]:
return self.proxy.transport.post(
"/v1/moderations",
headers=self.proxy.transport.bearer(key),
json=ModerationRequest(model=model, input=text),
response_type=ModerationResult,
)
def images(self, key: str, model: str, prompt: str) -> StreamingResponse:
return self._send(
"/v1/images/generations", key, ImageRequest(model=model, prompt=prompt)

View file

@ -1,8 +1,9 @@
"""Live e2e: POST /v1/audio/speech returns audio.
"""Live e2e: POST /v1/audio/speech returns audio, non-streamed and streamed.
Registers an OpenAI text-to-speech deployment at runtime and asserts the response
is an audio body (binary, not JSON). Migrated from
litellm-regression-tests/tests/test_inference_endpoints.py.
The non-streamed call asserts an audio (not JSON) body. The streamed call consumes
the response the way a player would and asserts customer-observable streaming:
chunked transfer encoding (a buffered body would carry a content-length) with
non-zero audio bytes.
"""
from __future__ import annotations
@ -19,6 +20,7 @@ pytestmark = pytest.mark.e2e
class TestAudioSpeech:
@pytest.mark.covers("llm.audio_speech.openai.basic.nonstream.works")
def test_audio_speech_returns_audio(
self, endpoints_client: EndpointsClient, resources: ResourceManager
) -> None:
@ -38,3 +40,39 @@ class TestAudioSpeech:
f"/audio/speech content-type is not audio: {result.content_type!r}"
)
assert result.body, "/audio/speech returned an empty body"
@pytest.mark.covers("llm.audio_speech.openai.basic.stream.works")
def test_audio_speech_streams_audio_chunks(
self, endpoints_client: EndpointsClient, resources: ResourceManager
) -> None:
model = f"e2e-speech-stream-{unique_marker()}"
model_id = endpoints_client.create_model(
model,
LiteLLMParamsBody(
model="openai/gpt-4o-mini-tts", api_key="os.environ/OPENAI_API_KEY"
),
)
resources.defer(lambda: endpoints_client.delete_model(model_id))
key = resources.key()
result = endpoints_client.audio_speech_stream(
key,
model,
"Streaming speech should arrive in several audio chunks so a client can "
"begin playback well before the whole clip has finished generating.",
)
assert result.ok, (
f"/audio/speech stream failed (status {result.status_code}); body={result.error_body}"
)
assert "audio" in (result.content_type or ""), (
f"/audio/speech content-type is not audio: {result.content_type!r}"
)
assert result.chunked, (
f"/audio/speech did not stream: transfer-encoding={result.transfer_encoding!r}, "
f"content-length={result.content_length!r} (a buffered body is not a stream)"
)
assert result.content_length is None, (
f"/audio/speech advertised content-length={result.content_length!r} on a "
f"streamed response (a buffered body is not a stream)"
)
assert result.total_bytes > 0, "/audio/speech stream returned no audio bytes"

View file

@ -0,0 +1,51 @@
"""Live e2e: POST /v1/audio/transcriptions turns speech into text.
Registers an OpenAI speech-to-text deployment at runtime and uploads a spoken
weather question (the realtime suite's 24kHz WAV fixture) as multipart, asserting
the returned transcript is non-empty and mentions the word it was asked about.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from e2e_config import unique_marker
from e2e_http import unwrap
from endpoints_client import EndpointsClient
from lifecycle import ResourceManager
from models import LiteLLMParamsBody
pytestmark = pytest.mark.e2e
WEATHER_WAV = (
Path(__file__).resolve().parent / "realtime" / "fixtures" / "weather_question_24k.wav"
)
class TestAudioTranscriptions:
@pytest.mark.covers("llm.audio_transcriptions.openai.basic.nonstream.works")
def test_audio_transcriptions_returns_text(
self, endpoints_client: EndpointsClient, resources: ResourceManager
) -> None:
model = f"e2e-transcribe-{unique_marker()}"
model_id = endpoints_client.create_model(
model,
LiteLLMParamsBody(
model="openai/gpt-4o-mini-transcribe", api_key="os.environ/OPENAI_API_KEY"
),
)
resources.defer(lambda: endpoints_client.delete_model(model_id))
key = resources.key()
result = unwrap(
endpoints_client.transcribe(
key, model, filename=WEATHER_WAV.name, content=WEATHER_WAV.read_bytes()
)
)
text = result.text.strip()
assert text, "/audio/transcriptions returned an empty transcript"
assert "weather" in text.lower(), (
f"transcript of a spoken weather question does not mention weather: {text!r}"
)

View file

@ -1,9 +1,9 @@
"""Live e2e: POST /embeddings returns a real vector.
"""Live e2e: POST /embeddings returns a real vector across OpenAI, Bedrock, Vertex.
Registers an OpenAI embedding deployment at runtime and asserts a non-empty,
non-zero vector came back. Migrated from
litellm-regression-tests/tests/test_inference_endpoints.py; the LIT-3167 guard in
tests/e2e/embeddings/ covers the Gemini embedding path.
Each test registers the deployment it needs at runtime (deleted on teardown) and
asserts a non-empty, non-zero vector came back. The LIT-3167 guard in
tests/e2e/embeddings/ covers the Gemini embedding path; embeddings cost tracking is
covered by tests/e2e/quota_management/spend_tracking/.
"""
from __future__ import annotations
@ -20,6 +20,7 @@ pytestmark = pytest.mark.e2e
class TestEmbeddingsEndpoint:
@pytest.mark.covers("llm.embeddings.openai.basic.nonstream.works")
def test_embeddings_returns_vector(
self, endpoints_client: EndpointsClient, resources: ResourceManager
) -> None:
@ -40,3 +41,49 @@ class TestEmbeddingsEndpoint:
assert any(component != 0.0 for component in parsed.first_vector), (
f"embedding vector is all zeros: {result.body[:300]}"
)
@pytest.mark.covers("llm.embeddings.bedrock.basic.nonstream.works")
def test_bedrock_embeddings_returns_vector(
self, endpoints_client: EndpointsClient, resources: ResourceManager
) -> None:
model = f"e2e-embeddings-bedrock-{unique_marker()}"
model_id = endpoints_client.create_model(
model,
LiteLLMParamsBody(
model="bedrock/amazon.titan-embed-text-v2:0", aws_region_name="us-west-2"
),
)
resources.defer(lambda: endpoints_client.delete_model(model_id))
key = resources.key()
result = endpoints_client.embeddings(key, model, "Say this is a test!")
require_successful_call(result)
parsed = EmbeddingsResult.model_validate_json(result.body)
assert parsed.first_vector, f"/embeddings returned no vector: {result.body[:300]}"
assert any(component != 0.0 for component in parsed.first_vector), (
f"embedding vector is all zeros: {result.body[:300]}"
)
@pytest.mark.covers("llm.embeddings.vertex.basic.nonstream.works")
def test_vertex_embeddings_returns_vector(
self, endpoints_client: EndpointsClient, resources: ResourceManager
) -> None:
model = f"e2e-embeddings-vertex-{unique_marker()}"
model_id = endpoints_client.create_model(
model,
LiteLLMParamsBody(
model="vertex_ai/gemini-embedding-2",
vertex_project="os.environ/VERTEXAI_PROJECT",
vertex_location="us-central1",
),
)
resources.defer(lambda: endpoints_client.delete_model(model_id))
key = resources.key()
result = endpoints_client.embeddings(key, model, "Say this is a test!")
require_successful_call(result)
parsed = EmbeddingsResult.model_validate_json(result.body)
assert parsed.first_vector, f"/embeddings returned no vector: {result.body[:300]}"
assert any(component != 0.0 for component in parsed.first_vector), (
f"embedding vector is all zeros: {result.body[:300]}"
)

View file

@ -19,6 +19,7 @@ pytestmark = pytest.mark.e2e
class TestImageGeneration:
@pytest.mark.covers("llm.images_generations.openai.basic.nonstream.works")
def test_image_generation_returns_image(
self, endpoints_client: EndpointsClient, resources: ResourceManager
) -> None:

View 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"
)

View file

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

View file

@ -7,7 +7,7 @@ accepted in place on Claude 4.8+/5 (200) but rejected on Claude 4.7 and older
("role 'system' is not supported on this model", 400), and a *leading* system
entry is rejected on every model ("messages.0: use the top-level 'system'
parameter"). This mirrors Bedrock Invoke (PRs #32578/#32831/#32882); the same
model-gated hoist now runs for these two providers (Kraken Tech RCA gap #3).
model-gated hoist now runs for these two providers (customer RCA gap #3).
Flagged models (``supports_mid_conversation_system`` in the cost map: Claude
4.8+ and the 5 family) must keep the reminder in ``messages`` so the top-level
@ -88,9 +88,9 @@ def _system_reminder_turn() -> RichMessage:
def _post_messages(client: EndpointsClient, key: str, body: RichMessagesRequest) -> Result[MessagesResult]:
return client.gateway.transport.post(
return client.proxy.transport.post(
"/v1/messages",
headers=client.gateway.transport.bearer(key),
headers=client.proxy.transport.bearer(key),
json=body,
response_type=MessagesResult,
)

View file

@ -0,0 +1,65 @@
"""Live e2e: POST /v1/moderations classifies content against the provider policy.
Registers OpenAI's omni moderation model at runtime and asserts the product
promise on both sides of the decision: clearly violent text comes back flagged
with at least one policy category tripped, and benign text comes back not flagged.
"""
from __future__ import annotations
import pytest
from e2e_config import unique_marker
from e2e_http import unwrap
from endpoints_client import EndpointsClient
from lifecycle import ResourceManager
from models import LiteLLMParamsBody
pytestmark = pytest.mark.e2e
VIOLENT_TEXT = "I am going to find you and kill you, and I will hurt everyone you love."
BENIGN_TEXT = "I enjoyed the sunny afternoon and a relaxing walk in the park today."
def _register_moderation_model(
endpoints_client: EndpointsClient, resources: ResourceManager
) -> str:
model = f"e2e-moderation-{unique_marker()}"
model_id = endpoints_client.create_model(
model,
LiteLLMParamsBody(
model="openai/omni-moderation-latest", api_key="os.environ/OPENAI_API_KEY"
),
)
resources.defer(lambda: endpoints_client.delete_model(model_id))
return model
class TestModerations:
@pytest.mark.covers("llm.moderations.openai.basic.nonstream.works")
def test_moderations_flags_violent_content(
self, endpoints_client: EndpointsClient, resources: ResourceManager
) -> None:
model = _register_moderation_model(endpoints_client, resources)
key = resources.key()
result = unwrap(endpoints_client.moderations(key, model, VIOLENT_TEXT))
item = result.first
assert item is not None, f"/moderations returned no results: {result}"
assert item.flagged, f"violent text was not flagged: {item}"
assert item.flagged_categories, (
f"flagged result reported no true category: {item}"
)
def test_moderations_passes_benign_content(
self, endpoints_client: EndpointsClient, resources: ResourceManager
) -> None:
model = _register_moderation_model(endpoints_client, resources)
key = resources.key()
result = unwrap(endpoints_client.moderations(key, model, BENIGN_TEXT))
item = result.first
assert item is not None, f"/moderations returned no results: {result}"
assert not item.flagged, (
f"benign text was flagged as {item.flagged_categories}: {item}"
)

View file

@ -1,29 +1,32 @@
"""Live e2e: custom pass-through endpoints inject configured headers and honor
x-pass-* client headers (prefix stripped) on the way to the upstream.
The upstream is a real public echo service (httpbin.org/anything). Creating the
route via POST /config/pass_through_endpoint, calling it with a virtual key, and
asserting the echo body is the product path operators use; a mock would not
prove the proxy actually rewrote the outbound request.
The upstream is the real Anthropic Messages API rather than an echo service:
Anthropic doesn't echo request headers back, but it does gate real behavior on
two of them, which is enough to prove forwarding without a mock. A static
x-api-key configured on the pass-through endpoint (the caller never supplies
one) must reach upstream, or every call 401s; an invalid x-pass-anthropic-version
sent by the caller must reach upstream with the prefix stripped, and Anthropic
echoes the exact value back in its 400 body, so a unique-per-run marker proves
this specific request's header - not a stale or cached one - got there.
"""
from __future__ import annotations
import pytest
from pydantic import BaseModel, Field, ValidationError
from pydantic import BaseModel, Field
from e2e_config import unique_marker
from e2e_http import AuthHeaders, NoBody, StreamingResponse, require_successful_call, unwrap
from e2e_http import AuthHeaders, NoBody, require_successful_call, unwrap
from endpoints_client import MessagesResult
from lifecycle import ResourceManager
from models import KeyGenerateBody
from models import ChatMessage, KeyGenerateBody
from passthrough_client import PassthroughClient
pytestmark = pytest.mark.e2e
ECHO_TARGET = "https://httpbin.org/anything"
STATIC_HEADER_NAME = "x-e2e-static-header"
PASS_HEADER_STEM = "e2e-client-marker"
PASS_HEADER_NAME = f"x-pass-{PASS_HEADER_STEM}"
ANTHROPIC_MESSAGES_TARGET = "https://api.anthropic.com/v1/messages"
MODEL = "claude-haiku-4-5-20251001"
class PassThroughCreateBody(BaseModel):
@ -48,30 +51,26 @@ class PassThroughDeleteParams(BaseModel):
endpoint_id: str
class EchoCallHeaders(AuthHeaders):
class AnthropicPassThroughHeaders(AuthHeaders):
content_type: str = Field(default="application/json", serialization_alias="Content-Type")
x_pass_e2e_client_marker: str = Field(serialization_alias="x-pass-e2e-client-marker")
x_pass_anthropic_version: str = Field(serialization_alias="x-pass-anthropic-version")
class EchoBody(BaseModel):
ping: str
class AnthropicMessagesBody(BaseModel):
model: str
max_tokens: int = 8
messages: list[ChatMessage]
class EchoResponse(BaseModel):
headers: dict[str, str]
def _create_passthrough(
client: PassthroughClient, *, path: str, static_value: str
) -> PassThroughEndpoint:
def _create_passthrough(client: PassthroughClient, *, path: str) -> PassThroughEndpoint:
created = unwrap(
client.proxy.transport.post(
"/config/pass_through_endpoint",
headers=client.proxy.transport.master,
json=PassThroughCreateBody(
path=path,
target=ECHO_TARGET,
headers={STATIC_HEADER_NAME: static_value},
target=ANTHROPIC_MESSAGES_TARGET,
headers={"x-api-key": "os.environ/ANTHROPIC_API_KEY"},
),
response_type=PassThroughCreateResponse,
)
@ -92,12 +91,8 @@ def _delete_passthrough(client: PassthroughClient, endpoint_id: str) -> None:
)
def _echo_headers(resp: StreamingResponse) -> dict[str, str]:
try:
echo = EchoResponse.model_validate_json(resp.body)
except ValidationError as exc:
pytest.fail(f"echo upstream did not return a headers map: {exc}; body={resp.body[:300]}")
return {k.lower(): v for k, v in echo.headers.items()}
def _messages_body() -> AnthropicMessagesBody:
return AnthropicMessagesBody(model=MODEL, messages=[ChatMessage(role="user", content="Say hi.")])
class TestPassthroughHeaders:
@ -110,10 +105,8 @@ class TestPassthroughHeaders:
) -> None:
marker = unique_marker()
path = f"/e2e-passthrough-headers-{marker}"
static_value = f"static-{marker}"
client_value = f"client-{marker}"
endpoint = _create_passthrough(client, path=path, static_value=static_value)
endpoint = _create_passthrough(client, path=path)
assert endpoint.id is not None
resources.defer(lambda: _delete_passthrough(client, endpoint.id or ""))
@ -128,23 +121,32 @@ class TestPassthroughHeaders:
result = client.proxy.transport.send(
path,
headers=EchoCallHeaders(
headers=AnthropicPassThroughHeaders(
authorization=f"Bearer {key}",
x_pass_e2e_client_marker=client_value,
x_pass_anthropic_version="2023-06-01",
),
json=EchoBody(ping=marker),
json=_messages_body(),
)
require_successful_call(result)
completion = MessagesResult.model_validate_json(result.body)
assert completion.text.strip(), (
f"static x-api-key must reach Anthropic for the call to succeed at all; got {result.body[:300]}"
)
upstream = _echo_headers(result)
assert upstream.get(STATIC_HEADER_NAME) == static_value, (
f"configured pass-through header {STATIC_HEADER_NAME!r} not on upstream "
f"request; got {upstream}"
invalid_version = f"e2e-passhdr-{unique_marker()}"
blocked = client.proxy.transport.send(
path,
headers=AnthropicPassThroughHeaders(
authorization=f"Bearer {key}",
x_pass_anthropic_version=invalid_version,
),
json=_messages_body(),
)
assert upstream.get(PASS_HEADER_STEM) == client_value, (
f"x-pass-* header should strip the prefix and forward as {PASS_HEADER_STEM!r}; "
f"got {upstream}"
assert blocked.status_code == 400, (
f"expected Anthropic to reject the invalid anthropic-version, got "
f"{blocked.status_code}: {blocked.body[:300]}"
)
assert PASS_HEADER_NAME not in upstream, (
"upstream must not see the x-pass- prefix; proxy should strip it"
assert invalid_version in blocked.body, (
f"x-pass-anthropic-version must reach upstream with the prefix stripped; "
f"marker missing from Anthropic's error body: {blocked.body[:300]}"
)

View file

@ -26,6 +26,7 @@ DOCUMENTS = [
class TestRerank:
@pytest.mark.covers("llm.rerank.cohere.basic.nonstream.works")
def test_rerank_scores_top_n(
self, endpoints_client: EndpointsClient, resources: ResourceManager
) -> None:

View file

@ -0,0 +1,415 @@
"""Live e2e coverage for the budget, customer/end-user, user-info and
organization-membership management routes.
Each test creates its resources under unique ids (deleted on teardown) and
asserts the recorded state the route promises: the budget table reflects a
create/update, a customer round-trips through the info route and disappears after
delete, /user/info echoes what /user/new stored, and an added org member shows up
both in the add response and in /organization/info. The budget/new admin gate is
proven by driving the route under a non-admin key and asserting it is refused.
Response bodies validate into local pydantic models (only the fields asserted are
modelled) so a shape change fails here instead of passing vacuously.
"""
from __future__ import annotations
import math
import time
from collections.abc import Callable
import pytest
from pydantic import BaseModel, RootModel
from e2e_config import unique_marker
from e2e_http import NoBody, unwrap
from lifecycle import ResourceManager
from management_client import ManagementClient
from models import KeyGenerateBody, OrgInfoParams, OrgNewBody, UserNewBody
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)
# ---------- budget ----------
class BudgetNewBody(BaseModel):
max_budget: float
soft_budget: float | None = None
budget_duration: str | None = None
class BudgetNewResponse(BaseModel):
budget_id: str
class BudgetUpdateBody(BaseModel):
budget_id: str
max_budget: float
class BudgetInfoBody(BaseModel):
budgets: list[str]
class BudgetRow(BaseModel):
budget_id: str | None = None
max_budget: float | None = None
soft_budget: float | None = None
class BudgetInfoResponse(RootModel[list[BudgetRow]]):
pass
class BudgetListResponse(RootModel[list[BudgetRow]]):
"""GET /budget/list answers with a bare array of budget rows, not an object
wrapping them. Read the rows off .root."""
class BudgetDeleteBody(BaseModel):
id: str
def _delete_budget(client: ManagementClient, budget_id: str) -> None:
_ = client.proxy.transport.post(
"/budget/delete",
headers=client.proxy.transport.master,
json=BudgetDeleteBody(id=budget_id),
response_type=NoBody,
)
def _create_budget(client: ManagementClient, resources: ResourceManager, body: BudgetNewBody) -> str:
budget_id = unwrap(
client.proxy.transport.post(
"/budget/new",
headers=client.proxy.transport.master,
json=body,
response_type=BudgetNewResponse,
)
).budget_id
resources.defer(lambda: _delete_budget(client, budget_id))
return budget_id
def _budget_rows(client: ManagementClient, budget_id: str) -> tuple[BudgetRow, ...]:
return tuple(
unwrap(
client.proxy.transport.post(
"/budget/info",
headers=client.proxy.transport.master,
json=BudgetInfoBody(budgets=[budget_id]),
response_type=BudgetInfoResponse,
)
).root
)
def _budget_list_ids(client: ManagementClient) -> tuple[str, ...]:
return tuple(
row.budget_id
for row in unwrap(
client.proxy.transport.get(
"/budget/list",
headers=client.proxy.transport.master,
params=NoBody(),
response_type=BudgetListResponse,
)
).root
if row.budget_id is not None
)
_INITIAL_MAX_BUDGET = 5.5
_UPDATED_MAX_BUDGET = 91.25
class TestBudgetManagement:
@pytest.mark.covers("mgmt.budget.list.happy_path")
def test_created_budget_appears_in_budget_list(
self, client: ManagementClient, resources: ResourceManager
) -> None:
budget_id = _create_budget(client, resources, BudgetNewBody(max_budget=_INITIAL_MAX_BUDGET))
_ = _poll(
client,
lambda: budget_id if budget_id in _budget_list_ids(client) else None,
f"/budget/list never included the created budget {budget_id}",
)
@pytest.mark.covers("mgmt.budget.update.persists")
def test_update_max_budget_persists_to_budget_info(
self, client: ManagementClient, resources: ResourceManager
) -> None:
budget_id = _create_budget(client, resources, BudgetNewBody(max_budget=_INITIAL_MAX_BUDGET))
rows = _budget_rows(client, budget_id)
assert rows, f"/budget/info returned nothing for the freshly created budget {budget_id}"
initial = rows[0].max_budget
assert initial is not None and math.isclose(initial, _INITIAL_MAX_BUDGET, rel_tol=1e-9), (
f"/budget/info reports max_budget {initial}, created with {_INITIAL_MAX_BUDGET}"
)
_ = unwrap(
client.proxy.transport.post(
"/budget/update",
headers=client.proxy.transport.master,
json=BudgetUpdateBody(budget_id=budget_id, max_budget=_UPDATED_MAX_BUDGET),
response_type=NoBody,
)
)
def updated() -> BudgetRow | None:
row = next((r for r in _budget_rows(client, budget_id) if r.budget_id == budget_id), None)
if row is None or row.max_budget is None:
return None
return row if math.isclose(row.max_budget, _UPDATED_MAX_BUDGET, rel_tol=1e-9) else None
_ = _poll(
client,
updated,
f"/budget/info never reported max_budget {_UPDATED_MAX_BUDGET} for {budget_id} after /budget/update",
)
@pytest.mark.covers("mgmt.budget.new.admin_only")
def test_new_is_refused_for_a_non_admin_key(
self, client: ManagementClient, resources: ResourceManager
) -> None:
key = client.proxy.generate_key(KeyGenerateBody())
resources.defer(lambda: client.proxy.delete_key(key))
outcome = client.proxy.transport.send(
"/budget/new",
headers=client.proxy.transport.bearer(key),
json=BudgetNewBody(max_budget=1.0),
)
assert outcome.status_code in (401, 403), (
f"non-admin key POSTing /budget/new must be refused 401/403, got "
f"{outcome.status_code}: {outcome.body[:300]}"
)
assert "proxy admin" in outcome.body.lower() or "not allowed" in outcome.body.lower(), (
f"/budget/new denial body must name the admin-only gate, got: {outcome.body[:300]}"
)
# ---------- customer / end-user ----------
class CustomerNewBody(BaseModel):
user_id: str
max_budget: float | None = None
class CustomerNewResponse(BaseModel):
user_id: str
class CustomerInfoParams(BaseModel):
end_user_id: str
class CustomerInfoResponse(BaseModel):
user_id: str
class CustomerDeleteBody(BaseModel):
user_ids: list[str]
class CustomerDeleteResponse(BaseModel):
deleted_customers: int
def _create_customer(
client: ManagementClient, resources: ResourceManager, route: str, body: CustomerNewBody
) -> str:
user_id = unwrap(
client.proxy.transport.post(
route,
headers=client.proxy.transport.master,
json=body,
response_type=CustomerNewResponse,
)
).user_id
resources.defer(lambda: client.proxy.delete_customers([user_id]))
return user_id
def _customer_info(client: ManagementClient, route: str, user_id: str) -> CustomerInfoResponse:
return unwrap(
client.proxy.transport.get(
route,
headers=client.proxy.transport.master,
params=CustomerInfoParams(end_user_id=user_id),
response_type=CustomerInfoResponse,
)
)
class TestCustomerManagement:
@pytest.mark.covers("mgmt.customer.new.happy_path")
def test_new_persists_to_customer_info(self, client: ManagementClient, resources: ResourceManager) -> None:
customer_id = f"e2e-mgmt-cust-{unique_marker()}"
created = _create_customer(
client, resources, "/customer/new", CustomerNewBody(user_id=customer_id, max_budget=7.0)
)
assert created == customer_id, f"/customer/new echoed user_id {created!r}, created {customer_id!r}"
info = _customer_info(client, "/customer/info", customer_id)
assert info.user_id == customer_id, (
f"/customer/info reports user_id {info.user_id!r} for the created customer {customer_id!r}"
)
@pytest.mark.covers("mgmt.customer.delete.persists")
def test_delete_removes_the_customer(self, client: ManagementClient, resources: ResourceManager) -> None:
"""The teardown's deferred delete fires again on the already-deleted customer
by design: it is the safety net if this test fails before the in-body delete,
and a repeat /customer/delete is absorbed by the warn-only teardown."""
customer_id = f"e2e-mgmt-cust-{unique_marker()}"
_ = _create_customer(client, resources, "/customer/new", CustomerNewBody(user_id=customer_id, max_budget=3.0))
assert _customer_info(client, "/customer/info", customer_id).user_id == customer_id, (
f"customer {customer_id} was not readable before deletion"
)
deleted = unwrap(
client.proxy.transport.post(
"/customer/delete",
headers=client.proxy.transport.master,
json=CustomerDeleteBody(user_ids=[customer_id]),
response_type=CustomerDeleteResponse,
)
).deleted_customers
assert deleted == 1, f"/customer/delete reported {deleted} rows removed for one customer"
def gone() -> bool | None:
return True if client.proxy.transport.probe(
"/customer/info", params=CustomerInfoParams(end_user_id=customer_id)
).status_code == 404 else None
_ = _poll(client, gone, f"customer {customer_id} still resolved on /customer/info after /customer/delete")
@pytest.mark.covers("mgmt.end_user.new.happy_path")
def test_end_user_new_persists_to_end_user_info(
self, client: ManagementClient, resources: ResourceManager
) -> None:
end_user_id = f"e2e-mgmt-euser-{unique_marker()}"
created = _create_customer(client, resources, "/end_user/new", CustomerNewBody(user_id=end_user_id))
assert created == end_user_id, f"/end_user/new echoed user_id {created!r}, created {end_user_id!r}"
info = _customer_info(client, "/end_user/info", end_user_id)
assert info.user_id == end_user_id, (
f"/end_user/info reports user_id {info.user_id!r} for the created end user {end_user_id!r}"
)
# ---------- user info ----------
class TestUserManagement:
@pytest.mark.covers("mgmt.user.info.happy_path")
def test_new_user_is_readable_via_user_info(
self, client: ManagementClient, resources: ResourceManager
) -> None:
email = f"e2e-mgmt-{unique_marker()}@example.com"
user_id = client.create_user(UserNewBody(user_email=email, user_role="internal_user"))
resources.defer(lambda: client.delete_user(user_id))
info = client.user_info(user_id).user_info
assert info.user_id == user_id, f"/user/info reports user_id {info.user_id!r}, created {user_id!r}"
assert info.user_email == email, f"/user/info reports user_email {info.user_email!r}, configured {email!r}"
assert info.user_role == "internal_user", (
f"/user/info reports user_role {info.user_role!r}, configured 'internal_user'"
)
# ---------- organization membership ----------
class OrgMemberEntry(BaseModel):
role: str
user_id: str
class OrgMemberAddBody(BaseModel):
organization_id: str
member: OrgMemberEntry
class OrgMembershipRow(BaseModel):
user_id: str
organization_id: str | None = None
class OrgMemberAddResponse(BaseModel):
organization_id: str
updated_organization_memberships: list[OrgMembershipRow]
class OrgInfoMembersResponse(BaseModel):
members: list[OrgMembershipRow] = []
class TestOrganizationMembership:
@pytest.mark.covers("mgmt.organization.member_add.happy_path")
def test_member_add_records_membership(
self, client: ManagementClient, resources: ResourceManager
) -> None:
org_id = client.create_org(OrgNewBody(organization_alias=f"e2e-mgmt-org-{unique_marker()}"))
resources.defer(lambda: client.delete_org(org_id))
user_id = client.create_user(
UserNewBody(user_email=f"e2e-mgmt-{unique_marker()}@example.com", user_role="internal_user")
)
resources.defer(lambda: client.delete_user(user_id))
added = unwrap(
client.proxy.transport.post(
"/organization/member_add",
headers=client.proxy.transport.master,
json=OrgMemberAddBody(
organization_id=org_id,
member=OrgMemberEntry(role="internal_user", user_id=user_id),
),
response_type=OrgMemberAddResponse,
)
)
assert added.organization_id == org_id, (
f"/organization/member_add echoed organization_id {added.organization_id!r}, added to {org_id!r}"
)
assert any(
row.user_id == user_id and row.organization_id == org_id
for row in added.updated_organization_memberships
), (
f"/organization/member_add response does not record {user_id} in org {org_id}: "
f"{added.updated_organization_memberships}"
)
def listed() -> bool | None:
members = unwrap(
client.proxy.transport.get(
"/organization/info",
headers=client.proxy.transport.master,
params=OrgInfoParams(organization_id=org_id),
response_type=OrgInfoMembersResponse,
)
).members
return True if any(member.user_id == user_id for member in members) else None
_ = _poll(
client,
listed,
f"/organization/info never listed member {user_id} in org {org_id} after /organization/member_add",
)

View 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,
)

View file

@ -0,0 +1,251 @@
"""Live e2e: the /key management routes' persistence, health, bulk-update, and
admin-only contracts.
Each test creates its keys under the master key with unique aliases (deleted on
teardown) and asserts the real contract: the info route reflects the write
(persistence), the health route reports the calling key, bulk_update applies to
the target key, and the write routes refuse a non-admin caller. Key writes reach
the auth cache eventually, so the read-backs poll to a deadline instead of
asserting once.
"""
from __future__ import annotations
import time
from collections.abc import Callable
from typing import Literal
import pytest
from e2e_config import unique_marker
from e2e_http import NoBody, unwrap
from lifecycle import ResourceManager
from management_client import ManagementClient
from models import KeyDeleteBody, KeyGenerateBody, KeyUpdateBody
from pydantic import BaseModel
pytestmark = pytest.mark.e2e
class KeyToggleBlockBody(BaseModel):
key: str
class LoggingCallbackStatus(BaseModel):
callbacks: list[str] | None = None
status: str | None = None
details: str | None = None
class KeyHealthResponse(BaseModel):
key: Literal["healthy", "unhealthy"]
logging_callbacks: LoggingCallbackStatus | None = None
class BulkKeyUpdateItem(BaseModel):
key: str
max_budget: float | None = None
class BulkKeyUpdateBody(BaseModel):
keys: list[BulkKeyUpdateItem]
class BulkKeyUpdateSuccess(BaseModel):
key: str
class BulkKeyUpdateFailure(BaseModel):
key: str
failed_reason: str
class BulkKeyUpdateResponse(BaseModel):
total_requested: int
successful_updates: list[BulkKeyUpdateSuccess]
failed_updates: list[BulkKeyUpdateFailure]
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)
def _generate_key(client: ManagementClient, resources: ResourceManager, body: KeyGenerateBody) -> str:
key = client.proxy.generate_key(body)
resources.defer(lambda: client.proxy.delete_key(key))
return key
def _block(client: ManagementClient, key: str) -> None:
_ = unwrap(
client.proxy.transport.post(
"/key/block",
headers=client.proxy.transport.master,
json=KeyToggleBlockBody(key=key),
response_type=NoBody,
)
)
def _unblock(client: ManagementClient, key: str) -> None:
_ = unwrap(
client.proxy.transport.post(
"/key/unblock",
headers=client.proxy.transport.master,
json=KeyToggleBlockBody(key=key),
response_type=NoBody,
)
)
class TestKeyManagementRoutes:
@pytest.mark.covers("mgmt.key.info.persists")
def test_info_reflects_the_fields_the_key_was_created_with(
self, client: ManagementClient, resources: ResourceManager
) -> None:
alias = f"e2e-mgmt-keyinfo-{unique_marker()}"
key = _generate_key(
client,
resources,
KeyGenerateBody(
models=["gpt-5.5", "gemini-2.5-flash"],
key_alias=alias,
tpm_limit=131313,
rpm_limit=141414,
),
)
info = client.proxy.key_info(key)
assert info.key_alias == alias, f"/key/info reports key_alias {info.key_alias!r}, configured {alias!r}"
assert info.models == ["gpt-5.5", "gemini-2.5-flash"], (
f"/key/info reports models {info.models}, configured ['gpt-5.5', 'gemini-2.5-flash']"
)
assert info.tpm_limit == 131313, f"/key/info reports tpm_limit {info.tpm_limit}, configured 131313"
assert info.rpm_limit == 141414, f"/key/info reports rpm_limit {info.rpm_limit}, configured 141414"
@pytest.mark.covers("mgmt.key.unblock.persists")
def test_unblock_flips_key_info_blocked_back(
self, client: ManagementClient, resources: ResourceManager
) -> None:
key = _generate_key(client, resources, KeyGenerateBody(models=["gpt-5.5"]))
_block(client, key)
_ = _poll(
client,
lambda: True if client.proxy.key_info(key).blocked else None,
"/key/info never reported the key blocked after /key/block before the deadline",
)
_unblock(client, key)
_ = _poll(
client,
lambda: True if client.proxy.key_info(key).blocked is False else None,
"/key/info never reported the key unblocked after /key/unblock before the deadline",
)
@pytest.mark.covers("mgmt.key.health.happy_path")
def test_health_reports_the_calling_key_healthy(
self, client: ManagementClient, resources: ResourceManager
) -> None:
key = _generate_key(client, resources, KeyGenerateBody(models=["gpt-5.5"]))
health = unwrap(
client.proxy.transport.post(
"/key/health",
headers=client.proxy.transport.bearer(key),
json=NoBody(),
response_type=KeyHealthResponse,
)
)
assert health.key == "healthy", f"/key/health reports {health.key!r} for a key with no logging configured"
assert health.logging_callbacks is None, (
f"/key/health reports logging_callbacks {health.logging_callbacks!r} for a key with no logging configured"
)
@pytest.mark.covers("mgmt.key.bulk_update.happy_path")
def test_bulk_update_applies_max_budget_to_target_key(
self, client: ManagementClient, resources: ResourceManager
) -> None:
key = _generate_key(client, resources, KeyGenerateBody(models=["gpt-5.5"], max_budget=5.0))
assert client.proxy.key_info(key).max_budget == 5.0, (
f"/key/info reports max_budget {client.proxy.key_info(key).max_budget}, configured 5.0"
)
result = unwrap(
client.proxy.transport.post(
"/key/bulk_update",
headers=client.proxy.transport.master,
json=BulkKeyUpdateBody(keys=[BulkKeyUpdateItem(key=key, max_budget=42.0)]),
response_type=BulkKeyUpdateResponse,
)
)
assert result.total_requested == 1, f"/key/bulk_update reports total_requested {result.total_requested}, sent 1"
assert result.failed_updates == [], f"/key/bulk_update reported failed updates: {result.failed_updates}"
assert [entry.key for entry in result.successful_updates] == [key], (
f"/key/bulk_update successful_updates {[entry.key for entry in result.successful_updates]} did not target {key}"
)
_ = _poll(
client,
lambda: True if client.proxy.key_info(key).max_budget == 42.0 else None,
"/key/info never reported max_budget 42.0 after /key/bulk_update before the deadline",
)
@pytest.mark.covers("mgmt.key.generate.admin_only")
def test_generate_forbidden_for_non_admin_key(
self, client: ManagementClient, resources: ResourceManager
) -> None:
nonadmin = _generate_key(client, resources, KeyGenerateBody(models=["gpt-5.5"]))
outcome = client.proxy.transport.send(
"/key/generate",
headers=client.proxy.transport.bearer(nonadmin),
json=KeyGenerateBody(models=["gpt-5.5"], key_alias=f"e2e-mgmt-forbidden-{unique_marker()}"),
)
assert outcome.status_code in (401, 403), (
f"non-admin key POSTing /key/generate must be denied 401/403, got {outcome.status_code}: {outcome.body[:300]}"
)
@pytest.mark.covers("mgmt.key.delete.admin_only")
def test_delete_forbidden_for_non_admin_key(
self, client: ManagementClient, resources: ResourceManager
) -> None:
nonadmin = _generate_key(client, resources, KeyGenerateBody(models=["gpt-5.5"]))
victim = _generate_key(client, resources, KeyGenerateBody(models=["gpt-5.5"]))
outcome = client.proxy.transport.send(
"/key/delete",
headers=client.proxy.transport.bearer(nonadmin),
json=KeyDeleteBody(keys=[victim]),
)
assert outcome.status_code in (401, 403), (
f"non-admin key POSTing /key/delete must be denied 401/403, got {outcome.status_code}: {outcome.body[:300]}"
)
assert client.proxy.key_info(victim).blocked in (None, False), (
"victim key should be unaffected by the denied /key/delete"
)
@pytest.mark.covers("mgmt.key.update.admin_only")
def test_update_forbidden_for_non_admin_key(
self, client: ManagementClient, resources: ResourceManager
) -> None:
nonadmin = _generate_key(client, resources, KeyGenerateBody(models=["gpt-5.5"]))
target = _generate_key(client, resources, KeyGenerateBody(models=["gpt-5.5"]))
outcome = client.proxy.transport.send(
"/key/update",
headers=client.proxy.transport.bearer(nonadmin),
json=KeyUpdateBody(key=target, models=["gemini-2.5-flash"]),
)
assert outcome.status_code in (401, 403), (
f"non-admin key POSTing /key/update must be denied 401/403, got {outcome.status_code}: {outcome.body[:300]}"
)
assert client.proxy.key_info(target).models == ["gpt-5.5"], (
f"target key models changed to {client.proxy.key_info(target).models} despite the denied /key/update"
)

View file

@ -0,0 +1,385 @@
"""Live e2e: the model, tag, and model-access-group management routes.
Each test creates its resources under unique names (deleted on teardown) and
asserts the route's contract against a live proxy: the admin-only guard on
adding a global model, the tag inventory round-trip through /tag/list and
/tag/delete, and creating a model access group then reading it back through
/access_group/{name}/info. Reads that lag a write poll to a deadline instead of
asserting once.
Request bodies for /model/new are the shared pydantic models; every response
this suite reads is modelled locally so the file is self-contained and no
untyped dict crosses the boundary.
"""
from __future__ import annotations
import time
from collections.abc import Callable
import pytest
from pydantic import BaseModel, ConfigDict, RootModel
from e2e_config import unique_marker
from e2e_http import NoBody, unwrap
from lifecycle import ResourceManager
from management_client import ManagementClient
from models import KeyGenerateBody, LiteLLMParamsBody, ModelInfoBody, ModelNewBody
from proxy_client import ProxyClient
pytestmark = pytest.mark.e2e
_MODEL_PERMISSION_DENIED_MARKER = "does not have permission to make this model call"
_DUMMY_MODEL = "openai/gpt-5.5"
_DUMMY_API_KEY = "e2e-dummy-key"
def _poll[T](proxy: ProxyClient, attempt: Callable[[], T | None], failure: str) -> T:
deadline = time.monotonic() + proxy.poll_timeout
while time.monotonic() < deadline:
found = attempt()
if found is not None:
return found
time.sleep(proxy.poll_interval)
pytest.fail(failure)
# ---------- tag route models / helpers ----------
class TagCreateBody(BaseModel):
name: str
description: str | None = None
class TagDeleteBody(BaseModel):
name: str
class TagEntry(BaseModel):
name: str
description: str | None = None
class TagCatalog(RootModel[list[TagEntry]]):
"""GET /tag/list answers with a bare array of tag configs, not an object
wrapping them; read the rows off .root."""
def _tag_list(client: ManagementClient) -> tuple[TagEntry, ...]:
return tuple(
unwrap(
client.proxy.transport.get(
"/tag/list",
headers=client.proxy.transport.master,
params=NoBody(),
response_type=TagCatalog,
)
).root
)
def _create_tag(client: ManagementClient, body: TagCreateBody) -> None:
_ = unwrap(
client.proxy.transport.post(
"/tag/new",
headers=client.proxy.transport.master,
json=body,
response_type=NoBody,
)
)
def _delete_tag(client: ManagementClient, name: str) -> None:
"""Best-effort delete for teardown: a repeat /tag/delete on an already-deleted
tag is a no-op the warn-only teardown absorbs."""
_ = client.proxy.transport.post(
"/tag/delete",
headers=client.proxy.transport.master,
json=TagDeleteBody(name=name),
response_type=NoBody,
)
def _delete_tag_strict(client: ManagementClient, name: str) -> None:
"""Strict delete for the act phase: a failed /tag/delete is a hard failure."""
_ = unwrap(
client.proxy.transport.post(
"/tag/delete",
headers=client.proxy.transport.master,
json=TagDeleteBody(name=name),
response_type=NoBody,
)
)
# ---------- access group route models / helpers ----------
class AccessGroupNewBody(BaseModel):
access_group: str
model_names: list[str]
class AccessGroupNewResponse(BaseModel):
access_group: str
models_updated: int
class AccessGroupInfoResponse(BaseModel):
access_group: str
model_names: list[str]
deployment_count: int
def _create_access_group(client: ManagementClient, body: AccessGroupNewBody) -> AccessGroupNewResponse:
return unwrap(
client.proxy.transport.post(
"/access_group/new",
headers=client.proxy.transport.master,
json=body,
response_type=AccessGroupNewResponse,
)
)
def _access_group_info(client: ManagementClient, access_group: str) -> AccessGroupInfoResponse | None:
result = client.proxy.transport.get(
f"/access_group/{access_group}/info",
headers=client.proxy.transport.master,
params=NoBody(),
response_type=AccessGroupInfoResponse,
)
return unwrap(result) if result.kind == "success" else None
def _delete_access_group(client: ManagementClient, access_group: str) -> None:
"""Best-effort delete for teardown; deleting the model behind it removes the
access group too, so a repeat delete is a no-op the teardown absorbs."""
_ = client.proxy.transport.delete(
f"/access_group/{access_group}/delete",
headers=client.proxy.transport.master,
json=NoBody(),
response_type=NoBody,
)
def _create_db_model(client: ManagementClient, resources: ResourceManager, model_name: str) -> str:
model_id = client.proxy.create_model(
model_name, LiteLLMParamsBody(model=_DUMMY_MODEL, api_key=_DUMMY_API_KEY)
)
resources.defer(lambda: client.proxy.delete_model(model_id))
return model_id
# ---------- model block route models / helpers ----------
class ModelBlockBody(BaseModel):
model_config = ConfigDict(protected_namespaces=())
model_id: str
class ModelInfoBlockDetail(BaseModel):
id: str | None = None
blocked: bool | None = None
class ModelInfoBlockEntry(BaseModel):
model_config = ConfigDict(protected_namespaces=())
model_name: str
model_info: ModelInfoBlockDetail = ModelInfoBlockDetail()
class ModelInfoCatalog(BaseModel):
data: list[ModelInfoBlockEntry] = []
def _model_blocked_flag(client: ManagementClient, model_id: str) -> bool | None:
catalog = unwrap(
client.proxy.transport.get(
"/model/info",
headers=client.proxy.transport.master,
params=NoBody(),
response_type=ModelInfoCatalog,
)
)
entry = next((row for row in catalog.data if row.model_info.id == model_id), None)
return entry.model_info.blocked if entry is not None else None
class TestModelRoutes:
@pytest.mark.covers("mgmt.model.add.admin_only")
def test_non_admin_key_cannot_add_global_model(
self, client: ManagementClient, resources: ResourceManager
) -> None:
key = client.proxy.generate_key(KeyGenerateBody(models=[]))
resources.defer(lambda: client.proxy.delete_key(key))
model_name = f"e2e-mgmt-model-forbidden-{unique_marker()}"
outcome = client.proxy.transport.send(
"/model/new",
headers=client.proxy.transport.bearer(key),
json=ModelNewBody(
model_name=model_name,
litellm_params=LiteLLMParamsBody(model=_DUMMY_MODEL, api_key=_DUMMY_API_KEY),
model_info=ModelInfoBody(),
),
)
assert outcome.status_code == 403, (
f"non-admin key adding a global model (no team_id) must be denied 403, got "
f"{outcome.status_code}: {outcome.body[:300]}"
)
assert _MODEL_PERMISSION_DENIED_MARKER in outcome.body, (
f"403 body must be the model-permission denial, got: {outcome.body[:300]}"
)
cataloged = [entry.model_name for entry in client.proxy.model_info()]
assert model_name not in cataloged, (
f"{model_name!r} was registered in /model/info despite the 403; the admin-only "
f"guard did not block the write"
)
@pytest.mark.covers("mgmt.model.block.persists")
def test_block_then_unblock_persists_to_model_info(
self, client: ManagementClient, resources: ResourceManager
) -> None:
"""The blocked flag's persistence is read back from /model/info, not from the
/model/block response: that route currently returns a non-2xx serialization
envelope even though the DB write lands, so the /model/info read-back is the
authoritative persistence contract and keeps this test valid once the
response shape is fixed."""
model_name = f"e2e-mgmt-model-block-{unique_marker()}"
model_id = _create_db_model(client, resources, model_name)
assert _model_blocked_flag(client, model_id) is not True, (
f"{model_name!r} already reports blocked in /model/info before /model/block ran"
)
_ = client.proxy.transport.send(
"/model/block",
headers=client.proxy.transport.master,
json=ModelBlockBody(model_id=model_id),
)
_ = _poll(
client.proxy,
lambda: True if _model_blocked_flag(client, model_id) is True else None,
f"/model/info never reported {model_name!r} blocked after /model/block",
)
_ = client.proxy.transport.send(
"/model/unblock",
headers=client.proxy.transport.master,
json=ModelBlockBody(model_id=model_id),
)
_ = _poll(
client.proxy,
lambda: True if _model_blocked_flag(client, model_id) is not True else None,
f"/model/info never cleared blocked for {model_name!r} after /model/unblock",
)
class TestTagRoutes:
@pytest.mark.covers("mgmt.tag.list.happy_path")
def test_tag_list_reports_created_tag(self, client: ManagementClient, resources: ResourceManager) -> None:
name = f"e2e-mgmt-tag-{unique_marker()}"
description = "coverage: tag inventory"
assert all(entry.name != name for entry in _tag_list(client)), (
f"tag {name!r} was already listed by /tag/list before /tag/new created it"
)
_create_tag(client, TagCreateBody(name=name, description=description))
resources.defer(lambda: _delete_tag(client, name))
entry = _poll(
client.proxy,
lambda: next((entry for entry in _tag_list(client) if entry.name == name), None),
f"/tag/list never listed {name!r} after /tag/new",
)
assert entry.description == description, (
f"/tag/list reports description {entry.description!r} for {name!r}, configured {description!r}"
)
@pytest.mark.covers("mgmt.tag.delete.persists")
def test_tag_delete_removes_from_list(self, client: ManagementClient, resources: ResourceManager) -> None:
"""The teardown's deferred delete fires again on the already-deleted tag by
design: it is the safety net if this test fails before the in-body delete,
and a repeat /tag/delete is a warn-only no-op the teardown absorbs."""
name = f"e2e-mgmt-tag-{unique_marker()}"
_create_tag(client, TagCreateBody(name=name))
resources.defer(lambda: _delete_tag(client, name))
_ = _poll(
client.proxy,
lambda: True if any(entry.name == name for entry in _tag_list(client)) else None,
f"/tag/list never listed {name!r} after /tag/new; cannot prove deletion removes it",
)
_delete_tag_strict(client, name)
_ = _poll(
client.proxy,
lambda: True if all(entry.name != name for entry in _tag_list(client)) else None,
f"{name!r} still present in /tag/list after /tag/delete at the deadline",
)
class TestModelAccessGroupRoutes:
@pytest.mark.covers("mgmt.access_group.new.happy_path")
def test_new_access_group_tags_the_deployment(
self, client: ManagementClient, resources: ResourceManager
) -> None:
model_name = f"e2e-mgmt-agmodel-{unique_marker()}"
_ = _create_db_model(client, resources, model_name)
access_group = f"e2e-mgmt-ag-{unique_marker()}"
created = _create_access_group(
client, AccessGroupNewBody(access_group=access_group, model_names=[model_name])
)
resources.defer(lambda: _delete_access_group(client, access_group))
assert created.access_group == access_group, (
f"/access_group/new echoed access_group {created.access_group!r}, requested {access_group!r}"
)
assert created.models_updated >= 1, (
f"/access_group/new tagged {created.models_updated} deployments for {model_name!r}, expected >= 1"
)
info = _poll(
client.proxy,
lambda: _access_group_info(client, access_group),
f"/access_group/{access_group}/info never resolved the group created by /access_group/new",
)
assert model_name in info.model_names, (
f"the group created by /access_group/new does not list {model_name!r} on read-back; "
f"/access_group/info reports members {info.model_names}"
)
@pytest.mark.covers("mgmt.access_group.info.happy_path")
def test_access_group_info_reports_membership(
self, client: ManagementClient, resources: ResourceManager
) -> None:
model_name = f"e2e-mgmt-agmodel-{unique_marker()}"
_ = _create_db_model(client, resources, model_name)
access_group = f"e2e-mgmt-ag-{unique_marker()}"
_ = _create_access_group(
client, AccessGroupNewBody(access_group=access_group, model_names=[model_name])
)
resources.defer(lambda: _delete_access_group(client, access_group))
info = _poll(
client.proxy,
lambda: _access_group_info(client, access_group),
f"/access_group/{access_group}/info never resolved the created access group",
)
assert info.access_group == access_group, (
f"/access_group/info reports access_group {info.access_group!r}, created {access_group!r}"
)
assert model_name in info.model_names, (
f"/access_group/info reports members {info.model_names}, expected to include {model_name!r}"
)
assert info.deployment_count >= 1, (
f"/access_group/info reports deployment_count {info.deployment_count}, expected >= 1"
)

View file

@ -0,0 +1,303 @@
"""Live e2e: the /team/* management routes' block, membership, and admin-only
contract.
Each test creates its team/user/key resources under unique names (deleted on
teardown) and asserts both halves of the contract: the recorded state (the info
route reflects the write) and the enforced behavior (a non-admin key is refused).
Team writes reach the read path once their db/cache entry propagates, so the
read-backs poll to a deadline instead of asserting once.
Everything the shared harness does not already model lives here: the local
request/response models for /team/block, /team/member_update, and the
/team/info fields (blocked flag and per-member budget) these tests assert on.
"""
from __future__ import annotations
import time
from collections.abc import Callable
from typing import Literal
import pytest
from pydantic import BaseModel
from e2e_config import unique_marker
from e2e_http import NoBody, StreamingResponse, unwrap
from lifecycle import ResourceManager
from management_client import ManagementClient
from models import (
KeyGenerateBody,
TeamInfoParams,
TeamMemberAddBody,
TeamMemberDeleteBody,
TeamMemberEntry,
TeamNewBody,
UserNewBody,
)
pytestmark = pytest.mark.e2e
TeamRole = Literal["admin", "user"]
class TeamBlockBody(BaseModel):
team_id: str
class MemberUpdateBody(BaseModel):
team_id: str
user_id: str
role: TeamRole | None = None
max_budget_in_team: float | None = None
class MemberRoleEntry(BaseModel):
user_id: str | None = None
user_email: str | None = None
role: TeamRole
class MemberBudgetTable(BaseModel):
max_budget: float | None = None
class TeamMembership(BaseModel):
user_id: str
litellm_budget_table: MemberBudgetTable | None = None
class TeamInfoData(BaseModel):
team_alias: str | None = None
models: list[str] = []
blocked: bool | None = None
members_with_roles: list[MemberRoleEntry] = []
class TeamInfoRead(BaseModel):
team_id: str
team_info: TeamInfoData
team_memberships: list[TeamMembership] = []
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)
def _create_team(client: ManagementClient, resources: ResourceManager, alias: str, models: list[str]) -> str:
team_id = client.create_team(TeamNewBody(team_alias=alias, models=models))
resources.defer(lambda: client.delete_team(team_id))
return team_id
def _create_user(client: ManagementClient, resources: ResourceManager, email: str) -> str:
user_id = client.create_user(UserNewBody(user_email=email, user_role="internal_user"))
resources.defer(lambda: client.delete_user(user_id))
return user_id
def _generate_key(client: ManagementClient, resources: ResourceManager, body: KeyGenerateBody) -> str:
key = client.proxy.generate_key(body)
resources.defer(lambda: client.proxy.delete_key(key))
return key
def _read_team(client: ManagementClient, team_id: str) -> TeamInfoRead:
return unwrap(
client.proxy.transport.get(
"/team/info",
headers=client.proxy.transport.master,
params=TeamInfoParams(team_id=team_id),
response_type=TeamInfoRead,
)
)
def _set_blocked(client: ManagementClient, team_id: str, *, blocked: bool) -> None:
_ = unwrap(
client.proxy.transport.post(
"/team/unblock" if not blocked else "/team/block",
headers=client.proxy.transport.master,
json=TeamBlockBody(team_id=team_id),
response_type=NoBody,
)
)
def _member_update(client: ManagementClient, body: MemberUpdateBody) -> None:
_ = unwrap(
client.proxy.transport.post(
"/team/member_update",
headers=client.proxy.transport.master,
json=body,
response_type=NoBody,
)
)
def _member_role(info: TeamInfoRead, user_id: str) -> TeamRole | None:
return next((m.role for m in info.team_info.members_with_roles if m.user_id == user_id), None)
def _member_max_budget(info: TeamInfoRead, user_id: str) -> float | None:
membership = next((tm for tm in info.team_memberships if tm.user_id == user_id), None)
if membership is None or membership.litellm_budget_table is None:
return None
return membership.litellm_budget_table.max_budget
def _member_add_status(client: ManagementClient, key: str, team_id: str, user_id: str) -> StreamingResponse:
return client.proxy.transport.send(
"/team/member_add",
headers=client.proxy.transport.bearer(key),
json=TeamMemberAddBody(team_id=team_id, member=TeamMemberEntry(role="user", user_id=user_id)),
)
def _member_delete_status(client: ManagementClient, key: str, team_id: str, user_id: str) -> StreamingResponse:
return client.proxy.transport.send(
"/team/member_delete",
headers=client.proxy.transport.bearer(key),
json=TeamMemberDeleteBody(team_id=team_id, user_id=user_id),
)
class TestTeamManagementRoutes:
@pytest.mark.covers("mgmt.team.info.happy_path")
def test_info_returns_created_team_fields(
self, client: ManagementClient, resources: ResourceManager
) -> None:
alias = f"e2e-team-info-{unique_marker()}"
team_id = _create_team(client, resources, alias, ["gemini-2.5-flash"])
info = _read_team(client, team_id)
assert info.team_id == team_id, f"/team/info echoed team_id {info.team_id!r}, requested {team_id!r}"
assert info.team_info.team_alias == alias, (
f"/team/info reports team_alias {info.team_info.team_alias!r}, configured {alias!r}"
)
assert info.team_info.models == ["gemini-2.5-flash"], (
f"/team/info reports models {info.team_info.models}, configured ['gemini-2.5-flash']"
)
@pytest.mark.covers("mgmt.team.block.persists")
def test_block_then_unblock_persists_to_team_info(
self, client: ManagementClient, resources: ResourceManager
) -> None:
team_id = _create_team(client, resources, f"e2e-team-block-{unique_marker()}", ["gemini-2.5-flash"])
assert not _read_team(client, team_id).team_info.blocked, "/team/info reports the team blocked before /team/block"
_set_blocked(client, team_id, blocked=True)
_ = _poll(
client,
lambda: True if _read_team(client, team_id).team_info.blocked else None,
"/team/info never reflected blocked=True after /team/block",
)
_set_blocked(client, team_id, blocked=False)
_ = _poll(
client,
lambda: True if _read_team(client, team_id).team_info.blocked is False else None,
"/team/info never reflected blocked=False after /team/unblock",
)
@pytest.mark.covers("mgmt.team.member_update.persists")
def test_member_update_persists_role_and_budget(
self, client: ManagementClient, resources: ResourceManager
) -> None:
user_id = _create_user(client, resources, f"e2e-team-mu-{unique_marker()}@example.com")
team_id = _create_team(client, resources, f"e2e-team-mu-{unique_marker()}", ["gemini-2.5-flash"])
client.add_team_member(team_id, user_id)
assert _member_role(_read_team(client, team_id), user_id) == "user", (
f"member {user_id} should start as role 'user' after /team/member_add"
)
budget = 4242.0
_member_update(client, MemberUpdateBody(team_id=team_id, user_id=user_id, role="admin", max_budget_in_team=budget))
def updated() -> bool | None:
info = _read_team(client, team_id)
return True if _member_role(info, user_id) == "admin" and _member_max_budget(info, user_id) == budget else None
_ = _poll(
client,
updated,
f"/team/info never reflected role=admin and max_budget={budget} for {user_id} after /team/member_update",
)
@pytest.mark.covers("mgmt.team.member_delete.persists")
def test_member_delete_persists_to_team_info(
self, client: ManagementClient, resources: ResourceManager
) -> None:
user_id = _create_user(client, resources, f"e2e-team-md-{unique_marker()}@example.com")
team_id = _create_team(client, resources, f"e2e-team-md-{unique_marker()}", ["gemini-2.5-flash"])
client.add_team_member(team_id, user_id)
assert _member_role(_read_team(client, team_id), user_id) == "user", (
f"/team/info does not list {user_id} as a member after /team/member_add"
)
client.delete_team_member(team_id, user_id)
_ = _poll(
client,
lambda: True if _member_role(_read_team(client, team_id), user_id) is None else None,
f"/team/info still lists {user_id} after /team/member_delete",
)
@pytest.mark.covers("mgmt.team.new.admin_only")
def test_new_is_denied_to_non_admin_keys(
self, client: ManagementClient, resources: ResourceManager
) -> None:
no_role_key = _generate_key(client, resources, KeyGenerateBody(models=[]))
internal_user_id = _create_user(client, resources, f"e2e-team-adm-{unique_marker()}@example.com")
internal_user_key = _generate_key(client, resources, KeyGenerateBody(user_id=internal_user_id))
for key, label in ((no_role_key, "role=None"), (internal_user_key, "internal_user")):
outcome = client.team_new_status(key, TeamNewBody(team_alias=f"e2e-team-adm-{unique_marker()}"))
assert outcome.status_code in (401, 403), (
f"/team/new by a {label} key must be denied 401/403, got {outcome.status_code}: {outcome.body[:300]}"
)
@pytest.mark.covers("mgmt.team.member_add.member_forbidden")
def test_member_add_forbidden_to_plain_member(
self, client: ManagementClient, resources: ResourceManager
) -> None:
_member_id, other_id, member_key, team_id = self._team_with_member_key(client, resources)
outcome = _member_add_status(client, member_key, team_id, other_id)
assert outcome.status_code == 403, (
f"/team/member_add by a plain team member must be 403, got {outcome.status_code}: {outcome.body[:300]}"
)
assert "not allowed" in outcome.body.lower(), (
f"403 body should say the call is not allowed, got: {outcome.body[:300]}"
)
@pytest.mark.covers("mgmt.team.member_delete.member_forbidden")
def test_member_delete_forbidden_to_plain_member(
self, client: ManagementClient, resources: ResourceManager
) -> None:
member_id, _other_id, member_key, team_id = self._team_with_member_key(client, resources)
outcome = _member_delete_status(client, member_key, team_id, member_id)
assert outcome.status_code == 403, (
f"/team/member_delete by a plain team member must be 403, got {outcome.status_code}: {outcome.body[:300]}"
)
assert "not allowed" in outcome.body.lower(), (
f"403 body should say the call is not allowed, got: {outcome.body[:300]}"
)
@staticmethod
def _team_with_member_key(
client: ManagementClient, resources: ResourceManager
) -> tuple[str, str, str, str]:
"""A team with a plain member (role user) whose key is scoped to that
user + team, plus a second user id the member could try to add."""
member_id = _create_user(client, resources, f"e2e-team-fb-{unique_marker()}@example.com")
other_id = _create_user(client, resources, f"e2e-team-fb-{unique_marker()}@example.com")
team_id = _create_team(client, resources, f"e2e-team-fb-{unique_marker()}", ["gemini-2.5-flash"])
client.add_team_member(team_id, member_id)
member_key = _generate_key(client, resources, KeyGenerateBody(user_id=member_id, team_id=team_id))
return member_id, other_id, member_key, team_id

View file

@ -85,6 +85,37 @@ class McpToolsListResponse(BaseModel):
return None
class BlockedWordSpec(BaseModel):
keyword: str
action: str = "BLOCK"
class ContentFilterMcpParams(BaseModel):
"""litellm_content_filter params scoped to the MCP tool-call hook. mode is
pre_mcp_call because a pre_call config silently no-ops on the tools/call path
(the event type is rewritten to pre_mcp_call for call_mcp_tool), and default_on
is required there because per-key/request guardrail selection is dropped from
the synthetic MCP request the hook sees."""
guardrail: str = "litellm_content_filter"
mode: str = "pre_mcp_call"
default_on: bool = True
blocked_words: list[BlockedWordSpec]
class GuardrailSpecBody(BaseModel):
guardrail_name: str
litellm_params: ContentFilterMcpParams
class GuardrailCreateBody(BaseModel):
guardrail: GuardrailSpecBody
class GuardrailCreateResponse(BaseModel):
guardrail_id: str
class McpCallToolBody(BaseModel):
name: str
arguments: dict[str, McpToolArg]
@ -186,6 +217,35 @@ class McpClient:
response_type=McpToolsListResponse,
)
def register_mcp_content_filter(self, *, name: str, blocked_keyword: str) -> str:
"""Register a default-on content-filter guardrail that runs on the MCP
tool-call hook (pre_mcp_call) and blocks a single keyword. The keyword is
unique per test, so default_on only ever intercepts this test's own
banned tool call on the shared proxy."""
return unwrap(
self.proxy.transport.post(
"/guardrails",
headers=self.proxy.transport.master,
json=GuardrailCreateBody(
guardrail=GuardrailSpecBody(
guardrail_name=name,
litellm_params=ContentFilterMcpParams(
blocked_words=[BlockedWordSpec(keyword=blocked_keyword)],
),
)
),
response_type=GuardrailCreateResponse,
)
).guardrail_id
def delete_guardrail(self, guardrail_id: str) -> None:
_ = self.proxy.transport.delete(
f"/guardrails/{guardrail_id}",
headers=self.proxy.transport.master,
json=NoBody(),
response_type=NoBody,
)
def call_tool(
self,
key: str,

View file

@ -0,0 +1,146 @@
"""Live e2e: a guardrail on the MCP tool-call path blocks banned content in the
tool arguments before the call reaches the upstream MCP server.
A general litellm_content_filter guardrail is configured with mode=pre_mcp_call
(the event type the proxy rewrites pre_call to for a call_mcp_tool) and default_on
(per-key/request guardrail selection is dropped from the synthetic MCP request the
hook sees, so default_on is how it attaches to tools/call). The banned keyword is
unique per run, so default_on only ever intercepts this test's own banned call.
Against the real Datadog MCP server, calling search_datadog_logs with the banned
keyword in the query is blocked with HTTP 400 attributed to the pre_mcp_call hook,
and the tool never runs; the same guardrail lets a clean query through to Datadog.
This is the enforced half (the block) plus the pass-through half in one spec.
"""
from __future__ import annotations
import time
from collections.abc import Callable
import pytest
from datadog_mcp import SEARCH_LOGS_TOOL, assert_dd_mcp_creds, register_datadog_mcp
from e2e_config import DD_SEARCH_FROM, unique_marker
from e2e_http import Result, Success, UnknownApiError, unwrap
from lifecycle import ResourceManager
from mcp_client import McpCallToolResponse, McpClient, McpToolArguments
pytestmark = pytest.mark.e2e
# Stage runs several data-plane pods behind the shared key, and each picks up a
# newly registered guardrail only on its next periodic DB sync (~30s in
# proxy_server.py). Every pod is guaranteed to have refreshed only once a full sync
# interval has elapsed since the create; before then a banned call routed to a
# lagging pod passes through as legitimate in-flight propagation, not a leak.
GUARDRAIL_FULL_SYNC_SECONDS = 40.0
POST_SYNC_VERIFICATION_CALLS = 4
def _poll_until_blocked(
search: Callable[[str], Result[McpCallToolResponse]], banned_keyword: str, client: McpClient
) -> Result[McpCallToolResponse]:
"""Retry a banned tool call until the guardrail blocks it (400) or the deadline
passes, returning the last result. Absorbs the control-plane -> data-plane
guardrail-sync delay so the check waits for enforcement instead of racing it."""
deadline = time.monotonic() + client.proxy.poll_timeout
last: Result[McpCallToolResponse] = search(f"tell me about {banned_keyword}")
while time.monotonic() < deadline:
if isinstance(last, UnknownApiError) and last.status_code == 400:
return last
time.sleep(client.proxy.poll_interval)
last = search(f"tell me about {banned_keyword}")
return last
class TestMcpToolCallGuardrail:
@pytest.mark.covers(
"guardrail.litellm_content_filter.pre_mcp_call.blocks",
exercised_on=["mcp_operations"],
)
def test_content_filter_blocks_banned_keyword_in_tool_args(
self, client: McpClient, resources: ResourceManager
) -> None:
assert_dd_mcp_creds()
marker = unique_marker()
banned_keyword = f"e2eblocked{marker}"
guardrail_id = client.register_mcp_content_filter(
name=f"e2e-mcp-cf-{marker}", blocked_keyword=banned_keyword
)
guardrail_created_at = time.monotonic()
resources.defer(lambda: client.delete_guardrail(guardrail_id))
server_id = register_datadog_mcp(client, resources)
key = client.generate_key(user_id=f"e2e-mcp-guard-{marker}", mcp_servers=[server_id])
resources.defer(lambda: client.proxy.delete_key(key))
tools = unwrap(client.list_tools(key))
tool_name = tools.tool_name_containing(server_id, SEARCH_LOGS_TOOL)
assert tool_name is not None, (
f"granted key never saw {SEARCH_LOGS_TOOL} on server {server_id}; "
f"tools={tools.tool_names_for_server(server_id)}"
)
def search(query: str) -> Result[McpCallToolResponse]:
arguments: McpToolArguments = {
"query": query,
"from": DD_SEARCH_FROM,
"to": "now",
"max_tokens": 500,
"telemetry": {"intent": "e2e mcp guardrail check"},
}
return client.call_tool(key, server_id=server_id, name=tool_name, arguments=arguments)
# Registering the guardrail is a control-plane write; the data-plane worker
# that serves tools/call picks it up on its next guardrail sync, so an
# immediate call can race the propagation and slip through. Poll the banned
# call to the deadline and require a block, so the check proves enforcement
# rather than catching a pre-sync pass-through. The keyword is unique per
# run, so this only ever intercepts this test's own call.
blocked = _poll_until_blocked(search, banned_keyword, client)
match blocked:
case UnknownApiError(status_code=400, body=body):
assert banned_keyword in body or "content blocked" in body.lower(), (
f"the block must name the content-filter reason, got: {body[:300]}"
)
assert "pre_mcp_call" in body, (
f"the block must be attributed to the MCP tool-call hook (pre_mcp_call), got: {body[:300]}"
)
case _:
pytest.fail(
"content_filter never blocked the banned keyword on the MCP tool call within "
f"{client.proxy.poll_timeout}s (guardrail sync to the data plane never landed); "
f"last result: {blocked}"
)
# The block above only proves the one pod that served it has synced; another
# pod could still lack the guardrail and let the banned call reach Datadog.
# Wait out the full sync interval from the create so every pod has refreshed
# from the DB, then require the banned call to stay blocked across several
# attempts. A pass-through now is a genuine partial-propagation leak, not a
# race. Client load balancing still can't guarantee every pod is hit, so this
# samples several worker selections rather than proving all pods synced.
sync_remaining = guardrail_created_at + GUARDRAIL_FULL_SYNC_SECONDS - time.monotonic()
if sync_remaining > 0:
time.sleep(sync_remaining)
for attempt in range(1, POST_SYNC_VERIFICATION_CALLS + 1):
reblocked = search(f"still about {banned_keyword} #{attempt}")
assert isinstance(reblocked, UnknownApiError) and reblocked.status_code == 400, (
"after the guardrail sync interval every data-plane pod must block the banned "
f"keyword, but attempt {attempt} of {POST_SYNC_VERIFICATION_CALLS} was allowed "
f"through (a pod still lacks the guardrail): {reblocked}"
)
if attempt < POST_SYNC_VERIFICATION_CALLS:
time.sleep(client.proxy.poll_interval)
allowed = search(f"e2e-clean-{marker}")
match allowed:
case Success(data=result):
assert result.is_error is not True, (
f"a clean MCP tool call must reach the server and not error, got: {result}"
)
case _:
pytest.fail(
f"a clean MCP tool call must pass the guardrail and reach the server; got {allowed}"
)

View file

@ -286,6 +286,7 @@ class CountTokensBody(BaseModel):
class AnthropicContentBlock(BaseModel):
type: str | None = None
text: str | None = None
class AnthropicMessagesResponse(BaseModel):
@ -817,3 +818,23 @@ class TagListResponse(RootModel[list[TagListEntry]]):
"""GET /tag/list answers with a bare array of tag configs (the stored tags plus
any dynamically-seen spend tags), not an object wrapping them. Read the rows off
.root."""
# ---------- health / lifecycle ----------
class ReadinessResponse(BaseModel):
"""GET /health/readiness (public probe). The low-detail payload a load
balancer sees: `status` plus the resolved DB state (`connected`,
`disconnected`, or `Not connected`)."""
status: str
db: str | None = None
class ReadinessDetailsResponse(ReadinessResponse):
"""GET /health/readiness/details (authenticated). Extends the public payload
with the diagnostics only an authenticated caller may read."""
litellm_version: str | None = None
success_callbacks: list[str] = []

View file

@ -0,0 +1,18 @@
"""`other` suite's `client` fixture.
Lifecycle (resources/scoped_key), proxy liveness gate, and the e2e/covers
markers all live in the parent tests/e2e/conftest.py. OtherClient holds the
shared ProxyClient so anything these tests create tears down through it.
"""
from __future__ import annotations
import pytest
from other_client import OtherClient, build_client
from proxy_client import ProxyClient
@pytest.fixture(scope="session")
def client(proxy: ProxyClient) -> OtherClient:
return build_client(proxy)

View file

@ -0,0 +1,73 @@
"""Client for the `other` holding-pen suite: the auth gate (master key vs an
invalid key on an admin route) and the process-lifecycle health probes
(liveness, public readiness, authenticated readiness diagnostics).
Holds the shared ProxyClient so `resources` / `scoped_key` still clean up, and
adds only the routes these behaviors need. The health probes deliberately send
no auth header (public routes), so they go through the transport with an empty
headers model rather than a bearer.
"""
from __future__ import annotations
from dataclasses import dataclass
from e2e_http import NoBody, ProbeResult, Result
from models import (
ReadinessDetailsResponse,
ReadinessResponse,
UserListParams,
UserListResponse,
)
from proxy_client import ProxyClient
@dataclass(frozen=True, slots=True)
class OtherClient:
proxy: ProxyClient
def liveness(self) -> ProbeResult:
"""GET /health/liveliness. Unauthenticated; the probe returns status +
raw body so the test can assert the worker reports itself alive."""
return self.proxy.transport.probe("/health/liveliness", params=NoBody())
def readiness_public(self) -> Result[ReadinessResponse]:
"""GET /health/readiness with no credential at all, proving the probe is
safe to expose to an unauthenticated load balancer."""
return self.proxy.transport.get(
"/health/readiness",
headers=NoBody(),
params=NoBody(),
response_type=ReadinessResponse,
)
def readiness_details(self, key: str) -> Result[ReadinessDetailsResponse]:
return self.proxy.transport.get(
"/health/readiness/details",
headers=self.proxy.transport.bearer(key),
params=NoBody(),
response_type=ReadinessDetailsResponse,
)
def readiness_details_unauthenticated(self) -> Result[ReadinessDetailsResponse]:
return self.proxy.transport.get(
"/health/readiness/details",
headers=NoBody(),
params=NoBody(),
response_type=ReadinessDetailsResponse,
)
def list_users_as(self, key: str) -> Result[UserListResponse]:
"""GET /user/list under `key`. Admin-only, so it doubles as the master
key's authorization proof: the master key (proxy admin) reads it, a
non-matching key is rejected before it ever reaches the handler."""
return self.proxy.transport.get(
"/user/list",
headers=self.proxy.transport.bearer(key),
params=UserListParams(user_ids="e2e-test-user"),
response_type=UserListResponse,
)
def build_client(proxy: ProxyClient) -> OtherClient:
return OtherClient(proxy=proxy)

View file

@ -0,0 +1,65 @@
"""Live e2e: the process-lifecycle probes Kubernetes and load balancers depend on.
Liveness and public readiness must answer without a credential (a load balancer
has none), and public readiness must distinguish a healthy worker from one whose
DB is unreachable by reporting the resolved DB state. The detailed readiness
route, by contrast, is authenticated: it exposes diagnostics (version, callbacks,
DB) and must reject an anonymous caller. The suite runs against a proxy configured
with a real database, so a healthy readiness payload reports the DB as connected;
a regression that stopped checking the DB, or dropped the public exposure, fails
here.
"""
from __future__ import annotations
import pytest
from e2e_config import MASTER_KEY
from e2e_http import UnauthorizedError, unwrap
from other_client import OtherClient
pytestmark = pytest.mark.e2e
class TestHealthLifecycle:
@pytest.mark.covers("other.lifecycle.liveness.ping")
def test_liveness_reports_alive_without_auth(self, client: OtherClient) -> None:
probe = client.liveness()
assert probe.status_code == 200, (
f"liveness must answer 200 for an unauthenticated probe, got "
f"{probe.status_code}: {probe.body[:200]}"
)
assert "alive" in probe.body.lower(), (
f"liveness body must confirm the worker is alive, got {probe.body[:200]}"
)
@pytest.mark.covers("other.lifecycle.readiness.public_probe")
def test_readiness_is_reachable_without_credentials(self, client: OtherClient) -> None:
readiness = unwrap(client.readiness_public())
assert readiness.status == "healthy", (
f"public readiness must report a healthy worker, got status {readiness.status!r}"
)
@pytest.mark.covers("other.lifecycle.readiness.reports_db_status")
def test_readiness_reports_connected_db(self, client: OtherClient) -> None:
readiness = unwrap(client.readiness_public())
assert readiness.db == "connected", (
"readiness must report the configured database as connected so an "
f"orchestrator can tell a healthy worker from a DB-unreachable one, got {readiness.db!r}"
)
@pytest.mark.covers("other.lifecycle.readiness_details.authenticated_diagnostics")
def test_readiness_details_require_auth_and_expose_diagnostics(self, client: OtherClient) -> None:
anonymous = client.readiness_details_unauthenticated()
assert isinstance(anonymous, UnauthorizedError), (
f"/health/readiness/details must reject an unauthenticated caller, got {anonymous}"
)
details = unwrap(client.readiness_details(MASTER_KEY))
assert details.status == "healthy", f"authenticated readiness status must be healthy, got {details.status!r}"
assert details.litellm_version is not None, (
"authenticated diagnostics must expose the litellm version"
)
assert details.db == "connected", (
f"authenticated diagnostics must report the DB as connected, got {details.db!r}"
)

View file

@ -0,0 +1,37 @@
"""Live e2e: the master key authenticates and is treated as a proxy admin, and a
key that is not the master key is rejected before reaching the handler.
/user/list is admin-only, so it proves both halves of the master-key contract in
one route: the master key reads it (authenticated + authorized as admin), while a
freshly minted, never-provisioned token is denied 401 by the auth layer. The
invalid case uses a unique, master-key-shaped token so the check exercises the
credential comparison rather than a value that could collide with a real key.
"""
from __future__ import annotations
import pytest
from e2e_config import MASTER_KEY, unique_marker
from e2e_http import UnauthorizedError, unwrap
from other_client import OtherClient
pytestmark = pytest.mark.e2e
class TestMasterKeyAuth:
@pytest.mark.covers("other.auth.master_key.valid_allows")
def test_master_key_authenticates_and_grants_admin_route(self, client: OtherClient) -> None:
listing = unwrap(client.list_users_as(MASTER_KEY))
assert listing.total >= 0, (
"master key reached the admin /user/list handler but the response did not "
f"carry a user count: {listing}"
)
@pytest.mark.covers("other.auth.master_key.invalid_denied")
def test_non_matching_master_key_is_denied(self, client: OtherClient) -> None:
bogus = f"sk-{unique_marker()}"
result = client.list_users_as(bogus)
assert isinstance(result, UnauthorizedError), (
f"a token that is not the master key must be rejected with 401, got {result}"
)

View file

@ -194,6 +194,7 @@ def test_streaming_messages_via_responses_bridge_tracks_spend(
@pytest.mark.covers("quota_management.spend_tracking.embeddings.logs_cost")
@pytest.mark.covers("llm.embeddings.openai.basic.nonstream.cost_logged")
def test_embedding_writes_nonzero_spend_row(
client: SpendClient, scoped_key: str
) -> None:

View file

@ -16,7 +16,7 @@ import e2e_http
from e2e_http import (
URL,
AuthHeaders,
FileUploadForm,
BinaryStream,
ProbeResult,
Result,
StreamingResponse,
@ -32,6 +32,15 @@ class Transport(Protocol):
self, path: str, *, headers: BaseModel, json: BaseModel
) -> StreamingResponse: ...
def stream_binary(
self,
path: str,
*,
headers: BaseModel,
json: BaseModel,
chunk_size: int = 8192,
) -> BinaryStream: ...
def send(
self,
path: str,
@ -65,6 +74,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](
@ -72,9 +85,10 @@ class Transport(Protocol):
path: str,
*,
headers: BaseModel,
form: FileUploadForm,
form: BaseModel,
filename: str,
content: bytes,
file_content_type: str = "application/jsonl",
params: BaseModel | None = None,
response_type: type[R],
) -> Result[R]: ...
@ -159,6 +173,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:
@ -166,6 +191,22 @@ class HttpTransport:
self._url(path), headers=headers, json=json, timeout=self.request_timeout
)
def stream_binary(
self,
path: str,
*,
headers: BaseModel,
json: BaseModel,
chunk_size: int = 8192,
) -> BinaryStream:
return e2e_http.stream_binary(
self._url(path),
headers=headers,
json=json,
chunk_size=chunk_size,
timeout=self.request_timeout,
)
def send(
self,
path: str,
@ -197,9 +238,10 @@ class HttpTransport:
path: str,
*,
headers: BaseModel,
form: FileUploadForm,
form: BaseModel,
filename: str,
content: bytes,
file_content_type: str = "application/jsonl",
params: BaseModel | None = None,
response_type: type[R],
) -> Result[R]:
@ -209,6 +251,7 @@ class HttpTransport:
form=form,
filename=filename,
content=content,
file_content_type=file_content_type,
params=params,
response_type=response_type,
timeout=self.request_timeout,
@ -234,6 +277,7 @@ CONTROL_PLANE_PREFIXES: tuple[str, ...] = (
"/tag",
"/budget",
"/model/",
"/access_group",
"/spend",
"/global",
"/config",
@ -318,11 +362,30 @@ 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:
return self._route(path).stream(path, headers=headers, json=json)
def stream_binary(
self,
path: str,
*,
headers: BaseModel,
json: BaseModel,
chunk_size: int = 8192,
) -> BinaryStream:
return self._route(path).stream_binary(
path, headers=headers, json=json, chunk_size=chunk_size
)
def send(
self,
path: str,
@ -344,9 +407,10 @@ class SplitTransport:
path: str,
*,
headers: BaseModel,
form: FileUploadForm,
form: BaseModel,
filename: str,
content: bytes,
file_content_type: str = "application/jsonl",
params: BaseModel | None = None,
response_type: type[R],
) -> Result[R]:
@ -356,6 +420,7 @@ class SplitTransport:
form=form,
filename=filename,
content=content,
file_content_type=file_content_type,
params=params,
response_type=response_type,
)

View file

@ -11,7 +11,7 @@
"user": "",
"team_id": "",
"organization_id": "",
"metadata": "{\"applied_guardrails\": [], \"batch_models\": null, \"mcp_tool_call_metadata\": null, \"vector_store_request_metadata\": null, \"guardrail_information\": null, \"usage_object\": {\"completion_tokens\": 20, \"prompt_tokens\": 10, \"total_tokens\": 30, \"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"model_map_information\": {\"model_map_key\": \"gpt-4o\", \"model_map_value\": {\"key\": \"gpt-4o\", \"max_tokens\": 16384, \"max_input_tokens\": 128000, \"max_output_tokens\": 16384, \"input_cost_per_token\": 2.5e-06, \"cache_creation_input_token_cost\": null, \"cache_read_input_token_cost\": 1.25e-06, \"input_cost_per_character\": null, \"input_cost_per_token_above_128k_tokens\": null, \"input_cost_per_token_above_200k_tokens\": null, \"input_cost_per_query\": null, \"input_cost_per_second\": null, \"input_cost_per_audio_token\": null, \"input_cost_per_token_batches\": 1.25e-06, \"output_cost_per_token_batches\": 5e-06, \"output_cost_per_token\": 1e-05, \"output_cost_per_audio_token\": null, \"output_cost_per_character\": null, \"output_cost_per_token_above_128k_tokens\": null, \"output_cost_per_character_above_128k_tokens\": null, \"output_cost_per_token_above_200k_tokens\": null, \"output_cost_per_second\": null, \"output_cost_per_image\": null, \"output_vector_size\": null, \"litellm_provider\": \"openai\", \"mode\": \"chat\", \"supports_system_messages\": true, \"supports_response_schema\": true, \"supports_vision\": true, \"supports_function_calling\": true, \"supports_tool_choice\": true, \"supports_assistant_prefill\": false, \"supports_prompt_caching\": true, \"supports_audio_input\": false, \"supports_audio_output\": false, \"supports_pdf_input\": false, \"supports_embedding_image_input\": false, \"supports_native_streaming\": null, \"supports_web_search\": true, \"supports_reasoning\": false, \"search_context_cost_per_query\": {\"search_context_size_low\": 0.03, \"search_context_size_medium\": 0.035, \"search_context_size_high\": 0.05}, \"tpm\": null, \"rpm\": null, \"supported_openai_params\": [\"frequency_penalty\", \"logit_bias\", \"logprobs\", \"top_logprobs\", \"max_tokens\", \"max_completion_tokens\", \"modalities\", \"prediction\", \"n\", \"presence_penalty\", \"seed\", \"stop\", \"stream\", \"stream_options\", \"temperature\", \"top_p\", \"tools\", \"tool_choice\", \"function_call\", \"functions\", \"max_retries\", \"extra_headers\", \"parallel_tool_calls\", \"audio\", \"response_format\", \"user\"]}}, \"additional_usage_values\": {\"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"user_api_key\": null, \"user_api_key_alias\": null, \"user_api_key_team_id\": null, \"user_api_key_project_id\": null, \"user_api_key_project_alias\": null, \"user_api_key_org_id\": null, \"user_api_key_user_id\": null, \"user_api_key_team_alias\": null, \"spend_logs_metadata\": null, \"requester_ip_address\": null, \"status\": null, \"proxy_server_request\": null, \"error_information\": null, \"attempted_retries\": null, \"max_retries\": null}",
"metadata": "{\"applied_guardrails\": [], \"batch_models\": null, \"mcp_tool_call_metadata\": null, \"vector_store_request_metadata\": null, \"guardrail_information\": null, \"compression_savings\": null, \"usage_object\": {\"completion_tokens\": 20, \"prompt_tokens\": 10, \"total_tokens\": 30, \"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"model_map_information\": {\"model_map_key\": \"gpt-4o\", \"model_map_value\": {\"key\": \"gpt-4o\", \"max_tokens\": 16384, \"max_input_tokens\": 128000, \"max_output_tokens\": 16384, \"input_cost_per_token\": 2.5e-06, \"cache_creation_input_token_cost\": null, \"cache_read_input_token_cost\": 1.25e-06, \"input_cost_per_character\": null, \"input_cost_per_token_above_128k_tokens\": null, \"input_cost_per_token_above_200k_tokens\": null, \"input_cost_per_query\": null, \"input_cost_per_second\": null, \"input_cost_per_audio_token\": null, \"input_cost_per_token_batches\": 1.25e-06, \"output_cost_per_token_batches\": 5e-06, \"output_cost_per_token\": 1e-05, \"output_cost_per_audio_token\": null, \"output_cost_per_character\": null, \"output_cost_per_token_above_128k_tokens\": null, \"output_cost_per_character_above_128k_tokens\": null, \"output_cost_per_token_above_200k_tokens\": null, \"output_cost_per_second\": null, \"output_cost_per_image\": null, \"output_vector_size\": null, \"litellm_provider\": \"openai\", \"mode\": \"chat\", \"supports_system_messages\": true, \"supports_response_schema\": true, \"supports_vision\": true, \"supports_function_calling\": true, \"supports_tool_choice\": true, \"supports_assistant_prefill\": false, \"supports_prompt_caching\": true, \"supports_audio_input\": false, \"supports_audio_output\": false, \"supports_pdf_input\": false, \"supports_embedding_image_input\": false, \"supports_native_streaming\": null, \"supports_web_search\": true, \"supports_reasoning\": false, \"search_context_cost_per_query\": {\"search_context_size_low\": 0.03, \"search_context_size_medium\": 0.035, \"search_context_size_high\": 0.05}, \"tpm\": null, \"rpm\": null, \"supported_openai_params\": [\"frequency_penalty\", \"logit_bias\", \"logprobs\", \"top_logprobs\", \"max_tokens\", \"max_completion_tokens\", \"modalities\", \"prediction\", \"n\", \"presence_penalty\", \"seed\", \"stop\", \"stream\", \"stream_options\", \"temperature\", \"top_p\", \"tools\", \"tool_choice\", \"function_call\", \"functions\", \"max_retries\", \"extra_headers\", \"parallel_tool_calls\", \"audio\", \"response_format\", \"user\"]}}, \"additional_usage_values\": {\"completion_tokens_details\": null, \"prompt_tokens_details\": null}, \"user_api_key\": null, \"user_api_key_alias\": null, \"user_api_key_team_id\": null, \"user_api_key_project_id\": null, \"user_api_key_project_alias\": null, \"user_api_key_org_id\": null, \"user_api_key_user_id\": null, \"user_api_key_team_alias\": null, \"spend_logs_metadata\": null, \"requester_ip_address\": null, \"status\": null, \"proxy_server_request\": null, \"error_information\": null, \"attempted_retries\": null, \"max_retries\": null}",
"cache_key": "Cache OFF",
"spend": 0.00022500000000000002,
"total_tokens": 30,

View file

@ -1780,7 +1780,10 @@ def test_update_key_budget_with_temp_budget_increase():
"temp_budget_expiry": expiry_in_isoformat,
},
)
assert _update_key_budget_with_temp_budget_increase(valid_token).max_budget == 200
result = _update_key_budget_with_temp_budget_increase(valid_token)
assert result.max_budget == 200
assert result is not valid_token
assert valid_token.max_budget == 100
@pytest.mark.asyncio

View file

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

View file

@ -219,8 +219,7 @@ def _gate(**overrides):
kwargs = {
"custom_llm_provider": "azure_ai",
"litellm_params": GenericLiteLLMParams(api_key="sk-azure", rust=True),
"stream": False,
"rust_stream_eligible": False,
"has_agentic_hook": False,
"model": "claude-sonnet-4-5",
"api_key": "sk-azure",
"api_base": "https://resource.services.ai.azure.com/anthropic",
@ -345,11 +344,11 @@ async def test_gate_skips_rust_for_unsupported_provider():
@pytest.mark.asyncio
async def test_gate_skips_rust_when_streaming_but_not_eligible():
async def test_gate_skips_rust_for_agentic_hook():
bridge = ExplodingAsyncMessages()
litellm.use_litellm_rust(True, amessages=bridge)
response = await _gate(stream=True, rust_stream_eligible=False)
response = await _gate(has_agentic_hook=True)
assert response is None
assert bridge.calls == 0
@ -362,8 +361,7 @@ async def test_gate_streams_through_rust_when_eligible_and_strips_stream_flag():
streaming_body = {**REQUEST_BODY, "stream": True}
response = await _gate(
stream=True,
rust_stream_eligible=True,
has_agentic_hook=False,
request_body=streaming_body,
)

View file

@ -412,7 +412,7 @@ class TestAzureAnthropicMidConversationSystem:
older Claude, and a *leading* system entry 400s on every model ("messages.0:
use the top-level 'system' parameter"). These tests pin the model-aware hoist
the config applies so Claude Code sessions neither collapse the prompt cache
on 4.8+ nor hard-fail on 4.7 and older (RCA: Kraken Tech high-spend)."""
on 4.8+ nor hard-fail on 4.7 and older (RCA: customer high-spend)."""
def test_supported_model_keeps_mid_conversation_system_in_place(self, local_model_cost_map):
messages = [

View file

@ -121,6 +121,20 @@ class TestHuggingFaceEmbedding:
assert response.usage.prompt_tokens > 0
assert response.usage.total_tokens == response.usage.prompt_tokens
def test_model_name_with_https_substring_uses_api_base(self):
api_base = "https://legit.example/embed"
litellm.embedding(
model="huggingface/my-https-endpoint",
input=["hello world"],
input_type="embed",
api_base=api_base,
)
self.mock_http.assert_called_once()
called_url = self.mock_http.call_args[0][0]
assert called_url == api_base
def test_embedding_with_sentence_similarity_task(self):
"""Test embedding when task type is sentence-similarity (requires 2+ sentences)"""

View file

@ -0,0 +1,55 @@
import os
import sys
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.abspath("../../../../.."))
import litellm
MOCK_COMPLETION_RESPONSE = {
"choices": [{"message": {"role": "assistant", "content": "hi there"}}],
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
}
def _mock_post_response():
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = "ok"
mock_response.json.return_value = MOCK_COMPLETION_RESPONSE
return mock_response
def test_model_name_with_https_substring_uses_api_base():
api_base = "https://legit.example"
with patch(
"litellm.llms.custom_httpx.http_handler.HTTPHandler.post"
) as mock_post:
mock_post.return_value = _mock_post_response()
litellm.completion(
model="oobabooga/my-https-model",
messages=[{"role": "user", "content": "hello"}],
api_base=api_base,
)
mock_post.assert_called_once()
called_url = mock_post.call_args[0][0]
assert called_url == f"{api_base}/v1/chat/completions"
def test_url_valued_model_still_targets_that_url():
with patch(
"litellm.llms.custom_httpx.http_handler.HTTPHandler.post"
) as mock_post:
mock_post.return_value = _mock_post_response()
litellm.completion(
model="oobabooga/https://sdk-user.example",
messages=[{"role": "user", "content": "hello"}],
)
mock_post.assert_called_once()
called_url = mock_post.call_args[0][0]
assert called_url == "https://sdk-user.example/v1/chat/completions"

View file

@ -591,7 +591,7 @@ class TestVertexAnthropicMidConversationSystem:
Claude, and a *leading* system entry 400s on every model ("messages.0: use
the top-level 'system' parameter"). These tests pin the model-aware hoist so
Claude Code sessions neither collapse the prompt cache on 4.8+ nor hard-fail
on 4.7 and older (RCA: Kraken Tech high-spend)."""
on 4.7 and older (RCA: customer high-spend)."""
def test_supported_model_keeps_mid_conversation_system_in_place(self, local_model_cost_map):
messages = [

View file

@ -0,0 +1,343 @@
"""Tests for the SSO identity assertion store (EMA subject-token capture).
Pins the contract of the store that PR 2's ``_id_jag`` subject-sourcing seam will read:
the carrier validates untyped IdP token-response values at the boundary, retention is
gated on an ``oauth2_id_jag`` server being registered, the row is encrypted at rest and
round-trips exactly, a store failure never escapes into the login path, and a salt-key
rotation re-encrypts stored rows like the sibling per-user credential tables.
"""
import json
import time
from unittest.mock import AsyncMock, MagicMock, patch
import jwt as pyjwt
import pytest
from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import (
assertion_from_sso_login,
ema_assertion_retention_enabled,
fetch_sso_identity_assertion,
persist_sso_identity_assertion,
retain_sso_identity_assertion_for_ema,
rotate_sso_identity_assertions_master_key,
)
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
from litellm.types.mcp import MCPAuth
SALT_KEY = "test-salt-key-for-sso-assertion-tests-1234"
SIGNING_KEY = "test-idp-signing-key-32-bytes-long-xxxx"
ISSUER = "https://idp.example.com"
@pytest.fixture(autouse=True)
def _set_salt_key(monkeypatch):
monkeypatch.setenv("LITELLM_SALT_KEY", SALT_KEY)
def _make_id_token(exp_offset: int = 3600, iss: str = ISSUER) -> str:
return pyjwt.encode(
{"iss": iss, "sub": "u1", "exp": int(time.time()) + exp_offset},
SIGNING_KEY,
algorithm="HS256",
)
def _make_prisma(stored: dict, db_has_id_jag_server: bool = False):
"""A fake prisma client whose sso-assertion table reads and writes ``stored``
(user_id -> assertion_b64), covering upsert, find_unique, find_many, and update.
``db_has_id_jag_server`` drives the retention gate's authoritative DB fallback;
it is wired explicitly so the gate never reads a truthy bare MagicMock."""
prisma = MagicMock()
prisma.db.litellm_mcpservertable.find_first = AsyncMock(
return_value=MagicMock() if db_has_id_jag_server else None
)
async def _upsert(where, data):
stored[where["user_id"]] = data["update"]["assertion_b64"]
async def _find_unique(where):
blob = stored.get(where["user_id"])
if blob is None:
return None
row = MagicMock()
row.user_id = where["user_id"]
row.assertion_b64 = blob
return row
async def _find_many():
rows = []
for user_id, blob in stored.items():
row = MagicMock()
row.user_id = user_id
row.assertion_b64 = blob
rows.append(row)
return rows
async def _update(where, data):
stored[where["user_id"]] = data["assertion_b64"]
prisma.db.litellm_ssoidentityassertion.upsert = AsyncMock(side_effect=_upsert)
prisma.db.litellm_ssoidentityassertion.find_unique = AsyncMock(side_effect=_find_unique)
prisma.db.litellm_ssoidentityassertion.find_many = AsyncMock(side_effect=_find_many)
prisma.db.litellm_ssoidentityassertion.update = AsyncMock(side_effect=_update)
return prisma
def _server_with_auth(auth_type):
server = MagicMock()
server.auth_type = auth_type
return server
def test_assertion_from_sso_login_happy_path():
token = _make_id_token()
assertion = assertion_from_sso_login(token, "rt_1")
assert assertion is not None
assert assertion.id_token.get_secret_value() == token
assert assertion.refresh_token is not None
assert assertion.refresh_token.get_secret_value() == "rt_1"
assert assertion.issuer == ISSUER
assert assertion.expires_at is not None
assert assertion.expires_at.timestamp() == pytest.approx(time.time() + 3600, abs=5)
def test_assertion_repr_never_leaks_token_material():
token = _make_id_token()
assertion = assertion_from_sso_login(token, "rt_secret_value")
rendered = repr(assertion) + str(assertion)
assert token not in rendered
assert "rt_secret_value" not in rendered
@pytest.mark.parametrize("id_token", [None, "", "not-a-jwt", 12345, ["x"], {"a": 1}])
def test_assertion_from_sso_login_rejects_unusable_id_token(id_token):
assert assertion_from_sso_login(id_token, "rt") is None
@pytest.mark.parametrize("refresh_token", [None, "", 123, ["rt"], {"rt": 1}])
def test_assertion_from_sso_login_drops_malformed_refresh_token(refresh_token):
assertion = assertion_from_sso_login(_make_id_token(), refresh_token)
assert assertion is not None
assert assertion.refresh_token is None
def test_assertion_without_exp_or_iss_still_retained():
token = pyjwt.encode({"sub": "u1"}, SIGNING_KEY, algorithm="HS256")
assertion = assertion_from_sso_login(token, None)
assert assertion is not None
assert assertion.expires_at is None
assert assertion.issuer is None
@pytest.mark.asyncio
async def test_retention_gate_requires_an_id_jag_server():
with (
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as manager,
patch("litellm.proxy.proxy_server.prisma_client", _make_prisma({}, db_has_id_jag_server=False)),
):
manager.config_mcp_servers = {
"s1": _server_with_auth(MCPAuth.oauth2),
"s2": _server_with_auth(None),
}
assert await ema_assertion_retention_enabled() is False
manager.config_mcp_servers = {
"s1": _server_with_auth(MCPAuth.oauth2),
"s2": _server_with_auth(MCPAuth.oauth2_id_jag),
}
assert await ema_assertion_retention_enabled() is True
@pytest.mark.asyncio
async def test_retention_gate_reads_the_db_when_config_declares_no_id_jag_server():
"""A DB-backed server added on another pod (or before this pod's DB load) must still enable
retention off the authoritative DB row; False only when neither authority knows one."""
with patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as manager:
manager.config_mcp_servers = {"s1": _server_with_auth(MCPAuth.oauth2)}
db_backed = _make_prisma({}, db_has_id_jag_server=True)
with patch("litellm.proxy.proxy_server.prisma_client", db_backed):
assert await ema_assertion_retention_enabled() is True
db_backed.db.litellm_mcpservertable.find_first.assert_awaited_once_with(
where={"auth_type": MCPAuth.oauth2_id_jag.value}
)
with patch("litellm.proxy.proxy_server.prisma_client", None):
assert await ema_assertion_retention_enabled() is False
@pytest.mark.asyncio
async def test_retention_gate_never_consults_the_registry_snapshot():
"""The registry is a per-process snapshot of DB state, stale in either direction: trusting
it positively would keep retaining bearer material after the last EMA server was removed on
another pod, trusting it negatively would drop writes for one added elsewhere. The gate must
judge only the config declaration and the DB row, so a stale snapshot listing an id_jag
server changes nothing."""
with (
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as manager,
patch("litellm.proxy.proxy_server.prisma_client", _make_prisma({}, db_has_id_jag_server=False)),
):
manager.config_mcp_servers = {}
manager.get_registry.return_value = {"stale": _server_with_auth(MCPAuth.oauth2_id_jag)}
assert await ema_assertion_retention_enabled() is False
manager.get_registry.assert_not_called()
@pytest.mark.asyncio
async def test_retain_persists_when_only_the_db_knows_the_id_jag_server():
stored = {}
prisma = _make_prisma(stored, db_has_id_jag_server=True)
with (
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as manager,
patch("litellm.proxy.proxy_server.prisma_client", prisma),
):
manager.config_mcp_servers = {}
await retain_sso_identity_assertion_for_ema(
user_id="user-a", assertion=assertion_from_sso_login(_make_id_token(), None)
)
assert "user-a" in stored
@pytest.mark.asyncio
async def test_persist_and_fetch_round_trip_encrypted_at_rest():
stored = {}
prisma = _make_prisma(stored)
token = _make_id_token()
assertion = assertion_from_sso_login(token, "rt_1")
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
await persist_sso_identity_assertion("user-a", assertion)
fetched = await fetch_sso_identity_assertion("user-a")
assert fetched is not None
assert fetched.id_token.get_secret_value() == token
assert fetched.refresh_token is not None
assert fetched.refresh_token.get_secret_value() == "rt_1"
assert fetched.issuer == assertion.issuer
assert fetched.expires_at == assertion.expires_at
assert token not in stored["user-a"]
assert "rt_1" not in stored["user-a"]
decrypted = decrypt_value_helper(stored["user-a"], "test", exception_type="debug")
assert json.loads(decrypted)["id_token"] == token
@pytest.mark.asyncio
async def test_persist_overwrites_previous_login():
stored = {}
prisma = _make_prisma(stored)
first = _make_id_token(exp_offset=100)
second = _make_id_token(exp_offset=7200)
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
await persist_sso_identity_assertion("user-a", assertion_from_sso_login(first, None))
await persist_sso_identity_assertion("user-a", assertion_from_sso_login(second, "rt_new"))
fetched = await fetch_sso_identity_assertion("user-a")
assert fetched is not None
assert fetched.id_token.get_secret_value() == second
assert fetched.refresh_token is not None
@pytest.mark.asyncio
async def test_fetch_missing_row_returns_none():
prisma = _make_prisma({})
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
assert await fetch_sso_identity_assertion("nobody") is None
@pytest.mark.asyncio
async def test_fetch_undecryptable_row_returns_none():
prisma = _make_prisma({"user-a": "not-an-encrypted-blob"})
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
assert await fetch_sso_identity_assertion("user-a") is None
@pytest.mark.asyncio
async def test_fetch_unparseable_payload_returns_none():
from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper
prisma = _make_prisma({"user-a": encrypt_value_helper("]]not json")})
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
assert await fetch_sso_identity_assertion("user-a") is None
@pytest.mark.asyncio
async def test_retain_noop_when_no_id_jag_server():
stored = {}
prisma = _make_prisma(stored)
with (
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as manager,
patch("litellm.proxy.proxy_server.prisma_client", prisma),
):
manager.config_mcp_servers = {"s1": _server_with_auth(MCPAuth.oauth2)}
await retain_sso_identity_assertion_for_ema(
user_id="user-a", assertion=assertion_from_sso_login(_make_id_token(), None)
)
prisma.db.litellm_ssoidentityassertion.upsert.assert_not_called()
assert stored == {}
@pytest.mark.asyncio
async def test_retain_persists_when_id_jag_server_registered():
stored = {}
prisma = _make_prisma(stored)
with (
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as manager,
patch("litellm.proxy.proxy_server.prisma_client", prisma),
):
manager.config_mcp_servers = {"s1": _server_with_auth(MCPAuth.oauth2_id_jag)}
await retain_sso_identity_assertion_for_ema(
user_id="user-a", assertion=assertion_from_sso_login(_make_id_token(), None)
)
assert "user-a" in stored
@pytest.mark.asyncio
async def test_retain_none_assertion_never_consults_gate_or_store():
gate = MagicMock()
with patch(
"litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store.ema_assertion_retention_enabled",
gate,
):
await retain_sso_identity_assertion_for_ema(user_id="user-a", assertion=None)
gate.assert_not_called()
@pytest.mark.asyncio
async def test_retain_swallows_store_failure():
prisma = MagicMock()
prisma.db.litellm_ssoidentityassertion.upsert = AsyncMock(side_effect=RuntimeError("db down"))
with (
patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as manager,
patch("litellm.proxy.proxy_server.prisma_client", prisma),
):
manager.config_mcp_servers = {"s1": _server_with_auth(MCPAuth.oauth2_id_jag)}
await retain_sso_identity_assertion_for_ema(
user_id="user-a", assertion=assertion_from_sso_login(_make_id_token(), None)
)
@pytest.mark.asyncio
async def test_rotation_reencrypts_under_new_key(monkeypatch):
stored = {}
prisma = _make_prisma(stored)
token = _make_id_token()
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
await persist_sso_identity_assertion("user-a", assertion_from_sso_login(token, None))
original_blob = stored["user-a"]
new_key = "rotated-sso-assertion-salt-key-5678"
await rotate_sso_identity_assertions_master_key(prisma_client=prisma, new_master_key=new_key)
assert stored["user-a"] != original_blob
monkeypatch.setenv("LITELLM_SALT_KEY", new_key)
decrypted = decrypt_value_helper(stored["user-a"], "test", exception_type="debug")
assert decrypted is not None
assert json.loads(decrypted)["id_token"] == token
@pytest.mark.asyncio
async def test_rotation_skips_unreadable_rows_but_rotates_readable_ones():
stored = {"good": None, "bad": "garbage-blob"}
prisma = _make_prisma(stored)
token = _make_id_token()
with patch("litellm.proxy.proxy_server.prisma_client", prisma):
await persist_sso_identity_assertion("good", assertion_from_sso_login(token, None))
good_blob_before = stored["good"]
await rotate_sso_identity_assertions_master_key(prisma_client=prisma, new_master_key="another-new-salt-key-0000")
assert stored["bad"] == "garbage-blob"
assert stored["good"] != good_blob_before

View file

@ -8031,3 +8031,120 @@ async def test_bare_origin_discovery_resolves_single_server_not_aggregate():
assert resource_response["authorization_servers"] == ["https://llm.example.com/test_oauth"]
finally:
global_mcp_server_manager.registry.clear()
@pytest.mark.asyncio
async def test_authorize_wall_names_the_fix_for_urlless_servers():
"""LIT-4629: the authorize wall previously said only "authorization url is not set" with no
hint that spec-only servers never discover; the detail must now name both remedies (manual
Authorization URL + Token URL, or an Issuer for RFC 8414 discovery)."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
authorize_with_server,
)
from litellm.types.mcp import MCPAuth, MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="urlless-wall",
name="sheets_wall",
server_name="sheets_wall",
url=None,
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
spec_path="https://example.com/openapi.yaml",
)
mock_request = MagicMock()
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with pytest.raises(HTTPException) as exc_info:
await authorize_with_server(
request=mock_request,
mcp_server=server,
client_id="client",
redirect_uri="http://localhost/callback",
)
assert exc_info.value.status_code == 400
detail_text = str(exc_info.value.detail)
assert "set Authorization URL and Token URL" in detail_text
assert "Issuer" in detail_text
@pytest.mark.asyncio
async def test_token_wall_names_the_fix_for_urlless_servers():
"""The /token wall is the second stop on the same misconfiguration (LIT-4629): after an admin
fills only the Authorization URL, the code exchange dies here; the detail must name the
remedies like the authorize wall does."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
exchange_token_with_server,
)
from litellm.types.mcp import MCPAuth, MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="urlless-token-wall",
name="sheets_token_wall",
server_name="sheets_token_wall",
url=None,
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
spec_path="https://example.com/openapi.yaml",
authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
)
mock_request = MagicMock()
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with pytest.raises(HTTPException) as exc_info:
await exchange_token_with_server(
request=mock_request,
mcp_server=server,
grant_type="authorization_code",
code="auth-code",
redirect_uri="http://localhost/callback",
client_id="client",
client_secret=None,
code_verifier="verifier",
)
assert exc_info.value.status_code == 400
detail_text = str(exc_info.value.detail)
assert "set Token URL manually" in detail_text
assert "Issuer" in detail_text
@pytest.mark.asyncio
async def test_register_wall_names_the_fix_for_urlless_servers():
"""The /register wall serves the same missing-authorization-url 400 as authorize; its detail
must carry the same actionable remedies."""
from litellm.proxy._experimental.mcp_server.discoverable_endpoints import (
register_client_with_server,
)
from litellm.types.mcp import MCPAuth, MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
server = MCPServer(
server_id="urlless-register-wall",
name="sheets_register_wall",
server_name="sheets_register_wall",
url=None,
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
spec_path="https://example.com/openapi.yaml",
)
mock_request = MagicMock()
mock_request.base_url = "https://litellm.example.com/"
mock_request.headers = {}
with pytest.raises(HTTPException) as exc_info:
await register_client_with_server(
request=mock_request,
mcp_server=server,
client_name="client",
grant_types=None,
response_types=None,
token_endpoint_auth_method=None,
)
assert exc_info.value.status_code == 400
detail_text = str(exc_info.value.detail)
assert "set Authorization URL and Token URL" in detail_text
assert "Issuer" in detail_text

View file

@ -1033,3 +1033,166 @@ class TestResolveByokMcpAuthHeader:
check_mock.assert_awaited_once_with(server, user_auth)
assert result == "caller-header"
class TestOpenApiResolvedUpstreamAuth:
"""LIT-4629: spec_path servers egress through plain httpx, so the manager's OpenAPI arm must
materialize the v2-resolved credential into the `_request_resolved_auth_headers` ContextVar;
before the fix the resolved token never reached the upstream API."""
def _oauth_server(self, **overrides: Any) -> MCPServer:
fields: Dict[str, Any] = dict(
server_id="srv-sheets",
name="google_sheets",
server_name="google_sheets",
url=None,
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
spec_path="https://example.com/sheets-openapi.yaml",
)
fields.update(overrides)
return MCPServer(**fields)
@pytest.mark.asyncio
async def test_call_tool_openapi_injects_v2_resolved_token_contextvar(self):
"""The managed spec_path arm resolves the v2 credential and sets the ContextVar; kills
the mutant that drops the resolve_openapi_upstream_auth call in call_tool."""
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
_request_resolved_auth_headers,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
StaticHeaderAuth,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok
manager = MCPServerManager()
server = self._oauth_server()
user_auth = UserAPIKeyAuth(user_id="alice", api_key="sk-user")
captured: Dict[str, Any] = {}
async def fake_openapi_handler(_server, _name, _arguments):
captured["resolved"] = _request_resolved_auth_headers.get()
return MagicMock()
with patch.object(manager, "_resolve_mcp_server_for_tool_call", return_value=server):
with patch.object(
manager._cred_provider,
"resolve_credentials",
new=AsyncMock(return_value=Ok(StaticHeaderAuth("Bearer stored-user-token"))),
):
with patch.object(manager, "_call_openapi_tool_handler", side_effect=fake_openapi_handler):
await manager.call_tool(
server_name=server.server_name,
name="get_values",
arguments={},
user_api_key_auth=user_auth,
)
assert captured["resolved"] == {"Authorization": "Bearer stored-user-token"}
assert _request_resolved_auth_headers.get() is None
@pytest.mark.asyncio
async def test_call_tool_openapi_m2m_missing_token_url_fails_closed(self):
"""A url-less M2M spec server with no token_url must fail with a typed error instead of
egressing unauthenticated (the pre-#32259 silent failure this arm previously preserved).
Drives the real adapter/resolver chain: ClientCredentialsConfig with missing grant fields
resolves to a misconfigured CredError, raised as an HTTPException."""
from fastapi import HTTPException
manager = MCPServerManager()
server = self._oauth_server(
oauth2_flow="client_credentials",
client_id="m2m-client",
client_secret="m2m-secret",
token_url=None,
)
called = AsyncMock()
with patch.object(manager, "_resolve_mcp_server_for_tool_call", return_value=server):
with patch.object(manager, "_call_openapi_tool_handler", new=called):
with pytest.raises(HTTPException):
await manager.call_tool(
server_name=server.server_name,
name="get_values",
arguments={},
user_api_key_auth=UserAPIKeyAuth(user_id="alice", api_key="sk-user"),
)
called.assert_not_awaited()
@pytest.mark.asyncio
async def test_caller_oauth2_headers_never_become_resolved_for_byok_server(self):
"""Greptile P1 regression: BYOK servers defer to v1 (to_server_spec None), and the v1 arm
must never promote caller-supplied oauth2 headers into the resolved-auth slot, where they
would override the per-server BYOK credential and leak the caller's gateway Authorization
upstream."""
manager = MCPServerManager()
server = MCPServer(
server_id="byok-spec",
name="byok_spec",
server_name="byok_spec",
url=None,
transport=MCPTransport.http,
auth_type=MCPAuth.api_key,
spec_path="https://example.com/openapi.yaml",
is_byok=True,
)
resolved, forwarded = await manager.resolve_openapi_upstream_auth(
mcp_server=server,
oauth2_headers={"Authorization": "Bearer sk-litellm-gateway-key"},
raw_headers=None,
mcp_auth_header="user-byok-key",
user_api_key_auth=UserAPIKeyAuth(user_id="alice", api_key="sk-user"),
forwarded_headers=None,
)
assert resolved is None
assert forwarded is None
@pytest.mark.asyncio
async def test_v1_server_threads_stored_headers_only_without_caller_headers(self):
"""The v1 (unmigrated) arm resolves the stored per-user token only when the caller sent no
oauth2 headers of their own; with caller headers present the stored lookup is skipped and
nothing is promoted to resolved."""
manager = MCPServerManager()
server = MCPServer(
server_id="v1-spec",
name="v1_spec",
server_name="v1_spec",
url=None,
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
spec_path="https://example.com/openapi.yaml",
delegate_auth_to_upstream=True,
)
stored = {"Authorization": "Bearer stored-v1-token"}
user_auth = UserAPIKeyAuth(user_id="alice", api_key="sk-user")
with patch.object(
manager, "_resolve_oauth2_headers_for_tool_call", new=AsyncMock(return_value=stored)
) as lookup:
resolved, _ = await manager.resolve_openapi_upstream_auth(
mcp_server=server,
oauth2_headers=None,
raw_headers=None,
mcp_auth_header=None,
user_api_key_auth=user_auth,
forwarded_headers=None,
)
assert resolved == stored
lookup.assert_awaited_once_with(server, None, user_auth)
with patch.object(
manager, "_resolve_oauth2_headers_for_tool_call", new=AsyncMock(return_value=stored)
) as lookup:
resolved, _ = await manager.resolve_openapi_upstream_auth(
mcp_server=server,
oauth2_headers={"Authorization": "Bearer caller-supplied"},
raw_headers=None,
mcp_auth_header=None,
user_api_key_auth=user_auth,
forwarded_headers=None,
)
assert resolved is None
lookup.assert_not_awaited()

View file

@ -5597,7 +5597,7 @@ class TestMCPServerTimestamps:
async def test_build_mcp_server_from_table_persists_discovered_oauth_endpoints(self):
"""A DB-backed oauth2 server with no configured endpoints discovers them and must write
authorization_url, token_url, and scopes back to the row; otherwise the resolved values
live only in memory and one failed re-discovery serves 400 "authorization url is not set"
live only in memory and one failed re-discovery serves the 400 "authorization url is not configured"
from /authorize. registration_url must never be persisted because
_dcr_bridge_relays_client_registration keys off that column."""
manager = MCPServerManager()
@ -8891,3 +8891,140 @@ async def test_resolve_toolset_tool_permissions_single_db_fetch_across_checks():
assert first == {"server-a": ["lookup_status"]}
assert second == first
list_toolsets_mock.assert_awaited_once()
class TestMaterializeAuthHeaders:
"""_materialize_auth_headers drives one step of a resolved httpx.Auth's own flow to turn it
into a header dict for the OpenAPI egress arm, which sends plain headers and cannot carry an
httpx.Auth. Generic across auth shapes via the resolver-arm header_name convention."""
@pytest.mark.asyncio
async def test_static_header_auth_materializes_its_header(self):
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
_materialize_auth_headers,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
StaticHeaderAuth,
)
headers = await _materialize_auth_headers(StaticHeaderAuth("Bearer stored-token"))
assert headers == {"Authorization": "Bearer stored-token"}
@pytest.mark.asyncio
async def test_client_credentials_bearer_auth_materializes_bearer(self):
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
_materialize_auth_headers,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.client_credentials import (
ClientCredentialsBearerAuth,
)
async def _refetch(_stale: str):
return None
headers = await _materialize_auth_headers(ClientCredentialsBearerAuth("m2m-token", _refetch))
assert headers == {"Authorization": "Bearer m2m-token"}
@pytest.mark.asyncio
async def test_noop_and_none_materialize_to_none(self):
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
_materialize_auth_headers,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
NoOpAuth,
)
assert await _materialize_auth_headers(None) is None
assert await _materialize_auth_headers(NoOpAuth()) is None
class TestUrllessIssuerDiscovery:
"""LIT-4629: servers with no url (OpenAPI spec_path, stdio) run no resource discovery, so
their OAuth endpoints could only ever come from manual entry; an admin-pinned issuer is a
url-independent trust anchor (RFC 8414 section 3.3) and must unlock discovery for them."""
def _urlless_row(self, **overrides):
fields = dict(
server_id="urlless-1",
alias="sheets_urlless",
description="spec-only server",
url=None,
spec_path="https://example.com/sheets-openapi.yaml",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
created_at=datetime.now(),
updated_at=datetime.now(),
)
fields.update(overrides)
return LiteLLM_MCPServerTable(**fields)
@pytest.mark.asyncio
async def test_urlless_server_with_issuer_discovers_endpoints(self):
"""The gate previously required bool(server_url), so a url-less server with an issuer
configured never ran the issuer-anchored fetch and /authorize 400d. Kills the mutant that
restores the bare bool(server_url) term."""
manager = MCPServerManager()
row = self._urlless_row(issuer="https://accounts.google.com")
resolved = MCPOAuthMetadata(
authorization_url="https://accounts.google.com/o/oauth2/v2/auth",
token_url="https://oauth2.googleapis.com/token",
)
resource_rooted = AsyncMock(return_value=None)
with (
patch.object(manager, "_fetch_issuer_anchored_oauth_metadata", new=AsyncMock(return_value=resolved)) as anchored,
patch.object(manager, "_descovery_metadata", new=resource_rooted),
):
built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)
anchored.assert_awaited_once_with("https://accounts.google.com", None)
resource_rooted.assert_not_awaited()
assert built.issuer_is_anchored is True
assert built.authorization_url == "https://accounts.google.com/o/oauth2/v2/auth"
assert built.token_url == "https://oauth2.googleapis.com/token"
@pytest.mark.asyncio
async def test_urlless_server_without_issuer_stays_undiscovered(self):
"""With neither a url nor an issuer there is no discovery source; the build must not
attempt any fetch and the endpoints stay unset (manual entry remains the only path)."""
manager = MCPServerManager()
row = self._urlless_row()
anchored = AsyncMock()
resource_rooted = AsyncMock()
with (
patch.object(manager, "_fetch_issuer_anchored_oauth_metadata", new=anchored),
patch.object(manager, "_descovery_metadata", new=resource_rooted),
):
built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)
anchored.assert_not_awaited()
resource_rooted.assert_not_awaited()
assert built.authorization_url is None
assert built.token_url is None
assert built.issuer_is_anchored is False
@pytest.mark.asyncio
async def test_urlless_obo_with_issuer_discovers_token_url(self):
"""oauth2_token_exchange is not a discovery auth type, so the plain gate relax alone
would leave a url-less OBO server undiscovered; with an issuer pinned and no configured
exchange endpoint it must resolve token_url through the issuer-anchored fetch. Kills the
mutant that drops the OBO widening from the anchor computation."""
manager = MCPServerManager()
row = self._urlless_row(
alias="obo_urlless",
auth_type=MCPAuth.oauth2_token_exchange,
issuer="https://idp.example.com",
)
resolved = MCPOAuthMetadata(token_url="https://idp.example.com/token")
resource_rooted = AsyncMock(return_value=None)
with (
patch.object(manager, "_fetch_issuer_anchored_oauth_metadata", new=AsyncMock(return_value=resolved)) as anchored,
patch.object(manager, "_descovery_metadata", new=resource_rooted),
):
built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False)
anchored.assert_awaited_once_with("https://idp.example.com", None)
resource_rooted.assert_not_awaited()
assert built.token_url == "https://idp.example.com/token"

View file

@ -17,6 +17,7 @@ import pytest
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
_request_auth_header,
_request_extra_headers,
_request_resolved_auth_headers,
_resolve_param_list,
_resolve_ref,
build_input_schema,
@ -1207,3 +1208,61 @@ class TestRequestExtraHeaders:
call_args = async_client.get.call_args
headers_sent = call_args[1]["headers"]
assert "X-TOKEN" not in headers_sent
@pytest.mark.asyncio
async def test_resolved_auth_headers_win_over_every_other_authorization_source(self):
"""The gateway-resolved credential (stored per-user OAuth / minted M2M token) is
authoritative: it must override the BYOK override, static headers, and forwarded caller
headers on the Authorization name, case-insensitively, mirroring _resolve_v2_auth's rule
on the MCPClient path. Without this, a spec_path oauth2 server's completed OAuth flow
stores a token that never reaches the upstream API (LIT-4629)."""
operation = {}
func = create_tool_function(
path="/secure",
method="get",
operation=operation,
base_url="https://api.example.com",
headers={"authorization": "Bearer static-operator"},
)
with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
async_client = _create_mock_client("get", "secure-data")
mock_client.return_value = async_client
extra_token = _request_extra_headers.set({"Authorization": "Bearer caller-forwarded"})
auth_token = _request_auth_header.set("Bearer byok-credential")
resolved_token = _request_resolved_auth_headers.set({"Authorization": "Bearer resolved-oauth"})
try:
result = await func()
finally:
_request_auth_header.reset(auth_token)
_request_extra_headers.reset(extra_token)
_request_resolved_auth_headers.reset(resolved_token)
assert result == "secure-data"
headers_sent = async_client.get.call_args[1]["headers"]
authorization_values = [v for k, v in headers_sent.items() if k.lower() == "authorization"]
assert authorization_values == ["Bearer resolved-oauth"]
@pytest.mark.asyncio
async def test_resolved_auth_headers_not_leaked_between_calls(self):
"""After resetting the resolved-auth ContextVar, subsequent calls send no credential."""
operation = {}
func = create_tool_function(
path="/data",
method="get",
operation=operation,
base_url="https://api.example.com",
)
with patch(GET_ASYNC_CLIENT_TARGET) as mock_client:
async_client = _create_mock_client("get", "ok")
mock_client.return_value = async_client
token = _request_resolved_auth_headers.set({"Authorization": "Bearer resolved-oauth"})
_request_resolved_auth_headers.reset(token)
await func()
headers_sent = async_client.get.call_args[1]["headers"]
assert "Authorization" not in headers_sent

View file

@ -218,3 +218,86 @@ async def test_openapi_local_tool_denied_when_server_not_resolvable():
assert exc.value.status_code == 503
pre_call.assert_not_awaited()
handle_local.assert_not_awaited()
@pytest.mark.asyncio
async def test_openapi_local_tool_injects_resolved_oauth_token():
"""LIT-4629: the local-registry (OpenAPI) dispatch is the primary egress for spec_path
tools, and before the fix it dropped the gateway-resolved OAuth credential entirely, so a
user's completed OAuth flow stored a token that never reached the upstream API. The resolved
credential must land in the `_request_resolved_auth_headers` ContextVar the tool closure
reads. Kills the mutant that deletes the resolve_openapi_upstream_auth call in server.py."""
from litellm.proxy._experimental.mcp_server import server as mcp_module
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
_request_resolved_auth_headers,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import (
StaticHeaderAuth,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Ok
from litellm.types.mcp import MCPAuth, MCPTransport
from litellm.types.mcp_server.mcp_server_manager import MCPServer
user = UserAPIKeyAuth(
api_key="sk-user",
user_id="alice",
user_role=LitellmUserRoles.INTERNAL_USER.value,
)
oauth_server = MCPServer(
server_id="srv-sheets",
name="google_sheets",
server_name="google_sheets",
url=None,
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
spec_path="https://example.com/sheets-openapi.yaml",
)
fake_tool = MagicMock()
fake_tool.name = "get_values"
captured: dict = {}
async def handle_local(_name, _arguments):
captured["resolved"] = _request_resolved_auth_headers.get()
return []
with (
patch.object(
mcp_module.global_mcp_server_manager,
"_get_mcp_server_from_tool_name",
return_value=oauth_server,
),
patch.object(
mcp_module.global_mcp_server_manager,
"pre_call_tool_check",
new=AsyncMock(return_value={}),
),
patch.object(
mcp_module.global_mcp_tool_registry,
"get_tool",
return_value=fake_tool,
),
patch.object(
mcp_module.global_mcp_server_manager._cred_provider,
"resolve_credentials",
new=AsyncMock(return_value=Ok(StaticHeaderAuth("Bearer stored-user-token"))),
),
patch(
"litellm.proxy._experimental.mcp_server.server._handle_local_mcp_tool",
new=handle_local,
),
patch(
"litellm.proxy._experimental.mcp_server.server.MCPRequestHandler.is_tool_allowed",
return_value=True,
),
):
await mcp_module.execute_mcp_tool(
name="get_values",
arguments={},
allowed_mcp_servers=[oauth_server],
start_time=datetime.now(timezone.utc),
user_api_key_auth=user,
)
assert captured["resolved"] == {"Authorization": "Bearer stored-user-token"}
assert _request_resolved_auth_headers.get() is None

View file

@ -1587,7 +1587,7 @@ class TestGetDynamicLitellmParamsClearsAdminConfigOnBaseOverride:
}
out = get_dynamic_litellm_params(
litellm_params=dict(admin_params),
request_kwargs={"base_url": "https://attacker.example"},
request_kwargs={"base_url": "https://attacker.example", "api_key": "sk-caller"},
)
assert "aws_access_key_id" not in out
assert "aws_secret_access_key" not in out
@ -1608,7 +1608,7 @@ class TestGetDynamicLitellmParamsClearsAdminConfigOnBaseOverride:
}
out = get_dynamic_litellm_params(
litellm_params=dict(admin_params),
request_kwargs={"api_base": "self-hosted.example.com:50051"},
request_kwargs={"api_base": "self-hosted.example.com:50051", "api_key": "sk-caller"},
)
assert out["api_base"] == "self-hosted.example.com:50051"
assert "nvcf_function_id" not in out
@ -1626,7 +1626,7 @@ class TestGetDynamicLitellmParamsClearsAdminConfigOnBaseOverride:
}
out = get_dynamic_litellm_params(
litellm_params=dict(admin_params),
request_kwargs={"api_base": "self-hosted.example.com:50051"},
request_kwargs={"api_base": "self-hosted.example.com:50051", "api_key": "sk-caller"},
)
assert out["api_base"] == "self-hosted.example.com:50051"
assert "use_ssl" not in out
@ -1651,6 +1651,7 @@ class TestGetDynamicLitellmParamsClearsAdminConfigOnBaseOverride:
},
request_kwargs={
"api_base": "https://attacker.example",
"api_key": "sk-caller",
"organization": "org-attacker",
"extra_body": {"attacker": "value"},
},
@ -1674,6 +1675,7 @@ class TestGetDynamicLitellmParamsClearsAdminConfigOnBaseOverride:
},
request_kwargs={
"api_base": "https://attacker.example",
"api_key": "sk-caller",
"organization": "",
"extra_body": "",
},
@ -1701,6 +1703,310 @@ class TestGetDynamicLitellmParamsClearsAdminConfigOnBaseOverride:
assert out["api_version"] == "2026-04-01"
assert out["api_base"] == "https://admin.upstream/v1"
def test_client_api_key_used_when_supplied_with_base_override(self):
from litellm.router_utils.clientside_credential_handler import (
get_dynamic_litellm_params,
)
out = get_dynamic_litellm_params(
litellm_params={
"model": "gpt-4",
"api_key": "sk-admin-secret",
"api_base": "https://admin.upstream/v1",
},
request_kwargs={
"api_base": "https://attacker.example",
"api_key": "sk-client-byok",
},
)
assert out["api_key"] == "sk-client-byok"
assert "sk-admin-secret" not in str(out)
_OPENAI_CHAT_RESPONSE = {
"id": "chatcmpl-x",
"object": "chat.completion",
"created": 1,
"model": "gpt-4",
"choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2},
}
class TestClientsideBaseOverrideOutboundKey:
"""Drive a completion through the router and assert on the outbound request
when the caller overrides ``api_base``."""
def _router(self):
from litellm import Router
return Router(
model_list=[
{
"model_name": "gpt-4",
"litellm_params": {
"model": "openai/gpt-4",
"api_key": "sk-SERVER-CONFIG",
"api_base": "https://admin.upstream/v1",
},
}
]
)
@pytest.fixture(autouse=True)
def _ambient_server_key(self, monkeypatch):
import litellm
monkeypatch.setenv("OPENAI_API_KEY", "sk-SERVER-ENV")
monkeypatch.setattr(litellm, "api_key", None, raising=False)
def test_caller_key_override_sends_caller_key_never_server_key(self):
import httpx
import respx
with respx.mock:
route = respx.post("https://caller.example/v1/chat/completions").mock(
return_value=httpx.Response(200, json=_OPENAI_CHAT_RESPONSE)
)
self._router().completion(
model="gpt-4",
messages=[{"role": "user", "content": "hi"}],
api_base="https://caller.example/v1",
api_key="sk-CALLER",
)
authorization = route.calls.last.request.headers.get("authorization")
assert authorization == "Bearer sk-CALLER"
assert "SERVER" not in (authorization or "")
def _rounds_deep_api_base_payload(rounds, field):
"""Build a fallbacks payload with ``api_base`` on a target nested ``rounds``
fallback-rounds deep, each round wrapped in its own grouping dict."""
node = {"model": "leaf", "api_base": "https://attacker.example"}
for i in range(rounds):
node = {"model": f"m{i}", field: [{"grp": [node]}]}
return {"model": "gpt-4", field: [{"grp": [node]}]}
class TestIsRequestBodySafeBlocksFallbackSmuggle:
"""``is_request_body_safe`` runs the banned-param check on every dict target
inside the fallback lists."""
@pytest.fixture(autouse=True)
def _disable_url_validation(self, monkeypatch):
import litellm
monkeypatch.setattr(litellm, "user_url_validation", False, raising=False)
@pytest.mark.parametrize(
"fallback_key",
["fallbacks", "context_window_fallbacks", "content_policy_fallbacks"],
)
def test_api_base_smuggled_via_nested_fallback_is_rejected(self, fallback_key):
with pytest.raises(ValueError, match="api_base"):
is_request_body_safe(
request_body={
"model": "gpt-4",
fallback_key: [
{
"gpt-4": [
{"model": "evil", "api_base": "https://attacker.example"},
]
}
],
},
general_settings={},
llm_router=None,
model="gpt-4",
)
def test_string_only_fallbacks_are_accepted(self):
assert (
is_request_body_safe(
request_body={
"model": "gpt-4",
"fallbacks": [{"gpt-4": ["gpt-3.5-turbo", "claude-3-haiku"]}],
},
general_settings={},
llm_router=None,
model="gpt-4",
)
is True
)
def test_benign_dict_fallback_entry_is_accepted(self):
assert (
is_request_body_safe(
request_body={
"model": "gpt-4",
"fallbacks": [{"gpt-4": [{"model": "gpt-3.5-turbo"}]}],
},
general_settings={},
llm_router=None,
model="gpt-4",
)
is True
)
def test_smuggled_fallback_allowed_under_proxy_wide_opt_in(self):
assert (
is_request_body_safe(
request_body={
"model": "gpt-4",
"fallbacks": [
{"gpt-4": [{"model": "byok", "api_base": "https://my-byok.example"}]}
],
},
general_settings={"allow_client_side_credentials": True},
llm_router=None,
model="gpt-4",
)
is True
)
@pytest.mark.parametrize(
"fallback_field",
["fallbacks", "context_window_fallbacks", "content_policy_fallbacks"],
)
@pytest.mark.parametrize("surface", ["top_level", "router_settings_override"])
def test_deeply_nested_api_base_smuggle_rejected_on_both_surfaces(self, fallback_field, surface):
nested = [
{
"always-fail": [
{
"model": "x",
fallback_field: [
{"x": [{"model": "deepseek-chat", "api_base": "http://attacker"}]}
],
}
]
}
]
request_body = {"model": "gpt-4"}
if surface == "top_level":
request_body[fallback_field] = nested
else:
request_body["router_settings_override"] = {fallback_field: nested}
with pytest.raises(ValueError, match="api_base"):
is_request_body_safe(
request_body=request_body,
general_settings={},
llm_router=None,
model="gpt-4",
)
def test_router_settings_override_single_level_api_base_rejected(self):
with pytest.raises(ValueError, match="api_base"):
is_request_body_safe(
request_body={
"model": "gpt-4",
"router_settings_override": {
"fallbacks": [{"gpt-4": [{"model": "x", "api_base": "http://attacker"}]}]
},
},
general_settings={},
llm_router=None,
model="gpt-4",
)
def test_model_less_config_dict_api_base_rejected(self):
with pytest.raises(ValueError, match="api_base"):
is_request_body_safe(
request_body={
"model": "gpt-4",
"fallbacks": [{"gpt-4": [{"api_base": "http://attacker"}]}],
},
general_settings={},
llm_router=None,
model="gpt-4",
)
def test_nested_api_base_caught_across_router_fallback_rounds(self):
"""An ``api_base`` target nested ``ROUTER_MAX_FALLBACKS - 1`` rounds deep
is still reached and rejected."""
import litellm
with pytest.raises(ValueError, match="api_base"):
is_request_body_safe(
request_body=_rounds_deep_api_base_payload(litellm.ROUTER_MAX_FALLBACKS - 1, "fallbacks"),
general_settings={},
llm_router=None,
model="gpt-4",
)
def test_grouping_only_deep_chain_is_rejected_at_depth_limit(self):
"""A deep grouping-only chain (``{"g": [{"g": [...]}]}``) is rejected at the
validation-depth limit rather than accepted or raising RecursionError."""
node: object = ["safe-model"]
for _ in range(5000):
node = [{"grp": node}]
with pytest.raises(ValueError, match="depth"):
is_request_body_safe(
request_body={"model": "gpt-4", "fallbacks": node},
general_settings={},
llm_router=None,
model="gpt-4",
)
def test_pathologically_deep_model_nesting_is_rejected(self):
with pytest.raises(ValueError, match="depth"):
is_request_body_safe(
request_body=_rounds_deep_api_base_payload(5000, "fallbacks"),
general_settings={},
llm_router=None,
model="gpt-4",
)
class TestIsRequestBodySafeRejectsUrlValuedFallback:
@pytest.mark.parametrize("fallback_field", ["fallbacks", "context_window_fallbacks", "content_policy_fallbacks"])
def test_url_valued_string_fallback_is_rejected(self, fallback_field):
with pytest.raises(ValueError, match="URL-valued fallback"):
is_request_body_safe(
request_body={
"model": "gpt-4",
fallback_field: [{"gpt-4": ["huggingface/http://attacker.example/path"]}],
},
general_settings={},
llm_router=None,
model="gpt-4",
)
@pytest.mark.parametrize("fallback_field", ["fallbacks", "context_window_fallbacks", "content_policy_fallbacks"])
def test_url_valued_dict_model_fallback_is_rejected(self, fallback_field):
with pytest.raises(ValueError, match="URL-valued fallback"):
is_request_body_safe(
request_body={
"model": "gpt-4",
fallback_field: [{"gpt-4": [{"model": "huggingface/http://attacker.example/path"}]}],
},
general_settings={},
llm_router=None,
model="gpt-4",
)
def test_ordinary_string_fallback_is_allowed(self):
assert (
is_request_body_safe(
request_body={"model": "gpt-4", "fallbacks": [{"gpt-4": ["gpt-4-backup"]}]},
general_settings={},
llm_router=None,
model="gpt-4",
)
is True
)
def test_ordinary_dict_model_fallback_is_allowed(self):
assert (
is_request_body_safe(
request_body={"model": "gpt-4", "fallbacks": [{"gpt-4": [{"model": "gpt-4-backup"}]}]},
general_settings={},
llm_router=None,
model="gpt-4",
)
is True
)
class TestIsRequestBodySafeBlocksEndpointTargetingFields:
"""
@ -1823,6 +2129,46 @@ class TestIsRequestBodySafeBlocksBedrockProjectOverride:
)
class TestIsRequestBodySafeBlocksVertexCredentialAlias:
@pytest.mark.parametrize("field", ["vertex_ai_credentials"])
def test_field_in_request_body_is_rejected(self, field):
with pytest.raises(ValueError, match=field):
is_request_body_safe(
request_body={"model": "gpt-4", field: "attacker-supplied"},
general_settings={},
llm_router=None,
model="gpt-4",
)
@pytest.mark.parametrize("field", ["vertex_ai_credentials"])
def test_admin_opt_in_proxy_wide_allows(self, field):
assert (
is_request_body_safe(
request_body={"model": "gpt-4", field: "byok-supplied"},
general_settings={"allow_client_side_credentials": True},
llm_router=None,
model="gpt-4",
)
is True
)
def test_legitimate_request_body_param_still_allowed(self):
assert (
is_request_body_safe(
request_body={
"model": "gpt-4",
"temperature": 0.7,
"max_tokens": 128,
"user": "end-user-123",
},
general_settings={},
llm_router=None,
model="gpt-4",
)
is True
)
class TestIsRequestBodySafeBlocksNVCFFunctionOverride:
"""``nvcf_function_id`` is rejected as a request-body param unless the
admin opted in proxy-wide or per-deployment."""

View file

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

Some files were not shown because too many files have changed in this diff Show more