mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
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:
commit
0309f219d8
258 changed files with 16019 additions and 4544 deletions
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -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...
|
||||
|
|
|
|||
15
.github/pull_request_template.md
vendored
15
.github/pull_request_template.md
vendored
|
|
@ -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" -->
|
||||
|
|
|
|||
3
.github/workflows/test-code-quality.yml
vendored
3
.github/workflows/test-code-quality.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
57
.github/workflows/test-litellm-ui-unit.yml
vendored
Normal file
57
.github/workflows/test-litellm-ui-unit.yml
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
name: UI Unit Tests
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- litellm_internal_staging
|
||||
- litellm_oss_staging
|
||||
- "litellm_**"
|
||||
push:
|
||||
branches:
|
||||
- litellm_internal_staging
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
ui-unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ui/litellm-dashboard
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
cache-dependency-path: ui/litellm-dashboard/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run UI unit tests (Vitest)
|
||||
env:
|
||||
CI: "true"
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
if [ -n "$BASE_SHA" ]; then
|
||||
echo "Pull request: running only tests related to changes since $BASE_SHA"
|
||||
npm run test -- --run --changed "$BASE_SHA" --passWithNoTests \
|
||||
--pool forks --poolOptions.forks.maxForks=4
|
||||
else
|
||||
echo "Push to $GITHUB_REF_NAME: running the full suite"
|
||||
npm run test -- --run --pool forks --poolOptions.forks.maxForks=4
|
||||
fi
|
||||
|
|
@ -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")
|
||||
);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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==",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)}")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@
|
|||
"limit": 33
|
||||
},
|
||||
"DTZ005": {
|
||||
"limit": 244
|
||||
"limit": 241
|
||||
},
|
||||
"DTZ006": {
|
||||
"limit": 13
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
81
tests/code_coverage_tests/check_e2e_no_raw_requests.py
Normal file
81
tests/code_coverage_tests/check_e2e_no_raw_requests.py
Normal 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())
|
||||
|
|
@ -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.
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
292
tests/e2e/a2a/a2a_client.py
Normal 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
17
tests/e2e/a2a/conftest.py
Normal 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)
|
||||
202
tests/e2e/a2a/test_a2a_agent_e2e.py
Normal file
202
tests/e2e/a2a/test_a2a_agent_e2e.py
Normal 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}")
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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)"}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
66
tests/e2e/guardrails/test_openai_moderation_guardrail_e2e.py
Normal file
66
tests/e2e/guardrails/test_openai_moderation_guardrail_e2e.py
Normal 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}"
|
||||
)
|
||||
211
tests/e2e/guardrails/test_presidio_guardrail_e2e.py
Normal file
211
tests/e2e/guardrails/test_presidio_guardrail_e2e.py
Normal 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}"
|
||||
)
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
51
tests/e2e/llm_translation/test_audio_transcriptions_e2e.py
Normal file
51
tests/e2e/llm_translation/test_audio_transcriptions_e2e.py
Normal 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}"
|
||||
)
|
||||
|
|
@ -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]}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
155
tests/e2e/llm_translation/test_messages_azure_foundry_e2e.py
Normal file
155
tests/e2e/llm_translation/test_messages_azure_foundry_e2e.py
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
"""Live e2e: POST /v1/messages routed to Azure AI Foundry Anthropic deployments.
|
||||
|
||||
Registers `azure_ai/<claude>` deployments at runtime and drives the Messages
|
||||
endpoint through the gateway across the behaviors an Anthropic client relies on:
|
||||
a basic completion, a streamed completion, and tool use (non-streaming and
|
||||
streaming). Auth is the Azure API key (`x-api-key`); the deployment reads
|
||||
`AZURE_AI_API_BASE` / `AZURE_AI_API_KEY` from the proxy env, so no secret is
|
||||
sent in the request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from e2e_config import unique_marker
|
||||
from e2e_http import StreamingResponse, require_successful_call, unwrap
|
||||
from endpoints_client import EndpointsClient
|
||||
from lifecycle import ResourceManager
|
||||
from models import (
|
||||
AnthropicCustomTool,
|
||||
AnthropicMessagesBody,
|
||||
ChatMessage,
|
||||
JsonSchemaProperty,
|
||||
LiteLLMParamsBody,
|
||||
ToolInputSchema,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
AZURE_FOUNDRY_MODEL = "azure_ai/claude-haiku-4-5"
|
||||
|
||||
WEATHER_TOOL = AnthropicCustomTool(
|
||||
name="get_weather",
|
||||
description="Get the current weather for a city.",
|
||||
input_schema=ToolInputSchema(
|
||||
properties={"city": JsonSchemaProperty(type="string")},
|
||||
required=["city"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _assert_streamed_ok(result: StreamingResponse) -> None:
|
||||
require_successful_call(result)
|
||||
assert result.is_streaming, f"response was not streamed: {result.headers}"
|
||||
assert not result.stream_error, f"stream errored: {result.stream_error}"
|
||||
assert result.stream_events, "stream produced no SSE events"
|
||||
assert any("content_block_delta" in event for event in result.stream_events), (
|
||||
"stream carried no content deltas"
|
||||
)
|
||||
assert any("message_stop" in event for event in result.stream_events), (
|
||||
"stream never reached message_stop"
|
||||
)
|
||||
|
||||
|
||||
class TestAzureFoundryMessages:
|
||||
def _register(
|
||||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
) -> tuple[str, str]:
|
||||
model = f"e2e-azure-foundry-messages-{unique_marker()}"
|
||||
model_id = endpoints_client.create_model(
|
||||
model,
|
||||
LiteLLMParamsBody(
|
||||
model=AZURE_FOUNDRY_MODEL,
|
||||
api_base="os.environ/AZURE_AI_API_BASE",
|
||||
api_key="os.environ/AZURE_AI_API_KEY",
|
||||
),
|
||||
)
|
||||
resources.defer(lambda: endpoints_client.delete_model(model_id))
|
||||
return model, resources.key(models=[model])
|
||||
|
||||
@pytest.mark.covers("llm.messages.azure_foundry.basic.nonstream.works")
|
||||
def test_basic_nonstream(
|
||||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
) -> None:
|
||||
model, key = self._register(endpoints_client, resources)
|
||||
response = unwrap(
|
||||
endpoints_client.proxy.messages(
|
||||
key,
|
||||
AnthropicMessagesBody(
|
||||
model=model,
|
||||
max_tokens=64,
|
||||
messages=[ChatMessage(role="user", content="Reply with one word.")],
|
||||
),
|
||||
)
|
||||
)
|
||||
assert response.content, f"no content blocks in response: {response}"
|
||||
text = "".join(block.text or "" for block in response.content if block.type == "text")
|
||||
assert text.strip(), f"/v1/messages returned no text: {response}"
|
||||
|
||||
@pytest.mark.covers("llm.messages.azure_foundry.basic.stream.works")
|
||||
def test_basic_stream(
|
||||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
) -> None:
|
||||
model, key = self._register(endpoints_client, resources)
|
||||
result = endpoints_client.proxy.messages_stream(
|
||||
key,
|
||||
AnthropicMessagesBody(
|
||||
model=model,
|
||||
max_tokens=64,
|
||||
stream=True,
|
||||
messages=[ChatMessage(role="user", content="Count from one to three.")],
|
||||
),
|
||||
)
|
||||
_assert_streamed_ok(result)
|
||||
|
||||
@pytest.mark.covers("llm.messages.azure_foundry.tool_use.nonstream.works")
|
||||
def test_tool_use_nonstream(
|
||||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
) -> None:
|
||||
model, key = self._register(endpoints_client, resources)
|
||||
response = unwrap(
|
||||
endpoints_client.proxy.messages(
|
||||
key,
|
||||
AnthropicMessagesBody(
|
||||
model=model,
|
||||
max_tokens=256,
|
||||
tools=[WEATHER_TOOL],
|
||||
messages=[
|
||||
ChatMessage(role="user", content="What is the weather in Paris? Use the tool.")
|
||||
],
|
||||
),
|
||||
)
|
||||
)
|
||||
assert response.content, f"no content blocks in response: {response}"
|
||||
assert any(block.type == "tool_use" for block in response.content), (
|
||||
f"model did not call the tool: {response}"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("llm.messages.azure_foundry.tool_use.stream.works")
|
||||
def test_tool_use_stream(
|
||||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
) -> None:
|
||||
model, key = self._register(endpoints_client, resources)
|
||||
result = endpoints_client.proxy.messages_stream(
|
||||
key,
|
||||
AnthropicMessagesBody(
|
||||
model=model,
|
||||
max_tokens=256,
|
||||
stream=True,
|
||||
tools=[WEATHER_TOOL],
|
||||
messages=[
|
||||
ChatMessage(role="user", content="What is the weather in Paris? Use the tool.")
|
||||
],
|
||||
),
|
||||
)
|
||||
require_successful_call(result)
|
||||
assert result.is_streaming, f"response was not streamed: {result.headers}"
|
||||
assert not result.stream_error, f"stream errored: {result.stream_error}"
|
||||
assert result.stream_events, "stream produced no SSE events"
|
||||
assert any("tool_use" in event for event in result.stream_events), (
|
||||
"stream carried no tool_use block"
|
||||
)
|
||||
assert any("message_stop" in event for event in result.stream_events), (
|
||||
"stream never reached message_stop"
|
||||
)
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
"""Live e2e: POST /v1/messages (Anthropic Messages API) returns a real completion.
|
||||
|
||||
Registers an Anthropic deployment at runtime, drives the Messages endpoint through
|
||||
the gateway, and asserts an assistant message with text came back. Migrated from
|
||||
the gateway, and asserts an assistant message with text came back, both
|
||||
non-streaming and streamed. Migrated from
|
||||
litellm-regression-tests/tests/test_inference_endpoints.py.
|
||||
"""
|
||||
|
||||
|
|
@ -10,18 +11,34 @@ from __future__ import annotations
|
|||
import pytest
|
||||
|
||||
from e2e_config import unique_marker
|
||||
from e2e_http import require_successful_call
|
||||
from e2e_http import require_successful_call, unwrap
|
||||
from endpoints_client import EndpointsClient, MessagesResult
|
||||
from lifecycle import ResourceManager
|
||||
from models import LiteLLMParamsBody
|
||||
from models import (
|
||||
AnthropicCustomTool,
|
||||
AnthropicMessagesBody,
|
||||
ChatMessage,
|
||||
JsonSchemaProperty,
|
||||
LiteLLMParamsBody,
|
||||
ToolInputSchema,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
WEATHER_TOOL = AnthropicCustomTool(
|
||||
name="get_weather",
|
||||
description="Get the current weather for a city.",
|
||||
input_schema=ToolInputSchema(
|
||||
properties={"city": JsonSchemaProperty(type="string")},
|
||||
required=["city"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestAnthropicMessages:
|
||||
def test_messages_returns_completion(
|
||||
def _register(
|
||||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
) -> None:
|
||||
) -> tuple[str, str]:
|
||||
model = f"e2e-messages-{unique_marker()}"
|
||||
model_id = endpoints_client.create_model(
|
||||
model,
|
||||
|
|
@ -30,10 +47,66 @@ class TestAnthropicMessages:
|
|||
),
|
||||
)
|
||||
resources.defer(lambda: endpoints_client.delete_model(model_id))
|
||||
key = resources.key()
|
||||
return model, resources.key()
|
||||
|
||||
@pytest.mark.covers("llm.messages.anthropic.basic.nonstream.works")
|
||||
def test_messages_returns_completion(
|
||||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
) -> None:
|
||||
model, key = self._register(endpoints_client, resources)
|
||||
|
||||
result = endpoints_client.messages(key, model, "reply with one word")
|
||||
require_successful_call(result)
|
||||
parsed = MessagesResult.model_validate_json(result.body)
|
||||
assert parsed.role == "assistant", f"unexpected role: {result.body[:300]}"
|
||||
assert parsed.text.strip(), f"/v1/messages returned no text: {result.body[:300]}"
|
||||
|
||||
@pytest.mark.covers("llm.messages.anthropic.basic.stream.works")
|
||||
def test_messages_streams_completion(
|
||||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
) -> None:
|
||||
model, key = self._register(endpoints_client, resources)
|
||||
|
||||
result = endpoints_client.proxy.messages_stream(
|
||||
key,
|
||||
AnthropicMessagesBody(
|
||||
model=model,
|
||||
max_tokens=64,
|
||||
stream=True,
|
||||
messages=[ChatMessage(role="user", content="Count from one to three.")],
|
||||
),
|
||||
)
|
||||
require_successful_call(result)
|
||||
assert result.is_streaming, f"response was not streamed: {result.headers}"
|
||||
assert not result.stream_error, f"stream errored: {result.stream_error}"
|
||||
assert result.stream_events, "stream produced no SSE events"
|
||||
assert any("content_block_delta" in event for event in result.stream_events), (
|
||||
"stream carried no content deltas"
|
||||
)
|
||||
assert any("message_stop" in event for event in result.stream_events), (
|
||||
"stream never reached message_stop"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("llm.messages.anthropic.tool_use.nonstream.works")
|
||||
def test_messages_tool_use(
|
||||
self, endpoints_client: EndpointsClient, resources: ResourceManager
|
||||
) -> None:
|
||||
model, key = self._register(endpoints_client, resources)
|
||||
|
||||
response = unwrap(
|
||||
endpoints_client.proxy.messages(
|
||||
key,
|
||||
AnthropicMessagesBody(
|
||||
model=model,
|
||||
max_tokens=256,
|
||||
tools=[WEATHER_TOOL],
|
||||
messages=[
|
||||
ChatMessage(role="user", content="What is the weather in Paris? Use the tool.")
|
||||
],
|
||||
),
|
||||
)
|
||||
)
|
||||
assert response.content, f"no content blocks in response: {response}"
|
||||
assert any(block.type == "tool_use" for block in response.content), (
|
||||
f"model did not call the tool: {response}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
65
tests/e2e/llm_translation/test_moderations_e2e.py
Normal file
65
tests/e2e/llm_translation/test_moderations_e2e.py
Normal 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}"
|
||||
)
|
||||
|
|
@ -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]}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
415
tests/e2e/management/test_budget_customer_user_org_e2e.py
Normal file
415
tests/e2e/management/test_budget_customer_user_org_e2e.py
Normal 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",
|
||||
)
|
||||
698
tests/e2e/management/test_config_misc_endpoints_e2e.py
Normal file
698
tests/e2e/management/test_config_misc_endpoints_e2e.py
Normal file
|
|
@ -0,0 +1,698 @@
|
|||
"""Live e2e: the config and miscellaneous Management/UI routes.
|
||||
|
||||
One method per registry cell, each asserting the real contract against a live
|
||||
proxy: read-only inventory routes return their documented shape, stateless
|
||||
validators compute their verdict from the request, and the write routes persist
|
||||
so a read-back reflects the change. The two routes that mutate global proxy state
|
||||
(cache settings and router settings, both driven from the admin UI) are exercised
|
||||
with a benign, self-restoring change so a shared proxy is left as it was found.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from e2e_config import unique_marker
|
||||
from e2e_http import NoBody, Success, unwrap, unwrap_status
|
||||
from lifecycle import ResourceManager
|
||||
from management_client import ManagementClient
|
||||
from models import KeyGenerateBody, LiteLLMParamsBody, TeamNewBody
|
||||
|
||||
pytestmark = pytest.mark.e2e
|
||||
|
||||
|
||||
def _poll[T](client: ManagementClient, attempt: Callable[[], T | None], failure: str) -> T:
|
||||
deadline = time.monotonic() + client.proxy.poll_timeout
|
||||
while time.monotonic() < deadline:
|
||||
found = attempt()
|
||||
if found is not None:
|
||||
return found
|
||||
time.sleep(client.proxy.poll_interval)
|
||||
pytest.fail(failure)
|
||||
|
||||
|
||||
# ---- callbacks -------------------------------------------------------------
|
||||
|
||||
|
||||
class CallbacksListResponse(BaseModel):
|
||||
success: list[str]
|
||||
failure: list[str]
|
||||
success_and_failure: list[str]
|
||||
|
||||
|
||||
# ---- cost estimate ---------------------------------------------------------
|
||||
|
||||
|
||||
class CostEstimateBody(BaseModel):
|
||||
model: str
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
num_requests_per_day: int | None = None
|
||||
|
||||
|
||||
class CostEstimateResponse(BaseModel):
|
||||
model: str
|
||||
input_tokens: int
|
||||
output_tokens: int
|
||||
cost_per_request: float
|
||||
input_cost_per_request: float
|
||||
output_cost_per_request: float
|
||||
margin_cost_per_request: float
|
||||
daily_cost: float | None = None
|
||||
provider: str | None = None
|
||||
|
||||
|
||||
# ---- credential migration check --------------------------------------------
|
||||
|
||||
|
||||
class MigrationReport(BaseModel):
|
||||
residual_legacy: int
|
||||
total_undecryptable: int
|
||||
|
||||
|
||||
class MigrationCheckResponse(BaseModel):
|
||||
status: str
|
||||
report: MigrationReport
|
||||
|
||||
|
||||
# ---- tool + workflow inventories -------------------------------------------
|
||||
|
||||
|
||||
class ToolListEntry(BaseModel):
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class ToolListResponse(BaseModel):
|
||||
tools: list[ToolListEntry]
|
||||
total: int
|
||||
|
||||
|
||||
class WorkflowRunEntry(BaseModel):
|
||||
workflow_id: str | None = None
|
||||
|
||||
|
||||
class WorkflowRunsResponse(BaseModel):
|
||||
runs: list[WorkflowRunEntry]
|
||||
count: int
|
||||
|
||||
|
||||
# ---- compliance ------------------------------------------------------------
|
||||
|
||||
|
||||
class ComplianceGdprBody(BaseModel):
|
||||
request_id: str
|
||||
user_id: str
|
||||
model: str
|
||||
timestamp: str
|
||||
|
||||
|
||||
class ComplianceCheck(BaseModel):
|
||||
check_name: str
|
||||
article: str
|
||||
passed: bool
|
||||
detail: str
|
||||
|
||||
|
||||
class ComplianceResponse(BaseModel):
|
||||
compliant: bool
|
||||
regulation: str
|
||||
checks: list[ComplianceCheck]
|
||||
|
||||
|
||||
# ---- cache settings --------------------------------------------------------
|
||||
|
||||
|
||||
class CacheSettingsValue(BaseModel):
|
||||
type: str
|
||||
host: str = ""
|
||||
port: str = ""
|
||||
|
||||
|
||||
class CacheSettingsUpdateBody(BaseModel):
|
||||
cache_settings: CacheSettingsValue
|
||||
|
||||
|
||||
class CacheCurrentValues(BaseModel):
|
||||
type: str | None = None
|
||||
host: str | None = None
|
||||
port: str | None = None
|
||||
|
||||
|
||||
class CacheGetResponse(BaseModel):
|
||||
current_values: CacheCurrentValues
|
||||
|
||||
|
||||
class CacheUpdateResponse(BaseModel):
|
||||
status: str
|
||||
settings: CacheSettingsValue
|
||||
|
||||
|
||||
# ---- fallback management ---------------------------------------------------
|
||||
|
||||
|
||||
class FallbackShape(BaseModel):
|
||||
model: str
|
||||
fallback_models: list[str]
|
||||
fallback_type: str
|
||||
|
||||
|
||||
class FallbackCreateBody(FallbackShape):
|
||||
pass
|
||||
|
||||
|
||||
class FallbackResponse(FallbackShape):
|
||||
message: str
|
||||
|
||||
|
||||
class FallbackGetParams(BaseModel):
|
||||
fallback_type: str
|
||||
|
||||
|
||||
class FallbackGetResponse(FallbackShape):
|
||||
pass
|
||||
|
||||
|
||||
# ---- jwt key mapping -------------------------------------------------------
|
||||
|
||||
|
||||
class JwtKeyMappingNewBody(BaseModel):
|
||||
jwt_claim_name: str
|
||||
jwt_claim_value: str
|
||||
key: str
|
||||
description: str
|
||||
|
||||
|
||||
class JwtInfoParams(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
class JwtDeleteBody(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
class JwtKeyMappingResponse(BaseModel):
|
||||
id: str
|
||||
jwt_claim_name: str
|
||||
jwt_claim_value: str
|
||||
is_active: bool
|
||||
description: str | None = None
|
||||
|
||||
|
||||
# ---- router settings via /config/update ------------------------------------
|
||||
|
||||
|
||||
class RouterSettingsPatch(BaseModel):
|
||||
num_retries: int
|
||||
|
||||
|
||||
class ConfigUpdateBody(BaseModel):
|
||||
router_settings: RouterSettingsPatch
|
||||
|
||||
|
||||
class ConfigUpdateResponse(BaseModel):
|
||||
message: str
|
||||
|
||||
|
||||
class RouterCurrentValues(BaseModel):
|
||||
num_retries: int | None = None
|
||||
|
||||
|
||||
class RouterSettingsResponse(BaseModel):
|
||||
current_values: RouterCurrentValues
|
||||
|
||||
|
||||
# ---- mcp server submission -------------------------------------------------
|
||||
|
||||
|
||||
class McpRegisterBody(BaseModel):
|
||||
server_name: str
|
||||
url: str
|
||||
transport: str
|
||||
description: str
|
||||
|
||||
|
||||
class McpServerResponse(BaseModel):
|
||||
server_id: str
|
||||
server_name: str | None = None
|
||||
approval_status: str
|
||||
transport: str
|
||||
url: str | None = None
|
||||
|
||||
|
||||
class TestInventoryRoutes:
|
||||
@pytest.mark.covers("mgmt.callback.list.happy_path")
|
||||
def test_callbacks_list_reports_active_logging_callbacks(self, client: ManagementClient) -> None:
|
||||
listing = unwrap(
|
||||
client.proxy.transport.get(
|
||||
"/callbacks/list",
|
||||
headers=client.proxy.transport.master,
|
||||
params=NoBody(),
|
||||
response_type=CallbacksListResponse,
|
||||
)
|
||||
)
|
||||
every = [*listing.success, *listing.failure, *listing.success_and_failure]
|
||||
assert every, "/callbacks/list reported no active logging callbacks; the proxy always runs the db logger"
|
||||
assert "_ProxyDBLogger" in every, (
|
||||
f"/callbacks/list omitted the always-on _ProxyDBLogger spend logger; got {every}"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("mgmt.tool_management.list.happy_path")
|
||||
def test_tool_list_returns_catalog_with_consistent_total(self, client: ManagementClient) -> None:
|
||||
listing = unwrap(
|
||||
client.proxy.transport.get(
|
||||
"/v1/tool/list",
|
||||
headers=client.proxy.transport.master,
|
||||
params=NoBody(),
|
||||
response_type=ToolListResponse,
|
||||
)
|
||||
)
|
||||
assert listing.total == len(listing.tools), (
|
||||
f"/v1/tool/list total {listing.total} disagrees with the {len(listing.tools)} tools returned"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("mgmt.workflow.list.happy_path")
|
||||
def test_workflow_runs_list_returns_consistent_count(self, client: ManagementClient) -> None:
|
||||
listing = unwrap(
|
||||
client.proxy.transport.get(
|
||||
"/v1/workflows/runs",
|
||||
headers=client.proxy.transport.master,
|
||||
params=NoBody(),
|
||||
response_type=WorkflowRunsResponse,
|
||||
)
|
||||
)
|
||||
assert listing.count == len(listing.runs), (
|
||||
f"/v1/workflows/runs count {listing.count} disagrees with the {len(listing.runs)} runs returned"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("mgmt.credential_migration.check.happy_path")
|
||||
def test_credential_migration_check_reports_residual_scan(self, client: ManagementClient) -> None:
|
||||
report = unwrap(
|
||||
client.proxy.transport.get(
|
||||
"/credentials/migrate-encryption/check",
|
||||
headers=client.proxy.transport.master,
|
||||
params=NoBody(),
|
||||
response_type=MigrationCheckResponse,
|
||||
)
|
||||
)
|
||||
assert report.status == "success", f"migrate-encryption/check status {report.status!r}, expected 'success'"
|
||||
assert report.report.residual_legacy >= 0, (
|
||||
f"residual_legacy count is negative ({report.report.residual_legacy}); the scan is broken"
|
||||
)
|
||||
assert report.report.total_undecryptable >= 0, (
|
||||
f"total_undecryptable count is negative ({report.report.total_undecryptable}); the scan is broken"
|
||||
)
|
||||
|
||||
|
||||
class TestCostEstimate:
|
||||
@pytest.mark.covers("mgmt.cost_tracking.estimate.happy_path")
|
||||
def test_estimate_computes_cost_from_token_counts(self, client: ManagementClient) -> None:
|
||||
estimate = unwrap(
|
||||
client.proxy.transport.post(
|
||||
"/cost/estimate",
|
||||
headers=client.proxy.transport.master,
|
||||
json=CostEstimateBody(
|
||||
model="gpt-4o-mini", input_tokens=1000, output_tokens=500, num_requests_per_day=100
|
||||
),
|
||||
response_type=CostEstimateResponse,
|
||||
)
|
||||
)
|
||||
assert estimate.input_cost_per_request > 0, (
|
||||
f"input cost per request is {estimate.input_cost_per_request}; a priced model must cost more than zero"
|
||||
)
|
||||
assert estimate.output_cost_per_request > 0, (
|
||||
f"output cost per request is {estimate.output_cost_per_request}; a priced model must cost more than zero"
|
||||
)
|
||||
expected_per_request = (
|
||||
estimate.input_cost_per_request + estimate.output_cost_per_request + estimate.margin_cost_per_request
|
||||
)
|
||||
assert math.isclose(estimate.cost_per_request, expected_per_request, rel_tol=1e-9), (
|
||||
f"cost_per_request {estimate.cost_per_request} != input+output+margin {expected_per_request}"
|
||||
)
|
||||
assert estimate.daily_cost is not None and math.isclose(
|
||||
estimate.daily_cost, estimate.cost_per_request * 100, rel_tol=1e-9
|
||||
), f"daily_cost {estimate.daily_cost} != cost_per_request * 100 requests {estimate.cost_per_request * 100}"
|
||||
|
||||
|
||||
class TestComplianceRoutes:
|
||||
@pytest.mark.covers("mgmt.compliance.gdpr.happy_path")
|
||||
def test_gdpr_check_derives_verdict_from_the_request(self, client: ManagementClient) -> None:
|
||||
result = unwrap(
|
||||
client.proxy.transport.post(
|
||||
"/compliance/gdpr",
|
||||
headers=client.proxy.transport.master,
|
||||
json=ComplianceGdprBody(
|
||||
request_id=f"e2e-gdpr-{unique_marker()}",
|
||||
user_id=f"e2e-user-{unique_marker()}",
|
||||
model="gpt-4o-mini",
|
||||
timestamp="2026-07-21T00:00:00Z",
|
||||
),
|
||||
response_type=ComplianceResponse,
|
||||
)
|
||||
)
|
||||
assert result.regulation == "GDPR", (
|
||||
f"/compliance/gdpr reported regulation {result.regulation!r}, expected 'GDPR'"
|
||||
)
|
||||
articles = {check.article for check in result.checks}
|
||||
assert articles == {"Art. 32", "Art. 5(1)(c)", "Art. 30"}, (
|
||||
f"/compliance/gdpr returned articles {articles}, expected the three GDPR articles"
|
||||
)
|
||||
assert result.compliant == all(check.passed for check in result.checks), (
|
||||
"the overall compliant verdict must be the conjunction of the individual checks"
|
||||
)
|
||||
assert all(check.check_name and check.detail for check in result.checks), (
|
||||
"every compliance check must carry a name and a human-readable detail"
|
||||
)
|
||||
|
||||
|
||||
class TestCacheSettings:
|
||||
@pytest.mark.covers("mgmt.cache_settings.update.happy_path")
|
||||
def test_update_persists_cache_backend_to_get(
|
||||
self, client: ManagementClient, resources: ResourceManager
|
||||
) -> None:
|
||||
"""Exercise the update route without changing global state: capture the live
|
||||
cache backend and write exactly that back, so the config the proxy ends on is
|
||||
byte-for-byte the one it started with. A teardown restore of the same captured
|
||||
settings is the safety net if the body fails partway. The update route is only
|
||||
meaningful against a configured cache, so an unconfigured proxy fails loudly
|
||||
here rather than being silently switched to redis."""
|
||||
before = self._read_settings(client)
|
||||
assert before.type is not None, (
|
||||
"GET /cache/settings reported no cache type; refusing to invent one and mutate the shared proxy"
|
||||
)
|
||||
captured = CacheSettingsValue(type=before.type, host=before.host or "", port=before.port or "")
|
||||
resources.defer(lambda: self._write_settings(client, captured))
|
||||
|
||||
updated = unwrap(
|
||||
client.proxy.transport.post(
|
||||
"/cache/settings",
|
||||
headers=client.proxy.transport.master,
|
||||
json=CacheSettingsUpdateBody(cache_settings=captured),
|
||||
response_type=CacheUpdateResponse,
|
||||
)
|
||||
)
|
||||
assert updated.status == "success", f"/cache/settings update status {updated.status!r}, expected 'success'"
|
||||
assert updated.settings.type == captured.type, (
|
||||
f"/cache/settings echoed type {updated.settings.type!r}, wrote {captured.type!r}"
|
||||
)
|
||||
|
||||
def reflected() -> CacheCurrentValues | None:
|
||||
current = self._read_settings(client)
|
||||
return current if current.type == captured.type else None
|
||||
|
||||
after = _poll(client, reflected, f"/cache/settings never reported type {captured.type!r} after the update")
|
||||
assert after.host == captured.host and after.port == captured.port, (
|
||||
f"/cache/settings persisted host/port {after.host!r}/{after.port!r}, "
|
||||
f"wrote {captured.host!r}/{captured.port!r}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _read_settings(client: ManagementClient) -> CacheCurrentValues:
|
||||
return unwrap(
|
||||
client.proxy.transport.get(
|
||||
"/cache/settings",
|
||||
headers=client.proxy.transport.master,
|
||||
params=NoBody(),
|
||||
response_type=CacheGetResponse,
|
||||
)
|
||||
).current_values
|
||||
|
||||
@staticmethod
|
||||
def _write_settings(client: ManagementClient, settings: CacheSettingsValue) -> None:
|
||||
_ = unwrap(
|
||||
client.proxy.transport.post(
|
||||
"/cache/settings",
|
||||
headers=client.proxy.transport.master,
|
||||
json=CacheSettingsUpdateBody(cache_settings=settings),
|
||||
response_type=CacheUpdateResponse,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestFallbackManagement:
|
||||
@pytest.mark.covers("mgmt.fallback_management.update.happy_path")
|
||||
def test_create_persists_and_is_read_back(self, client: ManagementClient, resources: ResourceManager) -> None:
|
||||
primary = f"e2e-fallback-primary-{unique_marker()}"
|
||||
secondary = f"e2e-fallback-secondary-{unique_marker()}"
|
||||
params = LiteLLMParamsBody(model="openai/gpt-5.5", api_key="e2e-dummy-key")
|
||||
primary_id = client.proxy.create_model(primary, params)
|
||||
resources.defer(lambda: client.proxy.delete_model(primary_id))
|
||||
secondary_id = client.proxy.create_model(secondary, params)
|
||||
resources.defer(lambda: client.proxy.delete_model(secondary_id))
|
||||
resources.defer(lambda: self._delete_fallback(client, primary))
|
||||
|
||||
created = unwrap(
|
||||
client.proxy.transport.post(
|
||||
"/fallback",
|
||||
headers=client.proxy.transport.master,
|
||||
json=FallbackCreateBody(model=primary, fallback_models=[secondary], fallback_type="general"),
|
||||
response_type=FallbackResponse,
|
||||
)
|
||||
)
|
||||
assert created.model == primary and created.fallback_models == [secondary], (
|
||||
f"/fallback echoed model={created.model!r} fallbacks={created.fallback_models}, "
|
||||
f"configured {primary!r} -> [{secondary!r}]"
|
||||
)
|
||||
|
||||
def read_back() -> FallbackGetResponse | None:
|
||||
result = client.proxy.transport.get(
|
||||
f"/fallback/{primary}",
|
||||
headers=client.proxy.transport.master,
|
||||
params=FallbackGetParams(fallback_type="general"),
|
||||
response_type=FallbackGetResponse,
|
||||
)
|
||||
match result:
|
||||
case Success(data=data) if secondary in data.fallback_models:
|
||||
return data
|
||||
case _:
|
||||
return None
|
||||
|
||||
got = _poll(client, read_back, f"GET /fallback/{primary} never reported {secondary} after /fallback")
|
||||
assert got.fallback_models == [secondary], (
|
||||
f"GET /fallback/{primary} reports fallbacks {got.fallback_models}, configured [{secondary!r}]"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _delete_fallback(client: ManagementClient, model: str) -> None:
|
||||
_ = client.proxy.transport.delete(
|
||||
f"/fallback/{model}",
|
||||
headers=client.proxy.transport.master,
|
||||
json=NoBody(),
|
||||
params=FallbackGetParams(fallback_type="general"),
|
||||
response_type=NoBody,
|
||||
)
|
||||
|
||||
|
||||
class TestJwtKeyMapping:
|
||||
@pytest.mark.covers("mgmt.jwt_key_mapping.new.happy_path")
|
||||
def test_new_persists_mapping_and_is_read_back(
|
||||
self, client: ManagementClient, resources: ResourceManager
|
||||
) -> None:
|
||||
key = client.proxy.generate_key(KeyGenerateBody())
|
||||
resources.defer(lambda: client.proxy.delete_key(key))
|
||||
claim_value = f"e2e_jwt_{unique_marker()}"
|
||||
|
||||
created = unwrap(
|
||||
client.proxy.transport.post(
|
||||
"/jwt/key/mapping/new",
|
||||
headers=client.proxy.transport.master,
|
||||
json=JwtKeyMappingNewBody(
|
||||
jwt_claim_name="team_id",
|
||||
jwt_claim_value=claim_value,
|
||||
key=key,
|
||||
description="e2e coverage mapping",
|
||||
),
|
||||
response_type=JwtKeyMappingResponse,
|
||||
)
|
||||
)
|
||||
resources.defer(lambda: self._delete_mapping(client, created.id))
|
||||
assert created.jwt_claim_value == claim_value and created.is_active, (
|
||||
f"/jwt/key/mapping/new returned claim_value={created.jwt_claim_value!r} active={created.is_active}, "
|
||||
f"configured {claim_value!r} active=True"
|
||||
)
|
||||
|
||||
info = unwrap(
|
||||
client.proxy.transport.get(
|
||||
"/jwt/key/mapping/info",
|
||||
headers=client.proxy.transport.master,
|
||||
params=JwtInfoParams(id=created.id),
|
||||
response_type=JwtKeyMappingResponse,
|
||||
)
|
||||
)
|
||||
assert info.id == created.id and info.jwt_claim_name == "team_id" and info.jwt_claim_value == claim_value, (
|
||||
f"/jwt/key/mapping/info reports {info.jwt_claim_name!r}={info.jwt_claim_value!r} for id {info.id}, "
|
||||
f"created team_id={claim_value!r}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _delete_mapping(client: ManagementClient, mapping_id: str) -> None:
|
||||
_ = client.proxy.transport.post(
|
||||
"/jwt/key/mapping/delete",
|
||||
headers=client.proxy.transport.master,
|
||||
json=JwtDeleteBody(id=mapping_id),
|
||||
response_type=NoBody,
|
||||
)
|
||||
|
||||
|
||||
class TestRouterSettings:
|
||||
@pytest.mark.covers("mgmt.router_settings.update.happy_path")
|
||||
def test_config_update_persists_router_setting_to_get(
|
||||
self, client: ManagementClient, resources: ResourceManager
|
||||
) -> None:
|
||||
"""/config/update is the only write path for router_settings (there is no
|
||||
dedicated router-settings write route). The change is restored on teardown so
|
||||
the shared proxy keeps its original retry policy."""
|
||||
original = self._read_num_retries(client)
|
||||
assert original is not None, "GET /router/settings did not report num_retries; cannot prove a change"
|
||||
resources.defer(lambda: self._write_num_retries(client, original))
|
||||
|
||||
target = original + 5
|
||||
response = unwrap(
|
||||
client.proxy.transport.post(
|
||||
"/config/update",
|
||||
headers=client.proxy.transport.master,
|
||||
json=ConfigUpdateBody(router_settings=RouterSettingsPatch(num_retries=target)),
|
||||
response_type=ConfigUpdateResponse,
|
||||
)
|
||||
)
|
||||
assert "success" in response.message.lower(), (
|
||||
f"/config/update reported {response.message!r}, expected a success message"
|
||||
)
|
||||
|
||||
_ = _poll(
|
||||
client,
|
||||
lambda: True if self._read_num_retries(client) == target else None,
|
||||
f"GET /router/settings never reported num_retries {target} after /config/update",
|
||||
)
|
||||
|
||||
self._write_num_retries(client, original)
|
||||
restored = _poll(
|
||||
client,
|
||||
lambda: original if self._read_num_retries(client) == original else None,
|
||||
f"GET /router/settings never returned to the original num_retries {original} after the restore",
|
||||
)
|
||||
assert restored == original, f"router num_retries left at {restored}, expected the original {original}"
|
||||
|
||||
@staticmethod
|
||||
def _read_num_retries(client: ManagementClient) -> int | None:
|
||||
return unwrap(
|
||||
client.proxy.transport.get(
|
||||
"/router/settings",
|
||||
headers=client.proxy.transport.master,
|
||||
params=NoBody(),
|
||||
response_type=RouterSettingsResponse,
|
||||
)
|
||||
).current_values.num_retries
|
||||
|
||||
@staticmethod
|
||||
def _write_num_retries(client: ManagementClient, value: int) -> None:
|
||||
_ = unwrap(
|
||||
client.proxy.transport.post(
|
||||
"/config/update",
|
||||
headers=client.proxy.transport.master,
|
||||
json=ConfigUpdateBody(router_settings=RouterSettingsPatch(num_retries=value)),
|
||||
response_type=ConfigUpdateResponse,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class TestMcpServerSubmission:
|
||||
@pytest.mark.covers("mgmt.mcp_server.register.happy_path")
|
||||
def test_register_submits_pending_server(self, client: ManagementClient, resources: ResourceManager) -> None:
|
||||
"""A non-admin, team-scoped key submits an MCP server for review; the proxy
|
||||
stores it as pending_review without loading it into the runtime registry."""
|
||||
team_id = client.create_team(TeamNewBody(team_alias=f"e2e-mcp-team-{unique_marker()}"))
|
||||
resources.defer(lambda: client.delete_team(team_id))
|
||||
team_key = client.proxy.generate_key(KeyGenerateBody(team_id=team_id))
|
||||
resources.defer(lambda: client.proxy.delete_key(team_key))
|
||||
|
||||
server_name = f"e2e_mcp_{unique_marker()}"
|
||||
submitted = unwrap_status(
|
||||
client.proxy.transport.post(
|
||||
"/v1/mcp/server/register",
|
||||
headers=client.proxy.transport.bearer(team_key),
|
||||
json=McpRegisterBody(
|
||||
server_name=server_name,
|
||||
url="https://example.com/mcp",
|
||||
transport="sse",
|
||||
description="e2e coverage submission",
|
||||
),
|
||||
response_type=McpServerResponse,
|
||||
),
|
||||
201,
|
||||
)
|
||||
resources.defer(lambda: self._delete_server(client, submitted.server_id))
|
||||
assert submitted.approval_status == "pending_review", (
|
||||
f"a user submission must be pending_review, got {submitted.approval_status!r}"
|
||||
)
|
||||
assert submitted.server_name == server_name and submitted.transport == "sse", (
|
||||
f"/v1/mcp/server/register echoed name={submitted.server_name!r} transport={submitted.transport!r}, "
|
||||
f"configured {server_name!r}/sse"
|
||||
)
|
||||
|
||||
@pytest.mark.covers("mgmt.mcp_server.approve.persists")
|
||||
def test_approve_activates_submission_and_persists(
|
||||
self, client: ManagementClient, resources: ResourceManager
|
||||
) -> None:
|
||||
"""An admin approving a pending submission flips it to active, and the change
|
||||
persists to a fresh read of the server."""
|
||||
team_id = client.create_team(TeamNewBody(team_alias=f"e2e-mcp-team-{unique_marker()}"))
|
||||
resources.defer(lambda: client.delete_team(team_id))
|
||||
team_key = client.proxy.generate_key(KeyGenerateBody(team_id=team_id))
|
||||
resources.defer(lambda: client.proxy.delete_key(team_key))
|
||||
|
||||
submitted = unwrap(
|
||||
client.proxy.transport.post(
|
||||
"/v1/mcp/server/register",
|
||||
headers=client.proxy.transport.bearer(team_key),
|
||||
json=McpRegisterBody(
|
||||
server_name=f"e2e_mcp_{unique_marker()}",
|
||||
url="https://example.com/mcp",
|
||||
transport="sse",
|
||||
description="e2e coverage submission",
|
||||
),
|
||||
response_type=McpServerResponse,
|
||||
)
|
||||
)
|
||||
resources.defer(lambda: self._delete_server(client, submitted.server_id))
|
||||
assert submitted.approval_status == "pending_review", (
|
||||
f"a fresh submission must be pending_review before approval, got {submitted.approval_status!r}"
|
||||
)
|
||||
|
||||
approved = unwrap(
|
||||
client.proxy.transport.put(
|
||||
f"/v1/mcp/server/{submitted.server_id}/approve",
|
||||
headers=client.proxy.transport.master,
|
||||
json=NoBody(),
|
||||
response_type=McpServerResponse,
|
||||
)
|
||||
)
|
||||
assert approved.approval_status == "active", (
|
||||
f"approve must flip the submission to active, got {approved.approval_status!r}"
|
||||
)
|
||||
|
||||
fetched = unwrap(
|
||||
client.proxy.transport.get(
|
||||
f"/v1/mcp/server/{submitted.server_id}",
|
||||
headers=client.proxy.transport.master,
|
||||
params=NoBody(),
|
||||
response_type=McpServerResponse,
|
||||
)
|
||||
)
|
||||
assert fetched.server_id == submitted.server_id and fetched.approval_status == "active", (
|
||||
f"GET /v1/mcp/server/{submitted.server_id} reports approval_status {fetched.approval_status!r} "
|
||||
"after approve, expected 'active'"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _delete_server(client: ManagementClient, server_id: str) -> None:
|
||||
_ = client.proxy.transport.delete(
|
||||
f"/v1/mcp/server/{server_id}",
|
||||
headers=client.proxy.transport.master,
|
||||
json=NoBody(),
|
||||
response_type=NoBody,
|
||||
)
|
||||
251
tests/e2e/management/test_key_management_e2e.py
Normal file
251
tests/e2e/management/test_key_management_e2e.py
Normal 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"
|
||||
)
|
||||
385
tests/e2e/management/test_model_tag_accessgroup_e2e.py
Normal file
385
tests/e2e/management/test_model_tag_accessgroup_e2e.py
Normal 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"
|
||||
)
|
||||
303
tests/e2e/management/test_team_management_e2e.py
Normal file
303
tests/e2e/management/test_team_management_e2e.py
Normal 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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
146
tests/e2e/mcp/test_mcp_guardrail_e2e.py
Normal file
146
tests/e2e/mcp/test_mcp_guardrail_e2e.py
Normal 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}"
|
||||
)
|
||||
|
|
@ -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] = []
|
||||
|
|
|
|||
18
tests/e2e/other/conftest.py
Normal file
18
tests/e2e/other/conftest.py
Normal 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)
|
||||
73
tests/e2e/other/other_client.py
Normal file
73
tests/e2e/other/other_client.py
Normal 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)
|
||||
65
tests/e2e/other/test_health_lifecycle_e2e.py
Normal file
65
tests/e2e/other/test_health_lifecycle_e2e.py
Normal 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}"
|
||||
)
|
||||
37
tests/e2e/other/test_master_key_auth_e2e.py
Normal file
37
tests/e2e/other/test_master_key_auth_e2e.py
Normal 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}"
|
||||
)
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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)"""
|
||||
|
||||
|
|
|
|||
55
tests/test_litellm/llms/oobabooga/chat/test_oobabooga.py
Normal file
55
tests/test_litellm/llms/oobabooga/chat/test_oobabooga.py
Normal 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"
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue