diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index edfb3536ad3..75d4d13eb71 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -24,7 +24,7 @@ "limit": 42 }, "reportExplicitAny": { - "limit": 10397 + "limit": 10389 }, "reportFunctionMemberAccess": { "limit": 11 diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260717000000_add_mcp_server_oauth_client_table/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260717000000_add_mcp_server_oauth_client_table/migration.sql new file mode 100644 index 00000000000..7aa6cdb1e33 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260717000000_add_mcp_server_oauth_client_table/migration.sql @@ -0,0 +1,9 @@ +-- CreateTable +CREATE TABLE IF NOT EXISTS "LiteLLM_MCPServerOAuthClient" ( + "server_id" TEXT NOT NULL, + "credentials" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "LiteLLM_MCPServerOAuthClient_pkey" PRIMARY KEY ("server_id") +); diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index f842bf13da9..a99cec49417 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -396,6 +396,13 @@ model LiteLLM_MCPUserEnvVars { @@index([server_id]) } +model LiteLLM_MCPServerOAuthClient { + server_id String @id + credentials Json? + 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 diff --git a/litellm-rust/crates/CODING_STANDARDS/PROVIDER_CODING_STANDARDS.md b/litellm-rust/crates/CODING_STANDARDS/PROVIDER_CODING_STANDARDS.md new file mode 100644 index 00000000000..c7980b11147 --- /dev/null +++ b/litellm-rust/crates/CODING_STANDARDS/PROVIDER_CODING_STANDARDS.md @@ -0,0 +1,53 @@ +# Provider coding standards (litellm-rust) + +Rules for adding or changing an LLM provider/route in `litellm-rust`. OCR (`MISTRAL_OCR_CONFIG`) is the reference; `messages` (`ANTHROPIC_MESSAGES_CONFIG`) is the next port. + +## Provider resolution + +1. Always resolve the provider/model first with `get_custom_llm_provider` (`core/src/routing_utils/provider.rs`). Nothing downstream may branch on a raw model string. +2. Model/provider is resolved once, in `prepare.rs`, and passed down as typed fields. Don't re-resolve or re-parse it in transforms or handlers. + +## Transforms and the base config + +3. Every route defines a base config trait with `transform_request` + `transform_response` (+ `complete_url`, `supported_params`), living in `core/src//transformation.rs` (e.g. `AnthropicMessagesProviderConfig`, mirroring `OcrProviderConfig`). +4. Each provider implements that trait as a `const __CONFIG` in `core/src/providers///transformation.rs`, mirroring the Python provider tree. +5. Individual configs implement only the request/response transforms. Shared behavior (param filtering, defaults) stays as trait default methods so future providers inherit existing logic instead of reimplementing it. +6. Prefer composition: a provider that extends another reuses the base trait's defaults or wraps another config; don't copy transform bodies between providers. + +## Boundaries + +7. Layers never cross: `core` = pure transforms/types (no network, env, secrets, auth, logging, global mutable state); `ai-gateway` = all I/O, auth headers, HTTP/SSE, lifecycle hooks; `python-bridge` = thin PyO3 adapter. +8. Generic/route files contain zero provider-specific branches. A provider is one module under `core/src/providers///`; a route is a module, never a new crate. +9. Route entry point stays thin: `()` -> `prepare_*` -> `CallLifecycle::run_request`, which owns the pre_call -> during_call -> provider call -> success/failure order and phase timing. Handlers validate and delegate; no business logic in them. +10. Constants (URLs, env-var names, API versions, error messages) live in a crate `constants.rs`, never inline. Env reads happen only at the host/config layer, with the `DEFAULT_*` fallback defined in `constants.rs`. + +## Types and errors + +11. Typed contracts only: no bare `serde_json::Value` / `String` / `Vec` as a transform input or output. Parse wire bytes into typed structs/enums at the host edge; a `type` discriminator is a typed field, not a raw string. +12. Model failures as values: return typed `CoreError`, don't panic. No `unwrap`/`expect`/`panic!` on user or provider input. +13. No mutation: build values in one shot (comprehensions/iterators, `collect`), prefer immutable bindings and owned typed structs over seeding-and-mutating. +14. Early returns over deep nesting; small focused files over god modules. +15. Preserve Python output shape intentionally. If a field is always serialized as `null` for parity, keep it and pin it with a test. + +## Safety and data minimization + +16. Never log request/response bodies, base64 payloads, document contents, or secrets. Truncate and bound any upstream body before it crosses a host boundary. +17. Treat empty/whitespace credentials, URLs, and config values as absent at the host resolution layer. +18. Host I/O sets connect + request timeouts (no unbounded waits), reuses a shared HTTP client, and prefers rustls TLS. + +## Tests and rollout + +19. Every provider transform ships tests for: supported-param filtering, request body shape, response normalization, missing/null fields, bad input, and `*_match_python` fixture parity. +20. Lifecycle/hook tests cover hook order, success + failure callback payloads, pre-call guardrail blocking before any provider I/O, during-call body mutation, and provider-error mapping. +21. Rust paths stay off by default and behind Python parity tests (disabled / enabled-equals-Python / bridge-unavailable fallback) until parity is proven. + +## Checks before push + +22. Run, and keep green: + ```bash + cd litellm-rust + cargo fmt --check + cargo clippy -p litellm-ai-gateway --all-targets --features server -- -D warnings + cargo clippy -p litellm-core -p litellm-python-bridge --all-targets -- -D warnings + cargo test --workspace + ``` diff --git a/litellm-rust/crates/ai-gateway/src/constants.rs b/litellm-rust/crates/ai-gateway/src/constants.rs index 109b648f5db..557fe5d53d4 100644 --- a/litellm-rust/crates/ai-gateway/src/constants.rs +++ b/litellm-rust/crates/ai-gateway/src/constants.rs @@ -28,3 +28,15 @@ pub(crate) const DEFAULT_FLUSH_INTERVAL_MS: u64 = 500; /// Provider attributed to realtime sessions in the logging payload. #[cfg(feature = "server")] pub(crate) const DEFAULT_PROVIDER: &str = "openai"; + +/// Full-request timeout ceiling for Anthropic Messages provider calls, in +/// seconds. Mirrors the Python Anthropic Messages default. The per-request +/// timeout from `litellm_params` still overrides this on the request builder. +pub(crate) const MESSAGES_TIMEOUT_SECS: u64 = 600; + +/// Connect timeout for Anthropic Messages provider calls, in seconds. +pub(crate) const MESSAGES_CONNECT_TIMEOUT_SECS: u64 = 10; + +/// Max characters of an upstream error body echoed across the host boundary +/// before truncation, so provider bodies are bounded and data-minimized. +pub(crate) const MESSAGES_ERROR_BODY_MAX_CHARS: usize = 256; diff --git a/litellm-rust/crates/ai-gateway/src/io/messages.rs b/litellm-rust/crates/ai-gateway/src/io/messages.rs new file mode 100644 index 00000000000..b784d2b62a1 --- /dev/null +++ b/litellm-rust/crates/ai-gateway/src/io/messages.rs @@ -0,0 +1 @@ +pub use crate::messages::{messages, MessagesRequest}; diff --git a/litellm-rust/crates/ai-gateway/src/io/mod.rs b/litellm-rust/crates/ai-gateway/src/io/mod.rs index 3b566027646..7bc9642d192 100644 --- a/litellm-rust/crates/ai-gateway/src/io/mod.rs +++ b/litellm-rust/crates/ai-gateway/src/io/mod.rs @@ -1,3 +1,4 @@ +pub mod messages; pub mod ocr; pub mod realtime; pub mod realtime_pool; diff --git a/litellm-rust/crates/ai-gateway/src/lib.rs b/litellm-rust/crates/ai-gateway/src/lib.rs index d8ef7bb5ba1..db4c8211a5a 100644 --- a/litellm-rust/crates/ai-gateway/src/lib.rs +++ b/litellm-rust/crates/ai-gateway/src/lib.rs @@ -12,6 +12,7 @@ //! for the load-time config reader. pub mod io; +pub mod messages; pub mod ocr; /// GIL-activity tracking. Pure (atomics only); shared by the `server` routes and diff --git a/litellm-rust/crates/ai-gateway/src/messages/client.rs b/litellm-rust/crates/ai-gateway/src/messages/client.rs new file mode 100644 index 00000000000..6281270b964 --- /dev/null +++ b/litellm-rust/crates/ai-gateway/src/messages/client.rs @@ -0,0 +1,15 @@ +use std::sync::OnceLock; +use std::time::Duration; + +use crate::constants::{MESSAGES_CONNECT_TIMEOUT_SECS, MESSAGES_TIMEOUT_SECS}; + +pub(super) fn http_client() -> &'static reqwest::Client { + static CLIENT: OnceLock = OnceLock::new(); + CLIENT.get_or_init(|| { + reqwest::Client::builder() + .timeout(Duration::from_secs(MESSAGES_TIMEOUT_SECS)) + .connect_timeout(Duration::from_secs(MESSAGES_CONNECT_TIMEOUT_SECS)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) + }) +} diff --git a/litellm-rust/crates/ai-gateway/src/messages/common_utils.rs b/litellm-rust/crates/ai-gateway/src/messages/common_utils.rs new file mode 100644 index 00000000000..fe4ac4cf26f --- /dev/null +++ b/litellm-rust/crates/ai-gateway/src/messages/common_utils.rs @@ -0,0 +1,50 @@ +use litellm_core::error::{json_type_name, CoreError}; +use litellm_core::messages::transformation::AnthropicMessagesProviderConfig; +use litellm_core::providers::azure_ai::messages::transformation::AZURE_ANTHROPIC_MESSAGES_CONFIG; +use litellm_core::CoreResult; +use serde_json::{Map, Value}; + +use crate::constants::MESSAGES_ERROR_BODY_MAX_CHARS; + +pub(super) fn truncate_error_body(body: &str) -> String { + if body.chars().count() <= MESSAGES_ERROR_BODY_MAX_CHARS { + return body.to_string(); + } + let truncated: String = body.chars().take(MESSAGES_ERROR_BODY_MAX_CHARS).collect(); + format!("{truncated}... (truncated)") +} + +pub(super) fn messages_provider_config( + provider: &str, +) -> Option<&'static dyn AnthropicMessagesProviderConfig> { + match provider { + "azure_ai" => Some(&AZURE_ANTHROPIC_MESSAGES_CONFIG), + _ => None, + } +} + +pub(super) fn string_headers( + extra_headers: Option>, +) -> CoreResult> { + extra_headers + .unwrap_or_default() + .into_iter() + .map(|(key, value)| { + value + .as_str() + .map(|value| (key.clone(), value.to_string())) + .ok_or_else(|| { + CoreError::InvalidRequest(format!( + "messages extra_headers.{key} must be a string, got {}", + json_type_name(&value) + )) + }) + }) + .collect() +} + +pub(super) fn has_header(headers: &[(String, String)], name: &str) -> bool { + headers + .iter() + .any(|(key, _)| key.eq_ignore_ascii_case(name)) +} diff --git a/litellm-rust/crates/ai-gateway/src/messages/handler.rs b/litellm-rust/crates/ai-gateway/src/messages/handler.rs new file mode 100644 index 00000000000..dd4a2f22aa7 --- /dev/null +++ b/litellm-rust/crates/ai-gateway/src/messages/handler.rs @@ -0,0 +1,47 @@ +use litellm_core::error::CoreError; +use litellm_core::CoreResult; +use serde_json::Value; + +use super::client::http_client; +use super::common_utils::truncate_error_body; +use super::types::ProviderMessagesRequest; + +pub(super) async fn execute_messages_provider_call( + request: ProviderMessagesRequest, +) -> CoreResult { + let mut request_builder = http_client().post(&request.url).json(&request.body); + for (key, value) in &request.upstream_headers { + request_builder = request_builder.header(key, value); + } + if let Some(duration) = request.timeout { + request_builder = request_builder.timeout(duration); + } + + let response = request_builder + .send() + .await + .map_err(|err| CoreError::Network(err.to_string()))?; + + let status = response.status(); + let text = response + .text() + .await + .map_err(|err| CoreError::Network(err.to_string()))?; + + if !status.is_success() { + return Err(CoreError::Http { + status: status.as_u16(), + body: truncate_error_body(&text), + }); + } + + let response = serde_json::from_str(&text).map_err(|err| { + CoreError::InvalidResponse(format!("invalid messages response JSON: {err}")) + })?; + let transformed = request + .config + .transform_response(&request.model, response)?; + serde_json::to_value(transformed).map_err(|err| { + CoreError::InvalidResponse(format!("failed to serialize messages response: {err}")) + }) +} diff --git a/litellm-rust/crates/ai-gateway/src/messages/mod.rs b/litellm-rust/crates/ai-gateway/src/messages/mod.rs new file mode 100644 index 00000000000..7ed81474c47 --- /dev/null +++ b/litellm-rust/crates/ai-gateway/src/messages/mod.rs @@ -0,0 +1,21 @@ +use litellm_core::CoreResult; +use serde_json::Value; + +mod client; +mod common_utils; +mod handler; +mod prepare; +mod types; + +pub use types::MessagesRequest; + +use handler::execute_messages_provider_call; +use prepare::prepare_messages_call; + +pub async fn messages(request: MessagesRequest<'_>) -> CoreResult { + let prepared = prepare_messages_call(request)?; + execute_messages_provider_call(prepared).await +} + +#[cfg(test)] +mod tests; diff --git a/litellm-rust/crates/ai-gateway/src/messages/prepare.rs b/litellm-rust/crates/ai-gateway/src/messages/prepare.rs new file mode 100644 index 00000000000..47105b39954 --- /dev/null +++ b/litellm-rust/crates/ai-gateway/src/messages/prepare.rs @@ -0,0 +1,72 @@ +use litellm_core::messages::transformation::MessagesAuthStrategy; +use litellm_core::routing_utils::provider::{get_custom_llm_provider, CustomLlmProvider}; +use litellm_core::CoreError; +use litellm_core::CoreResult; + +use super::common_utils::{has_header, messages_provider_config, string_headers}; +use super::types::{MessagesRequest, ProviderMessagesRequest}; + +pub(super) fn prepare_messages_call( + request: MessagesRequest<'_>, +) -> CoreResult { + let provider_info = get_custom_llm_provider(request.model, request.custom_llm_provider) + .or_else(|| { + request + .custom_llm_provider + .map(|provider| CustomLlmProvider { + model: request.model, + custom_llm_provider: provider, + }) + }) + .ok_or_else(|| { + CoreError::InvalidProvider( + "unable to resolve custom_llm_provider for messages request".to_string(), + ) + })?; + let model = provider_info.model.to_string(); + let provider = provider_info.custom_llm_provider; + + let config = messages_provider_config(provider) + .ok_or_else(|| CoreError::InvalidProvider(provider.to_string()))?; + let env_lookup = |key: &str| std::env::var(key).ok(); + + let mut headers = string_headers(request.extra_headers)?; + + let auth_strategy = config.auth_strategy(); + if !has_header(&headers, auth_strategy.header_name()) { + let api_key = config.resolve_api_key(request.api_key, &env_lookup)?; + let auth_header = match auth_strategy { + MessagesAuthStrategy::Bearer => { + ("authorization".to_string(), format!("Bearer {api_key}")) + } + MessagesAuthStrategy::Header(name) => (name.to_string(), api_key), + }; + headers.push(auth_header); + } + + for (name, value) in config.default_headers() { + if !has_header(&headers, name) { + headers.push((name.to_string(), value.to_string())); + } + } + + let url = config.complete_url(request.api_base, &model, &env_lookup)?; + let typed_request = serde_json::from_value(request.body).map_err(|err| { + CoreError::InvalidRequest(format!("invalid Anthropic messages request: {err}")) + })?; + let transformed = config.transform_request(typed_request)?; + let body = serde_json::to_value(transformed).map_err(|err| { + CoreError::InvalidRequest(format!( + "failed to serialize Anthropic messages request: {err}" + )) + })?; + + Ok(ProviderMessagesRequest { + model, + config, + url, + body, + upstream_headers: headers, + timeout: request.timeout, + }) +} diff --git a/litellm-rust/crates/ai-gateway/src/messages/tests.rs b/litellm-rust/crates/ai-gateway/src/messages/tests.rs new file mode 100644 index 00000000000..30f6642400e --- /dev/null +++ b/litellm-rust/crates/ai-gateway/src/messages/tests.rs @@ -0,0 +1,259 @@ +use std::time::Duration; + +use litellm_core::error::CoreError; +use serde_json::{json, Map, Value}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +use super::common_utils::{ + has_header, messages_provider_config, string_headers, truncate_error_body, +}; +use super::{messages, MessagesRequest}; + +async fn read_http_request(socket: &mut TcpStream) -> String { + let mut request = Vec::new(); + let mut buffer = [0_u8; 1024]; + let header_end = loop { + let n = socket.read(&mut buffer).await.expect("reads request"); + if n == 0 { + break request.len(); + } + request.extend_from_slice(&buffer[..n]); + if let Some(position) = request.windows(4).position(|window| window == b"\r\n\r\n") { + break position + 4; + } + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + while request.len().saturating_sub(header_end) < content_length { + let n = socket.read(&mut buffer).await.expect("reads body"); + if n == 0 { + break; + } + request.extend_from_slice(&buffer[..n]); + } + String::from_utf8(request).expect("request is utf8") +} + +fn write_response(body: &str) -> String { + format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ) +} + +#[test] +fn provider_config_only_resolves_azure_ai() { + assert!(messages_provider_config("azure_ai").is_some()); + assert!(messages_provider_config("anthropic").is_none()); + assert!(messages_provider_config("openai").is_none()); +} + +#[test] +fn truncate_error_body_caps_long_payloads() { + let body = "x".repeat(400); + let truncated = truncate_error_body(&body); + assert!(truncated.ends_with("... (truncated)")); + let prefix_chars = truncated + .strip_suffix("... (truncated)") + .expect("truncated marker present") + .chars() + .count(); + assert_eq!(prefix_chars, 256); +} + +#[test] +fn string_headers_rejects_non_string_values() { + let headers = json!({"x-count": 3}).as_object().unwrap().clone(); + let err = string_headers(Some(headers)).expect_err("non-string header rejected"); + assert!(matches!(err, CoreError::InvalidRequest(_))); +} + +#[test] +fn has_header_is_case_insensitive() { + let headers = vec![("X-Api-Key".to_string(), "secret".to_string())]; + assert!(has_header(&headers, "x-api-key")); + assert!(!has_header(&headers, "authorization")); +} + +#[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"); + 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_1","type":"message","role":"assistant","content":[{"type":"text","text":"hi"}],"model":"claude-sonnet-4-5","stop_reason":"end_turn","usage":{"input_tokens":1,"output_tokens":2}}"#; + socket + .write_all(write_response(response_body).as_bytes()) + .await + .expect("writes response"); + request + }); + + let response = messages(MessagesRequest { + model: "claude-sonnet-4-5", + body: json!({ + "model": "claude-sonnet-4-5", + "max_tokens": 1024, + "messages": [{ + "role": "user", + "content": [{ + "type": "text", + "text": "hi", + "cache_control": {"type": "ephemeral", "scope": "global"} + }] + }] + }), + api_key: Some("sk-azure"), + api_base: Some(&format!("http://{addr}")), + custom_llm_provider: Some("azure_ai"), + extra_headers: None, + timeout: Some(Duration::from_secs(5)), + }) + .await + .expect("messages request succeeds"); + + assert_eq!(response["content"][0]["text"], "hi"); + assert_eq!(response["stop_reason"], "end_turn"); + + let request = server.await.expect("server task completes"); + let (head, body) = request.split_once("\r\n\r\n").expect("has body"); + assert!(head.starts_with("POST /anthropic/v1/messages "), "{head}"); + let head_lower = head.to_ascii_lowercase(); + assert!(head_lower.contains("x-api-key: sk-azure"), "{head}"); + assert!( + head_lower.contains("anthropic-version: 2023-06-01"), + "{head}" + ); + assert!( + head_lower.contains("content-type: application/json"), + "{head}" + ); + + let sent_body: Value = serde_json::from_str(body).expect("body is json"); + assert_eq!( + sent_body["messages"][0]["content"][0]["cache_control"], + json!({"type": "ephemeral"}) + ); +} + +#[tokio::test] +async fn messages_does_not_duplicate_auth_when_x_api_key_supplied() { + 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_2","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( + "x-api-key".to_string(), + Value::String("from-python".to_string()), + ); + headers.insert( + "anthropic-beta".to_string(), + Value::String("token-efficient-tools-2025-02-19".to_string()), + ); + + messages(MessagesRequest { + model: "claude-sonnet-4-5", + body: json!({"model": "claude-sonnet-4-5", "max_tokens": 8, "messages": []}), + api_key: Some("rust-fallback-key"), + api_base: Some(&format!("http://{addr}")), + custom_llm_provider: Some("azure_ai"), + extra_headers: Some(headers), + timeout: Some(Duration::from_secs(5)), + }) + .await + .expect("messages request succeeds"); + + let request = server.await.expect("server task completes"); + let head = request + .split_once("\r\n\r\n") + .expect("has body") + .0 + .to_ascii_lowercase(); + let api_key_count = head + .lines() + .filter(|line| line.starts_with("x-api-key:")) + .count(); + assert_eq!(api_key_count, 1, "{head}"); + assert!(head.contains("x-api-key: from-python"), "{head}"); + assert!( + head.contains("anthropic-beta: token-efficient-tools-2025-02-19"), + "{head}" + ); + assert!(!head.contains("rust-fallback-key"), "{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"); + let addr = listener.local_addr().expect("addr"); + + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accepts request"); + let _ = read_http_request(&mut socket).await; + let body = "unauthorized"; + let response = format!( + "HTTP/1.1 401 Unauthorized\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", + body.len(), + body + ); + socket + .write_all(response.as_bytes()) + .await + .expect("writes response"); + }); + + let err = 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: None, + timeout: Some(Duration::from_secs(5)), + }) + .await + .expect_err("provider error propagates"); + + assert!(matches!(err, CoreError::Http { status: 401, .. })); +} + +#[tokio::test] +async fn messages_rejects_unsupported_provider() { + let err = messages(MessagesRequest { + model: "claude-3-5-sonnet", + body: json!({"model": "claude-3-5-sonnet", "max_tokens": 8, "messages": []}), + api_key: Some("sk"), + api_base: Some("http://127.0.0.1:1"), + custom_llm_provider: Some("anthropic"), + extra_headers: None, + timeout: Some(Duration::from_millis(50)), + }) + .await + .expect_err("unsupported provider errors"); + + assert!(matches!(err, CoreError::InvalidProvider(provider) if provider == "anthropic")); +} diff --git a/litellm-rust/crates/ai-gateway/src/messages/types.rs b/litellm-rust/crates/ai-gateway/src/messages/types.rs new file mode 100644 index 00000000000..6840ff57cc4 --- /dev/null +++ b/litellm-rust/crates/ai-gateway/src/messages/types.rs @@ -0,0 +1,23 @@ +use std::time::Duration; + +use litellm_core::messages::transformation::AnthropicMessagesProviderConfig; +use serde_json::{Map, Value}; + +pub struct MessagesRequest<'a> { + pub model: &'a str, + pub body: Value, + pub api_key: Option<&'a str>, + pub api_base: Option<&'a str>, + pub custom_llm_provider: Option<&'a str>, + pub extra_headers: Option>, + pub timeout: Option, +} + +pub(crate) struct ProviderMessagesRequest { + pub(crate) model: String, + pub(crate) config: &'static dyn AnthropicMessagesProviderConfig, + pub(crate) url: String, + pub(crate) body: Value, + pub(crate) upstream_headers: Vec<(String, String)>, + pub(crate) timeout: Option, +} diff --git a/litellm-rust/crates/core/src/error.rs b/litellm-rust/crates/core/src/error.rs index b3e0519b772..c2b08eee0c0 100644 --- a/litellm-rust/crates/core/src/error.rs +++ b/litellm-rust/crates/core/src/error.rs @@ -19,9 +19,9 @@ pub enum CoreError { InvalidRequest(String), #[error("{0}")] Auth(String), - #[error("OCR request failed with status {status}: {body}")] + #[error("upstream request failed with status {status}: {body}")] Http { status: u16, body: String }, - #[error("OCR network error: {0}")] + #[error("upstream network error: {0}")] Network(String), #[error("routing error: {0}")] Routing(String), diff --git a/litellm-rust/crates/core/src/lib.rs b/litellm-rust/crates/core/src/lib.rs index 555a04ce853..27154f5a08b 100644 --- a/litellm-rust/crates/core/src/lib.rs +++ b/litellm-rust/crates/core/src/lib.rs @@ -1,5 +1,6 @@ pub mod call_lifecycle; pub mod error; +pub mod messages; pub mod ocr; pub mod providers; pub mod realtime; diff --git a/litellm-rust/crates/core/src/messages/mod.rs b/litellm-rust/crates/core/src/messages/mod.rs new file mode 100644 index 00000000000..ec2fbb969a6 --- /dev/null +++ b/litellm-rust/crates/core/src/messages/mod.rs @@ -0,0 +1,2 @@ +pub mod transformation; +pub mod types; diff --git a/litellm-rust/crates/core/src/messages/transformation.rs b/litellm-rust/crates/core/src/messages/transformation.rs new file mode 100644 index 00000000000..3a34a58de6f --- /dev/null +++ b/litellm-rust/crates/core/src/messages/transformation.rs @@ -0,0 +1,59 @@ +use crate::error::CoreResult; + +use super::types::{AnthropicMessagesRequest, AnthropicMessagesResponse}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MessagesAuthStrategy { + Bearer, + Header(&'static str), +} + +impl MessagesAuthStrategy { + pub fn header_name(self) -> &'static str { + match self { + Self::Bearer => "authorization", + Self::Header(header_name) => header_name, + } + } +} + +pub trait AnthropicMessagesProviderConfig: Sync { + fn complete_url( + &self, + api_base: Option<&str>, + model: &str, + env_lookup: &dyn Fn(&str) -> Option, + ) -> CoreResult; + + fn resolve_api_key( + &self, + api_key: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, + ) -> CoreResult; + + fn auth_strategy(&self) -> MessagesAuthStrategy { + MessagesAuthStrategy::Header("x-api-key") + } + + fn default_headers(&self) -> &'static [(&'static str, &'static str)] { + &[ + ("anthropic-version", "2023-06-01"), + ("content-type", "application/json"), + ] + } + + fn transform_request( + &self, + request: AnthropicMessagesRequest, + ) -> CoreResult { + Ok(request) + } + + fn transform_response( + &self, + _model: &str, + response: AnthropicMessagesResponse, + ) -> CoreResult { + Ok(response) + } +} diff --git a/litellm-rust/crates/core/src/messages/types.rs b/litellm-rust/crates/core/src/messages/types.rs new file mode 100644 index 00000000000..11fe17ea40f --- /dev/null +++ b/litellm-rust/crates/core/src/messages/types.rs @@ -0,0 +1,110 @@ +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum SystemPrompt { + Text(String), + Blocks(Vec), +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum MessageContent { + Text(String), + Blocks(Vec), +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct ContentBlock { + #[serde(skip_serializing_if = "Option::is_none")] + pub cache_control: Option, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct CacheControl { + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub cache_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ttl: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope: Option, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnthropicMessage { + pub role: String, + pub content: MessageContent, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnthropicMessagesRequest { + pub model: String, + pub messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub system: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_sequences: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_k: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tool_choice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub thinking: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub container: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mcp_servers: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub context_management: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub output_config: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub speed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub inference_geo: Option, + #[serde(flatten)] + pub extra: Map, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct AnthropicMessagesResponse { + pub id: String, + #[serde(rename = "type")] + pub message_type: String, + pub role: String, + pub model: String, + pub content: Vec, + // Anthropic always includes stop_reason / stop_sequence, null until the turn + // ends; serialize them even when None so callers see the same shape as Python. + pub stop_reason: Option, + pub stop_sequence: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub usage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub container: Option, + #[serde(flatten)] + pub extra: Map, +} diff --git a/litellm-rust/crates/core/src/providers/anthropic/messages/mod.rs b/litellm-rust/crates/core/src/providers/anthropic/messages/mod.rs new file mode 100644 index 00000000000..f239b6921fa --- /dev/null +++ b/litellm-rust/crates/core/src/providers/anthropic/messages/mod.rs @@ -0,0 +1 @@ +pub mod transformation; diff --git a/litellm-rust/crates/core/src/providers/anthropic/messages/transformation.rs b/litellm-rust/crates/core/src/providers/anthropic/messages/transformation.rs new file mode 100644 index 00000000000..829f2260d3c --- /dev/null +++ b/litellm-rust/crates/core/src/providers/anthropic/messages/transformation.rs @@ -0,0 +1,142 @@ +use crate::error::{CoreError, CoreResult}; +use crate::messages::transformation::{AnthropicMessagesProviderConfig, MessagesAuthStrategy}; + +const ANTHROPIC_API_KEY_ENV: &str = "ANTHROPIC_API_KEY"; +const ANTHROPIC_API_BASE_ENV: &str = "ANTHROPIC_API_BASE"; +const DEFAULT_ANTHROPIC_API_BASE: &str = "https://api.anthropic.com"; +const MESSAGES_PATH_SUFFIX: &str = "/v1/messages"; + +pub struct AnthropicMessagesConfig; + +pub const ANTHROPIC_MESSAGES_CONFIG: AnthropicMessagesConfig = AnthropicMessagesConfig; + +pub fn non_empty(value: Option<&str>) -> Option<&str> { + value.map(str::trim).filter(|value| !value.is_empty()) +} + +pub fn resolve_anthropic_api_key( + api_key: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, +) -> CoreResult { + non_empty(api_key) + .map(str::to_string) + .or_else(|| env_lookup(ANTHROPIC_API_KEY_ENV).filter(|value| !value.trim().is_empty())) + .ok_or_else(|| { + CoreError::Auth( + "Missing Anthropic API Key - Set `api_key` or the ANTHROPIC_API_KEY \ + environment variable" + .to_string(), + ) + }) +} + +pub fn complete_anthropic_url( + api_base: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, +) -> String { + let api_base = non_empty(api_base) + .map(str::to_string) + .or_else(|| env_lookup(ANTHROPIC_API_BASE_ENV).filter(|value| !value.trim().is_empty())) + .unwrap_or_else(|| DEFAULT_ANTHROPIC_API_BASE.to_string()); + + let api_base = api_base.trim_end_matches('/'); + if api_base.ends_with(MESSAGES_PATH_SUFFIX) { + return api_base.to_string(); + } + format!("{api_base}{MESSAGES_PATH_SUFFIX}") +} + +impl AnthropicMessagesProviderConfig for AnthropicMessagesConfig { + fn complete_url( + &self, + api_base: Option<&str>, + _model: &str, + env_lookup: &dyn Fn(&str) -> Option, + ) -> CoreResult { + Ok(complete_anthropic_url(api_base, env_lookup)) + } + + fn resolve_api_key( + &self, + api_key: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, + ) -> CoreResult { + resolve_anthropic_api_key(api_key, env_lookup) + } + + fn auth_strategy(&self) -> MessagesAuthStrategy { + MessagesAuthStrategy::Header("x-api-key") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn url_defaults_to_public_anthropic_endpoint() { + assert_eq!( + complete_anthropic_url(None, &|_| None), + "https://api.anthropic.com/v1/messages" + ); + } + + #[test] + fn url_appends_messages_suffix_to_custom_base() { + assert_eq!( + complete_anthropic_url(Some("https://proxy.internal"), &|_| None), + "https://proxy.internal/v1/messages" + ); + } + + #[test] + fn url_leaves_complete_messages_endpoint_untouched() { + assert_eq!( + complete_anthropic_url(Some("https://proxy.internal/v1/messages"), &|_| None), + "https://proxy.internal/v1/messages" + ); + } + + #[test] + fn url_falls_back_to_env_base() { + let with_env = |key: &str| { + (key == ANTHROPIC_API_BASE_ENV).then(|| "https://env.anthropic".to_string()) + }; + assert_eq!( + complete_anthropic_url(Some(" "), &with_env), + "https://env.anthropic/v1/messages" + ); + } + + #[test] + fn api_key_prefers_param_then_env_then_errors() { + assert_eq!( + resolve_anthropic_api_key(Some("sk-param"), &|_| None).unwrap(), + "sk-param" + ); + let with_env = |key: &str| (key == ANTHROPIC_API_KEY_ENV).then(|| "sk-env".to_string()); + assert_eq!( + resolve_anthropic_api_key(Some(" "), &with_env).unwrap(), + "sk-env" + ); + assert!(matches!( + resolve_anthropic_api_key(None, &|_| None).expect_err("missing key"), + CoreError::Auth(_) + )); + } + + #[test] + fn auth_strategy_and_default_headers_match_anthropic() { + assert_eq!( + ANTHROPIC_MESSAGES_CONFIG.auth_strategy().header_name(), + "x-api-key" + ); + assert_eq!( + ANTHROPIC_MESSAGES_CONFIG.default_headers(), + &[ + ("anthropic-version", "2023-06-01"), + ("content-type", "application/json"), + ] + ); + } +} diff --git a/litellm-rust/crates/core/src/providers/anthropic/mod.rs b/litellm-rust/crates/core/src/providers/anthropic/mod.rs new file mode 100644 index 00000000000..ba63992f3cb --- /dev/null +++ b/litellm-rust/crates/core/src/providers/anthropic/mod.rs @@ -0,0 +1 @@ +pub mod messages; diff --git a/litellm-rust/crates/core/src/providers/azure_ai/messages/mod.rs b/litellm-rust/crates/core/src/providers/azure_ai/messages/mod.rs new file mode 100644 index 00000000000..f239b6921fa --- /dev/null +++ b/litellm-rust/crates/core/src/providers/azure_ai/messages/mod.rs @@ -0,0 +1 @@ +pub mod transformation; diff --git a/litellm-rust/crates/core/src/providers/azure_ai/messages/transformation.rs b/litellm-rust/crates/core/src/providers/azure_ai/messages/transformation.rs new file mode 100644 index 00000000000..13e79b087c7 --- /dev/null +++ b/litellm-rust/crates/core/src/providers/azure_ai/messages/transformation.rs @@ -0,0 +1,512 @@ +use crate::error::{CoreError, CoreResult}; +use crate::messages::transformation::{AnthropicMessagesProviderConfig, MessagesAuthStrategy}; +use crate::messages::types::{ + AnthropicMessage, AnthropicMessagesRequest, AnthropicMessagesResponse, ContentBlock, + MessageContent, SystemPrompt, +}; +use crate::providers::anthropic::messages::transformation::{ + non_empty, AnthropicMessagesConfig, ANTHROPIC_MESSAGES_CONFIG, +}; +use serde_json::{Map, Value}; + +const AZURE_API_KEY_ENV: &str = "AZURE_API_KEY"; +const AZURE_API_BASE_ENV: &str = "AZURE_API_BASE"; +const ANTHROPIC_PATH_SEGMENT: &str = "/anthropic"; +const MESSAGES_PATH_SUFFIX: &str = "/v1/messages"; +const SYSTEM_ROLE: &str = "system"; +const TEXT_BLOCK_TYPE: &str = "text"; + +pub struct AzureAnthropicMessagesConfig { + anthropic: AnthropicMessagesConfig, +} + +pub const AZURE_ANTHROPIC_MESSAGES_CONFIG: AzureAnthropicMessagesConfig = + AzureAnthropicMessagesConfig { + anthropic: ANTHROPIC_MESSAGES_CONFIG, + }; + +pub fn resolve_azure_api_key( + api_key: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, +) -> CoreResult { + non_empty(api_key) + .map(str::to_string) + .or_else(|| env_lookup(AZURE_API_KEY_ENV).filter(|value| !value.trim().is_empty())) + .ok_or_else(|| { + CoreError::Auth( + "Missing Azure API Key - Set `api_key` or the AZURE_API_KEY environment variable" + .to_string(), + ) + }) +} + +pub fn complete_azure_anthropic_url( + api_base: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, +) -> CoreResult { + let api_base = non_empty(api_base) + .map(str::to_string) + .or_else(|| env_lookup(AZURE_API_BASE_ENV).filter(|value| !value.trim().is_empty())) + .ok_or_else(|| { + CoreError::Auth( + "Missing Azure API Base - Set `api_base` or the AZURE_API_BASE environment variable. \ + Expected format: https://.services.ai.azure.com/anthropic" + .to_string(), + ) + })?; + + let api_base = api_base.trim_end_matches('/'); + + if api_base.ends_with(MESSAGES_PATH_SUFFIX) { + return Ok(api_base.to_string()); + } + + let with_anthropic = match api_base.split_once(ANTHROPIC_PATH_SEGMENT) { + Some((prefix, _)) => format!("{prefix}{ANTHROPIC_PATH_SEGMENT}"), + None => format!("{api_base}{ANTHROPIC_PATH_SEGMENT}"), + }; + Ok(format!("{with_anthropic}{MESSAGES_PATH_SUFFIX}")) +} + +fn strip_scope_from_block(block: &mut ContentBlock) { + if let Some(cache_control) = block.cache_control.as_mut() { + cache_control.scope = None; + } +} + +fn strip_scope_from_system(system: &mut SystemPrompt) { + if let SystemPrompt::Blocks(blocks) = system { + blocks.iter_mut().for_each(strip_scope_from_block); + } +} + +fn strip_scope_from_message(message: &mut AnthropicMessage) { + if let MessageContent::Blocks(blocks) = &mut message.content { + blocks.iter_mut().for_each(strip_scope_from_block); + } +} + +fn text_content_block(text: String) -> ContentBlock { + let extra = Map::from_iter([ + ( + "type".to_string(), + Value::String(TEXT_BLOCK_TYPE.to_string()), + ), + ("text".to_string(), Value::String(text)), + ]); + ContentBlock { + cache_control: None, + extra, + } +} + +fn content_into_blocks(content: MessageContent) -> Vec { + match content { + MessageContent::Text(text) => vec![text_content_block(text)], + MessageContent::Blocks(blocks) => blocks, + } +} + +fn system_into_blocks(system: Option) -> Vec { + match system { + None => Vec::new(), + Some(SystemPrompt::Text(text)) => vec![text_content_block(text)], + Some(SystemPrompt::Blocks(blocks)) => blocks, + } +} + +fn fold_system_role_messages(request: AnthropicMessagesRequest) -> AnthropicMessagesRequest { + if !request.messages.iter().any(|msg| msg.role == SYSTEM_ROLE) { + return request; + } + + let (system_messages, chat_messages): (Vec, Vec) = request + .messages + .into_iter() + .partition(|msg| msg.role == SYSTEM_ROLE); + + let folded_system: Vec = system_into_blocks(request.system) + .into_iter() + .chain( + system_messages + .into_iter() + .flat_map(|msg| content_into_blocks(msg.content)), + ) + .collect(); + + AnthropicMessagesRequest { + messages: chat_messages, + system: (!folded_system.is_empty()).then_some(SystemPrompt::Blocks(folded_system)), + ..request + } +} + +impl AnthropicMessagesProviderConfig for AzureAnthropicMessagesConfig { + fn complete_url( + &self, + api_base: Option<&str>, + _model: &str, + env_lookup: &dyn Fn(&str) -> Option, + ) -> CoreResult { + complete_azure_anthropic_url(api_base, env_lookup) + } + + fn resolve_api_key( + &self, + api_key: Option<&str>, + env_lookup: &dyn Fn(&str) -> Option, + ) -> CoreResult { + resolve_azure_api_key(api_key, env_lookup) + } + + fn auth_strategy(&self) -> MessagesAuthStrategy { + self.anthropic.auth_strategy() + } + + fn default_headers(&self) -> &'static [(&'static str, &'static str)] { + self.anthropic.default_headers() + } + + fn transform_request( + &self, + request: AnthropicMessagesRequest, + ) -> CoreResult { + let mut request = fold_system_role_messages(request); + if let Some(system) = request.system.as_mut() { + strip_scope_from_system(system); + } + request + .messages + .iter_mut() + .for_each(strip_scope_from_message); + self.anthropic.transform_request(request) + } + + fn transform_response( + &self, + model: &str, + response: AnthropicMessagesResponse, + ) -> CoreResult { + self.anthropic.transform_response(model, response) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn request_from(value: serde_json::Value) -> AnthropicMessagesRequest { + serde_json::from_value(value).expect("valid request") + } + + fn to_value(request: AnthropicMessagesRequest) -> serde_json::Value { + serde_json::to_value(request).expect("serializable request") + } + + #[test] + fn url_appends_anthropic_and_messages_suffix() { + let url = + complete_azure_anthropic_url(Some("https://resource.services.ai.azure.com"), &|_| None) + .expect("url builds"); + assert_eq!( + url, + "https://resource.services.ai.azure.com/anthropic/v1/messages" + ); + } + + #[test] + fn url_keeps_existing_anthropic_segment() { + let url = complete_azure_anthropic_url( + Some("https://resource.services.ai.azure.com/anthropic"), + &|_| None, + ) + .expect("url builds"); + assert_eq!( + url, + "https://resource.services.ai.azure.com/anthropic/v1/messages" + ); + } + + #[test] + fn url_leaves_complete_messages_endpoint_untouched() { + for base in [ + "https://resource.services.ai.azure.com/anthropic/v1/messages", + "https://resource.services.ai.azure.com/v1/messages", + ] { + assert_eq!( + complete_azure_anthropic_url(Some(base), &|_| None).expect("url builds"), + base + ); + } + } + + #[test] + fn url_trims_trailing_slash_and_truncates_after_anthropic() { + let url = complete_azure_anthropic_url( + Some("https://resource.services.ai.azure.com/anthropic/extra/"), + &|_| None, + ) + .expect("url builds"); + assert_eq!( + url, + "https://resource.services.ai.azure.com/anthropic/v1/messages" + ); + } + + #[test] + fn url_falls_back_to_env_then_errors_when_absent() { + let with_env = |key: &str| { + (key == AZURE_API_BASE_ENV).then(|| "https://env.services.ai.azure.com".to_string()) + }; + assert_eq!( + complete_azure_anthropic_url(None, &with_env).expect("url builds"), + "https://env.services.ai.azure.com/anthropic/v1/messages" + ); + let err = complete_azure_anthropic_url(Some(" "), &|_| None).expect_err("missing base"); + assert!(matches!(err, CoreError::Auth(_))); + } + + #[test] + fn resolve_api_key_prefers_param_then_env() { + assert_eq!( + resolve_azure_api_key(Some("sk-param"), &|_| None).unwrap(), + "sk-param" + ); + let with_env = |key: &str| (key == AZURE_API_KEY_ENV).then(|| "sk-env".to_string()); + assert_eq!( + resolve_azure_api_key(Some(" "), &with_env).unwrap(), + "sk-env" + ); + assert!(matches!( + resolve_azure_api_key(None, &|_| None).expect_err("missing key"), + CoreError::Auth(_) + )); + } + + #[test] + fn auth_strategy_is_x_api_key() { + assert_eq!( + AZURE_ANTHROPIC_MESSAGES_CONFIG + .auth_strategy() + .header_name(), + "x-api-key" + ); + } + + #[test] + fn default_headers_match_python() { + assert_eq!( + AZURE_ANTHROPIC_MESSAGES_CONFIG.default_headers(), + &[ + ("anthropic-version", "2023-06-01"), + ("content-type", "application/json"), + ] + ); + } + + #[test] + fn transform_request_strips_scope_from_system_and_messages() { + let request = request_from(json!({ + "model": "claude-sonnet-4-5", + "max_tokens": 1024, + "system": [ + { + "type": "text", + "text": "sys", + "cache_control": {"type": "ephemeral", "ttl": "1h", "scope": "global"} + } + ], + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "hi", + "cache_control": {"type": "ephemeral", "scope": "global"} + }, + {"type": "text", "text": "no cache control"} + ] + } + ] + })); + + let transformed = to_value( + AZURE_ANTHROPIC_MESSAGES_CONFIG + .transform_request(request) + .expect("request transforms"), + ); + + assert_eq!( + transformed["system"][0]["cache_control"], + json!({"type": "ephemeral", "ttl": "1h"}) + ); + assert_eq!( + transformed["messages"][0]["content"][0]["cache_control"], + json!({"type": "ephemeral"}) + ); + assert_eq!( + transformed["messages"][0]["content"][1], + json!({"type": "text", "text": "no cache control"}) + ); + } + + #[test] + fn transform_request_is_idempotent_and_preserves_string_system() { + let request = request_from(json!({ + "model": "claude-sonnet-4-5", + "max_tokens": 16, + "system": "plain string system", + "messages": [{"role": "user", "content": "hi"}] + })); + let once = AZURE_ANTHROPIC_MESSAGES_CONFIG + .transform_request(request) + .expect("request transforms"); + let twice = AZURE_ANTHROPIC_MESSAGES_CONFIG + .transform_request(once.clone()) + .expect("request transforms"); + assert_eq!(once, twice); + assert_eq!(to_value(once)["system"], json!("plain string system")); + } + + #[test] + fn transform_request_preserves_all_supported_params() { + let body = json!({ + "model": "claude-sonnet-4-5", + "max_tokens": 256, + "messages": [{"role": "user", "content": "hi"}], + "system": "be terse", + "metadata": {"user_id": "u1"}, + "stop_sequences": ["STOP"], + "stream": false, + "temperature": 0.4, + "top_p": 0.9, + "top_k": 40, + "tools": [{"name": "get_weather", "input_schema": {"type": "object"}}], + "tool_choice": {"type": "auto"}, + "thinking": {"type": "enabled", "budget_tokens": 1024}, + "service_tier": "auto", + "container": {"id": "c1"}, + "mcp_servers": [{"type": "url", "url": "https://mcp.example", "name": "x"}], + "context_management": {"edits": []}, + "output_format": {"type": "json_schema"}, + "output_config": {"effort": "high"}, + "speed": "fast", + "inference_geo": "us", + "litellm_metadata": {"trace": "abc"} + }); + let transformed = to_value( + AZURE_ANTHROPIC_MESSAGES_CONFIG + .transform_request(request_from(body.clone())) + .expect("request transforms"), + ); + assert_eq!(transformed, body); + } + + #[test] + fn transform_request_folds_system_role_message_into_top_level_system() { + let request = request_from(json!({ + "model": "claude-sonnet-4-5", + "max_tokens": 256, + "system": [{"type": "text", "text": "base system"}], + "messages": [ + {"role": "user", "content": "fix the bug"}, + {"role": "system", "content": "Available agent types: claude"} + ] + })); + + let transformed = to_value( + AZURE_ANTHROPIC_MESSAGES_CONFIG + .transform_request(request) + .expect("request transforms"), + ); + + assert_eq!( + transformed["messages"], + json!([{"role": "user", "content": "fix the bug"}]) + ); + assert_eq!( + transformed["system"], + json!([ + {"type": "text", "text": "base system"}, + {"type": "text", "text": "Available agent types: claude"} + ]) + ); + } + + #[test] + fn transform_request_folds_system_role_when_no_top_level_system() { + let request = request_from(json!({ + "model": "claude-sonnet-4-5", + "max_tokens": 256, + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "hi"}]}, + {"role": "system", "content": [{"type": "text", "text": "sys block"}]} + ] + })); + + let transformed = to_value( + AZURE_ANTHROPIC_MESSAGES_CONFIG + .transform_request(request) + .expect("request transforms"), + ); + + assert_eq!( + transformed["messages"], + json!([{"role": "user", "content": [{"type": "text", "text": "hi"}]}]) + ); + assert_eq!( + transformed["system"], + json!([{"type": "text", "text": "sys block"}]) + ); + } + + #[test] + fn transform_request_leaves_requests_without_system_role_untouched() { + let body = json!({ + "model": "claude-sonnet-4-5", + "max_tokens": 256, + "system": "be terse", + "messages": [ + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"} + ] + }); + let transformed = to_value( + AZURE_ANTHROPIC_MESSAGES_CONFIG + .transform_request(request_from(body.clone())) + .expect("request transforms"), + ); + assert_eq!(transformed, body); + } + + #[test] + fn transform_request_rejects_non_object_body() { + let err = serde_json::from_value::(json!("bad")) + .expect_err("non-object body should error"); + assert!(err.is_data()); + } + + #[test] + fn transform_response_passes_through() { + let response: AnthropicMessagesResponse = serde_json::from_value(json!({ + "id": "msg_1", + "type": "message", + "role": "assistant", + "content": [{"type": "text", "text": "hello"}], + "model": "claude-sonnet-4-5", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": {"input_tokens": 1, "output_tokens": 2} + })) + .expect("valid response"); + let transformed = AZURE_ANTHROPIC_MESSAGES_CONFIG + .transform_response("claude-sonnet-4-5", response) + .expect("response transforms"); + let value = serde_json::to_value(transformed).expect("serializable"); + assert_eq!(value["stop_reason"], json!("end_turn")); + assert_eq!(value["stop_sequence"], json!(null)); + assert_eq!(value["content"][0]["text"], json!("hello")); + } +} diff --git a/litellm-rust/crates/core/src/providers/azure_ai/mod.rs b/litellm-rust/crates/core/src/providers/azure_ai/mod.rs index 3621ff6a2fd..5d13fa93e00 100644 --- a/litellm-rust/crates/core/src/providers/azure_ai/mod.rs +++ b/litellm-rust/crates/core/src/providers/azure_ai/mod.rs @@ -1 +1,2 @@ +pub mod messages; pub mod ocr; diff --git a/litellm-rust/crates/core/src/providers/mod.rs b/litellm-rust/crates/core/src/providers/mod.rs index d75e750a0ba..dc9dc515e7d 100644 --- a/litellm-rust/crates/core/src/providers/mod.rs +++ b/litellm-rust/crates/core/src/providers/mod.rs @@ -1,3 +1,4 @@ +pub mod anthropic; pub mod azure_ai; pub mod mistral; pub mod openai; diff --git a/litellm-rust/crates/core/tests/workspace_crate_allowlist.rs b/litellm-rust/crates/core/tests/workspace_crate_allowlist.rs index a56d19b8242..656ba033b62 100644 --- a/litellm-rust/crates/core/tests/workspace_crate_allowlist.rs +++ b/litellm-rust/crates/core/tests/workspace_crate_allowlist.rs @@ -62,12 +62,17 @@ fn parse_members(manifest: &str) -> BTreeSet { members } -/// The immediate subdirectory names under `crates/`. +/// The crate subdirectory names under `crates/`. +/// +/// A directory counts as a crate only when it holds a `Cargo.toml`; non-crate +/// directories (e.g. docs like `CODING_STANDARDS/`) are ignored so they can live +/// under `crates/` without tripping the crate-proliferation guard. fn crate_dirs(root: &Path) -> BTreeSet { fs::read_dir(root.join("crates")) .expect("crates/ directory should exist") .filter_map(Result::ok) .filter(|entry| entry.file_type().map(|ty| ty.is_dir()).unwrap_or(false)) + .filter(|entry| entry.path().join("Cargo.toml").is_file()) .map(|entry| entry.file_name().to_string_lossy().into_owned()) .collect() } diff --git a/litellm-rust/crates/python-bridge/src/lib.rs b/litellm-rust/crates/python-bridge/src/lib.rs index 271864581f3..77f4427127a 100644 --- a/litellm-rust/crates/python-bridge/src/lib.rs +++ b/litellm-rust/crates/python-bridge/src/lib.rs @@ -1,5 +1,6 @@ use std::time::Duration; +use litellm_ai_gateway::io::messages::{messages as run_messages, MessagesRequest}; use litellm_ai_gateway::io::ocr::{ocr as run_ocr, OcrRequest}; use litellm_core::error::CoreError; use pyo3::exceptions::{PyRuntimeError, PyValueError}; @@ -171,6 +172,92 @@ fn aocr( }) } +type MarshaledMessagesInputs = (Value, Option>, Option); + +fn marshal_messages_inputs( + py: Python<'_>, + body: Py, + extra_headers: Option>, + timeout_seconds: Option, +) -> PyResult { + let body = py_to_json(py, body.bind(py))?; + if !body.is_object() { + return Err(PyValueError::new_err("body must be a dict")); + } + let extra_headers = match extra_headers { + Some(headers) => Some(optional_object_to_map(py, "extra_headers", Some(headers))?), + None => None, + }; + Ok((body, extra_headers, optional_timeout(timeout_seconds))) +} + +#[pyfunction] +#[pyo3(signature = (model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None))] +#[allow(clippy::too_many_arguments)] +fn messages( + py: Python<'_>, + model: String, + body: Py, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + extra_headers: Option>, + timeout_seconds: Option, +) -> PyResult> { + let (body, extra_headers, timeout) = + marshal_messages_inputs(py, body, extra_headers, timeout_seconds)?; + + let result = gil::release_gil(py, || { + pyo3_async_runtimes::tokio::get_runtime().block_on(run_messages(MessagesRequest { + model: &model, + body, + api_key: api_key.as_deref(), + api_base: api_base.as_deref(), + custom_llm_provider: custom_llm_provider.as_deref(), + extra_headers, + timeout, + })) + }); + + match result { + Ok(value) => json_to_py(py, value), + Err(err) => Err(core_error_to_pyerr(err)), + } +} + +#[pyfunction] +#[pyo3(signature = (model, body, api_key=None, api_base=None, custom_llm_provider=None, extra_headers=None, timeout_seconds=None))] +#[allow(clippy::too_many_arguments)] +fn amessages( + py: Python<'_>, + model: String, + body: Py, + api_key: Option, + api_base: Option, + custom_llm_provider: Option, + extra_headers: Option>, + timeout_seconds: Option, +) -> PyResult> { + let (body, extra_headers, timeout) = + marshal_messages_inputs(py, body, extra_headers, timeout_seconds)?; + + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let value = run_messages(MessagesRequest { + model: &model, + body, + api_key: api_key.as_deref(), + api_base: api_base.as_deref(), + custom_llm_provider: custom_llm_provider.as_deref(), + extra_headers, + timeout, + }) + .await + .map_err(core_error_to_pyerr)?; + + Python::attach(|py| json_to_py(py, value)) + }) +} + #[pyfunction] fn gil_stats(py: Python<'_>) -> PyResult> { let stats = PyDict::new(py); @@ -182,6 +269,8 @@ fn gil_stats(py: Python<'_>) -> PyResult> { fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_function(wrap_pyfunction!(ocr, module)?)?; module.add_function(wrap_pyfunction!(aocr, module)?)?; + module.add_function(wrap_pyfunction!(messages, module)?)?; + module.add_function(wrap_pyfunction!(amessages, module)?)?; module.add_function(wrap_pyfunction!(gil_stats, module)?)?; Ok(()) } diff --git a/litellm/integrations/otel/emitter.py b/litellm/integrations/otel/emitter.py index f97f8b8394c..8651cf586cd 100644 --- a/litellm/integrations/otel/emitter.py +++ b/litellm/integrations/otel/emitter.py @@ -72,6 +72,42 @@ def _stamp_litellm_error_attributes(span: Span, error: SpanError) -> None: span.set_attribute(LiteLLMError.LLM_PROVIDER, error.llm_provider) +def stamp_error( + span: Span, + error: SpanError, + *, + record_event: bool = True, + set_status: bool = True, +) -> tuple[str, str] | None: + """Stamp the full v2 error attribute set on ``span`` and return the resolved + ``(error_type, message)`` pair, or ``None`` when the error carries neither a + type nor a message. + + Shared by the LLM-call span (``finish_span``) and the proxy-level failure + spans (the FastAPI SERVER span and the ``auth`` phase span) so every v2 error + span carries identical keys. The semconv ``exception`` event rides alongside + the attributes so backends that map unknown string attrs to a truncated + ``keyword`` (e.g. Elasticsearch's 1024-char ``ignore_above``) still see the + full untruncated message on the recognized event field. ``record_event`` and + ``set_status`` are opt-outs for callers whose span lifecycle (``use_span``) or + owner (the FastAPI instrumentor) already records the event or the status. + """ + if not (error.error_type or error.message): + return None + error_type = error.error_type or "error" + message = error.message or error.error_type or "error" + _stamp_otel_error_attributes(span, error_type, message) + _stamp_litellm_error_attributes(span, error) + if set_status: + span.set_status(Status(StatusCode.ERROR, message)) + if record_event: + span.add_event( + ExceptionEvent.NAME, + {ExceptionEvent.TYPE: error_type, ExceptionEvent.MESSAGE: message}, + ) + return error_type, message + + class SpanEmitter: def __init__( self, @@ -212,21 +248,10 @@ class SpanEmitter: ) else None ) - if error and (error.error_type or error.message): - error_type = error.error_type or "error" - message = error.message or error.error_type or "error" - _stamp_otel_error_attributes(span, error_type, message) - _stamp_litellm_error_attributes(span, error) - span.set_status(Status(StatusCode.ERROR, message)) - # Also emit the semconv ``exception`` event so backends that - # dynamic-map unknown string span attrs to ``keyword`` (e.g. - # Elasticsearch with a 1024-char ``ignore_above``) still see the - # full untruncated message on the recognized event field. - span.add_event( - ExceptionEvent.NAME, - {ExceptionEvent.TYPE: error_type, ExceptionEvent.MESSAGE: message}, - ) - if self._event_recorder is not None and role is SpanRole.LLM_CALL: + if error: + stamped = stamp_error(span, error) + if stamped is not None and self._event_recorder is not None and role is SpanRole.LLM_CALL: + error_type, message = stamped self._event_recorder.record_operation_exception( span_context=span.get_span_context(), error_type=error_type, diff --git a/litellm/integrations/otel/logger.py b/litellm/integrations/otel/logger.py index be72fabd387..778f5342e90 100644 --- a/litellm/integrations/otel/logger.py +++ b/litellm/integrations/otel/logger.py @@ -24,7 +24,7 @@ from litellm.integrations.otel.plumbing.context import ( set_request_baggage, set_request_root_span, ) -from litellm.integrations.otel.emitter import SpanEmitter +from litellm.integrations.otel.emitter import SpanEmitter, stamp_error from litellm.integrations.otel.mappers import resolve_mappers from litellm.integrations.otel.model.metadata import ( LLMCallEvent, @@ -59,6 +59,7 @@ from litellm.integrations.otel.model.spans import SpanRole, span_role_for_servic from litellm.integrations.otel.model.utils import to_ns if TYPE_CHECKING: + from litellm.proxy._types import UserAPIKeyAuth from litellm.types.utils import ( StandardLoggingGuardrailInformation, StandardLoggingPayload, @@ -66,6 +67,33 @@ if TYPE_CHECKING: LITELLM_TRACER_NAME = "litellm" + +def _span_error_from_exception( + exception: "Exception | None", + *, + status_code: int | None = None, + traceback_str: str | None = None, +) -> SpanError: + """A ``SpanError`` for a proxy-level failure that never produced a + ``StandardLoggingPayload`` (auth / validation / malformed-body rejections), + mirroring ``_parse_error``'s field mapping so it stamps the same v2 keys a + failed LLM call does. ``status_code`` pins ``error.code`` to the real response + status, matching v1's SERVER-span behavior.""" + from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup + + info = StandardLoggingPayloadSetup.get_error_information( + original_exception=exception, + traceback_str=traceback_str, + ) + return SpanError( + error_type=info.get("error_class") or info.get("error_code") or None, + message=info.get("error_message") or None, + code=str(status_code) if status_code is not None else (info.get("error_code") or None), + stack_trace=info.get("traceback") or None, + llm_provider=info.get("llm_provider") or None, + ) + + # Any callback whose class belongs to one of these modules is "the OTel # callback" for proxy-global-registration purposes. _OTEL_MODULES = ( @@ -558,7 +586,12 @@ class OpenTelemetryV2(CustomLogger): def start_phase_span(self, name: str) -> "Iterator[Span]": span = self._emitter.start_span(SpanRole.SERVICE, name) with use_span(span, end_on_exit=True): - yield span + try: + yield span + except Exception as exc: + if is_recordable_span(span): + stamp_error(span, _span_error_from_exception(exc), record_event=False, set_status=False) + raise async def async_pre_call_hook( self, @@ -573,6 +606,48 @@ class OpenTelemetryV2(CustomLogger): ) return data + def record_error_attributes_on_span( + self, + span: "Span | None", + exception: "Exception | None", + status_code: int, + ) -> None: + """Stamp the v2 error.* attributes on the FastAPI-owned SERVER span for a + failure that dies before any LLM-call span exists (malformed body, auth / + validation rejection). Called from the proxy's global exception handler via + ``_close_dangling_otel_server_span``. The instrumentor still owns the span's + status and lifecycle, so this only decorates it — never sets status, never + ends it — and emits no exception event, matching v1's SERVER-span behavior + and avoiding a duplicate of the event ``async_post_call_failure_hook`` or + the ``auth`` phase span already records.""" + if span is None or not is_recordable_span(span): + return + stamp_error( + span, + _span_error_from_exception(exception, status_code=status_code), + record_event=False, + set_status=False, + ) + + async def async_post_call_failure_hook( + self, + request_data: dict, + original_exception: Exception, + user_api_key_dict: "UserAPIKeyAuth", + traceback_str: "str | None" = None, + ) -> None: + """Stamp error.* on the request's root SERVER span for a proxy-level + failure that never reached an LLM call (empty body rejected in the + endpoint, auth failure), so the failed request carries the same error keys + a failed LLM call does. v1's ``OpenTelemetry`` implemented this same hook; + v2 lost it when it stopped subclassing ``OpenTelemetry``, which is the + LIT-4179 regression for pre-call failures.""" + span = request_root_span() or user_api_key_dict.parent_otel_span + if span is None or not is_recordable_span(span): + return None + stamp_error(span, _span_error_from_exception(original_exception, traceback_str=traceback_str)) + return None + def emit_guardrail_span(self, entry: "StandardLoggingGuardrailInformation") -> None: # Emitted by the guardrail-recording code the moment a guardrail finishes, # not from a post-call hook — that hook does not fire on every path (a diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index b47fc50e196..3e6f9ee08ee 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -153,6 +153,9 @@ if TYPE_CHECKING: from aiohttp import ClientSession from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj + from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import ( + AnthropicMessagesStreamingResponse, + ) from litellm.llms.base_llm.passthrough.transformation import BasePassthroughConfig from litellm.types.llms.openai_evals import ( CancelEvalResponse, @@ -2091,6 +2094,37 @@ 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), + model=model, + api_key=api_key, + api_base=api_base, + headers=headers, + request_body=request_body, + timeout=self._resolve_anthropic_messages_timeout( + litellm_params=litellm_params, + stream=stream or False, + custom_llm_provider=custom_llm_provider, + ), + ) + if rust_messages_response is not None: + if stream: + return self._rust_anthropic_messages_fake_stream(rust_messages_response) + return await self._finalize_anthropic_messages_response( + initial_response=rust_messages_response, + model=model, + messages=messages, + anthropic_messages_provider_config=anthropic_messages_provider_config, + anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, + logging_obj=logging_obj, + custom_llm_provider=custom_llm_provider, + api_key=api_key, + kwargs=kwargs, + ) + response = await self._async_post_anthropic_messages_with_http_error_retry( async_httpx_client=async_httpx_client, request_url=request_url, @@ -2165,6 +2199,31 @@ class BaseLLMHTTPHandler: logging_obj=logging_obj, ) + return await self._finalize_anthropic_messages_response( + initial_response=initial_response, + model=model, + messages=messages, + anthropic_messages_provider_config=anthropic_messages_provider_config, + anthropic_messages_optional_request_params=anthropic_messages_optional_request_params, + logging_obj=logging_obj, + custom_llm_provider=custom_llm_provider, + api_key=api_key, + kwargs=kwargs, + ) + + async def _finalize_anthropic_messages_response( + self, + *, + initial_response: AnthropicMessagesResponse, + model: str, + messages: list[dict], + anthropic_messages_provider_config: BaseAnthropicMessagesConfig, + anthropic_messages_optional_request_params: dict, + logging_obj: LiteLLMLoggingObj, + custom_llm_provider: str, + api_key: str | None, + kwargs: dict, + ) -> AnthropicMessagesResponse | AsyncIterator: # Inject api_key into kwargs so follow-up calls in agentic hooks can # authenticate. api_key is a named param here (not in kwargs), so # _prepare_followup_kwargs would miss it otherwise. @@ -2188,6 +2247,70 @@ class BaseLLMHTTPHandler: "anthropic_messages", ) + @staticmethod + async def _maybe_rust_anthropic_messages( + *, + custom_llm_provider: str, + litellm_params: GenericLiteLLMParams, + stream: bool, + rust_stream_eligible: bool, + model: str, + api_key: str | None, + api_base: str | None, + headers: dict, + request_body: dict, + timeout: float | httpx.Timeout | None, + ) -> AnthropicMessagesResponse | None: + if custom_llm_provider != "azure_ai" or litellm_params.get("rust") is not True: + return None + if stream and not rust_stream_eligible: + return None + + from litellm.rust_bridge import messages as rust_messages_bridge + + upstream_body = {key: value for key, value in request_body.items() if key != "stream"} + try: + rust_response = await rust_messages_bridge.amessages( + model=model, + body=upstream_body, + api_key=api_key, + api_base=api_base, + custom_llm_provider=custom_llm_provider, + extra_headers=headers, + timeout=timeout, + ) + except Exception as rust_error: # noqa: BLE001 # rollout-safety fallback: any Rust bridge failure must fall back to the Python path + verbose_logger.debug( + "Rust Anthropic messages bridge raised %s; falling back to Python path", + type(rust_error).__name__, + ) + return None + if rust_response is None: + return None + + response_obj = cast(AnthropicMessagesResponse, dict(rust_response)) + response_obj["_hidden_params"] = {"additional_headers": {"x-litellm-rust": "true"}} + return response_obj + + @staticmethod + def _rust_anthropic_messages_fake_stream( + rust_response: AnthropicMessagesResponse, + ) -> "AnthropicMessagesStreamingResponse": + from litellm.llms.anthropic.experimental_pass_through.messages.fake_stream_iterator import ( + FakeAnthropicMessagesStreamIterator, + ) + from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import ( + AnthropicMessagesStreamHiddenParams, + AnthropicMessagesStreamingResponse, + ) + + completion_stream = cast(AsyncIterator[bytes], FakeAnthropicMessagesStreamIterator(response=rust_response)) + hidden_params = AnthropicMessagesStreamHiddenParams(additional_headers={"x-litellm-rust": "true"}) + return AnthropicMessagesStreamingResponse( + completion_stream=completion_stream, + hidden_params=hidden_params, + ) + def anthropic_messages_handler( self, model: str, diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py index d55eb3ac014..9fe970f7fa9 100644 --- a/litellm/proxy/_experimental/mcp_server/db.py +++ b/litellm/proxy/_experimental/mcp_server/db.py @@ -33,6 +33,7 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import ( from litellm.proxy.utils import PrismaClient from litellm.repositories.object_permission_repository import ObjectPermissionRepository from litellm.repositories.table_repositories import ( + MCPServerOAuthClientRepository, MCPServerRepository, MCPUserCredentialsRepository, ) @@ -374,6 +375,12 @@ def encrypt_credentials(credentials: MCPCredentials, encryption_key: Optional[st value=client_secret, new_encryption_key=encryption_key, ) + client_private_key = credentials.get("client_private_key") + if client_private_key is not None: + credentials["client_private_key"] = encrypt_value_helper( + value=client_private_key, + new_encryption_key=encryption_key, + ) # AWS SigV4 credential fields aws_access_key_id = credentials.get("aws_access_key_id") if aws_access_key_id is not None: @@ -405,6 +412,7 @@ def decrypt_credentials( "auth_value", "client_id", "client_secret", + "client_private_key", "aws_access_key_id", "aws_secret_access_key", "aws_session_token", @@ -639,6 +647,7 @@ async def delete_mcp_server( for model, label in ( (prisma_client.db.litellm_mcpusercredentials, "credential"), (prisma_client.db.litellm_mcpuserenvvars, "env var"), + (prisma_client.db.litellm_mcpserveroauthclient, "OAuth client"), ): try: await model.delete_many(where={"server_id": server_id}) @@ -823,26 +832,66 @@ async def update_mcp_server( return updated_mcp_server -async def rotate_mcp_server_credentials_master_key(prisma_client: PrismaClient, touched_by: str, new_master_key: str): +async def get_mcp_server_oauth_client_credentials(prisma_client: PrismaClient, server_id: str) -> object | None: + """Read the persisted (encrypted) DCR OAuth client blob for a server from the + server-scoped store, or None. Config.yaml-declared servers have no + LiteLLM_MCPServerTable row, so their dynamically registered client lives here keyed + by server_id. The returned value is the raw credentials blob for + ``_get_persisted_dcr_credentials`` to parse.""" + row = await MCPServerOAuthClientRepository(prisma_client).table.find_unique(where={"server_id": server_id}) + if row is None: + return None + return row.credentials + + +async def upsert_mcp_server_oauth_client_credentials( + prisma_client: PrismaClient, server_id: str, credentials: MCPCredentials +) -> None: + """Persist a server's dynamically registered OAuth client (RFC 7591 DCR) in the + server-scoped store keyed by server_id, independent of any LiteLLM_MCPServerTable row. + client_id/client_secret are encrypted at rest with the same salt key used for the + server row's credentials blob, so ``_apply_persisted_dcr_credentials`` decrypts them the + same way regardless of which store a server's client came from.""" from litellm.litellm_core_utils.safe_json_dumps import safe_dumps + encrypted = encrypt_credentials(credentials=dict(credentials), encryption_key=_get_salt_key()) + blob = safe_dumps(encrypted) + await MCPServerOAuthClientRepository(prisma_client).table.upsert( + where={"server_id": server_id}, + data={ + "create": {"server_id": server_id, "credentials": blob}, + "update": {"credentials": blob}, + }, + ) + + +def _reencrypt_mcp_credentials_blob(credentials: object, new_master_key: str) -> str | None: + """Decrypt an at-rest MCP credentials blob with the current key and re-encrypt it under + new_master_key, returning the serialized blob or None when there is nothing to rotate. Shared by + every table that stores an encrypted MCP credentials blob so a master-key rotation covers them + uniformly and cannot silently skip one.""" + if not credentials: + return None + from litellm.litellm_core_utils.safe_json_dumps import safe_dumps # noqa: PLC0415 # avoids circular import + + creds_dict = json.loads(credentials) if isinstance(credentials, str) else dict(credentials) + decrypted = decrypt_credentials(credentials=cast(MCPCredentials, creds_dict)) + encrypted = encrypt_credentials(credentials=decrypted, encryption_key=new_master_key) + return safe_dumps(encrypted) + + +async def rotate_mcp_server_credentials_master_key(prisma_client: PrismaClient, touched_by: str, new_master_key: str): + from litellm.litellm_core_utils.safe_json_dumps import safe_dumps # noqa: PLC0415 # avoids circular import + mcp_servers = await MCPServerRepository(prisma_client).table.find_many() updated = 0 for mcp_server in mcp_servers: update_data: Dict[str, Any] = {} - credentials = mcp_server.credentials - if credentials: - # Decrypt with current key first, then re-encrypt with new key - decrypted_credentials = decrypt_credentials( - credentials=cast(MCPCredentials, dict(credentials)), - ) - encrypted_credentials = encrypt_credentials( - credentials=decrypted_credentials, - encryption_key=new_master_key, - ) - update_data["credentials"] = safe_dumps(encrypted_credentials) + rotated_credentials = _reencrypt_mcp_credentials_blob(mcp_server.credentials, new_master_key) + if rotated_credentials is not None: + update_data["credentials"] = rotated_credentials rotated_env_vars = _reencrypt_global_env_var_values(mcp_server.env_vars, new_master_key) if rotated_env_vars is not None: @@ -857,9 +906,23 @@ async def rotate_mcp_server_credentials_master_key(prisma_client: PrismaClient, data=update_data, ) updated += 1 + + oauth_clients = await MCPServerOAuthClientRepository(prisma_client).table.find_many() + oauth_updated = 0 + for oauth_client in oauth_clients: + rotated_credentials = _reencrypt_mcp_credentials_blob(oauth_client.credentials, new_master_key) + if rotated_credentials is None: + continue + await MCPServerOAuthClientRepository(prisma_client).table.update( + where={"server_id": oauth_client.server_id}, + data={"credentials": rotated_credentials}, + ) + oauth_updated += 1 + verbose_proxy_logger.info( - "rotate_mcp_server_credentials_master_key: rotated %d MCP server row(s)", + "rotate_mcp_server_credentials_master_key: rotated %d MCP server row(s) and %d OAuth-client row(s)", updated, + oauth_updated, ) diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 54aff86aab2..1af64749304 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -971,43 +971,93 @@ def _apply_persisted_dcr_credentials(mcp_server: MCPServer, credentials: _Persis return True -async def _get_persisted_mcp_server_with_dcr_client_id( - mcp_server: MCPServer, -) -> Optional[tuple["LiteLLM_MCPServerTable", _PersistedDcrCredentials]]: - from litellm.proxy._experimental.mcp_server.db import get_mcp_server # noqa: PLC0415 - from litellm.proxy.utils import get_prisma_client_or_throw # noqa: PLC0415 +async def _load_store_dcr_credentials(mcp_server: MCPServer) -> _PersistedDcrCredentials | None: + """DCR client persisted in the server-scoped OAuth-client store for a config-declared server + (which has no LiteLLM_MCPServerTable row). Returns None when the store has no usable client_id + or the DB is unreachable.""" + from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415 # avoids circular import + get_mcp_server_oauth_client_credentials, + ) + from litellm.proxy.utils import get_prisma_client_or_throw # noqa: PLC0415 # avoids circular import try: prisma_client = get_prisma_client_or_throw("Database not connected. Cannot read MCP OAuth client registration.") - persisted_mcp_server = await get_mcp_server( - prisma_client=prisma_client, - server_id=mcp_server.server_id, + blob = await get_mcp_server_oauth_client_credentials( + prisma_client=prisma_client, server_id=mcp_server.server_id ) - except Exception as exc: # noqa: BLE001 + except Exception as exc: # noqa: BLE001 # best-effort read; DB may be unreachable verbose_logger.debug( - "register_client_with_server: failed to read persisted DCR client registration for server_id=%s: %s", + "register_client_with_server: failed to read stored DCR client for server_id=%s: %s", mcp_server.server_id, exc, ) return None - if persisted_mcp_server is None: - return None - - credentials = _get_persisted_dcr_credentials(persisted_mcp_server.credentials) + credentials = _get_persisted_dcr_credentials(blob) if credentials is None or not credentials.client_id: return None + return credentials - return persisted_mcp_server, credentials + +async def hydrate_config_server_dcr_client(mcp_server: MCPServer) -> bool: + """Overlay a config-declared server's persisted DCR client onto its in-memory object so token + refresh can authenticate. Config.yaml servers have no LiteLLM_MCPServerTable row, so their + minted client lives in the server-scoped store; without this overlay the in-memory server + carries no client_id after a restart. An explicit client_id set in config.yaml wins and is never + overwritten by a persisted store client.""" + if mcp_server.client_id: + return False + credentials = await _load_store_dcr_credentials(mcp_server) + if credentials is None: + return False + return _apply_persisted_dcr_credentials(mcp_server, credentials) + + +async def _resolve_persisted_dcr_client( + mcp_server: MCPServer, +) -> tuple[Optional["LiteLLM_MCPServerTable"], _PersistedDcrCredentials | None]: + """Resolve a server's persisted DCR client using the same two-level rule the write path uses, so + read and write always agree. First, whether the server HAS a LiteLLM_MCPServerTable row: a row is + always resolved to that row and the store is never consulted for a server that has a row, so a + caller-chosen server_id colliding with a config-declared server cannot inherit that config + server's client, and a row that exists but carries no usable client_id yields (row, None) rather + than a store fallback. Second, among rowless servers: a config-declared server keeps its client in + the server-scoped store, while a rowless non-config server is a throwaway temp/session server with + no persisted client. Returns (row_or_None, credentials_or_None); the row is only needed by the + reuse path to refresh the registry for a DB-declared server.""" + from litellm.proxy._experimental.mcp_server.db import get_mcp_server # noqa: PLC0415 # avoids circular import + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # avoids circular import + global_mcp_server_manager, + ) + from litellm.proxy.utils import get_prisma_client_or_throw # noqa: PLC0415 # avoids circular import + + try: + prisma_client = get_prisma_client_or_throw("Database not connected. Cannot read MCP OAuth client registration.") + row = await get_mcp_server(prisma_client=prisma_client, server_id=mcp_server.server_id) + except Exception as exc: # noqa: BLE001 # best-effort read; DB may be unreachable + verbose_logger.debug( + "register_client_with_server: failed to read persisted DCR client for server_id=%s: %s", + mcp_server.server_id, + exc, + ) + return None, None + + if row is not None: + credentials = _get_persisted_dcr_credentials(row.credentials) + if credentials is not None and credentials.client_id: + return row, credentials + return row, None + if global_mcp_server_manager.is_config_declared_server(mcp_server.server_id): + return None, await _load_store_dcr_credentials(mcp_server) + return None, None async def _reuse_persisted_dcr_client_if_available( mcp_server: MCPServer, current_redirect_uri: Optional[str] = None ) -> bool: - persisted = await _get_persisted_mcp_server_with_dcr_client_id(mcp_server) - if persisted is None: + persisted_mcp_server, credentials = await _resolve_persisted_dcr_client(mcp_server) + if credentials is None: return False - persisted_mcp_server, credentials = persisted if current_redirect_uri is not None and _redirect_uri_not_registered(credentials, current_redirect_uri): verbose_logger.debug( "register_client_with_server: not reusing persisted DCR client for server_id=%s; its registered " @@ -1021,18 +1071,19 @@ async def _reuse_persisted_dcr_client_if_available( if not _apply_persisted_dcr_credentials(mcp_server, credentials): return False - from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 - global_mcp_server_manager, - ) - - try: - await global_mcp_server_manager.update_server(persisted_mcp_server) - except Exception as exc: # noqa: BLE001 - verbose_logger.warning( - "register_client_with_server: failed to refresh persisted DCR client registration for server_id=%s: %s", - mcp_server.server_id, - exc, + if persisted_mcp_server is not None: + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 # avoids circular import + global_mcp_server_manager, ) + + try: + await global_mcp_server_manager.update_server(persisted_mcp_server) + except Exception as exc: # noqa: BLE001 # best-effort registry refresh + verbose_logger.warning( + "register_client_with_server: failed to refresh persisted DCR client registration for server_id=%s: %s", + mcp_server.server_id, + exc, + ) return bool(mcp_server.client_id) @@ -1044,10 +1095,9 @@ async def _persisted_dcr_redirect_uri_is_stale(mcp_server: MCPServer, current_re otherwise short-circuits registration before any redirect check can run. Servers without a persisted DCR recording (admin-configured client_id, or registered before redirect_uris were recorded) are never reported stale.""" - persisted = await _get_persisted_mcp_server_with_dcr_client_id(mcp_server) - if persisted is None: + _, credentials = await _resolve_persisted_dcr_client(mcp_server) + if credentials is None: return False - _, credentials = persisted if not _redirect_uri_not_registered(credentials, current_redirect_uri): return False verbose_logger.warning( @@ -1067,7 +1117,10 @@ DcrRegistrationPersistenceResult = Literal["persisted", "reused", "skipped", "fa async def _persist_dcr_client_registration( mcp_server: MCPServer, registration_response: object, current_redirect_uri: str ) -> DcrRegistrationPersistenceResult: - """Persist the dynamically registered OAuth client (RFC 7591) onto the MCP server row. + """Persist the dynamically registered OAuth client (RFC 7591) to its single home: the server's + ``LiteLLM_MCPServerTable`` row when it has one, otherwise the server-scoped store when the server + is config-declared. A rowless server that is not config-declared is a throwaway temp/session + server, so its client is overlaid in memory only and not persisted. The interactive authorization_code flow mints a ``client_id`` via Dynamic Client Registration that discovery cannot re-derive; without persisting it the autonomous @@ -1106,16 +1159,20 @@ async def _persist_dcr_client_registration( if await _reuse_persisted_dcr_client_if_available(mcp_server, current_redirect_uri=current_redirect_uri): return "reused" + token_endpoint_auth_method = ( + "client_secret_basic" if registration.token_endpoint_auth_method == "client_secret_basic" else None + ) credentials: MCPCredentials = { "client_id": registration.client_id, "client_secret": registration.client_secret, - "token_endpoint_auth_method": ( - "client_secret_basic" if registration.token_endpoint_auth_method == "client_secret_basic" else None - ), + "token_endpoint_auth_method": token_endpoint_auth_method, "redirect_uris": [current_redirect_uri], } - from litellm.proxy._experimental.mcp_server.db import update_mcp_server # noqa: PLC0415 + from litellm.proxy._experimental.mcp_server.db import ( # noqa: PLC0415 # avoids circular import + update_mcp_server, + upsert_mcp_server_oauth_client_credentials, + ) from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( # noqa: PLC0415 global_mcp_server_manager, ) @@ -1136,7 +1193,18 @@ async def _persist_dcr_client_registration( ), touched_by="mcp_oauth_dcr", ) - await global_mcp_server_manager.update_server(updated_row) + if updated_row is not None: + await global_mcp_server_manager.update_server(updated_row) + return "persisted" + if global_mcp_server_manager.is_config_declared_server(mcp_server.server_id): + await upsert_mcp_server_oauth_client_credentials( + prisma_client=prisma_client, + server_id=mcp_server.server_id, + credentials=credentials, + ) + mcp_server.client_id = registration.client_id + mcp_server.client_secret = registration.client_secret + mcp_server.token_endpoint_auth_method = token_endpoint_auth_method return "persisted" except Exception as exc: # noqa: BLE001 verbose_logger.warning( diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 732381edb0e..1ba608b9510 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -93,6 +93,8 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.token_exchange_ ) from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( AuthorizationCodeConfig, + CredError, + IdJagConfig, PassthroughConfig, ServerSpec, TokenExchangeConfig, @@ -621,6 +623,47 @@ def _consumes_caller_authorization(server: MCPServer) -> bool: ) +_REGISTRY_DUMP_SECRET_FIELDS = frozenset( + {"authentication_token", "client_secret", "client_private_key", "aws_secret_access_key", "aws_session_token"} +) + + +def _redacted_registry_dump(servers: dict[str, MCPServer]) -> dict[str, dict[str, str]]: + """A JSON-safe view of the server registry with credential fields masked, for debug logging. + + The registry holds long-lived secrets as plain strings (the static token, OAuth client secret, + the ID-JAG signing key, AWS keys); dumping them verbatim hands the gateway's client identity to + anyone who can read debug logs. + """ + dumps: dict[str, dict[str, object]] = {server_id: server.model_dump() for server_id, server in servers.items()} + return { + server_id: { + field: ("**REDACTED**" if field in _REGISTRY_DUMP_SECRET_FIELDS and value is not None else str(value)) + for field, value in dump.items() + } + for server_id, dump in dumps.items() + } + + +def _to_server_spec_fail_closed(server: MCPServer) -> Optional[ServerSpec]: + """`to_server_spec`, except a half-configured `oauth2_id_jag` server refuses instead of deferring. + + ID-JAG has no v1 arm, so deferring to v1 would let `resolve_mcp_auth` honor a caller x-mcp-* + override or fall through to the static `authentication_token`, both of which bypass the per-user + identity assertion the mode promises. That is an operator misconfiguration, not a fallback. + """ + spec = to_server_spec(server) + if spec is None and server.auth_type == MCPAuth.oauth2_id_jag: + raise_public( + CredError.of_misconfigured( + "oauth2_id_jag requires token_exchange_endpoint, id_jag_resource_token_endpoint, " + "client_id, and a client_secret or client_private_key; refusing to fall back to " + "a static credential." + ) + ) + return spec + + def _caller_authorization_fans_out( server: MCPServer, scope_servers: Optional[list[MCPServer]], @@ -1100,6 +1143,14 @@ class MCPServerManager: """ return self.config_mcp_servers | self.registry + def is_config_declared_server(self, server_id: str) -> bool: + """True when server_id was declared in config.yaml (present in the in-memory config map). + Config servers are rowless and persistent, so their DCR client belongs in the server-scoped + store; a rowless server that is NOT config-declared is a throwaway temp/session server whose + client must not be persisted. This never overrides the row-existence check: a server that has + a LiteLLM_MCPServerTable row is always resolved to that row first.""" + return server_id in self.config_mcp_servers + async def load_servers_from_config( self, mcp_servers_config: dict[str, Any], @@ -1318,6 +1369,12 @@ class MCPServerManager: "subject_token_type", DEFAULT_SUBJECT_TOKEN_TYPE, ), + # ID-JAG fields + id_jag_resource_token_endpoint=server_config.get("id_jag_resource_token_endpoint", None), + id_jag_resource=server_config.get("id_jag_resource", None), + client_private_key=server_config.get("client_private_key", None), + client_private_key_id=server_config.get("client_private_key_id", None), + client_assertion_signing_alg=server_config.get("client_assertion_signing_alg", "RS256"), token_exchange_profile=server_config.get("token_exchange_profile", "rfc8693"), allow_sampling=bool(server_config.get("allow_sampling", False)), allow_elicitation=bool(server_config.get("allow_elicitation", False)), @@ -1338,10 +1395,36 @@ class MCPServerManager: base_url=server_config.get("url", ""), ) - verbose_logger.debug(f"Loaded MCP Servers: {json.dumps(self.config_mcp_servers, indent=4, default=str)}") + verbose_logger.debug( + f"Loaded MCP Servers: {json.dumps(_redacted_registry_dump(self.config_mcp_servers), indent=4)}" + ) + + await self._hydrate_config_servers_dcr_clients() self.initialize_tool_name_to_mcp_server_name_mapping() + async def _hydrate_config_servers_dcr_clients(self) -> None: + """Overlay each config-declared server's persisted DCR client (from the server-scoped + store) onto its in-memory object so token refresh authenticates after a restart. A + best-effort no-op when the DB is unreachable at config-load time.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( # noqa: PLC0415 # circular import + hydrate_config_server_dcr_client, + ) + + for server in self.config_mcp_servers.values(): + try: + if await hydrate_config_server_dcr_client(server): + verbose_logger.debug( + "hydrated persisted DCR client onto config MCP server server_id=%s", + server.server_id, + ) + except Exception as exc: # noqa: BLE001 # best-effort hydration; never fail config load + verbose_logger.debug( + "load_servers_from_config: failed to hydrate DCR client for server_id=%s: %s", + server.server_id, + exc, + ) + async def _register_openapi_tools(self, spec_path: str, server: MCPServer, base_url: str): """ Register tools from an OpenAPI specification for a given server. @@ -1765,6 +1848,21 @@ class MCPServerManager: subject_token_type=mcp_server.subject_token_type or (credentials_dict.get("subject_token_type") if credentials_dict else None) or DEFAULT_SUBJECT_TOKEN_TYPE, + # ID-JAG fields — read from credentials JSON blob + id_jag_resource_token_endpoint=( + credentials_dict.get("id_jag_resource_token_endpoint") if credentials_dict else None + ), + id_jag_resource=(credentials_dict.get("id_jag_resource") if credentials_dict else None), + client_private_key=self._decrypt_credential_field( + credentials_dict.get("client_private_key") if credentials_dict else None, + "client_private_key", + credentials_are_encrypted, + ), + client_private_key_id=(credentials_dict.get("client_private_key_id") if credentials_dict else None), + client_assertion_signing_alg=( + credentials_dict.get("client_assertion_signing_alg") if credentials_dict else None + ) + or "RS256", token_exchange_profile=mcp_server.token_exchange_profile or (credentials_dict.get("token_exchange_profile") if credentials_dict else None) or "rfc8693", @@ -2641,9 +2739,10 @@ class MCPServerManager: ) if not conflicts: return auth, extra_headers - if isinstance(spec.config, (TokenExchangeConfig, AuthorizationCodeConfig)): + if isinstance(spec.config, (TokenExchangeConfig, AuthorizationCodeConfig, IdJagConfig)): # The resolver owns the per-user credential here (token_exchange's exchanged - # token, authorization_code's stored token). It is authoritative: a guardrail such + # token, authorization_code's stored token, id_jag's minted assertion). It is + # authoritative: a guardrail such # as MCPJWTSigner, static_headers, or any other injected Authorization must NOT # shadow it (otherwise the upstream gets e.g. the signer's JWT instead of the # exchanged token and rejects it). Drop the conflicting header so the resolved @@ -2734,20 +2833,23 @@ class MCPServerManager: Configured MCP client instance. """ transport = server.transport or MCPTransport.sse - spec = None if transport == MCPTransport.stdio else to_server_spec(server) + spec = None if transport == MCPTransport.stdio else _to_server_spec_fail_closed(server) provider = cred_provider or self._cred_provider # A caller-supplied per-request override (mcp_auth_header / x-mcp-*) defers to the v1 path # so it wins - except for the modes the v2 resolver owns per-caller (authorization_code's - # stored token, token_exchange's RFC 8693 minted token, and the passthrough modes' - # forwarded caller token). A caller must not be able to substitute another user's stored - # credential, nor silently disable the OBO exchange and forward an arbitrary bearer - # upstream, so we keep the v2 spec and ignore the override for these; the REST tools - # preview supplies its not-yet-persisted token through the resolver (cred_provider), - # never this path. + # stored token, token_exchange's RFC 8693 minted token, id_jag's minted assertion, and the + # passthrough modes' forwarded caller token). A caller must not be able to substitute another + # user's stored credential, nor silently disable the OBO / ID-JAG exchange and forward an + # arbitrary bearer upstream, so we keep the v2 spec and ignore the override for these; the + # REST tools preview supplies its not-yet-persisted token through the resolver + # (cred_provider), never this path. if ( spec is not None and mcp_auth_header - and not isinstance(spec.config, (AuthorizationCodeConfig, PassthroughConfig, TokenExchangeConfig)) + and not isinstance( + spec.config, + (AuthorizationCodeConfig, IdJagConfig, PassthroughConfig, TokenExchangeConfig), + ) ): spec = None auth_value = ( @@ -4276,10 +4378,13 @@ class MCPServerManager: if server_auth_header is None: server_auth_header = mcp_auth_header - # Extract subject token for OAuth2 Token Exchange (OBO) flow + # Extract subject token for OAuth2 Token Exchange (OBO) and ID-JAG flows subject_token: Optional[str] = None extra_headers: Optional[dict[str, str]] = None - if mcp_server.auth_type == MCPAuth.oauth2_token_exchange: + if mcp_server.auth_type in ( + MCPAuth.oauth2_token_exchange, + MCPAuth.oauth2_id_jag, + ): subject_token = self._extract_bearer_token(oauth2_headers, raw_headers) elif mcp_server.auth_type == MCPAuth.oauth2: if mcp_server.has_client_credentials: @@ -4381,10 +4486,10 @@ class MCPServerManager: arguments=arguments, ) - if mcp_server.auth_type == MCPAuth.oauth2_token_exchange and subject_token: - # OBO: the exchanged token may have been revoked/rotated upstream since it was cached, so - # an upstream 401 gets one re-mint + retry. Gated to this mode; all others keep the plain - # single call below. + if mcp_server.auth_type in (MCPAuth.oauth2_token_exchange, MCPAuth.oauth2_id_jag) and subject_token: + # OBO / ID-JAG: the exchanged token may have been revoked/rotated upstream since it was + # cached, so an upstream 401 gets one invalidate + re-mint + retry. Gated to these modes; + # all others keep the plain single call below. async def _obo_call_tool_limited(): async with self._limit_outbound_concurrency(mcp_server): return await self._obo_call_tool_with_retry( @@ -4935,6 +5040,8 @@ class MCPServerManager: verbose_logger.debug("MCP registry refreshed (%s servers in registry)", len(registered_registry)) + await self._hydrate_config_servers_dcr_clients() + def get_mcp_servers_from_ids(self, server_ids: list[str]) -> list[MCPServer]: servers = [] registry = self.get_registry() diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/__init__.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/__init__.py index 73166a45d6e..2bdb8770e4e 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/__init__.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/__init__.py @@ -31,10 +31,14 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( AwsCredentialSource, AwsSigV4Config, Byok, + ClientAuth, ClientCredentialsConfig, + ClientSecretAuth, CredError, + IdJagConfig, NoneConfig, PassthroughConfig, + PrivateKeyJwtAuth, ServerSpec, SharedKey, StaticKeys, @@ -59,6 +63,10 @@ __all__ = [ "AuthorizationCodeConfig", "ClientCredentialsConfig", "TokenExchangeConfig", + "IdJagConfig", + "ClientAuth", + "PrivateKeyJwtAuth", + "ClientSecretAuth", "ApiKeyConfig", "ApiKeySource", "SharedKey", diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py index e87e8081ced..6631e38f524 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py @@ -21,9 +21,13 @@ from typing_extensions import assert_never from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( ApiKeyConfig, AuthorizationCodeConfig, + ClientAuth, + ClientSecretAuth, CredError, + IdJagConfig, NoneConfig, PassthroughConfig, + PrivateKeyJwtAuth, ServerSpec, SharedKey, Subject, @@ -35,6 +39,9 @@ if TYPE_CHECKING: from litellm.proxy._types import UserAPIKeyAuth from litellm.types.mcp_server.mcp_server_manager import MCPServer +_TOKEN_EXCHANGE_SUBJECT_TOKEN_DEFAULT = "urn:ietf:params:oauth:token-type:access_token" +_ID_JAG_SUBJECT_TOKEN_DEFAULT = "urn:ietf:params:oauth:token-type:id_token" + def to_subject(user_api_key_auth: Optional[UserAPIKeyAuth], subject_token: Optional[str]) -> Subject: """Map v1's authenticated principal onto the resolver's Subject. @@ -96,6 +103,8 @@ def to_server_spec(server: MCPServer) -> Optional[ServerSpec]: ) # client_credentials (M2M) and delegate/passthrough oauth2 stay on v1 return None + case MCPAuth.oauth2_id_jag: + return _id_jag_spec(server, resource) case MCPAuth.true_passthrough | MCPAuth.oauth_delegate: return ServerSpec(server_id=server.server_id, resource=resource, config=PassthroughConfig()) case MCPAuth.oauth2_token_exchange: @@ -167,6 +176,58 @@ def _shared_key_spec( ) +def _id_jag_spec(server: MCPServer, resource: str) -> Optional[ServerSpec]: + """Build an ID-JAG spec from the v1 server's raw fields, or defer (None) if half-configured. + + The enum already routes here, but a server missing an endpoint, ``client_id``, or any client-auth + secret would make ``IdJagConfig`` raise at construction; returning None instead defers to v1 so a + partially configured server does not 500. ``token_exchange_endpoint`` is leg 1 (the IdP org AS); + leg 2 is ``id_jag_resource_token_endpoint`` (the upstream resource AS). + """ + org_token_endpoint = server.token_exchange_endpoint + resource_token_endpoint = server.id_jag_resource_token_endpoint + client_id = server.client_id + client_auth = _id_jag_client_auth(server) + if not org_token_endpoint or not resource_token_endpoint or not client_id or client_auth is None: + return None + return ServerSpec( + server_id=server.server_id, + resource=resource, + config=IdJagConfig( + org_token_endpoint=org_token_endpoint, + resource_token_endpoint=resource_token_endpoint, + client_id=client_id, + client_auth=client_auth, + subject_token_type=_id_jag_subject_token_type(server), + audience=server.audience, + resource=server.id_jag_resource, + scopes=tuple(server.scopes or ()), + ), + ) + + +def _id_jag_client_auth(server: MCPServer) -> Optional[ClientAuth]: + """Private-key JWT when a key is configured, else client_secret, else None (defer to v1).""" + if server.client_private_key: + return PrivateKeyJwtAuth( + private_key=SecretStr(server.client_private_key), + key_id=server.client_private_key_id, + signing_alg=server.client_assertion_signing_alg, + ) + if server.client_secret: + return ClientSecretAuth(client_secret=SecretStr(server.client_secret)) + return None + + +def _id_jag_subject_token_type(server: MCPServer) -> str: + """ID-JAG asserts the user's id_token, so the token-exchange access_token default maps to id_token; + an explicitly configured value (e.g. a SAML2 assertion type) is honored verbatim.""" + configured = server.subject_token_type + if configured and configured != _TOKEN_EXCHANGE_SUBJECT_TOKEN_DEFAULT: + return configured + return _ID_JAG_SUBJECT_TOKEN_DEFAULT + + def raise_public(error: CredError) -> NoReturn: """Map a resolver CredError onto the proxy's public HTTP contract. The one edge that raises.""" match error.tag: diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py index ecfd471190c..7e5c073870a 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py @@ -16,6 +16,8 @@ follow-up PR with their seam. Pure v2: no imports from v1. from __future__ import annotations +import hashlib + import httpx from typing_extensions import assert_never @@ -33,6 +35,11 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.result import ( Ok, Result, ) +from litellm.proxy._experimental.mcp_server.outbound_credentials.token_endpoint import ( + ExchangedToken, + ExchangedTokenCache, + TokenEndpointClient, +) from litellm.proxy._experimental.mcp_server.outbound_credentials.token_exchanger import ( TokenExchanger, ) @@ -42,16 +49,24 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( AuthSpecKind, AwsSigV4Config, Byok, + ClientAuth, ClientCredentialsConfig, + ClientSecretAuth, CredError, + IdJagConfig, NoneConfig, PassthroughConfig, + PrivateKeyJwtAuth, ServerSpec, SharedKey, Subject, TokenExchangeConfig, ) +_TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" +_JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer" +_ID_JAG_REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:id-jag" + class _NullOAuthTokenStore: """Fail-closed default: with no token store wired, every user reads as not authorized.""" @@ -87,9 +102,13 @@ class UpstreamCredentialProvider: self, oauth_token_store: OAuthTokenStore | None = None, token_exchanger: TokenExchanger | None = None, + token_endpoint: TokenEndpointClient | None = None, + exchanged_tokens: ExchangedTokenCache | None = None, ) -> None: self._oauth_token_store: OAuthTokenStore = oauth_token_store or _NullOAuthTokenStore() self._token_exchanger: TokenExchanger = token_exchanger or _NullTokenExchanger() + self._token_endpoint: TokenEndpointClient = token_endpoint or TokenEndpointClient() + self._exchanged_tokens: ExchangedTokenCache = exchanged_tokens or ExchangedTokenCache() async def resolve_credentials(self, subject: Subject, server: ServerSpec) -> Result[httpx.Auth, CredError]: match server.config: @@ -103,6 +122,8 @@ class UpstreamCredentialProvider: return _not_implemented(AuthSpecKind.client_credentials) case TokenExchangeConfig() as config: return await self._token_exchange(subject, server, config) + case IdJagConfig() as config: + return await self._id_jag(subject, server, config) case AuthorizationCodeConfig(): return await self._authorization_code(subject, server) case AwsSigV4Config(): @@ -141,6 +162,53 @@ class UpstreamCredentialProvider: return Error(CredError.of_not_implemented("api_key BYOK source not implemented yet")) assert_never(config.key_source) + async def _id_jag(self, subject: Subject, server: ServerSpec, config: IdJagConfig) -> Result[httpx.Auth, CredError]: + if subject.inbound_token is None: + return Error( + CredError.of_precondition_required( + "ID-JAG requires a caller identity token; it asserts the calling " + "user's identity upstream and cannot use a static credential." + ) + ) + token = subject.inbound_token.get_secret_value() + cache_key = _id_jag_cache_key(token, server.server_id, config) + + async def _exchange() -> Result[ExchangedToken, CredError]: + leg1_params = { + "grant_type": _TOKEN_EXCHANGE_GRANT_TYPE, + "requested_token_type": _ID_JAG_REQUESTED_TOKEN_TYPE, + "subject_token": token, + "subject_token_type": config.subject_token_type, + **({"audience": config.audience} if config.audience else {}), + **({"resource": config.resource} if config.resource else {}), + **({"scope": " ".join(config.scopes)} if config.scopes else {}), + } + match await self._token_endpoint.fetch( + config.org_token_endpoint, + config.client_id, + leg1_params, + config.client_auth, + ): + case Error(err): + return Error(err) + case Ok(id_jag): + leg2_params = { + "grant_type": _JWT_BEARER_GRANT_TYPE, + "assertion": id_jag.access_token, + } + return await self._token_endpoint.fetch( + config.resource_token_endpoint, + config.client_id, + leg2_params, + config.client_auth, + ) + + match await self._exchanged_tokens.get_or_compute(cache_key, _exchange): + case Ok(access_token): + return Ok(StaticHeaderAuth(f"Bearer {access_token}")) + case Error(err): + return Error(err) + async def _authorization_code(self, subject: Subject, server: ServerSpec) -> Result[StaticHeaderAuth, CredError]: token = await self._authz_token(subject, server) if token is None: @@ -176,13 +244,19 @@ class UpstreamCredentialProvider: """Drop any cached credential the resolver owns for this `(subject, server)`. Used after an upstream rejects the injected credential, so the next resolve re-mints rather - than serving the same rejected token until TTL. Only `token_exchange` holds a re-mintable - cached credential here; other modes are a no-op. + than serving the same rejected token until TTL. `token_exchange` and `id_jag` hold a + re-mintable cached credential here; other modes are a no-op. """ - if isinstance(server.config, TokenExchangeConfig) and subject.inbound_token is not None: + if subject.inbound_token is None: + return + if isinstance(server.config, TokenExchangeConfig): await self._token_exchanger.invalidate( subject.inbound_token.get_secret_value(), server, server.config, tenant_id=subject.tenant_id ) + if isinstance(server.config, IdJagConfig): + self._exchanged_tokens.invalidate( + _id_jag_cache_key(subject.inbound_token.get_secret_value(), server.server_id, server.config) + ) async def _authz_token(self, subject: Subject, server: ServerSpec) -> OAuthToken | None: """The user's authorization_code token, or None when absent or the store is unreachable. @@ -196,5 +270,41 @@ class UpstreamCredentialProvider: return None +def _id_jag_cache_key(subject_token: str, server_id: str, config: IdJagConfig) -> str: + """Bind the cached leg-2 bearer to the caller token, the server, AND the config that minted it. + + Every exchange parameter derives from the config (endpoints, audience, resource, scopes, client + auth), so a server update that changes any of them must change the key; otherwise the old bearer, + authorized under the old policy, keeps being served until its TTL. Everything is hashed, so no + secret is held in the key. + """ + material = "\x00".join( + ( + subject_token, + server_id, + config.org_token_endpoint, + config.resource_token_endpoint, + config.client_id, + _client_auth_fingerprint(config.client_auth), + config.subject_token_type, + config.audience or "", + config.resource or "", + " ".join(config.scopes), + ) + ) + return hashlib.sha256(material.encode()).hexdigest() + + +def _client_auth_fingerprint(client_auth: ClientAuth) -> str: + match client_auth: + case PrivateKeyJwtAuth() as auth: + return "\x00".join( + ("private_key_jwt", auth.private_key.get_secret_value(), auth.key_id or "", auth.signing_alg) + ) + case ClientSecretAuth() as auth: + return "\x00".join(("client_secret", auth.client_secret.get_secret_value())) + assert_never(client_auth) + + def _not_implemented(kind: AuthSpecKind) -> Result[httpx.Auth, CredError]: return Error(CredError.of_not_implemented(f"{kind.value}: resolver arm not implemented yet")) diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/token_endpoint.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/token_endpoint.py new file mode 100644 index 00000000000..4bc5732ec0e --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/token_endpoint.py @@ -0,0 +1,225 @@ +"""An authenticated OAuth token-endpoint call plus a short-lived-token cache. + +`TokenEndpointClient.fetch` POSTs one grant to a token endpoint, authenticating the gateway as +an OAuth client via `client_auth` (RFC 7523 private-key JWT, or `client_secret_post`), and returns +the minted token or a typed `CredError`. `ExchangedTokenCache` memoizes the final token string per +opaque cache key with per-key single-flight, so concurrent callers share one round-trip and a hit +skips the endpoint entirely. + +Pure v2: no imports from the v1 MCP auth handlers. The multi-leg flows that compose these (ID-JAG, +and later token_exchange / client_credentials) live in the resolver arms; this collaborator owns +only the single authenticated call and the cache. +""" + +from __future__ import annotations + +import asyncio +import json +import time +import uuid +import weakref +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass + +import httpx +import jwt +from pydantic import BaseModel, ValidationError +from typing_extensions import assert_never + +from litellm._logging import verbose_proxy_logger +from litellm.caching.in_memory_cache import InMemoryCache +from litellm.constants import ( + MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL, + MCP_OAUTH2_TOKEN_CACHE_MIN_TTL, + MCP_OAUTH2_TOKEN_EXPIRY_BUFFER_SECONDS, + MCP_TOKEN_EXCHANGE_CACHE_MAX_SIZE, +) +from litellm.exceptions import Timeout +from litellm.llms.custom_httpx.http_handler import ( + get_async_httpx_client, # pyright: ignore[reportUnknownVariableType] # litellm http handler is untyped +) +from litellm.proxy._experimental.mcp_server.outbound_credentials.result import ( + Error, + Ok, + Result, +) +from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( + ClientAuth, + ClientSecretAuth, + CredError, + PrivateKeyJwtAuth, +) +from litellm.types.llms.custom_http import httpxSpecialProvider + +CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" +CLIENT_ASSERTION_LIFETIME_SECONDS = 60 + + +@dataclass(frozen=True, slots=True) +class ExchangedToken: + access_token: str + expires_in: int | None + + +class _TokenEndpointResponse(BaseModel): + access_token: str + expires_in: int | None = None + + +class TokenEndpointClient: + """One authenticated POST to an OAuth token endpoint, returning the minted token as a value.""" + + async def fetch( + self, + endpoint: str, + client_id: str, + grant_params: Mapping[str, str], + client_auth: ClientAuth, + ) -> Result[ExchangedToken, CredError]: + try: + data = {**grant_params, **_client_auth_params(endpoint, client_id, client_auth)} + except (ValueError, TypeError, NotImplementedError, jwt.PyJWTError): + verbose_proxy_logger.warning("MCP token endpoint %s: could not sign the client assertion", endpoint) + return Error( + CredError.of_misconfigured( + "token exchange failed: could not sign the client assertion; " + "check client_private_key and client_assertion_signing_alg" + ) + ) + try: + raw = await _post_form(endpoint, data) + except httpx.HTTPStatusError as exc: + verbose_proxy_logger.warning( + "MCP token endpoint %s failed with status %s", endpoint, exc.response.status_code + ) + return Error( + CredError.of_upstream_unavailable(f"token exchange failed with status {exc.response.status_code}") + ) + except (httpx.RequestError, Timeout) as exc: + verbose_proxy_logger.warning("MCP token endpoint %s unreachable: %s", endpoint, type(exc).__name__) + return Error( + CredError.of_upstream_unavailable( + f"token exchange failed: token endpoint unreachable ({type(exc).__name__})" + ) + ) + except json.JSONDecodeError: + verbose_proxy_logger.warning("MCP token endpoint %s returned a non-JSON response", endpoint) + return Error( + CredError.of_upstream_unavailable("token exchange failed: token endpoint returned a non-JSON response") + ) + if raw is None: + verbose_proxy_logger.warning("MCP token endpoint %s returned no response", endpoint) + return Error(CredError.of_upstream_unavailable("token exchange failed: no response from token endpoint")) + try: + parsed = _TokenEndpointResponse.model_validate(raw) + except ValidationError: + verbose_proxy_logger.warning("MCP token endpoint %s response missing access_token", endpoint) + return Error( + CredError.of_upstream_unavailable("token exchange failed: token endpoint response missing access_token") + ) + return Ok(ExchangedToken(access_token=parsed.access_token, expires_in=parsed.expires_in)) + + +class ExchangedTokenCache: + """Memoizes the final token string per key, single-flighting concurrent misses on one lock.""" + + def __init__(self) -> None: + self._cache = InMemoryCache( + max_size_in_memory=MCP_TOKEN_EXCHANGE_CACHE_MAX_SIZE, + default_ttl=MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL, + ) + self._locks: weakref.WeakValueDictionary[str, asyncio.Lock] = weakref.WeakValueDictionary() + + async def get_or_compute( + self, + cache_key: str, + compute: Callable[[], Awaitable[Result[ExchangedToken, CredError]]], + ) -> Result[str, CredError]: + cached = self._get(cache_key) + if cached is not None: + return Ok(cached) + async with self._lock(cache_key): + cached = self._get(cache_key) + if cached is not None: + return Ok(cached) + match await compute(): + case Ok(token): + self._cache.set_cache( # pyright: ignore[reportUnknownMemberType] # InMemoryCache is untyped + cache_key, + token.access_token, + ttl=_cache_ttl_seconds(token.expires_in), + ) + return Ok(token.access_token) + case Error(err): + return Error(err) + + def invalidate(self, cache_key: str) -> None: + """Evict one cached token so the next `get_or_compute` re-mints (e.g. after an upstream 401).""" + self._cache.delete_cache(cache_key) # pyright: ignore[reportUnknownMemberType] # InMemoryCache is untyped + + def _get(self, cache_key: str) -> str | None: + value = self._cache.get_cache(cache_key) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] # InMemoryCache is untyped; narrowed by isinstance below + return value if isinstance(value, str) else None + + def _lock(self, cache_key: str) -> asyncio.Lock: + lock = self._locks.get(cache_key) + if lock is None: + lock = asyncio.Lock() + self._locks[cache_key] = lock + return lock + + +def _cache_ttl_seconds(expires_in: int | None) -> int: + lifetime = expires_in if expires_in is not None else MCP_OAUTH2_TOKEN_CACHE_DEFAULT_TTL + return max( + lifetime - MCP_OAUTH2_TOKEN_EXPIRY_BUFFER_SECONDS, + MCP_OAUTH2_TOKEN_CACHE_MIN_TTL, + ) + + +async def _post_form(endpoint: str, data: dict[str, str]) -> object | None: + # litellm's httpx handler and httpx.Response are only partially typed; the token endpoint + # returns a JSON object that `_TokenEndpointResponse` validates, so the untyped boundary is + # contained here. A non-2xx raises `httpx.HTTPStatusError`, an unreachable endpoint raises + # `httpx.RequestError` (or litellm's `Timeout`, which the handler substitutes for + # `httpx.TimeoutException`), and a non-JSON body raises `json.JSONDecodeError`; `fetch` maps + # each to a CredError. + client = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP) # pyright: ignore[reportUnknownVariableType] # litellm http handler is untyped + response = await client.post(endpoint, data=data) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] # litellm http handler is untyped + if response is None: + return None + response.raise_for_status() + return response.json() # pyright: ignore[reportAny] # untyped JSON; validated by _TokenEndpointResponse in fetch + + +def _client_auth_params(endpoint: str, client_id: str, client_auth: ClientAuth) -> dict[str, str]: + match client_auth: + case PrivateKeyJwtAuth() as auth: + return { + "client_id": client_id, + "client_assertion_type": CLIENT_ASSERTION_TYPE, + "client_assertion": _client_assertion(endpoint, client_id, auth), + } + case ClientSecretAuth() as auth: + return { + "client_id": client_id, + "client_secret": auth.client_secret.get_secret_value(), + } + assert_never(client_auth) + + +def _client_assertion(endpoint: str, client_id: str, auth: PrivateKeyJwtAuth) -> str: + now = int(time.time()) + return jwt.encode( + { + "iss": client_id, + "sub": client_id, + "aud": endpoint, + "jti": uuid.uuid4().hex, + "iat": now, + "exp": now + CLIENT_ASSERTION_LIFETIME_SECONDS, + }, + auth.private_key.get_secret_value(), + algorithm=auth.signing_alg, + headers={"kid": auth.key_id} if auth.key_id else None, + ) diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py index 7e04be4f045..64a20255ab2 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py @@ -56,6 +56,7 @@ class AuthSpecKind(str, Enum): authorization_code = "authorization_code" # per-user 3LO; gateway-stored token client_credentials = "client_credentials" # gateway service account (M2M) token_exchange = "token_exchange" # RFC 8693: token endpoint + subject_token (OBO) + id_jag = "id_jag" # draft-ietf-oauth-identity-assertion-authz-grant: two-leg exchange then jwt-bearer api_key = "api_key" # static header, any scheme (BYOK = per-user-seeded source) passthrough = "passthrough" # client forwards an upstream-audience token none = "none" # no upstream credential; resolve yields a no-op auth, never an error @@ -225,6 +226,49 @@ class TokenExchangeConfig(BaseModel): scopes: tuple[str, ...] = () +class PrivateKeyJwtAuth(BaseModel): + """RFC 7523 private-key-JWT client authentication: the gateway signs a `client_assertion`.""" + + model_config = ConfigDict(frozen=True) + source: Literal["private_key_jwt"] = "private_key_jwt" + private_key: SecretStr + key_id: str | None = None + signing_alg: str = "RS256" + + +class ClientSecretAuth(BaseModel): + """`client_secret_post` client authentication: the gateway posts `client_id` + `client_secret`.""" + + model_config = ConfigDict(frozen=True) + source: Literal["client_secret"] = "client_secret" + client_secret: SecretStr + + +ClientAuth = Annotated[PrivateKeyJwtAuth | ClientSecretAuth, Field(discriminator="source")] + + +class IdJagConfig(BaseModel): + """draft-ietf-oauth-identity-assertion-authz-grant (Okta "AI agent token exchange"). + + Two legs: leg 1 is an RFC 8693 token exchange at the IdP org AS (`org_token_endpoint`) that + swaps the caller's identity token for an ID-JAG assertion; leg 2 is an RFC 7523 jwt-bearer at + the upstream resource AS (`resource_token_endpoint`) that swaps the assertion for the access + token. The gateway authenticates to both endpoints as `client_id` via `client_auth`. Required + fields are enforced at construction so a half-configured server cannot reach the arm. + """ + + model_config = ConfigDict(frozen=True) + kind: Literal[AuthSpecKind.id_jag] = AuthSpecKind.id_jag + org_token_endpoint: str + resource_token_endpoint: str + client_id: str + client_auth: ClientAuth + subject_token_type: str = "urn:ietf:params:oauth:token-type:id_token" + audience: str | None = None + resource: str | None = None + scopes: tuple[str, ...] = () + + class SharedKey(BaseModel): """A fixed key configured on the server, identical for every caller.""" @@ -323,6 +367,7 @@ AuthConfig = Annotated[ AuthorizationCodeConfig | ClientCredentialsConfig | TokenExchangeConfig + | IdJagConfig | ApiKeyConfig | PassthroughConfig | NoneConfig diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 3b640ee54fd..aed345c5db4 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1395,19 +1395,25 @@ def _close_dangling_otel_server_span(request: Request, status_code: int, exc: Op if open_telemetry_logger is None: return # Under OTel V2 the FastAPI instrumentor owns the server span (parent_otel_span - # is that same span), and it records the error + ends it itself. Ending it here - # would end it early — losing the http.* attributes the instrumentor stamps on - # completion — and double-end it. Leave it to the instrumentor. + # is that same span) and ends it itself with the http.* attributes stamped on + # completion. The instrumentor only records an error when the exception reaches + # it uncaught, but these handlers swallow it into a JSONResponse, so it never + # does; stamp the error.* attributes here (without ending or re-statusing the + # span, which the instrumentor still owns) so pre-call failures carry the error + # like v1 did. Otherwise close and annotate the dangling span ourselves. try: from litellm.integrations.otel.model.config import is_otel_v2_enabled - if is_otel_v2_enabled(): - return + v2_enabled = is_otel_v2_enabled() except Exception: - pass + v2_enabled = False try: from opentelemetry.trace import Status, StatusCode + if v2_enabled: + if status_code >= 400: + open_telemetry_logger.record_error_attributes_on_span(parent_otel_span, exc, status_code) + return open_telemetry_logger.set_response_status_code_attribute(parent_otel_span, status_code) if status_code >= 400: open_telemetry_logger.record_error_attributes_on_span(parent_otel_span, exc, status_code) @@ -1416,7 +1422,8 @@ def _close_dangling_otel_server_span(request: Request, status_code: int, exc: Op except Exception as e: verbose_proxy_logger.debug("Error closing dangling OTEL SERVER span: %s", str(e)) finally: - request.state.parent_otel_span = None + if not v2_enabled: + request.state.parent_otel_span = None @app.exception_handler(RequestValidationError) diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index f842bf13da9..a99cec49417 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -396,6 +396,13 @@ model LiteLLM_MCPUserEnvVars { @@index([server_id]) } +model LiteLLM_MCPServerOAuthClient { + server_id String @id + credentials Json? + 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 diff --git a/litellm/repositories/table_repositories.py b/litellm/repositories/table_repositories.py index 7ce4607e1ca..dc2a7d25259 100644 --- a/litellm/repositories/table_repositories.py +++ b/litellm/repositories/table_repositories.py @@ -77,6 +77,10 @@ class MCPUserCredentialsRepository(PrismaTableRepository): table_name = "litellm_mcpusercredentials" +class MCPServerOAuthClientRepository(PrismaTableRepository): + table_name = "litellm_mcpserveroauthclient" + + class PromptRepository(PrismaTableRepository): table_name = "litellm_prompttable" diff --git a/litellm/router.py b/litellm/router.py index b1a5405ebf1..0b1471dc527 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -4461,6 +4461,7 @@ class Router: model=model, request_kwargs=kwargs, messages=kwargs.get("messages", None), + input=kwargs.get("input", None), specific_deployment=kwargs.pop("specific_deployment", None), ) except Exception as e: @@ -4608,6 +4609,7 @@ class Router: deployment = self.get_available_deployment( model=model, messages=kwargs.get("messages", None), + input=kwargs.get("input", None), specific_deployment=kwargs.pop("specific_deployment", None), request_kwargs=kwargs, ) @@ -10002,11 +10004,44 @@ class Router: client = self.cache.get_cache(key=cache_key, parent_otel_span=parent_otel_span) return client + def _count_pre_call_check_tokens( + self, + messages: list[dict[str, str]] | None, + input: str | list | None, + instructions: str | None = None, + ) -> int: + """ + Count input tokens for context-window pre-call checks. + + Chat Completions send `messages`; the Responses API sends `input` (a string or + a list of Responses input items) plus an optional `instructions` system prompt. + The Responses payload is normalized to chat messages via the shared + LiteLLMCompletionResponsesConfig transform so the same token_counter path covers + both API surfaces and `instructions` tokens are included in the count. + """ + if messages is not None: + return litellm.token_counter(messages=messages) + if input is not None: + from openai.types.responses.response_create_params import ResponseInputParam + + from litellm.responses.litellm_completion_transformation.transformation import ( + LiteLLMCompletionResponsesConfig, + ) + + typed_input = cast(str | ResponseInputParam, input) # cast-ok: str | list matches transform input + input_messages = LiteLLMCompletionResponsesConfig.transform_responses_api_input_to_messages( + input=typed_input, + responses_api_request={"instructions": instructions} if instructions is not None else {}, + ) + return litellm.token_counter(messages=cast(list, input_messages)) # cast-ok: transformed chat messages + raise ValueError("Either messages or input must be provided to count tokens") + def _pre_call_checks( self, model: str, healthy_deployments: List, - messages: List[Dict[str, str]], + messages: list[dict[str, str]] | None = None, + input: str | list | None = None, request_kwargs: Optional[dict] = None, ): """ @@ -10036,6 +10071,10 @@ class Router: _rate_limit_error = False parent_otel_span = _get_parent_otel_span_from_kwargs(request_kwargs) + raw_instructions = request_kwargs.get("instructions") if request_kwargs else None + instructions = raw_instructions if isinstance(raw_instructions, str) else None + has_countable_input = messages is not None or input is not None + ## get model group RPM ## dt = get_utc_datetime() current_minute = dt.strftime("%H-%M") @@ -10058,10 +10097,12 @@ class Router: _deployment_model = base_model or _litellm_params.get("model", None) max_input_tokens = model_info.get("max_input_tokens") if isinstance(model_info, dict) else None - if isinstance(max_input_tokens, int): + if isinstance(max_input_tokens, int) and has_countable_input: if input_tokens is None: try: - input_tokens = litellm.token_counter(messages=messages) + input_tokens = self._count_pre_call_check_tokens( + messages=messages, input=input, instructions=instructions + ) except Exception as e: verbose_router_logger.error( "litellm.router.py::_pre_call_checks: failed to count tokens. Returning initial list of deployments. Got - {}".format( @@ -10526,11 +10567,12 @@ class Router: parent_otel_span=parent_otel_span, ) - if self.enable_pre_call_checks and messages is not None: + if self.enable_pre_call_checks and (messages is not None or input is not None): healthy_deployments = self._pre_call_checks( model=model, healthy_deployments=cast(List[Dict], healthy_deployments), messages=messages, + input=input, request_kwargs=request_kwargs, ) # check if user wants to do tag based routing @@ -11041,11 +11083,12 @@ class Router: healthy_deployments = self._filter_blocked_deployments(healthy_deployments) # filter pre-call checks - if self.enable_pre_call_checks and messages is not None: + if self.enable_pre_call_checks and (messages is not None or input is not None): healthy_deployments = self._pre_call_checks( model=model, healthy_deployments=healthy_deployments, messages=messages, + input=input, request_kwargs=request_kwargs, ) @@ -11195,11 +11238,12 @@ class Router: pass_through_deployments = self._filter_blocked_deployments(pass_through_deployments) # 5. Apply pre-call checks (if enabled) - if self.enable_pre_call_checks and messages is not None: + if self.enable_pre_call_checks and (messages is not None or input is not None): pass_through_deployments = self._pre_call_checks( model=model, healthy_deployments=pass_through_deployments, messages=messages, + input=input, request_kwargs=request_kwargs, ) diff --git a/litellm/rust_bridge/messages.py b/litellm/rust_bridge/messages.py new file mode 100644 index 00000000000..5abb21879d3 --- /dev/null +++ b/litellm/rust_bridge/messages.py @@ -0,0 +1,135 @@ +"""Thin Python wrapper for the native Rust Anthropic Messages bridge.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Awaitable, Final, Protocol, Union, cast + +import httpx + +from litellm.rust_bridge.timeouts import timeout_to_seconds + + +class RustMessages(Protocol): + def __call__( + self, + model: str, + body: dict[str, object], + api_key: str | None, + api_base: str | None, + custom_llm_provider: str | None, + extra_headers: dict[str, object] | None, + timeout_seconds: float | None, + ) -> dict[str, object]: + raise NotImplementedError + + +class RustAmessages(Protocol): + def __call__( + self, + model: str, + body: dict[str, object], + api_key: str | None, + api_base: str | None, + custom_llm_provider: str | None, + extra_headers: dict[str, object] | None, + timeout_seconds: float | None, + ) -> Awaitable[dict[str, object]]: + raise NotImplementedError + + +class _Unset: + pass + + +_UNSET: Final[_Unset] = _Unset() + + +@dataclass(slots=True) +class _RustMessagesState: + messages: RustMessages | None = None + amessages: RustAmessages | None = None + + +_STATE: Final[_RustMessagesState] = _RustMessagesState() + + +def set_rust_messages( + *, + messages: RustMessages | None | _Unset = _UNSET, + amessages: RustAmessages | None | _Unset = _UNSET, +) -> None: + if not isinstance(messages, _Unset): + _STATE.messages = messages + if not isinstance(amessages, _Unset): + _STATE.amessages = amessages + + +def load_rust_messages() -> RustMessages | None: + if _STATE.messages is not None: + return _STATE.messages + from litellm.rust_bridge import get_native_bridge + + native_bridge = get_native_bridge() + if native_bridge is None: + return None + return cast(RustMessages, getattr(native_bridge, "messages", None)) + + +def load_rust_amessages() -> RustAmessages | None: + if _STATE.amessages is not None: + return _STATE.amessages + from litellm.rust_bridge import get_native_bridge + + native_bridge = get_native_bridge() + if native_bridge is None: + return None + return cast(RustAmessages, getattr(native_bridge, "amessages", None)) + + +def messages( + *, + model: str, + body: dict[str, object], + api_key: str | None, + api_base: str | None, + custom_llm_provider: str | None, + extra_headers: dict[str, object] | None, + timeout: Union[float, httpx.Timeout] | None, +) -> dict[str, object] | None: + rust_messages = load_rust_messages() + if rust_messages is None: + return None + return rust_messages( + model=model, + body=body, + api_key=api_key, + api_base=api_base, + custom_llm_provider=custom_llm_provider, + extra_headers=extra_headers, + timeout_seconds=timeout_to_seconds(timeout), + ) + + +async def amessages( + *, + model: str, + body: dict[str, object], + api_key: str | None, + api_base: str | None, + custom_llm_provider: str | None, + extra_headers: dict[str, object] | None, + timeout: Union[float, httpx.Timeout] | None, +) -> dict[str, object] | None: + rust_amessages = load_rust_amessages() + if rust_amessages is None: + return None + return await rust_amessages( + model=model, + body=body, + api_key=api_key, + api_base=api_base, + custom_llm_provider=custom_llm_provider, + extra_headers=extra_headers, + timeout_seconds=timeout_to_seconds(timeout), + ) diff --git a/litellm/rust_bridge/ocr.py b/litellm/rust_bridge/ocr.py index 36a088b6b1a..35de2eb9727 100644 --- a/litellm/rust_bridge/ocr.py +++ b/litellm/rust_bridge/ocr.py @@ -3,10 +3,15 @@ from __future__ import annotations import os -from typing import Any, Awaitable, Final, Protocol, Union, cast +from typing import TYPE_CHECKING, Awaitable, Final, Protocol, Union, cast import httpx +from litellm.rust_bridge.timeouts import timeout_to_seconds as _timeout_to_seconds + +if TYPE_CHECKING: + from litellm.rust_bridge.messages import RustAmessages, RustMessages + class RustOcr(Protocol): def __call__( @@ -64,13 +69,28 @@ def use_litellm_rust( *, ocr: RustOcr | None | _Unset = _UNSET, aocr: RustAocr | None | _Unset = _UNSET, + messages: RustMessages | None | _Unset = _UNSET, + amessages: RustAmessages | None | _Unset = _UNSET, ) -> None: global _rust_ocr_enabled, _rust_ocr_impl, _rust_aocr_impl - _rust_ocr_enabled = enabled + configuring_ocr = not isinstance(ocr, _Unset) or not isinstance(aocr, _Unset) + configuring_messages = not isinstance(messages, _Unset) or not isinstance(amessages, _Unset) + if configuring_ocr or not configuring_messages: + _rust_ocr_enabled = enabled if not isinstance(ocr, _Unset): _rust_ocr_impl = ocr if not isinstance(aocr, _Unset): _rust_aocr_impl = aocr + if not configuring_messages: + return + from litellm.rust_bridge.messages import set_rust_messages + + if not isinstance(messages, _Unset) and not isinstance(amessages, _Unset): + set_rust_messages(messages=messages, amessages=amessages) + elif not isinstance(messages, _Unset): + set_rust_messages(messages=messages) + else: + set_rust_messages(amessages=amessages) def rust_ocr_enabled() -> bool: @@ -99,22 +119,14 @@ def load_rust_aocr() -> RustAocr | None: return cast(RustAocr, getattr(native_bridge, "aocr", None)) -def _timeout_to_seconds(timeout: Union[float, httpx.Timeout] | None) -> float | None: - if timeout is None: - return None - if isinstance(timeout, httpx.Timeout): - return timeout.read - return float(timeout) - - def ocr( *, model: str, - document: dict[str, Any], + document: dict[str, object], api_key: str | None, api_base: str | None, custom_llm_provider: str | None, - extra_headers: dict[str, Any] | None, + extra_headers: dict[str, object] | None, optional_params: dict[str, object], timeout: Union[float, httpx.Timeout] | None, ) -> dict[str, object] | None: @@ -123,11 +135,11 @@ def ocr( return None return rust_ocr( model=model, - document=cast(dict[str, object], document), + document=document, api_key=api_key, api_base=api_base, custom_llm_provider=custom_llm_provider, - extra_headers=cast(dict[str, object] | None, extra_headers), + extra_headers=extra_headers, optional_params=optional_params, timeout_seconds=_timeout_to_seconds(timeout), ) @@ -136,11 +148,11 @@ def ocr( async def aocr( *, model: str, - document: dict[str, Any], + document: dict[str, object], api_key: str | None, api_base: str | None, custom_llm_provider: str | None, - extra_headers: dict[str, Any] | None, + extra_headers: dict[str, object] | None, optional_params: dict[str, object], timeout: Union[float, httpx.Timeout] | None, ) -> dict[str, object] | None: @@ -149,11 +161,11 @@ async def aocr( return None return await rust_aocr( model=model, - document=cast(dict[str, object], document), + document=document, api_key=api_key, api_base=api_base, custom_llm_provider=custom_llm_provider, - extra_headers=cast(dict[str, object] | None, extra_headers), + extra_headers=extra_headers, optional_params=optional_params, timeout_seconds=_timeout_to_seconds(timeout), ) diff --git a/litellm/rust_bridge/timeouts.py b/litellm/rust_bridge/timeouts.py new file mode 100644 index 00000000000..4407986c3da --- /dev/null +++ b/litellm/rust_bridge/timeouts.py @@ -0,0 +1,15 @@ +"""Shared timeout conversion for the native Rust bridges.""" + +from __future__ import annotations + +from typing import Union + +import httpx + + +def timeout_to_seconds(timeout: Union[float, httpx.Timeout] | None) -> float | None: + if timeout is None: + return None + if isinstance(timeout, httpx.Timeout): + return timeout.read + return float(timeout) diff --git a/litellm/types/mcp.py b/litellm/types/mcp.py index ac411ad9d9a..377ba669082 100644 --- a/litellm/types/mcp.py +++ b/litellm/types/mcp.py @@ -38,6 +38,7 @@ class MCPAuth(str, enum.Enum): aws_sigv4 = "aws_sigv4" token = "token" oauth2_token_exchange = "oauth2_token_exchange" + oauth2_id_jag = "oauth2_id_jag" true_passthrough = "true_passthrough" oauth_delegate = "oauth_delegate" @@ -62,6 +63,7 @@ MCPAuthType = Optional[ MCPAuth.aws_sigv4, MCPAuth.token, MCPAuth.oauth2_token_exchange, + MCPAuth.oauth2_id_jag, MCPAuth.true_passthrough, MCPAuth.oauth_delegate, ] @@ -159,6 +161,31 @@ class MCPCredentials(TypedDict, total=False): the top-level request field. """ + id_jag_resource_token_endpoint: Optional[str] + """ + Resource authorization server JWT-bearer (RFC 7523) endpoint for ID-JAG leg 2 + """ + + id_jag_resource: Optional[str] + """ + Optional RFC 8707 resource indicator sent on ID-JAG leg 1 + """ + + client_private_key: Optional[str] + """ + PEM private key used to sign the private-key-JWT client_assertion (RFC 7523) + """ + + client_private_key_id: Optional[str] + """ + Key id (kid) advertised in the client_assertion JWT header + """ + + client_assertion_signing_alg: Optional[str] + """ + Signing algorithm for the client_assertion JWT. Default: RS256 + """ + token_endpoint_auth_method: Optional[MCPTokenEndpointAuthMethod] """ How the gateway authenticates to the upstream token endpoint. "client_secret_basic" diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index d0d8cc4cb28..8ae974b19a6 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -87,6 +87,15 @@ class MCPServer(BaseModel): token_exchange_endpoint: Optional[str] = None audience: Optional[str] = None subject_token_type: str = DEFAULT_SUBJECT_TOKEN_TYPE + # ID-JAG fields (draft-ietf-oauth-identity-assertion-authz-grant). + # Leg 1 reuses token_exchange_endpoint (IdP org-AS), audience (resource-AS + # identifier), scopes, subject_token_type, client_id/client_secret. Leg 2 + # posts the ID-JAG assertion to id_jag_resource_token_endpoint. + id_jag_resource_token_endpoint: Optional[str] = None + id_jag_resource: Optional[str] = None + client_private_key: Optional[str] = None + client_private_key_id: Optional[str] = None + client_assertion_signing_alg: str = "RS256" # Wire dialect: "rfc8693" (standard token-exchange grant) or "entra_obo" (Microsoft Entra # On-Behalf-Of, the RFC 7523 jwt-bearer grant + requested_token_use extension) token_exchange_profile: str = "rfc8693" diff --git a/pyproject.toml b/pyproject.toml index f2d3da80d2a..3dadbfae99e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -189,6 +189,7 @@ dev = [ e2e-dev = [ "playwright==1.61.0", "websockets>=15.0.1,<16.0", + "locust==2.45.0", ] proxy-dev = [ "prisma==0.11.0", diff --git a/schema.prisma b/schema.prisma index f842bf13da9..a99cec49417 100644 --- a/schema.prisma +++ b/schema.prisma @@ -396,6 +396,13 @@ model LiteLLM_MCPUserEnvVars { @@index([server_id]) } +model LiteLLM_MCPServerOAuthClient { + server_id String @id + credentials Json? + 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 diff --git a/tests/e2e/CLAUDE.md b/tests/e2e/CLAUDE.md index 67e9f4f78a7..58a330775c0 100644 --- a/tests/e2e/CLAUDE.md +++ b/tests/e2e/CLAUDE.md @@ -17,6 +17,7 @@ Each subdirectory under `tests/e2e/` is one suite, scoped to an endpoint family - `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 - `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, and does not use the shared transport harness @@ -33,7 +34,7 @@ class TestPromptCompression: def test_prompt_compression_accumulate_spend(self, key_id, user_id): for _ in range(10): - response = self.resources.gateway.post("gemini-2.5-flash", key_id, user_id) + response = self.resources.proxy.post("gemini-2.5-flash", key_id, user_id) compressed_value = ... assert response.cost == compressed_value # the cost was actually reduced ``` @@ -48,9 +49,9 @@ The shape is layered so tests stay declarative `transport.py` exposes a `Transport` Protocol with `post`, `get`, `delete`, `send`, `stream`, `probe`, plus `bearer(key)` and the `master` header. `HttpTransport` fulfils it, and `SplitTransport` routes each call by path to the data plane or the control plane so a split control-plane/data-plane deployment works without any change in the test -`e2e_gateway.py` holds `Gateway`, a frozen dataclass that wraps a `Transport` and adds the operations tests reuse: `generate_key` / `delete_key` / `key_info`, `model_info`, the LLM calls `chat` / `chat_stream` / `embed` / `ocr`, the spend read-back `spend_logs`, and the poll helpers `poll_logs_for_key` / `poll_logs_for_request_id` that loop to `poll_timeout` instead of sleeping once. Add a new route as a method here so other suites get it for free +`proxy_client.py` holds `ProxyClient`, a frozen dataclass that wraps a `Transport` and adds the operations tests reuse: `generate_key` / `delete_key` / `key_info`, `model_info`, the LLM calls `chat` / `chat_stream` / `embed` / `ocr`, the spend read-back `spend_logs`, and the poll helpers `poll_logs_for_key` / `poll_logs_for_request_id` that loop to `poll_timeout` instead of sleeping once. It is exposed as the session-scoped `proxy` fixture (see tests/e2e/conftest.py), which each suite's `client` fixture depends on and injects. Add a new route as a method here so other suites get it for free -Each suite provides its own `client` fixture (see `llm_translation/passthrough_client.py`), a frozen dataclass that holds the shared `Gateway` and adds suite-specific routes. Cleanup runs through that same `Gateway`, so whatever keys or customers your test creates get torn down by the `resources` fixture +Each suite provides its own `client` fixture (see `llm_translation/passthrough_client.py`), a frozen dataclass that holds the shared `ProxyClient` (as `.proxy`) and adds suite-specific routes. Cleanup runs through that same `ProxyClient`, so whatever keys or customers your test creates get torn down by the `resources` fixture Request and response bodies are typed pydantic models in `models.py`; only the fields a test reads are modelled, and nothing passes raw dicts. Outcomes come back as a `Result[R]` tagged union (`Success`, `NetworkError`, `UnauthorizedError`, `RateLimitedError`, `ValidationError`, `UnknownApiError`). Handle them with `match`, or call `unwrap(...)` when a non-success should fail the test. The harness hard-fails and never skips: a test marked `e2e` fails when no proxy answers its liveness probe, and once a request reaches the proxy any wrong behavior is likewise a hard failure, so a missing proxy turns the run red instead of being mistaken for a pass diff --git a/tests/e2e/CONTRIBUTING.md b/tests/e2e/CONTRIBUTING.md index 555ac0482e2..49d776cd64b 100644 --- a/tests/e2e/CONTRIBUTING.md +++ b/tests/e2e/CONTRIBUTING.md @@ -113,7 +113,7 @@ class TestPromptCompression: def test_prompt_compression_accumulate_spend(self, key_id, user_id): for _ in range(10): - response = self.resources.gateway.post("gemini-2.5-flash", key_id, user_id) + response = self.resources.proxy.post("gemini-2.5-flash", key_id, user_id) compressed_value = ... assert response.cost == compressed_value # the cost was actually reduced ``` @@ -128,9 +128,9 @@ The shape is layered so tests stay declarative `transport.py` exposes a `Transport` Protocol with `post`, `get`, `delete`, `send`, `stream`, `probe`, plus `bearer(key)` and the `master` header. `HttpTransport` fulfils it, and `SplitTransport` routes each call by path to the data plane or the control plane so a split control-plane/data-plane deployment works without any change in the test -`e2e_gateway.py` holds `Gateway`, a frozen dataclass that wraps a `Transport` and adds the operations tests reuse: `generate_key` / `delete_key` / `key_info`, `model_info`, the LLM calls `chat` / `chat_stream` / `embed` / `ocr`, the spend read-back `spend_logs`, and the poll helpers `poll_logs_for_key` / `poll_logs_for_request_id` that loop to `poll_timeout` instead of sleeping once. Add a new route as a method here so other suites get it for free +`proxy_client.py` holds `ProxyClient`, a frozen dataclass that wraps a `Transport` and adds the operations tests reuse: `generate_key` / `delete_key` / `key_info`, `model_info`, the LLM calls `chat` / `chat_stream` / `embed` / `ocr`, the spend read-back `spend_logs`, and the poll helpers `poll_logs_for_key` / `poll_logs_for_request_id` that loop to `poll_timeout` instead of sleeping once. It is exposed as the session-scoped `proxy` fixture (see tests/e2e/conftest.py), which each suite's `client` fixture depends on and injects. Add a new route as a method here so other suites get it for free -Each suite provides its own `client` fixture (see `llm_translation/passthrough_client.py`), a frozen dataclass that holds the shared `Gateway` and adds suite-specific routes. Cleanup runs through that same `Gateway`, so whatever keys or customers your test creates get torn down by the `resources` fixture +Each suite provides its own `client` fixture (see `llm_translation/passthrough_client.py`), a frozen dataclass that holds the shared `ProxyClient` (as `.proxy`) and adds suite-specific routes. Cleanup runs through that same `ProxyClient`, so whatever keys or customers your test creates get torn down by the `resources` fixture Request and response bodies are typed pydantic models in `models.py`; only the fields a test reads are modelled, and nothing passes raw dicts. Outcomes come back as a `Result[R]` tagged union (`Success`, `NetworkError`, `UnauthorizedError`, `RateLimitedError`, `ValidationError`, `UnknownApiError`). Handle them with `match`, or call `unwrap(...)` when a non-success should fail the test. The harness hard-fails and never skips: a test marked `e2e` fails when no proxy answers its liveness probe, and once a request reaches the proxy any wrong behavior is likewise a hard failure, so a missing proxy turns the run red instead of being mistaken for a pass diff --git a/tests/e2e/access_control/access_control_client.py b/tests/e2e/access_control/access_control_client.py index d7bc9c280aa..e95ad1f57ce 100644 --- a/tests/e2e/access_control/access_control_client.py +++ b/tests/e2e/access_control/access_control_client.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass -from e2e_gateway import Gateway, build_gateway +from proxy_client import ProxyClient from e2e_http import StreamingResponse from models import ( ChatBody, @@ -21,29 +21,29 @@ ROUTE_NOT_ALLOWED_MARKER = "not allowed to call this route" @dataclass(frozen=True, slots=True) class AccessControlClient: - gateway: Gateway + proxy: ProxyClient def llm_only_key(self) -> str: - return self.gateway.generate_key( + return self.proxy.generate_key( KeyGenerateBody(models=[], allowed_routes=["llm_api_routes"]) ) def delete_key(self, key: str) -> None: - self.gateway.delete_key(key) + self.proxy.delete_key(key) def chat_status(self, key: str, model: str, content: str) -> StreamingResponse: - return self.gateway.transport.send( + return self.proxy.transport.send( "/chat/completions", - headers=self.gateway.transport.bearer(key), + headers=self.proxy.transport.bearer(key), json=ChatBody( model=model, messages=[ChatMessage(role="user", content=content)] ), ) def create_model_status(self, key: str, model_name: str) -> StreamingResponse: - return self.gateway.transport.send( + return self.proxy.transport.send( "/model/new", - headers=self.gateway.transport.bearer(key), + headers=self.proxy.transport.bearer(key), json=ModelNewBody( model_name=model_name, litellm_params=LiteLLMParamsBody(model="openai/gpt-4o-mini"), @@ -52,5 +52,5 @@ class AccessControlClient: ) -def build_client() -> AccessControlClient: - return AccessControlClient(gateway=build_gateway()) +def build_client(proxy: ProxyClient) -> AccessControlClient: + return AccessControlClient(proxy=proxy) diff --git a/tests/e2e/access_control/conftest.py b/tests/e2e/access_control/conftest.py index b5681ff76ad..d7299014d08 100644 --- a/tests/e2e/access_control/conftest.py +++ b/tests/e2e/access_control/conftest.py @@ -3,8 +3,9 @@ import pytest from access_control_client import AccessControlClient, build_client +from proxy_client import ProxyClient @pytest.fixture(scope="session") -def client() -> AccessControlClient: - return build_client() +def client(proxy: ProxyClient) -> AccessControlClient: + return build_client(proxy) diff --git a/tests/e2e/batches/COVERAGE.md b/tests/e2e/batches/COVERAGE.md index 4debf50bd6c..ca48204962a 100644 --- a/tests/e2e/batches/COVERAGE.md +++ b/tests/e2e/batches/COVERAGE.md @@ -68,7 +68,7 @@ File delete asserts `object=="file"` and `deleted==True`. | File | Covers | |------|--------| -| `batch_client.py` | typed file upload/download + batch create/retrieve/cancel/list/delete over the shared Gateway; runtime batch model registration via /model/new; denial helpers | +| `batch_client.py` | typed file upload/download + batch create/retrieve/cancel/list/delete over the shared ProxyClient; runtime batch model registration via /model/new; denial helpers | | `capabilities.py` | the provider x scenario matrix + per-provider /model/new params + id-shape classifiers + per-provider raw-id assertion | | `conftest.py` | session-scoped batch deployment registration and teardown | | `test_batches_e2e.py` | parametrized lifecycle with per-endpoint output assertions, file upload/delete outputs, key-model-access denial | diff --git a/tests/e2e/batches/batch_client.py b/tests/e2e/batches/batch_client.py index fda4e87e478..7db5d0b6beb 100644 --- a/tests/e2e/batches/batch_client.py +++ b/tests/e2e/batches/batch_client.py @@ -1,5 +1,5 @@ """Client for the batches e2e suite: file upload/download and the batch -operations (create / retrieve / cancel / list) over the shared Gateway. +operations (create / retrieve / cancel / list) over the shared ProxyClient. Batch deployments are registered at runtime via /model/new (see conftest.py), not baked into the proxy config. `create_batch` returns the raw HTTP outcome @@ -16,7 +16,7 @@ from dataclasses import dataclass from pydantic import BaseModel -from e2e_gateway import Gateway, build_gateway +from proxy_client import ProxyClient from e2e_http import ( FileUploadForm, NoBody, @@ -85,13 +85,13 @@ def is_result_access_denied[R: BaseModel](result: Result[R]) -> bool: @dataclass(frozen=True, slots=True) class BatchClient: - gateway: Gateway + proxy: ProxyClient def create_model(self, model_name: str, litellm_params: LiteLLMParamsBody) -> str: - return self.gateway.create_model(model_name, litellm_params, mode="batch") + return self.proxy.create_model(model_name, litellm_params, mode="batch") def delete_model(self, model_id: str) -> None: - self.gateway.delete_model(model_id) + self.proxy.delete_model(model_id) def upload_file( self, @@ -102,9 +102,9 @@ class BatchClient: model: str | None = None, provider: str | None = None, ) -> Result[FileObject]: - return self.gateway.transport.upload( + return self.proxy.transport.upload( _files_path(provider), - headers=self.gateway.transport.bearer(key), + headers=self.proxy.transport.bearer(key), form=form, filename="batch_input.jsonl", content=content, @@ -115,18 +115,18 @@ class BatchClient: def create_batch( self, *, body: BatchCreateBody, key: str, provider: str | None = None ) -> StreamingResponse: - return self.gateway.transport.send( + return self.proxy.transport.send( _batches_path(provider), - headers=self.gateway.transport.bearer(key), + headers=self.proxy.transport.bearer(key), json=body, ) def retrieve_batch( self, batch_id: str, *, key: str, provider: str | None = None ) -> Result[BatchObject]: - return self.gateway.transport.get( + return self.proxy.transport.get( f"{_batches_path(provider)}/{batch_id}", - headers=self.gateway.transport.bearer(key), + headers=self.proxy.transport.bearer(key), params=NoBody(), response_type=BatchObject, ) @@ -134,9 +134,9 @@ class BatchClient: def cancel_batch( self, batch_id: str, *, key: str, provider: str | None = None ) -> Result[BatchObject]: - return self.gateway.transport.post( + return self.proxy.transport.post( f"{_batches_path(provider)}/{batch_id}/cancel", - headers=self.gateway.transport.bearer(key), + headers=self.proxy.transport.bearer(key), json=NoBody(), response_type=BatchObject, ) @@ -144,9 +144,9 @@ class BatchClient: def list_batches( self, *, key: str, provider: str | None = None ) -> Result[BatchList]: - return self.gateway.transport.get( + return self.proxy.transport.get( _batches_path(provider), - headers=self.gateway.transport.bearer(key), + headers=self.proxy.transport.bearer(key), params=NoBody(), response_type=BatchList, ) @@ -154,9 +154,9 @@ class BatchClient: def delete_file( self, file_id: str, *, key: str, provider: str | None = None ) -> Result[FileDeleteResponse]: - return self.gateway.transport.delete( + return self.proxy.transport.delete( f"{_files_path(provider)}/{file_id}", - headers=self.gateway.transport.bearer(key), + headers=self.proxy.transport.bearer(key), json=NoBody(), response_type=FileDeleteResponse, ) @@ -170,5 +170,5 @@ def _batches_path(provider: str | None) -> str: return f"/{provider}/v1/batches" if provider else "/v1/batches" -def build_client() -> BatchClient: - return BatchClient(gateway=build_gateway()) +def build_client(proxy: ProxyClient) -> BatchClient: + return BatchClient(proxy=proxy) diff --git a/tests/e2e/batches/conftest.py b/tests/e2e/batches/conftest.py index d3b6d42bc24..73e8918e2ee 100644 --- a/tests/e2e/batches/conftest.py +++ b/tests/e2e/batches/conftest.py @@ -1,7 +1,7 @@ """Batches suite's `client` fixture. The shared lifecycle (resources/scoped_key), proxy liveness gate, and e2e marker -live in the parent tests/e2e/conftest.py. BatchClient holds the shared Gateway, so +live in the parent tests/e2e/conftest.py. BatchClient holds the shared ProxyClient, so the `resources` fixture cleans up keys through it; tests register file deletes and batch cancels via `resources.defer(...)`. @@ -19,6 +19,7 @@ import pytest from batch_client import BatchClient, build_client from capabilities import PROVIDERS from e2e_http import NoBody +from proxy_client import ProxyClient def pytest_configure(config: pytest.Config) -> None: @@ -29,13 +30,13 @@ def pytest_configure(config: pytest.Config) -> None: @pytest.fixture(scope="session") -def client() -> BatchClient: - return build_client() +def client(proxy: ProxyClient) -> BatchClient: + return build_client(proxy) @pytest.fixture(scope="session") def batch_deployments(client: BatchClient) -> Iterator[None]: - probe = client.gateway.probe("/health/liveliness", params=NoBody()) + probe = client.proxy.probe("/health/liveliness", params=NoBody()) if not probe.healthy: yield return diff --git a/tests/e2e/batches/test_batches_e2e.py b/tests/e2e/batches/test_batches_e2e.py index 2ee7eb36a41..b483b420c84 100644 --- a/tests/e2e/batches/test_batches_e2e.py +++ b/tests/e2e/batches/test_batches_e2e.py @@ -372,17 +372,17 @@ def test_rate_limited_batch_create_leaves_no_unattributed_spend_row( environment and OOMed the e2e runner on stage. """ user_id = f"e2e-batch-rl-{unique_marker()}" - key = client.gateway.generate_key( + key = client.proxy.generate_key( KeyGenerateBody(models=[], tpm_limit=1_000_000, rpm_limit=1_000, user_id=user_id) ) - resources.defer(lambda: client.gateway.delete_key(key)) + resources.defer(lambda: client.proxy.delete_key(key)) window_start = datetime.now(timezone.utc) - timedelta(hours=1) window_end = window_start + timedelta(hours=2) before = frozenset( row.request_id for row in unattributed_rows( - client.gateway.spend_logs_window(start=window_start, end=window_end) + client.proxy.spend_logs_window(start=window_start, end=window_end) ) ) @@ -401,12 +401,12 @@ def test_rate_limited_batch_create_leaves_no_unattributed_spend_row( batch = BatchObject.model_validate_json(created.body) resources.defer(quietly(lambda: client.cancel_batch(batch.id, key=key))) - _ = client.gateway.poll_logs_for_key(key, min_rows=1) + _ = client.proxy.poll_logs_for_key(key, min_rows=1) new_orphans = [ row for row in unattributed_rows( - client.gateway.spend_logs_window(start=window_start, end=window_end) + client.proxy.spend_logs_window(start=window_start, end=window_end) ) if row.request_id not in before ] diff --git a/tests/e2e/claude_code/conftest.py b/tests/e2e/claude_code/conftest.py index 8f5c09fa4e2..aaad7667936 100644 --- a/tests/e2e/claude_code/conftest.py +++ b/tests/e2e/claude_code/conftest.py @@ -577,30 +577,30 @@ from claude_code._compat_models import ( # noqa: E402 ) -def _build_control_gateway(proxy: ProxyConfig): +def _build_control_plane_client(proxy_config: ProxyConfig): """Local import of the shared harness so the pure-unit-test tree under ``_driver_unit_tests/`` etc. never has to pull it in. The control plane transport is what /model/new lives on; SplitTransport routes it correctly for both monolithic and split deployments. - The endpoints come from the *resolved* proxy, not from a second + The endpoints come from the *resolved* proxy config, not from a second independent env read, so registration and the cells always hit the same host and key. Both planes get the one URL the cells use; the deployment is fronted by a single address that routes management and LLM paths itself.""" - from e2e_gateway import build_gateway + from proxy_client import build_proxy_client - return build_gateway( - base_url=proxy.base_url, - master_key=proxy.api_key, - control_plane_base_url=proxy.base_url, + return build_proxy_client( + base_url=proxy_config.base_url, + master_key=proxy_config.api_key, + control_plane_base_url=proxy_config.base_url, ) -def _register_deployment(gateway, deployment: CompatDeployment) -> str: +def _register_deployment(proxy, deployment: CompatDeployment) -> str: """Register one deployment and return its proxy-assigned model_id once it is servable on the data plane.""" - return gateway.create_model( + return proxy.create_model( deployment.model_name, deployment.litellm_params, ) @@ -624,20 +624,20 @@ def _compat_models_registered() -> Any: but do not abort the session: the cells that need that specific deployment will 400 with "Invalid model name" and fail loudly, which is the right signal (missing cred on the proxy side).""" - proxy = resolve_proxy() - if proxy is None: + proxy_config = resolve_proxy() + if proxy_config is None: yield return from requests import RequestException - gateway = _build_control_gateway(proxy) + proxy = _build_control_plane_client(proxy_config) registered_ids: list[str] = [] failures: list[tuple[str, str]] = [] try: for deployment in load_all_deployments(): try: - model_id = _register_deployment(gateway, deployment) + model_id = _register_deployment(proxy, deployment) registered_ids.append(model_id) except (AssertionError, RequestException) as exc: failures.append((deployment.model_name, str(exc))) @@ -656,7 +656,7 @@ def _compat_models_registered() -> Any: finally: for model_id in registered_ids: try: - gateway.delete_model(model_id) + proxy.delete_model(model_id) except (AssertionError, RequestException): # Best-effort — teardown surfaces via warnings inside # ``delete_model`` already; swallowing here so one flaky diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 88a9deecb7e..5347fffca4d 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -23,7 +23,8 @@ import requests from e2e_config import CONTROL_PLANE_BASE_URL, PROXY_BASE_URL from junit_properties import attach_result_properties -from lifecycle import GatewayProvider, ResourceManager +from lifecycle import ProxyClientProvider, ResourceManager +from proxy_client import ProxyClient, build_proxy_client _E2E_TEST_RAN = pytest.StashKey[bool]() @@ -38,6 +39,10 @@ def pytest_configure(config: pytest.Config) -> None: "markers", "covers(cell_id, *, exercised_on=()): coverage-registry cell(s) this test covers", ) + config.addinivalue_line( + "markers", + "load: heavy throughput/load test; collected last so it never perturbs latency-sensitive suites", + ) def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: @@ -46,9 +51,13 @@ def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: as `` entries, on every outcome including skips and setup errors. Downstream (Loki/Grafana) reads outcome and duration from the standard report and these properties for package rollups and coverage drill-down. See - junit_properties.py.""" + junit_properties.py. + + Also sort `load`-marked items last so a whole-tree run drives heavy throughput + traffic only after the latency-sensitive suites have finished.""" for item in items: attach_result_properties(item) + items.sort(key=lambda item: item.get_closest_marker("load") is not None) def _liveness_reason(label: str, base_url: str) -> str | None: @@ -120,11 +129,18 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None: sys.path.remove(spend_dir) +@pytest.fixture(scope="session") +def proxy() -> ProxyClient: + """The shared ProxyClient every suite's client is built from. Suite `client` + fixtures depend on this and inject it, so the proxy wiring lives in one place.""" + return build_proxy_client() + + @pytest.fixture -def resources(client: GatewayProvider) -> Iterator[ResourceManager]: +def resources(client: ProxyClientProvider) -> Iterator[ResourceManager]: """init -> run -> teardown: create a manager, run the test, release resources. - Cleanup goes through the shared Gateway, whatever the suite's client adds.""" - manager = ResourceManager(client=client.gateway) + Cleanup goes through the shared ProxyClient, whatever the suite's client adds.""" + manager = ResourceManager(client=client.proxy) manager.init() yield manager manager.teardown() diff --git a/tests/e2e/e2e_config.py b/tests/e2e/e2e_config.py index 529744d5a2c..2d0ad93e53d 100644 --- a/tests/e2e/e2e_config.py +++ b/tests/e2e/e2e_config.py @@ -61,6 +61,12 @@ POLL_TIMEOUT = float(os.environ.get("E2E_POLL_TIMEOUT", "120")) POLL_INTERVAL = float(os.environ.get("E2E_POLL_INTERVAL", "5")) REQUEST_TIMEOUT = float(os.environ.get("E2E_REQUEST_TIMEOUT", "60")) +LOAD_USERS = int(os.environ.get("E2E_LOAD_USERS", "750")) +LOAD_SPAWN_RATE = float(os.environ.get("E2E_LOAD_SPAWN_RATE", "50")) +LOAD_DURATION_SECONDS = float(os.environ.get("E2E_LOAD_DURATION_SECONDS", "60")) +LOAD_MIN_RPS = float(os.environ.get("E2E_LOAD_MIN_RPS", "355")) +LOAD_MAX_FAILURE_RATIO = float(os.environ.get("E2E_LOAD_MAX_FAILURE_RATIO", "0.01")) + def unique_marker() -> str: """A short unique token per call/run, so concurrent runs and the shared diff --git a/tests/e2e/lifecycle.py b/tests/e2e/lifecycle.py index b15986987a7..c0de074f0d2 100644 --- a/tests/e2e/lifecycle.py +++ b/tests/e2e/lifecycle.py @@ -13,7 +13,7 @@ the test body is run(), and the fixture's teardown is teardown(). from dataclasses import dataclass, field from typing import Callable, List, Protocol, runtime_checkable -from e2e_gateway import Gateway +from proxy_client import ProxyClient from models import KeyGenerateBody @@ -52,7 +52,7 @@ def run_case(case: E2ECase) -> None: @runtime_checkable class ResourceClient(Protocol): """Proxy operations the convenience creators use. Resource types without a - creator here are handled generically via ResourceManager.defer(). The Gateway + creator here are handled generically via ResourceManager.defer(). The ProxyClient satisfies this.""" def generate_key(self, body: KeyGenerateBody) -> str: ... @@ -63,12 +63,12 @@ class ResourceClient(Protocol): @runtime_checkable -class GatewayProvider(Protocol): - """Every suite's client exposes the shared Gateway, which the resources fixture +class ProxyClientProvider(Protocol): + """Every suite's client exposes the shared ProxyClient, which the resources fixture uses for cleanup. The client adds its own route methods on top.""" @property - def gateway(self) -> Gateway: ... + def proxy(self) -> ProxyClient: ... @dataclass diff --git a/tests/e2e/llm_translation/conftest.py b/tests/e2e/llm_translation/conftest.py index 5258b751a8c..f35ecf0760d 100644 --- a/tests/e2e/llm_translation/conftest.py +++ b/tests/e2e/llm_translation/conftest.py @@ -2,13 +2,14 @@ The shared lifecycle (resources/scoped_key), proxy liveness gate, and e2e marker live in the parent tests/e2e/conftest.py. PassthroughClient holds the shared -Gateway, so the `resources` fixture cleans up keys this suite creates. +ProxyClient, so the `resources` fixture cleans up keys this suite creates. """ import pytest from endpoints_client import EndpointsClient, build_endpoints_client from passthrough_client import PassthroughClient, build_client +from proxy_client import ProxyClient def pytest_configure(config: pytest.Config) -> None: @@ -19,10 +20,10 @@ def pytest_configure(config: pytest.Config) -> None: @pytest.fixture(scope="session") -def client() -> PassthroughClient: - return build_client() +def client(proxy: ProxyClient) -> PassthroughClient: + return build_client(proxy) @pytest.fixture(scope="session") -def endpoints_client() -> EndpointsClient: - return build_endpoints_client() +def endpoints_client(proxy: ProxyClient) -> EndpointsClient: + return build_endpoints_client(proxy) diff --git a/tests/e2e/llm_translation/endpoints_client.py b/tests/e2e/llm_translation/endpoints_client.py index c3816928564..e339922b4d1 100644 --- a/tests/e2e/llm_translation/endpoints_client.py +++ b/tests/e2e/llm_translation/endpoints_client.py @@ -13,7 +13,7 @@ from dataclasses import dataclass from pydantic import BaseModel -from e2e_gateway import Gateway, build_gateway +from proxy_client import ProxyClient from e2e_http import StreamingResponse from models import ChatMessage, LiteLLMParamsBody @@ -156,17 +156,17 @@ class ImagesResult(BaseModel): @dataclass(frozen=True, slots=True) class EndpointsClient: - gateway: Gateway + proxy: ProxyClient def create_model(self, model_name: str, litellm_params: LiteLLMParamsBody) -> str: - return self.gateway.create_model(model_name, litellm_params) + return self.proxy.create_model(model_name, litellm_params) def delete_model(self, model_id: str) -> None: - self.gateway.delete_model(model_id) + self.proxy.delete_model(model_id) def _send(self, path: str, key: str, body: BaseModel) -> StreamingResponse: - return self.gateway.transport.send( - path, headers=self.gateway.transport.bearer(key), json=body + return self.proxy.transport.send( + path, headers=self.proxy.transport.bearer(key), json=body ) def responses(self, key: str, model: str, text: str) -> StreamingResponse: @@ -216,5 +216,5 @@ class EndpointsClient: ) -def build_endpoints_client() -> EndpointsClient: - return EndpointsClient(gateway=build_gateway()) +def build_endpoints_client(proxy: ProxyClient) -> EndpointsClient: + return EndpointsClient(proxy=proxy) diff --git a/tests/e2e/llm_translation/passthrough_client.py b/tests/e2e/llm_translation/passthrough_client.py index 77dcf68a1e3..0576321ede1 100644 --- a/tests/e2e/llm_translation/passthrough_client.py +++ b/tests/e2e/llm_translation/passthrough_client.py @@ -14,7 +14,7 @@ from dataclasses import dataclass from pydantic import BaseModel, Field -from e2e_gateway import Gateway, build_gateway +from proxy_client import ProxyClient from e2e_http import Headers, StreamingResponse from models import ChatMessage @@ -108,7 +108,7 @@ def _tags_header(tags: list[str] | None) -> str | None: @dataclass(frozen=True, slots=True) class PassthroughClient: - gateway: Gateway + proxy: ProxyClient # ---- Gemini native passthrough (/gemini/v1beta/...) ----------------- @@ -121,7 +121,7 @@ class PassthroughClient: tools: list[GeminiTool] | None = None, tags: list[str] | None = None, ) -> StreamingResponse: - return self.gateway.transport.send( + return self.proxy.transport.send( f"/gemini/v1beta/models/{model}:generateContent", headers=GeminiHeaders(x_goog_api_key=key, tags=_tags_header(tags)), json=GeminiGenerateBody( @@ -132,7 +132,7 @@ class PassthroughClient: def gemini_stream( self, key: str, model: str, text: str, *, tags: list[str] | None = None ) -> StreamingResponse: - return self.gateway.transport.send( + return self.proxy.transport.send( f"/gemini/v1beta/models/{model}:streamGenerateContent", headers=GeminiHeaders(x_goog_api_key=key, tags=_tags_header(tags)), json=GeminiGenerateBody( @@ -151,7 +151,7 @@ class PassthroughClient: f"/vertex_ai/v1/projects/{project}/locations/{location}" f"/publishers/google/models/{model}:generateContent" ) - return self.gateway.transport.send( + return self.proxy.transport.send( path, headers=VertexHeaders(x_litellm_api_key=key), json=GeminiGenerateBody( @@ -172,7 +172,7 @@ class PassthroughClient: stream: bool = False, tags: list[str] | None = None, ) -> StreamingResponse: - return self.gateway.transport.send( + return self.proxy.transport.send( "/anthropic/v1/messages", headers=AnthropicHeaders(x_api_key=key, tags=_tags_header(tags)), json=AnthropicMessageBody( @@ -186,5 +186,5 @@ class PassthroughClient: ) -def build_client() -> PassthroughClient: - return PassthroughClient(gateway=build_gateway()) +def build_client(proxy: ProxyClient) -> PassthroughClient: + return PassthroughClient(proxy=proxy) diff --git a/tests/e2e/llm_translation/realtime/conftest.py b/tests/e2e/llm_translation/realtime/conftest.py index 8e6e596bcd3..752737e830e 100644 --- a/tests/e2e/llm_translation/realtime/conftest.py +++ b/tests/e2e/llm_translation/realtime/conftest.py @@ -1,7 +1,7 @@ """Realtime suite's `client` and `realtime_models` fixtures. The shared lifecycle (resources/scoped_key), proxy liveness gate, and e2e marker -live in the parent tests/e2e/conftest.py. RealtimeClient holds the shared Gateway, +live in the parent tests/e2e/conftest.py. RealtimeClient holds the shared ProxyClient, so the `resources` fixture cleans up keys this suite creates. `realtime_models` registers every provider's realtime deployment through /model/new @@ -15,11 +15,12 @@ from collections.abc import Iterator import pytest from realtime_client import PROVIDERS, RealtimeClient, build_client +from proxy_client import ProxyClient @pytest.fixture(scope="session") -def client() -> RealtimeClient: - return build_client() +def client(proxy: ProxyClient) -> RealtimeClient: + return build_client(proxy) @pytest.fixture(scope="session") @@ -34,4 +35,4 @@ def realtime_models(client: RealtimeClient) -> Iterator[dict[str, str]]: yield {provider_id: model_name for provider_id, model_name, _ in records} finally: for _, _, model_id in records: - client.gateway.delete_model(model_id) + client.proxy.delete_model(model_id) diff --git a/tests/e2e/llm_translation/realtime/realtime_client.py b/tests/e2e/llm_translation/realtime/realtime_client.py index b50160e3538..e6c5c19cbd1 100644 --- a/tests/e2e/llm_translation/realtime/realtime_client.py +++ b/tests/e2e/llm_translation/realtime/realtime_client.py @@ -22,7 +22,7 @@ from websockets.sync.client import connect from websockets.sync.connection import Connection from e2e_config import PROXY_BASE_URL, unique_marker -from e2e_gateway import Gateway, build_gateway +from proxy_client import ProxyClient from models import LiteLLMParamsBody _M = TypeVar("_M", bound=BaseModel) @@ -329,7 +329,7 @@ class RealtimeSession: @dataclass(frozen=True, slots=True) class RealtimeClient: - gateway: Gateway + proxy: ProxyClient def provision(self, provider: RealtimeProvider) -> tuple[str, str]: """Register this provider's realtime deployment through /model/new and return @@ -338,7 +338,7 @@ class RealtimeClient: show up as a realtime model on /model/info. add_deployment runs synchronously, so the deployment is connectable as soon as this returns.""" model_name = f"{provider.alias}-{unique_marker()}" - model_id = self.gateway.create_model( + model_id = self.proxy.create_model( model_name, provider.litellm_params, mode="realtime" ) return model_name, model_id @@ -355,5 +355,5 @@ class RealtimeClient: yield RealtimeSession(connection=connection) -def build_client() -> RealtimeClient: - return RealtimeClient(gateway=build_gateway()) +def build_client(proxy: ProxyClient) -> RealtimeClient: + return RealtimeClient(proxy=proxy) diff --git a/tests/e2e/llm_translation/test_cache_control.py b/tests/e2e/llm_translation/test_cache_control.py index ba701f4869c..a2c17b0fb66 100644 --- a/tests/e2e/llm_translation/test_cache_control.py +++ b/tests/e2e/llm_translation/test_cache_control.py @@ -81,9 +81,9 @@ def _cache_chat( RichMessage(role="user", content=[TextBlock(text="Reply with one word.")]), ], ) - return client.gateway.transport.post( + return client.proxy.transport.post( "/chat/completions", - headers=client.gateway.transport.bearer(key), + headers=client.proxy.transport.bearer(key), json=body, response_type=ChatResponse, ) @@ -120,11 +120,11 @@ class TestCacheControl: self, client: PassthroughClient, resources: ResourceManager ) -> None: model = f"e2e-bedrock-cache-{unique_marker()}" - model_id = client.gateway.create_model( + model_id = client.proxy.create_model( model, LiteLLMParamsBody(model=BEDROCK_MODEL, aws_region_name="us-east-1"), ) - resources.defer(lambda: client.gateway.delete_model(model_id)) + resources.defer(lambda: client.proxy.delete_model(model_id)) _assert_cache_read_on_second_call(client, resources.key(), model) @pytest.mark.covers( @@ -135,7 +135,7 @@ class TestCacheControl: self, client: PassthroughClient, resources: ResourceManager ) -> None: model = f"e2e-vertex-cache-{unique_marker()}" - model_id = client.gateway.create_model( + model_id = client.proxy.create_model( model, LiteLLMParamsBody( model=VERTEX_MODEL, @@ -144,5 +144,5 @@ class TestCacheControl: vertex_credentials=os.environ.get("VERTEXAI_CREDENTIALS"), ), ) - resources.defer(lambda: client.gateway.delete_model(model_id)) + resources.defer(lambda: client.proxy.delete_model(model_id)) _assert_cache_read_on_second_call(client, resources.key(), model) diff --git a/tests/e2e/llm_translation/test_chat_completions_regression_e2e.py b/tests/e2e/llm_translation/test_chat_completions_regression_e2e.py index 5cc4ff308fa..f882bc5b4e4 100644 --- a/tests/e2e/llm_translation/test_chat_completions_regression_e2e.py +++ b/tests/e2e/llm_translation/test_chat_completions_regression_e2e.py @@ -43,7 +43,7 @@ class TestChatCompletionsRegression: self, client: PassthroughClient, scoped_key: str, model: str, route: str ) -> None: response = unwrap( - client.gateway.chat( + client.proxy.chat( scoped_key, ChatBody( model=model, diff --git a/tests/e2e/llm_translation/test_custom_pricing_e2e.py b/tests/e2e/llm_translation/test_custom_pricing_e2e.py index 7894b447be9..b4ff631a56b 100644 --- a/tests/e2e/llm_translation/test_custom_pricing_e2e.py +++ b/tests/e2e/llm_translation/test_custom_pricing_e2e.py @@ -23,7 +23,7 @@ import pytest from pydantic import BaseModel, RootModel from e2e_config import unique_marker -from e2e_gateway import Gateway +from proxy_client import ProxyClient from e2e_http import Success, unwrap from endpoints_client import EndpointsClient from lifecycle import ResourceManager @@ -116,14 +116,14 @@ def _model_info_entry(entries: list[ModelInfoEntry], model_name: str) -> ModelIn pytest.fail(f"{model_name} absent from /model/info; the override did not load") -def _poll_breakdown_row(gateway: Gateway, key: str, response_id: str | None) -> _SpendRow: +def _poll_breakdown_row(proxy: ProxyClient, key: str, response_id: str | None) -> _SpendRow: """Poll /spend/logs until the call's row lands with a cost breakdown (rows flush ~60s behind the call via proxy_batch_write_at).""" - deadline = time.monotonic() + gateway.poll_timeout + deadline = time.monotonic() + proxy.poll_timeout while time.monotonic() < deadline: - result = gateway.transport.get( + result = proxy.transport.get( "/spend/logs", - headers=gateway.transport.master, + headers=proxy.transport.master, params=SpendLogsParams(api_key=key), response_type=_SpendRows, ) @@ -144,7 +144,7 @@ def _poll_breakdown_row(gateway: Gateway, key: str, response_id: str | None) -> return row if priced and response_id is None: return priced[0] - time.sleep(gateway.poll_interval) + time.sleep(proxy.poll_interval) pytest.fail("no spend row with a cost breakdown landed before the deadline") @@ -158,7 +158,7 @@ class TestCustomPricing: model = _provision_custom_priced(endpoints_client, resources) chat = unwrap( - endpoints_client.gateway.chat( + endpoints_client.proxy.chat( scoped_key, ChatBody( model=model, @@ -172,7 +172,7 @@ class TestCustomPricing: ) ) - row = _poll_breakdown_row(endpoints_client.gateway, scoped_key, chat.id) + row = _poll_breakdown_row(endpoints_client.proxy, scoped_key, chat.id) assert row.metadata and row.metadata.cost_breakdown # guaranteed by the poll breakdown = row.metadata.cost_breakdown @@ -198,7 +198,7 @@ class TestCustomPricing: self, endpoints_client: EndpointsClient, resources: ResourceManager ) -> None: model = _provision_custom_priced(endpoints_client, resources) - entry = _model_info_entry(endpoints_client.gateway.model_info(), model) + entry = _model_info_entry(endpoints_client.proxy.model_info(), model) assert entry.litellm_params.input_cost_per_token == CUSTOM_INPUT_RATE, ( f"/model/info litellm_params input rate " @@ -223,7 +223,7 @@ class TestCustomPricing: output_cost_per_token=None, ) - entries = {entry.model_name: entry for entry in endpoints_client.gateway.model_info()} + entries = {entry.model_name: entry for entry in endpoints_client.proxy.model_info()} custom_entry = entries.get(custom) sibling_entry = entries.get(sibling) assert custom_entry is not None, f"{custom} absent from /model/info" diff --git a/tests/e2e/llm_translation/test_deepseek_reasoning_e2e.py b/tests/e2e/llm_translation/test_deepseek_reasoning_e2e.py index 5adb8c24f9f..b06b241c0b5 100644 --- a/tests/e2e/llm_translation/test_deepseek_reasoning_e2e.py +++ b/tests/e2e/llm_translation/test_deepseek_reasoning_e2e.py @@ -33,11 +33,11 @@ PROMPT = "What is 17 + 26? Answer with just the number." def _register_reasoner(client: PassthroughClient, resources: ResourceManager) -> str: model = f"e2e-deepseek-reasoner-{unique_marker()}" - model_id = client.gateway.create_model( + model_id = client.proxy.create_model( model, LiteLLMParamsBody(model=REASONER, api_key="os.environ/DEEPSEEK_API_KEY"), ) - resources.defer(lambda: client.gateway.delete_model(model_id)) + resources.defer(lambda: client.proxy.delete_model(model_id)) return model @@ -56,7 +56,7 @@ class TestDeepSeekReasoningDisable: key = resources.key() response = unwrap( - client.gateway.chat( + client.proxy.chat( key, ChatBody( model=model, @@ -78,7 +78,7 @@ class TestDeepSeekReasoningDisable: key = resources.key() response = unwrap( - client.gateway.chat( + client.proxy.chat( key, ChatBody( model=model, @@ -100,7 +100,7 @@ class TestDeepSeekReasoningDisable: key = resources.key() response = unwrap( - client.gateway.chat( + client.proxy.chat( key, ChatBody( model=model, diff --git a/tests/e2e/llm_translation/test_messages_mid_conversation_system_e2e.py b/tests/e2e/llm_translation/test_messages_mid_conversation_system_e2e.py index 1025d603fca..4b3191e60bb 100644 --- a/tests/e2e/llm_translation/test_messages_mid_conversation_system_e2e.py +++ b/tests/e2e/llm_translation/test_messages_mid_conversation_system_e2e.py @@ -76,9 +76,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, ) diff --git a/tests/e2e/llm_translation/test_ocr_rust_e2e.py b/tests/e2e/llm_translation/test_ocr_rust_e2e.py index e735d9c01b5..cdbf1883314 100644 --- a/tests/e2e/llm_translation/test_ocr_rust_e2e.py +++ b/tests/e2e/llm_translation/test_ocr_rust_e2e.py @@ -150,7 +150,7 @@ class TestRustOcrGateway: resources.defer(lambda: endpoints_client.delete_model(model_id)) key = resources.key() - response = unwrap(endpoints_client.gateway.ocr(key, OcrBody(model=model, document=case.document))) + response = unwrap(endpoints_client.proxy.ocr(key, OcrBody(model=model, document=case.document))) _assert_ocr_document(response) diff --git a/tests/e2e/llm_translation/test_passthrough_e2e.py b/tests/e2e/llm_translation/test_passthrough_e2e.py index 37d55c665b3..c8806faf3ea 100644 --- a/tests/e2e/llm_translation/test_passthrough_e2e.py +++ b/tests/e2e/llm_translation/test_passthrough_e2e.py @@ -35,7 +35,7 @@ def _fetch_cost_breakdown(client: PassthroughClient, result: StreamingResponse) whole point of passthrough spend tracking. """ assert result.call_id, "passthrough response had no x-litellm-call-id header" - rows = client.gateway.poll_logs_for_request_id( + rows = client.proxy.poll_logs_for_request_id( result.call_id, predicate=lambda rs: (rs[0].spend or 0) > 0, ) diff --git a/tests/e2e/llm_translation/test_provider_features_e2e.py b/tests/e2e/llm_translation/test_provider_features_e2e.py index 822a1d8d4d5..2ea1d28748d 100644 --- a/tests/e2e/llm_translation/test_provider_features_e2e.py +++ b/tests/e2e/llm_translation/test_provider_features_e2e.py @@ -37,17 +37,17 @@ class TestServiceTier: self, client: PassthroughClient, resources: ResourceManager ) -> None: model = f"e2e-service-tier-{unique_marker()}" - model_id = client.gateway.create_model( + model_id = client.proxy.create_model( model, LiteLLMParamsBody( model="openai/gpt-5.5", api_key="os.environ/OPENAI_API_KEY" ), ) - resources.defer(lambda: client.gateway.delete_model(model_id)) + resources.defer(lambda: client.proxy.delete_model(model_id)) key = resources.key() response = unwrap( - client.gateway.chat( + client.proxy.chat( key, ChatBody( model=model, diff --git a/tests/e2e/llm_translation/test_vertex_passthrough_e2e.py b/tests/e2e/llm_translation/test_vertex_passthrough_e2e.py index 78d2bb358d2..b6d90b7f6a2 100644 --- a/tests/e2e/llm_translation/test_vertex_passthrough_e2e.py +++ b/tests/e2e/llm_translation/test_vertex_passthrough_e2e.py @@ -91,9 +91,9 @@ def _add_vertex_passthrough_model( client: PassthroughClient, model_name: str, project: str, credentials: str ) -> str: return unwrap( - client.gateway.transport.post( + client.proxy.transport.post( "/model/new", - headers=client.gateway.transport.master, + headers=client.proxy.transport.master, json=_ModelNewBody( model_name=model_name, litellm_params=_VertexDeploymentParams( @@ -111,9 +111,9 @@ def _add_vertex_passthrough_model( def _delete_model(client: PassthroughClient, model_id: str) -> None: - _ = client.gateway.transport.post( + _ = client.proxy.transport.post( "/model/delete", - headers=client.gateway.transport.master, + headers=client.proxy.transport.master, json=_ModelDeleteBody(id=model_id), response_type=NoBody, ) @@ -126,7 +126,7 @@ def _costed_row(client: PassthroughClient, call_id: str | None) -> SpendLogRow: a billed Vertex call that LiteLLM did not track is the exact regression #31689 guards against.""" assert call_id, "vertex passthrough response had no x-litellm-call-id header" - rows = client.gateway.poll_logs_for_request_id( + rows = client.proxy.poll_logs_for_request_id( call_id, predicate=lambda rs: (rs[0].spend or 0) > 0, ) diff --git a/tests/e2e/load/conftest.py b/tests/e2e/load/conftest.py new file mode 100644 index 00000000000..e2d135092fb --- /dev/null +++ b/tests/e2e/load/conftest.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from requests import RequestException + +from e2e_gateway import Gateway +from e2e_http import NoBody, Success +from load_client import LoadClient, build_client +from load_constants import LOAD_MODEL +from models import KeyGenerateBody, LiteLLMParamsBody, ModelsListResponse +from lifecycle import ResourceManager + +LOAD_MODEL_PARAMS = LiteLLMParamsBody( + model="openai/load-mock", + mock_response="This is a mock response for the throughput load test.", +) + + +@pytest.fixture(scope="session") +def client() -> LoadClient: + return build_client() + + +def _model_is_servable(gateway: Gateway, model_name: str) -> bool: + result = gateway.transport.get( + "/v1/models", + headers=gateway.transport.master, + params=NoBody(), + response_type=ModelsListResponse, + ) + return isinstance(result, Success) and any(entry.id == model_name for entry in result.data.data) + + +@pytest.fixture(scope="session", autouse=True) +def _ensure_load_model( # pyright: ignore[reportUnusedFunction] # pytest autouse session fixture, wired by name + client: LoadClient, +) -> Iterator[None]: + gateway = client.gateway + if _model_is_servable(gateway, LOAD_MODEL): + yield + return + + try: + model_id = gateway.create_model(LOAD_MODEL, LOAD_MODEL_PARAMS) + except (AssertionError, RequestException) as exc: + if _model_is_servable(gateway, LOAD_MODEL): + yield + return + raise AssertionError( + f"failed to register {LOAD_MODEL!r} for the throughput load test " + f"(not listed on the data plane and /model/new failed): {exc}" + ) from exc + + try: + yield + finally: + gateway.delete_model(model_id) + + +@pytest.fixture +def load_key(resources: ResourceManager, client: LoadClient) -> str: + key = client.gateway.generate_key(KeyGenerateBody(models=[LOAD_MODEL], user_id="e2e-load")) + resources.defer(lambda: client.gateway.delete_key(key)) + return key diff --git a/tests/e2e/load/load_client.py b/tests/e2e/load/load_client.py new file mode 100644 index 00000000000..df7c91fadf9 --- /dev/null +++ b/tests/e2e/load/load_client.py @@ -0,0 +1,14 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from e2e_gateway import Gateway, build_gateway + + +@dataclass(frozen=True, slots=True) +class LoadClient: + gateway: Gateway + + +def build_client() -> LoadClient: + return LoadClient(gateway=build_gateway()) diff --git a/tests/e2e/load/load_constants.py b/tests/e2e/load/load_constants.py new file mode 100644 index 00000000000..fd97f1398f4 --- /dev/null +++ b/tests/e2e/load/load_constants.py @@ -0,0 +1,3 @@ +from __future__ import annotations + +LOAD_MODEL = "load-mock" diff --git a/tests/e2e/load/locust_load.py b/tests/e2e/load/locust_load.py new file mode 100644 index 00000000000..990e24ee064 --- /dev/null +++ b/tests/e2e/load/locust_load.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +from pydantic import BaseModel, TypeAdapter + +_LOCUSTFILE = Path(__file__).with_name("locustfile.py") + + +class _LocustStatEntry(BaseModel): + num_requests: int + num_failures: int + start_time: float + last_request_timestamp: float + + +_STATS_ADAPTER: TypeAdapter[list[_LocustStatEntry]] = TypeAdapter(list[_LocustStatEntry]) + + +@dataclass(frozen=True, slots=True) +class LoadResult: + requests: int + failures: int + requests_per_second: float + + @property + def failure_ratio(self) -> float: + return self.failures / self.requests if self.requests else 1.0 + + +def _aggregate(entries: list[_LocustStatEntry]) -> LoadResult: + requests = sum(entry.num_requests for entry in entries) + failures = sum(entry.num_failures for entry in entries) + if not entries or requests == 0: + return LoadResult(requests=requests, failures=failures, requests_per_second=0.0) + elapsed = max(entry.last_request_timestamp for entry in entries) - min(entry.start_time for entry in entries) + rps = requests / elapsed if elapsed > 0 else 0.0 + return LoadResult(requests=requests, failures=failures, requests_per_second=rps) + + +def run_chat_load( + *, + base_url: str, + api_key: str, + model: str, + users: int, + spawn_rate: float, + duration_seconds: float, +) -> LoadResult: + completed = subprocess.run( + [ + sys.executable, + "-m", + "locust", + "--headless", + "--json", + "--locustfile", + str(_LOCUSTFILE), + "--host", + base_url, + "--users", + str(users), + "--spawn-rate", + str(spawn_rate), + "--run-time", + f"{int(duration_seconds)}s", + "--exit-code-on-error", + "0", + ], + env={**os.environ, "LOAD_API_KEY": api_key, "LOAD_MODEL": model}, + capture_output=True, + text=True, + timeout=duration_seconds + 120, + check=False, + ) + if completed.returncode != 0: + raise RuntimeError( + f"locust exited {completed.returncode} before it could report throughput " + f"(a startup failure, not request failures, which are folded into the JSON summary via " + f"--exit-code-on-error 0):\n{completed.stderr}" + ) + try: + entries = _STATS_ADAPTER.validate_json(completed.stdout) + except ValueError as exc: + raise RuntimeError( + f"locust exited 0 but did not print a parseable --json throughput summary on stdout; " + f"got stdout={completed.stdout!r}, stderr={completed.stderr!r}" + ) from exc + return _aggregate(entries) diff --git a/tests/e2e/load/locustfile.py b/tests/e2e/load/locustfile.py new file mode 100644 index 00000000000..4aa7517ca0b --- /dev/null +++ b/tests/e2e/load/locustfile.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import os + +from locust import FastHttpUser, constant, task + +_MODEL = os.environ["LOAD_MODEL"] +_HEADERS = {"Authorization": f"Bearer {os.environ['LOAD_API_KEY']}"} +_PAYLOAD = { + "model": _MODEL, + "messages": [{"role": "user", "content": "load test ping"}], + "temperature": 0, + "max_tokens": 16, +} + + +class ChatUser(FastHttpUser): + wait_time = constant(0) + + @task + def chat(self) -> None: + self.client.post( # pyright: ignore[reportUnknownMemberType] # locust FastHttpSession.post types json/**kwargs as Any + "/chat/completions", + json=_PAYLOAD, + headers=_HEADERS, + name="/chat/completions", + ) diff --git a/tests/e2e/load/test_chat_completions_throughput_e2e.py b/tests/e2e/load/test_chat_completions_throughput_e2e.py new file mode 100644 index 00000000000..6dd5fff971a --- /dev/null +++ b/tests/e2e/load/test_chat_completions_throughput_e2e.py @@ -0,0 +1,42 @@ +import pytest + +from e2e_config import ( + LOAD_DURATION_SECONDS, + LOAD_MAX_FAILURE_RATIO, + LOAD_MIN_RPS, + LOAD_SPAWN_RATE, + LOAD_USERS, + PROXY_BASE_URL, +) +from load_client import LoadClient +from load_constants import LOAD_MODEL +from locust_load import run_chat_load + +pytestmark = [pytest.mark.e2e, pytest.mark.load] + + +class TestChatCompletionsThroughput: + @pytest.mark.covers("reliability.perf.throughput.under_slo") + def test_sustains_throughput_slo_under_load(self, client: LoadClient, load_key: str) -> None: + result = run_chat_load( + base_url=PROXY_BASE_URL, + api_key=load_key, + model=LOAD_MODEL, + users=LOAD_USERS, + spawn_rate=LOAD_SPAWN_RATE, + duration_seconds=LOAD_DURATION_SECONDS, + ) + + assert result.requests > 0, ( + f"no requests completed against {PROXY_BASE_URL} in {LOAD_DURATION_SECONDS}s; " + f"the load generator never drove traffic (proxy unreachable or model unservable)" + ) + assert result.failure_ratio <= LOAD_MAX_FAILURE_RATIO, ( + f"{result.failures}/{result.requests} requests failed " + f"({result.failure_ratio:.1%} > {LOAD_MAX_FAILURE_RATIO:.1%} allowed); " + f"throughput of {result.requests_per_second:.1f} RPS is not a clean read under this error rate" + ) + assert result.requests_per_second >= LOAD_MIN_RPS, ( + f"sustained {result.requests_per_second:.1f} RPS over {LOAD_DURATION_SECONDS}s with " + f"{LOAD_USERS} users, below the {LOAD_MIN_RPS} RPS SLO; the proxy request path regressed under load" + ) diff --git a/tests/e2e/logging/conftest.py b/tests/e2e/logging/conftest.py index 65be753154e..2285eb8d695 100644 --- a/tests/e2e/logging/conftest.py +++ b/tests/e2e/logging/conftest.py @@ -13,6 +13,7 @@ import pytest from logging_client import LangfuseCreds, LoggingClient, build_logging_client, load_langfuse_creds from datadog_reader import DdLogsReader, build_dd_logs_reader from otel_client import OtelReader, build_otel_reader +from proxy_client import ProxyClient def pytest_configure(config: pytest.Config) -> None: @@ -23,11 +24,11 @@ def pytest_configure(config: pytest.Config) -> None: @pytest.fixture(scope="session") -def client() -> LoggingClient: - """The logging suite's client: holds the shared Gateway so `resources` / +def client(proxy: ProxyClient) -> LoggingClient: + """The logging suite's client: holds the shared ProxyClient so `resources` / `scoped_key` clean up keys and teams, and adds `/metrics` scraping plus Langfuse read-back.""" - return build_logging_client() + return build_logging_client(proxy) @pytest.fixture(scope="session") diff --git a/tests/e2e/logging/logging_client.py b/tests/e2e/logging/logging_client.py index 8be573d72a9..7053bd1dfd2 100644 --- a/tests/e2e/logging/logging_client.py +++ b/tests/e2e/logging/logging_client.py @@ -1,7 +1,7 @@ """Client for the logging e2e suite: team/key/org-scoped Langfuse OTEL callbacks, chat (including tools), Prometheus scrape, and Langfuse observation read-back. -Holds the shared Gateway so the ``resources`` fixture cleans up keys, teams, +Holds the shared ProxyClient so the ``resources`` fixture cleans up keys, teams, users, orgs, and models it creates. External Langfuse reads go through ``e2e_http`` (the only module allowed to call ``requests.*``). @@ -24,7 +24,7 @@ import pytest from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, ValidationError from e2e_config import POLL_INTERVAL, POLL_TIMEOUT -from e2e_gateway import Gateway, build_gateway +from proxy_client import ProxyClient from e2e_http import ( URL, AuthHeaders, @@ -262,7 +262,7 @@ def observation_has_guardrail(obs: LangfuseObservation, *, guardrail_name: str) @dataclass(frozen=True, slots=True) class LoggingClient: - gateway: Gateway + proxy: ProxyClient def key_with_alias( self, @@ -274,7 +274,7 @@ class LoggingClient: organization_id: str | None = None, metadata: KeyMetadata | None = None, ) -> str: - return self.gateway.generate_key( + return self.proxy.generate_key( KeyGenerateBody( key_alias=alias, models=models, @@ -286,7 +286,7 @@ class LoggingClient: ) def delete_key(self, key: str) -> None: - self.gateway.delete_key(key) + self.proxy.delete_key(key) def create_team( self, @@ -296,9 +296,9 @@ class LoggingClient: organization_id: str | None = None, ) -> str: return unwrap( - self.gateway.transport.post( + self.proxy.transport.post( "/team/new", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=TeamNewBody( team_alias=alias, models=models, @@ -309,18 +309,18 @@ class LoggingClient: ).team_id def delete_team(self, team_id: str) -> None: - _ = self.gateway.transport.post( + _ = self.proxy.transport.post( "/team/delete", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=TeamDeleteBody(team_ids=[team_id]), response_type=NoBody, ) def create_user(self, *, user_email: str, user_id: str | None = None) -> str: return unwrap( - self.gateway.transport.post( + self.proxy.transport.post( "/user/new", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=UserNewBody( user_email=user_email, user_role="internal_user", @@ -331,27 +331,27 @@ class LoggingClient: ).user_id def delete_user(self, user_id: str) -> None: - _ = self.gateway.transport.post( + _ = self.proxy.transport.post( "/user/delete", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=UserDeleteBody(user_ids=[user_id]), response_type=NoBody, ) def create_org(self, alias: str, *, models: list[str]) -> str: return unwrap( - self.gateway.transport.post( + self.proxy.transport.post( "/organization/new", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=OrgNewBody(organization_alias=alias, models=models), response_type=OrgNewResponse, ) ).organization_id def delete_org(self, organization_id: str) -> None: - _ = self.gateway.transport.delete( + _ = self.proxy.transport.delete( "/organization/delete", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=OrgDeleteBody(organization_ids=[organization_id]), response_type=NoBody, ) @@ -364,9 +364,9 @@ class LoggingClient: callback_type: Literal["success", "failure", "success_and_failure"] = "success_and_failure", ) -> None: response = unwrap( - self.gateway.transport.post( + self.proxy.transport.post( f"/team/{team_id}/callback", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=TeamCallbackBody( callback_name="langfuse_otel", callback_type=callback_type, @@ -382,9 +382,9 @@ class LoggingClient: def create_tool_permission_guardrail(self, name: str, *, allowed_tool: str) -> str: """Register a tool_permission guardrail that allows one tool and denies the rest.""" response = unwrap( - self.gateway.transport.post( + self.proxy.transport.post( "/guardrails", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=CreateGuardrailBody( guardrail=GuardrailSpec( guardrail_name=name, @@ -412,22 +412,22 @@ class LoggingClient: return guardrail_id def delete_guardrail(self, guardrail_id: str) -> None: - _ = self.gateway.transport.delete( + _ = self.proxy.transport.delete( f"/guardrails/{guardrail_id}", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=NoBody(), response_type=NoBody, ) def create_model(self, model_name: str, litellm_params: LiteLLMParamsBody) -> str: - return self.gateway.create_model(model_name, litellm_params) + return self.proxy.create_model(model_name, litellm_params) def delete_model(self, model_id: str) -> None: - self.gateway.delete_model(model_id) + self.proxy.delete_model(model_id) def chat(self, key: str, model: str, text: str) -> ChatResponse: return unwrap( - self.gateway.chat( + self.proxy.chat( key, ChatBody( model=model, @@ -459,10 +459,10 @@ class LoggingClient: guardrails=guardrails, ) if stream: - return self.gateway.chat_stream(key, body) - return self.gateway.transport.send( + return self.proxy.chat_stream(key, body) + return self.proxy.transport.send( "/chat/completions", - headers=self.gateway.transport.bearer(key), + headers=self.proxy.transport.bearer(key), json=body, ) @@ -479,11 +479,11 @@ class LoggingClient: stream=True if stream else None, ) if stream: - return self.gateway.transport.stream( - "/v1/messages", headers=self.gateway.transport.bearer(key), json=body + return self.proxy.transport.stream( + "/v1/messages", headers=self.proxy.transport.bearer(key), json=body ) - return self.gateway.transport.send( - "/v1/messages", headers=self.gateway.transport.bearer(key), json=body + return self.proxy.transport.send( + "/v1/messages", headers=self.proxy.transport.bearer(key), json=body ) def responses_raw( @@ -498,15 +498,15 @@ class LoggingClient: model=model, input=text, max_output_tokens=max_output_tokens, stream=True if stream else None ) if stream: - return self.gateway.transport.stream( - "/v1/responses", headers=self.gateway.transport.bearer(key), json=body + return self.proxy.transport.stream( + "/v1/responses", headers=self.proxy.transport.bearer(key), json=body ) - return self.gateway.transport.send( - "/v1/responses", headers=self.gateway.transport.bearer(key), json=body + return self.proxy.transport.send( + "/v1/responses", headers=self.proxy.transport.bearer(key), json=body ) def scrape_metrics(self) -> str: - return self.gateway.probe("/metrics", params=NoBody()).body + return self.proxy.probe("/metrics", params=NoBody()).body def poll_proxy_spend_for_key( self, @@ -529,7 +529,7 @@ class LoggingClient: return False return True - rows = self.gateway.poll_logs_for_key( + rows = self.proxy.poll_logs_for_key( key, min_rows=1, predicate=lambda rs: any(_matches(r) for r in rs) ) for row in rows: @@ -623,15 +623,15 @@ def first_ok(client: LoggingClient, send: Callable[[], StreamingResponse]) -> St the data plane's auth cache picks it up, so retry on 401 to a deadline; a 401 is rejected before the LLM call, so it cannot contaminate delivery or trace assertions. Any other failure is behavior under test and fails hard.""" - deadline = time.monotonic() + client.gateway.poll_timeout + deadline = time.monotonic() + client.proxy.poll_timeout while True: outcome = send() if outcome.ok: return outcome if outcome.status_code != 401 or time.monotonic() >= deadline: require_successful_call(outcome) - time.sleep(client.gateway.poll_interval) + time.sleep(client.proxy.poll_interval) -def build_logging_client() -> LoggingClient: - return LoggingClient(gateway=build_gateway()) +def build_logging_client(proxy: ProxyClient) -> LoggingClient: + return LoggingClient(proxy=proxy) diff --git a/tests/e2e/logging/test_datadog_log_e2e.py b/tests/e2e/logging/test_datadog_log_e2e.py index 48c111b6467..94811c6217e 100644 --- a/tests/e2e/logging/test_datadog_log_e2e.py +++ b/tests/e2e/logging/test_datadog_log_e2e.py @@ -52,7 +52,7 @@ def _assert_datadog_configured(client: LoggingClient) -> None: """Recorded state: the proxy reports the DataDog callback among its active callbacks, so a missing destination config fails here, before any delivery-based assertion can time out confusingly.""" - result = client.gateway.probe("/health/readiness/details", params=NoBody()) + result = client.proxy.probe("/health/readiness/details", params=NoBody()) assert result.status_code == 200, ( f"/health/readiness/details must answer 200, got {result.status_code}: {result.body[:300]}" ) diff --git a/tests/e2e/logging/test_otel_trace_e2e.py b/tests/e2e/logging/test_otel_trace_e2e.py index 4db8813b1f9..b0a4988594e 100644 --- a/tests/e2e/logging/test_otel_trace_e2e.py +++ b/tests/e2e/logging/test_otel_trace_e2e.py @@ -48,7 +48,7 @@ def _assert_otel_destination_configured(client: LoggingClient) -> None: """Recorded state: the proxy reports the OTEL v2 logger among its active callbacks, so a missing/failed destination config fails here, before any traffic-based assertion can time out confusingly.""" - result = client.gateway.probe("/health/readiness/details", params=NoBody()) + result = client.proxy.probe("/health/readiness/details", params=NoBody()) assert result.status_code == 200, ( f"/health/readiness/details must answer 200, got {result.status_code}: {result.body[:300]}" ) @@ -698,13 +698,13 @@ class TestOtelTraceCompleteness: key = client.key_with_alias(f"otel-err-{unique_marker()}", models=[model_name]) resources.defer(lambda: client.delete_key(key)) - deadline = time.monotonic() + client.gateway.poll_timeout + deadline = time.monotonic() + client.proxy.poll_timeout while True: outcome = client.chat_raw(key, model_name, "trigger an upstream auth failure", max_tokens=16) assert not outcome.ok, "the call must fail; the deployment's upstream key is invalid" if "AnthropicException" in outcome.body or time.monotonic() >= deadline: break - time.sleep(client.gateway.poll_interval) + time.sleep(client.proxy.poll_interval) assert "AnthropicException" in outcome.body, ( "never saw the upstream provider failure before the deadline; the key may still be " f"propagating - last outcome {outcome.status_code}: {outcome.body[:200]}" diff --git a/tests/e2e/logging/test_prometheus_cardinality_e2e.py b/tests/e2e/logging/test_prometheus_cardinality_e2e.py index 163293a3009..44e3d93c07b 100644 --- a/tests/e2e/logging/test_prometheus_cardinality_e2e.py +++ b/tests/e2e/logging/test_prometheus_cardinality_e2e.py @@ -55,13 +55,13 @@ class TestPrometheusPerKeyCardinality: assert response.model, f"driver call for {alias} returned no model: {response}" wanted = frozenset(aliases) - deadline = time.monotonic() + client.gateway.poll_timeout + deadline = time.monotonic() + client.proxy.poll_timeout seen: frozenset[str] = frozenset() while time.monotonic() < deadline: seen = _aliases_in_metric(client.scrape_metrics(), REQUESTS_METRIC, ALIAS_LABEL) if wanted <= seen: break - time.sleep(client.gateway.poll_interval) + time.sleep(client.proxy.poll_interval) missing = wanted - seen assert not missing, ( diff --git a/tests/e2e/management/conftest.py b/tests/e2e/management/conftest.py index 4f2dc874a33..52c618f4fcd 100644 --- a/tests/e2e/management/conftest.py +++ b/tests/e2e/management/conftest.py @@ -14,6 +14,7 @@ import pytest from e2e_config import UI_BASE_URL, UI_PASSWORD, UI_USERNAME from management_client import ManagementClient, build_client +from proxy_client import ProxyClient if TYPE_CHECKING: from playwright.sync_api import Browser, Page @@ -27,8 +28,8 @@ def pytest_configure(config: pytest.Config) -> None: @pytest.fixture(scope="session") -def client() -> ManagementClient: - return build_client() +def client(proxy: ProxyClient) -> ManagementClient: + return build_client(proxy) @pytest.fixture(scope="session") diff --git a/tests/e2e/management/management_client.py b/tests/e2e/management/management_client.py index 144a3c0c435..e967fb7b504 100644 --- a/tests/e2e/management/management_client.py +++ b/tests/e2e/management/management_client.py @@ -1,4 +1,4 @@ -"""Client for the management-routes e2e suite: the shared Gateway plus the +"""Client for the management-routes e2e suite: the shared ProxyClient plus the key/team/user/organization writes, the info/list read-backs the tests assert, and the raw-status calls judged by HTTP outcome (chat under a scoped key, an llm-only key hitting a management route). @@ -9,7 +9,7 @@ from __future__ import annotations import time from dataclasses import dataclass -from e2e_gateway import Gateway, build_gateway +from proxy_client import ProxyClient from e2e_http import NoBody, ProbeResult, Result, StreamingResponse, Success, UnknownApiError, unwrap from models import ( ChatBody, @@ -50,17 +50,17 @@ _TEAM_READY_SLEEP_SECONDS = 0.4 @dataclass(frozen=True, slots=True) class ManagementClient: - gateway: Gateway + proxy: ProxyClient def llm_only_key(self) -> str: - return self.gateway.generate_key(KeyGenerateBody(models=[], allowed_routes=["llm_api_routes"])) + return self.proxy.generate_key(KeyGenerateBody(models=[], allowed_routes=["llm_api_routes"])) def update_key_models(self, key: str, models: list[str]) -> None: last: Result[NoBody] | None = None for attempt in range(5): - last = self.gateway.transport.post( + last = self.proxy.transport.post( "/key/update", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=KeyUpdateBody(key=key, models=models), response_type=NoBody, ) @@ -79,11 +79,11 @@ class ManagementClient: def delete_key_strict(self, key: str) -> None: """Strict delete for the act phase of a test: a failed delete is a hard - failure, unlike the warn-only Gateway.delete_key used at teardown.""" + failure, unlike the warn-only ProxyClient.delete_key used at teardown.""" _ = unwrap( - self.gateway.transport.post( + self.proxy.transport.post( "/key/delete", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=KeyDeleteBody(keys=[key]), response_type=NoBody, ) @@ -91,9 +91,9 @@ class ManagementClient: def key_alias_count(self, key_alias: str) -> int: return unwrap( - self.gateway.transport.get( + self.proxy.transport.get( "/key/list", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, params=KeyListParams(key_alias=key_alias), response_type=KeyListResponse, ) @@ -101,9 +101,9 @@ class ManagementClient: def create_team(self, body: TeamNewBody) -> str: team_id = unwrap( - self.gateway.transport.post( + self.proxy.transport.post( "/team/new", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=body, response_type=TeamNewResponse, ) @@ -112,32 +112,32 @@ class ManagementClient: return team_id def delete_team(self, team_id: str) -> None: - _ = self.gateway.transport.post( + _ = self.proxy.transport.post( "/team/delete", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=TeamDeleteBody(team_ids=[team_id]), response_type=NoBody, ) def team_info(self, team_id: str) -> TeamData: return unwrap( - self.gateway.transport.get( + self.proxy.transport.get( "/team/info", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, params=TeamInfoParams(team_id=team_id), response_type=TeamInfoResponse, ) ).team_info def team_info_status(self, team_id: str) -> ProbeResult: - return self.gateway.transport.probe("/team/info", params=TeamInfoParams(team_id=team_id)) + return self.proxy.transport.probe("/team/info", params=TeamInfoParams(team_id=team_id)) def _wait_for_team(self, team_id: str) -> None: last: Result[TeamInfoResponse] | None = None for _ in range(_TEAM_READY_ATTEMPTS): - last = self.gateway.transport.get( + last = self.proxy.transport.get( "/team/info", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, params=TeamInfoParams(team_id=team_id), response_type=TeamInfoResponse, ) @@ -152,9 +152,9 @@ class ManagementClient: def add_team_member(self, team_id: str, user_id: str) -> None: last: Result[NoBody] | None = None for attempt in range(_TEAM_READY_ATTEMPTS): - last = self.gateway.transport.post( + last = self.proxy.transport.post( "/team/member_add", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=TeamMemberAddBody(team_id=team_id, member=TeamMemberEntry(role="user", user_id=user_id)), response_type=NoBody, ) @@ -173,9 +173,9 @@ class ManagementClient: def delete_team_member(self, team_id: str, user_id: str) -> None: _ = unwrap( - self.gateway.transport.post( + self.proxy.transport.post( "/team/member_delete", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=TeamMemberDeleteBody(team_id=team_id, user_id=user_id), response_type=NoBody, ) @@ -183,27 +183,27 @@ class ManagementClient: def create_user(self, body: UserNewBody) -> str: return unwrap( - self.gateway.transport.post( + self.proxy.transport.post( "/user/new", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=body, response_type=UserNewResponse, ) ).user_id def delete_user(self, user_id: str) -> None: - _ = self.gateway.transport.post( + _ = self.proxy.transport.post( "/user/delete", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=UserDeleteBody(user_ids=[user_id]), response_type=NoBody, ) def user_info(self, user_id: str) -> UserInfoResponse: return unwrap( - self.gateway.transport.get( + self.proxy.transport.get( "/user/info", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, params=UserInfoParams(user_id=user_id), response_type=UserInfoResponse, ) @@ -211,9 +211,9 @@ class ManagementClient: def user_count(self, user_id: str) -> int: return unwrap( - self.gateway.transport.get( + self.proxy.transport.get( "/user/list", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, params=UserListParams(user_ids=user_id), response_type=UserListResponse, ) @@ -221,48 +221,48 @@ class ManagementClient: def create_org(self, body: OrgNewBody) -> str: return unwrap( - self.gateway.transport.post( + self.proxy.transport.post( "/organization/new", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=body, response_type=OrgNewResponse, ) ).organization_id def delete_org(self, organization_id: str) -> None: - _ = self.gateway.transport.delete( + _ = self.proxy.transport.delete( "/organization/delete", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=OrgDeleteBody(organization_ids=[organization_id]), response_type=NoBody, ) def org_info(self, organization_id: str) -> OrgInfoResponse: return unwrap( - self.gateway.transport.get( + self.proxy.transport.get( "/organization/info", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, params=OrgInfoParams(organization_id=organization_id), response_type=OrgInfoResponse, ) ) def chat_status(self, key: str, model: str, content: str) -> StreamingResponse: - return self.gateway.transport.send( + return self.proxy.transport.send( "/chat/completions", - headers=self.gateway.transport.bearer(key), + headers=self.proxy.transport.bearer(key), json=ChatBody(model=model, messages=[ChatMessage(role="user", content=content)], max_tokens=16), ) def key_generate_status(self, key: str, body: KeyGenerateBody) -> StreamingResponse: - return self.gateway.transport.send("/key/generate", headers=self.gateway.transport.bearer(key), json=body) + return self.proxy.transport.send("/key/generate", headers=self.proxy.transport.bearer(key), json=body) def team_new_status(self, key: str, body: TeamNewBody) -> StreamingResponse: - return self.gateway.transport.send("/team/new", headers=self.gateway.transport.bearer(key), json=body) + return self.proxy.transport.send("/team/new", headers=self.proxy.transport.bearer(key), json=body) def user_new_status(self, key: str, body: UserNewBody) -> StreamingResponse: - return self.gateway.transport.send("/user/new", headers=self.gateway.transport.bearer(key), json=body) + return self.proxy.transport.send("/user/new", headers=self.proxy.transport.bearer(key), json=body) -def build_client() -> ManagementClient: - return ManagementClient(gateway=build_gateway()) +def build_client(proxy: ProxyClient) -> ManagementClient: + return ManagementClient(proxy=proxy) diff --git a/tests/e2e/management/test_key_models_dropdown_e2e.py b/tests/e2e/management/test_key_models_dropdown_e2e.py index 78e3f9e1a7b..20bd2191ac4 100644 --- a/tests/e2e/management/test_key_models_dropdown_e2e.py +++ b/tests/e2e/management/test_key_models_dropdown_e2e.py @@ -104,8 +104,8 @@ def _provision_team(client: ManagementClient, resources: ResourceManager, alias: def _provision_key( client: ManagementClient, resources: ResourceManager, alias: str, team_id: str | None = None ) -> str: - key = client.gateway.generate_key(KeyGenerateBody(key_alias=alias, models=["gpt-5.5"], team_id=team_id)) - resources.defer(lambda: client.gateway.delete_key(key)) + key = client.proxy.generate_key(KeyGenerateBody(key_alias=alias, models=["gpt-5.5"], team_id=team_id)) + resources.defer(lambda: client.proxy.delete_key(key)) return key @@ -122,9 +122,9 @@ class TestKeyModelsDropdownUI: assert "All Team Models" not in options, f"teamless create offered 'All Team Models': {options}" key = _submit_create_modal(ui_page, sentinel_label="All Proxy Models") - resources.defer(lambda: client.gateway.delete_key(key)) + resources.defer(lambda: client.proxy.delete_key(key)) - info = client.gateway.key_info(key) + info = client.proxy.key_info(key) assert info.models == ["all-proxy-models"], f"persisted models {info.models}" assert info.team_id is None, f"teamless key persisted with team {info.team_id}" @@ -144,9 +144,9 @@ class TestKeyModelsDropdownUI: assert "all-proxy-models" not in options, f"team key create offered the raw sentinel: {options}" key = _submit_create_modal(ui_page, sentinel_label="All Team Models") - resources.defer(lambda: client.gateway.delete_key(key)) + resources.defer(lambda: client.proxy.delete_key(key)) - info = client.gateway.key_info(key) + info = client.proxy.key_info(key) assert info.models == ["all-team-models"], f"persisted models {info.models}" assert info.team_id == team_id, f"persisted team {info.team_id}, expected {team_id}" diff --git a/tests/e2e/management/test_management_e2e.py b/tests/e2e/management/test_management_e2e.py index 7a05a5c520f..adbf3e8b065 100644 --- a/tests/e2e/management/test_management_e2e.py +++ b/tests/e2e/management/test_management_e2e.py @@ -27,18 +27,18 @@ from models import KeyGenerateBody, OrgNewBody, TeamNewBody, UserNewBody pytestmark = pytest.mark.e2e def _poll[T](client: ManagementClient, attempt: Callable[[], T | None], failure: str) -> T: - deadline = time.monotonic() + client.gateway.poll_timeout + deadline = time.monotonic() + client.proxy.poll_timeout while time.monotonic() < deadline: found = attempt() if found is not None: return found - time.sleep(client.gateway.poll_interval) + time.sleep(client.proxy.poll_interval) pytest.fail(failure) def _generate_key(client: ManagementClient, resources: ResourceManager, body: KeyGenerateBody) -> str: - key = client.gateway.generate_key(body) - resources.defer(lambda: client.gateway.delete_key(key)) + key = client.proxy.generate_key(body) + resources.defer(lambda: client.proxy.delete_key(key)) return key @@ -114,7 +114,7 @@ class TestKeyRoutes: KeyGenerateBody(models=["gemini-2.5-flash"], key_alias=alias, tpm_limit=424242, rpm_limit=424243), ) - info = client.gateway.key_info(key) + 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 == ["gemini-2.5-flash"], ( f"/key/info reports models {info.models}, configured ['gemini-2.5-flash']" @@ -143,7 +143,7 @@ class TestKeyRoutes: client.update_key_models(key, ["gpt-5.5"]) - info = client.gateway.key_info(key) + info = client.proxy.key_info(key) assert info.models == ["gpt-5.5"], ( f"/key/info reports models {info.models} after /key/update to ['gpt-5.5']" ) @@ -184,7 +184,7 @@ class TestTeamRoutes: ) key = _generate_key(client, resources, KeyGenerateBody(team_id=team_id)) - key_info = client.gateway.key_info(key) + key_info = client.proxy.key_info(key) assert key_info.team_id == team_id, ( f"key generated under team {team_id} carries team_id {key_info.team_id!r} in /key/info" ) @@ -260,7 +260,7 @@ class TestManagementRoutePermissions: self, client: ManagementClient, resources: ResourceManager ) -> None: key = client.llm_only_key() - resources.defer(lambda: client.gateway.delete_key(key)) + resources.defer(lambda: client.proxy.delete_key(key)) marker = unique_marker() alias = f"e2e-mgmt-forbidden-key-{marker}" team_id = f"e2e-mgmt-forbidden-team-{marker}" diff --git a/tests/e2e/mcp/conftest.py b/tests/e2e/mcp/conftest.py index 77fef574706..3f970f3c008 100644 --- a/tests/e2e/mcp/conftest.py +++ b/tests/e2e/mcp/conftest.py @@ -2,15 +2,16 @@ The shared lifecycle (resources/scoped_key), proxy liveness handling, and the `e2e`/`covers` markers live in the parent tests/e2e/conftest.py. McpClient holds -the shared Gateway, so the `resources` fixture tears down whatever this suite -creates (keys via the Gateway, MCP servers via the deferred cleanups). +the shared ProxyClient, so the `resources` fixture tears down whatever this suite +creates (keys via the ProxyClient, MCP servers via the deferred cleanups). """ import pytest from mcp_client import McpClient, build_client +from proxy_client import ProxyClient @pytest.fixture(scope="session") -def client() -> McpClient: - return build_client() +def client(proxy: ProxyClient) -> McpClient: + return build_client(proxy) diff --git a/tests/e2e/mcp/mcp_client.py b/tests/e2e/mcp/mcp_client.py index a1dac3fdac4..59358305ee7 100644 --- a/tests/e2e/mcp/mcp_client.py +++ b/tests/e2e/mcp/mcp_client.py @@ -15,9 +15,9 @@ from dataclasses import dataclass from pydantic import BaseModel, ConfigDict, Field, RootModel -from e2e_gateway import Gateway, build_gateway from e2e_http import Headers, NoBody, Result, unwrap from models import KeyGenerateBody, ObjectPermission +from proxy_client import ProxyClient class ApiKeyHeaders(Headers): @@ -92,31 +92,31 @@ class McpCallToolResponse(BaseModel): @dataclass(frozen=True, slots=True) class McpClient: - gateway: Gateway + proxy: ProxyClient def register_server(self, *, server_name: str, alias: str, url: str) -> str: return unwrap( - self.gateway.transport.post( + self.proxy.transport.post( "/v1/mcp/server", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=McpServerNewBody(server_name=server_name, alias=alias, url=url), response_type=McpServerNewResponse, ) ).server_id def delete_server(self, server_id: str) -> None: - _ = self.gateway.transport.delete( + _ = self.proxy.transport.delete( f"/v1/mcp/server/{server_id}", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=NoBody(), response_type=NoBody, ) def registered_servers(self) -> list[McpServerRow]: return unwrap( - self.gateway.transport.get( + self.proxy.transport.get( "/v1/mcp/server", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, params=NoBody(), response_type=McpServersListResponse, ) @@ -126,12 +126,12 @@ class McpClient: object_permission = ( ObjectPermission(mcp_servers=mcp_servers) if mcp_servers is not None else None ) - return self.gateway.generate_key( + return self.proxy.generate_key( KeyGenerateBody(models=[], user_id=user_id, object_permission=object_permission) ) def list_tools(self, key: str) -> Result[McpToolsListResponse]: - return self.gateway.transport.get( + return self.proxy.transport.get( "/mcp-rest/tools/list", headers=ApiKeyHeaders(x_litellm_api_key=key), params=NoBody(), @@ -141,7 +141,7 @@ class McpClient: def call_tool( self, key: str, *, server_id: str, name: str, arguments: dict[str, int] ) -> Result[McpCallToolResponse]: - return self.gateway.transport.post( + return self.proxy.transport.post( "/mcp-rest/tools/call", headers=ApiKeyHeaders(x_litellm_api_key=key), json=McpCallToolBody(name=name, arguments=arguments, server_id=server_id), @@ -149,5 +149,5 @@ class McpClient: ) -def build_client() -> McpClient: - return McpClient(gateway=build_gateway()) +def build_client(proxy: ProxyClient) -> McpClient: + return McpClient(proxy=proxy) diff --git a/tests/e2e/mcp/test_mcp_key_access_e2e.py b/tests/e2e/mcp/test_mcp_key_access_e2e.py index eaa49af5b69..ee316b44e68 100644 --- a/tests/e2e/mcp/test_mcp_key_access_e2e.py +++ b/tests/e2e/mcp/test_mcp_key_access_e2e.py @@ -40,7 +40,7 @@ def _register_math_server(client: McpClient, resources: ResourceManager) -> str: def _key(client: McpClient, resources: ResourceManager, *, mcp_servers: list[str] | None) -> str: label = "allowed" if mcp_servers else "denied" key = client.generate_key(user_id=f"e2e-mcp-{label}-{unique_marker()}", mcp_servers=mcp_servers) - resources.defer(lambda: client.gateway.delete_key(key)) + resources.defer(lambda: client.proxy.delete_key(key)) return key diff --git a/tests/e2e/models.py b/tests/e2e/models.py index 39832d1a17f..daf85b7fc74 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -268,7 +268,7 @@ class SpendLogsParams(BaseModel): raise ValueError( "unfiltered /spend/logs returns the entire spend table and OOMs the " "runner on long-lived environments; filter by request_id or api_key, " - "or use Gateway.spend_logs_window for a bounded /spend/logs/v2 read" + "or use ProxyClient.spend_logs_window for a bounded /spend/logs/v2 read" ) return self @@ -448,6 +448,7 @@ class LiteLLMParamsBody(BaseModel): extra_headers: dict[str, str] | None = None use_in_pass_through: bool | None = None complexity_router_config: dict[str, object] | None = None + mock_response: str | None = None ModelMode = Literal["batch", "realtime", "image_generation"] diff --git a/tests/e2e/e2e_gateway.py b/tests/e2e/proxy_client.py similarity index 96% rename from tests/e2e/e2e_gateway.py rename to tests/e2e/proxy_client.py index ad8b2e833a8..0dbb80990c0 100644 --- a/tests/e2e/e2e_gateway.py +++ b/tests/e2e/proxy_client.py @@ -1,8 +1,8 @@ -"""Gateway: the shared proxy operations, DI'd into every client (composition). +"""ProxyClient: the shared proxy operations, DI'd into every client (composition). A frozen-slots dataclass holding a Transport plus poll config. Clients hold a -Gateway and add their own route methods; the lifecycle ResourceManager uses the -Gateway's key/customer methods for cleanup. Read-backs are eventually consistent +ProxyClient and add their own route methods; the lifecycle ResourceManager uses the +ProxyClient's key/customer methods for cleanup. Read-backs are eventually consistent (proxy_batch_write_at ~60s) so they poll to a deadline. """ @@ -69,7 +69,7 @@ RowsPredicate = Callable[[list[SpendLogRow]], bool] @dataclass(frozen=True, slots=True) -class Gateway: +class ProxyClient: transport: Transport poll_timeout: float = 120.0 poll_interval: float = 5.0 @@ -319,13 +319,13 @@ class Gateway: return self.transport.probe(path, params=params) -def build_gateway( +def build_proxy_client( *, base_url: str = PROXY_BASE_URL, master_key: str = MASTER_KEY, control_plane_base_url: str = CONTROL_PLANE_BASE_URL, -) -> Gateway: - """The Gateway every suite's client is built from: a SplitTransport that routes +) -> ProxyClient: + """The ProxyClient every suite's client is built from: a SplitTransport that routes LLM calls to the data plane (PROXY_BASE_URL) and management/admin calls to the control plane (CONTROL_PLANE_BASE_URL), with the shared poll budget. The two base URLs are the same for a monolithic proxy, so routing is then a no-op. @@ -334,7 +334,7 @@ def build_gateway( way than ``e2e_config``'s env names (see ``claude_code/_env.py``); they must pass all three together, since a caller that overrides only the data plane would leave management calls pointed at the env default.""" - return Gateway( + return ProxyClient( transport=SplitTransport( data=HttpTransport( base_url=base_url, diff --git a/tests/e2e/pytest.ini b/tests/e2e/pytest.ini index d3b2193226b..e9611df139b 100644 --- a/tests/e2e/pytest.ini +++ b/tests/e2e/pytest.ini @@ -5,3 +5,4 @@ addopts = --strict-markers --strict-config markers = e2e: live test that requires a running proxy and real provider keys + load: heavy throughput/load test; collected last so it never perturbs latency-sensitive suites diff --git a/tests/e2e/quota_management/budgets/budget_client.py b/tests/e2e/quota_management/budgets/budget_client.py index c2f5dcdd57c..53da8f68b1b 100644 --- a/tests/e2e/quota_management/budgets/budget_client.py +++ b/tests/e2e/quota_management/budgets/budget_client.py @@ -1,4 +1,4 @@ -"""Client for budget e2e tests: the shared Gateway plus budget-bearing entity +"""Client for budget e2e tests: the shared ProxyClient plus budget-bearing entity management (user / team / team-member / org / customer / tag / budget-table) and info reads. @@ -15,7 +15,7 @@ from dataclasses import dataclass from pydantic import AliasPath, BaseModel, Field, RootModel -from e2e_gateway import Gateway, build_gateway +from proxy_client import ProxyClient from e2e_http import NoBody, Result, StreamingResponse, Success, unwrap from models import ( AnthropicMessagesBody, @@ -185,9 +185,9 @@ def model_budget(model: str, limit: float, period: str = "30d") -> dict[str, Mod @dataclass(frozen=True, slots=True) class BudgetClient: - gateway: Gateway + proxy: ProxyClient - # ---- generic key ops (delegate to the shared Gateway) --------------- + # ---- generic key ops (delegate to the shared ProxyClient) --------------- def generate_key( self, @@ -203,7 +203,7 @@ class BudgetClient: budget_fallbacks: dict[str, list[str]] | None = None, budget_limits: list[BudgetWindow] | None = None, ) -> str: - return self.gateway.generate_key( + return self.proxy.generate_key( KeyGenerateBody( models=models or [], max_budget=max_budget, @@ -219,10 +219,10 @@ class BudgetClient: ) def delete_key(self, key: str) -> None: - self.gateway.delete_key(key) + self.proxy.delete_key(key) def delete_customers(self, user_ids: list[str]) -> None: - self.gateway.delete_customers(user_ids) + self.proxy.delete_customers(user_ids) # ---- chat (raw HTTP outcome: a budget block surfaces as a non-2xx) -- @@ -236,9 +236,9 @@ class BudgetClient: user: str | None = None, tags: list[str] | None = None, ) -> StreamingResponse: - return self.gateway.transport.send( + return self.proxy.transport.send( "/chat/completions", - headers=self.gateway.transport.bearer(key), + headers=self.proxy.transport.bearer(key), json=ChatBody( model=model, messages=[ChatMessage(role="user", content=content)], @@ -256,9 +256,9 @@ class BudgetClient: *, max_tokens: int = 16, ) -> StreamingResponse: - return self.gateway.transport.send( + return self.proxy.transport.send( "/v1/messages", - headers=self.gateway.transport.bearer(key), + headers=self.proxy.transport.bearer(key), json=AnthropicMessagesBody( model=model, messages=[ChatMessage(role="user", content=content)], @@ -270,26 +270,26 @@ class BudgetClient: def create_user(self, *, max_budget: float, budget_duration: str | None = None) -> str: return unwrap( - self.gateway.transport.post( + self.proxy.transport.post( "/user/new", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=UserNewBody(max_budget=max_budget, budget_duration=budget_duration), response_type=UserNewResponse, ) ).user_id def delete_user(self, user_id: str) -> None: - _ = self.gateway.transport.post( + _ = self.proxy.transport.post( "/user/delete", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=UserDeleteBody(user_ids=[user_id]), response_type=NoBody, ) def user_info(self, user_id: str) -> UserInfoRow | None: - result = self.gateway.transport.get( + result = self.proxy.transport.get( "/user/info", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, params=UserInfoParams(user_id=user_id), response_type=UserInfoResponse, ) @@ -302,9 +302,9 @@ class BudgetClient: # ---- customer / end-user ------------------------------------------- def create_customer(self, customer_id: str, *, max_budget: float) -> str: - resp = self.gateway.transport.send( + resp = self.proxy.transport.send( "/customer/new", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=CustomerNewBody(user_id=customer_id, max_budget=max_budget), ) assert resp.ok, resp.body @@ -314,9 +314,9 @@ class BudgetClient: def create_org(self, *, max_budget: float, alias: str, budget_duration: str | None = None) -> str: return unwrap( - self.gateway.transport.post( + self.proxy.transport.post( "/organization/new", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=OrgNewBody( organization_alias=alias, max_budget=max_budget, @@ -330,9 +330,9 @@ class BudgetClient: """The id of the budget row backing an org; its budget_reset_at is read via budget_info (LIT-4570: /organization/new stores budget_duration without scheduling budget_reset_at, so the reset job's first tick schedules it).""" - result = self.gateway.transport.get( + result = self.proxy.transport.get( "/organization/info", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, params=OrgInfoParams(organization_id=org_id), response_type=OrgInfoResponse, ) @@ -343,9 +343,9 @@ class BudgetClient: return None def delete_org(self, org_id: str) -> None: - _ = self.gateway.transport.delete( + _ = self.proxy.transport.delete( "/organization/delete", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=OrgDeleteBody(organization_ids=[org_id]), response_type=NoBody, ) @@ -362,9 +362,9 @@ class BudgetClient: budget_limits: list[BudgetWindow] | None = None, ) -> str: team_id = unwrap( - self.gateway.transport.post( + self.proxy.transport.post( "/team/new", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=TeamNewBody( team_alias=alias, max_budget=max_budget, @@ -379,9 +379,9 @@ class BudgetClient: return team_id def delete_team(self, team_id: str) -> None: - _ = self.gateway.transport.post( + _ = self.proxy.transport.post( "/team/delete", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=TeamDeleteBody(team_ids=[team_id]), response_type=NoBody, ) @@ -389,9 +389,9 @@ class BudgetClient: def _wait_for_team(self, team_id: str) -> None: last: Result[TeamInfoResponse] | None = None for _ in range(_TEAM_READY_ATTEMPTS): - last = self.gateway.transport.get( + last = self.proxy.transport.get( "/team/info", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, params=TeamInfoParams(team_id=team_id), response_type=TeamInfoResponse, ) @@ -406,9 +406,9 @@ class BudgetClient: def add_team_member(self, team_id: str, user_id: str, *, max_budget_in_team: float | None = None) -> None: last_body = "" for attempt in range(_TEAM_READY_ATTEMPTS): - resp = self.gateway.transport.send( + resp = self.proxy.transport.send( "/team/member_add", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=TeamMemberAddBody( team_id=team_id, member=TeamMember(role="user", user_id=user_id), @@ -432,9 +432,9 @@ class BudgetClient: max_budget_in_team: float | None = None, budget_duration: str | None = None, ) -> None: - resp = self.gateway.transport.send( + resp = self.proxy.transport.send( "/team/member_update", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=TeamMemberUpdateBody( team_id=team_id, user_id=user_id, @@ -448,9 +448,9 @@ class BudgetClient: """The member's per-team budget_reset_at as /team/info reports it, or None if no reset is scheduled. The reset job advances this each time the window elapses; a job that skips the row leaves it pinned forever.""" - result = self.gateway.transport.get( + result = self.proxy.transport.get( "/team/info", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, params=TeamInfoParams(team_id=team_id), response_type=TeamInfoResponse, ) @@ -466,18 +466,18 @@ class BudgetClient: # ---- tag ------------------------------------------------------------ def create_tag(self, name: str, *, max_budget: float) -> str: - resp = self.gateway.transport.send( + resp = self.proxy.transport.send( "/tag/new", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=TagNewBody(name=name, max_budget=max_budget), ) assert resp.ok, resp.body return name def delete_tag(self, name: str) -> None: - _ = self.gateway.transport.post( + _ = self.proxy.transport.post( "/tag/delete", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=TagDeleteBody(name=name), response_type=NoBody, ) @@ -492,9 +492,9 @@ class BudgetClient: budget_duration: str | None = None, ) -> str: return unwrap( - self.gateway.transport.post( + self.proxy.transport.post( "/budget/new", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=BudgetNewBody( max_budget=max_budget, soft_budget=soft_budget, @@ -505,17 +505,17 @@ class BudgetClient: ).budget_id def delete_budget(self, budget_id: str) -> None: - _ = self.gateway.transport.post( + _ = self.proxy.transport.post( "/budget/delete", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=BudgetDeleteBody(id=budget_id), response_type=NoBody, ) def budget_info(self, budget_id: str) -> tuple[BudgetRow, ...]: - result = self.gateway.transport.post( + result = self.proxy.transport.post( "/budget/info", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=BudgetInfoBody(budgets=[budget_id]), response_type=BudgetInfoResponse, ) @@ -526,5 +526,5 @@ class BudgetClient: return () -def build_client() -> BudgetClient: - return BudgetClient(gateway=build_gateway()) +def build_client(proxy: ProxyClient) -> BudgetClient: + return BudgetClient(proxy=proxy) diff --git a/tests/e2e/quota_management/budgets/conftest.py b/tests/e2e/quota_management/budgets/conftest.py index 4299d2ffd49..639761cdfc8 100644 --- a/tests/e2e/quota_management/budgets/conftest.py +++ b/tests/e2e/quota_management/budgets/conftest.py @@ -1,7 +1,7 @@ """Budgets suite's `client` fixture. The shared lifecycle (resources/scoped_key), proxy liveness gate, and e2e marker -live in the parent tests/e2e/conftest.py. BudgetClient holds the shared Gateway, +live in the parent tests/e2e/conftest.py. BudgetClient holds the shared ProxyClient, so the `resources` fixture cleans up keys through it; tests register entity deletes via `resources.defer(...)`. """ @@ -9,8 +9,9 @@ via `resources.defer(...)`. import pytest from budget_client import BudgetClient, build_client +from proxy_client import ProxyClient @pytest.fixture(scope="session") -def client() -> BudgetClient: - return build_client() +def client(proxy: ProxyClient) -> BudgetClient: + return build_client(proxy) diff --git a/tests/e2e/quota_management/budgets/test_budget_crud_e2e.py b/tests/e2e/quota_management/budgets/test_budget_crud_e2e.py index 47473326f32..5070ec89704 100644 --- a/tests/e2e/quota_management/budgets/test_budget_crud_e2e.py +++ b/tests/e2e/quota_management/budgets/test_budget_crud_e2e.py @@ -30,7 +30,7 @@ def test_budget_crud_roundtrip(client: BudgetClient, resources: ResourceManager) # Attach the budget to a key and confirm the key reflects it. key = client.generate_key(budget_id=budget_id) resources.defer(lambda: client.delete_key(key)) - info = client.gateway.key_info(key) + info = client.proxy.key_info(key) linked = info.litellm_budget_table assert info.budget_id == budget_id or (linked is not None and linked.max_budget == 12.5), ( f"key does not reflect attached budget: {info.budget_id}, {linked}" @@ -49,7 +49,7 @@ def test_budget_duration_schedules_reset_on_key(client: BudgetClient, resources: key = client.generate_key(max_budget=10.0, budget_duration="30d") resources.defer(lambda: client.delete_key(key)) - reset_at = client.gateway.key_info(key).budget_reset_at + reset_at = client.proxy.key_info(key).budget_reset_at assert reset_at, "budget_duration did not set budget_reset_at on the key" # budget_duration schedules a FUTURE reset. Don't assume now+30d exactly: the diff --git a/tests/e2e/quota_management/budgets/test_budget_fallback_e2e.py b/tests/e2e/quota_management/budgets/test_budget_fallback_e2e.py index 56197dcfee2..fe6db8f0454 100644 --- a/tests/e2e/quota_management/budgets/test_budget_fallback_e2e.py +++ b/tests/e2e/quota_management/budgets/test_budget_fallback_e2e.py @@ -53,7 +53,7 @@ def test_budget_fallback_reroutes_anthropic_messages_to_openai( # The rerouted call must be recorded under the fallback model, not the # exhausted primary - proving spend tracking followed the reroute. - rows = client.gateway.poll_logs_for_key( + rows = client.proxy.poll_logs_for_key( key, predicate=lambda rows: any(FALLBACK_MODEL in (r.model or "") for r in rows) ) assert any(FALLBACK_MODEL in (r.model or "") for r in rows), ( diff --git a/tests/e2e/quota_management/budgets/test_budget_reset_advances_e2e.py b/tests/e2e/quota_management/budgets/test_budget_reset_advances_e2e.py index bc634f70b13..88cb1a045de 100644 --- a/tests/e2e/quota_management/budgets/test_budget_reset_advances_e2e.py +++ b/tests/e2e/quota_management/budgets/test_budget_reset_advances_e2e.py @@ -62,7 +62,7 @@ def test_key_with_budget_duration_schedules_reset_at_creation( key = client.generate_key(max_budget=TINY_CAP, budget_duration=f"{WINDOW_SECONDS}s") resources.defer(lambda: client.delete_key(key)) - info = client.gateway.key_info(key) + info = client.proxy.key_info(key) assert info.budget_reset_at is not None, "budget_duration set no budget_reset_at" assert _as_datetime(info.budget_reset_at) > _as_datetime("1970-01-01T00:00:00Z") @@ -99,7 +99,7 @@ def test_key_budget_reset_at_advances_after_window( key = client.generate_key(max_budget=TINY_CAP, budget_duration=f"{WINDOW_SECONDS}s") resources.defer(lambda: client.delete_key(key)) - before_raw = client.gateway.key_info(key).budget_reset_at + before_raw = client.proxy.key_info(key).budget_reset_at assert before_raw is not None, "no budget_reset_at scheduled at creation" before = _as_datetime(before_raw) @@ -112,7 +112,7 @@ def test_key_budget_reset_at_advances_after_window( if not result.ok: assert is_budget_block(result), f"non-budget error during reset wait: {result.body[:200]}" continue - info = client.gateway.key_info(key) + info = client.proxy.key_info(key) assert info.budget_reset_at is not None, "budget_reset_at cleared by reset" assert _as_datetime(info.budget_reset_at) > before, ( "budget_reset_at did not advance past the pre-reset value" @@ -145,7 +145,7 @@ def test_multi_window_key_resets_each_window_independently( start = time.monotonic() _drive_to_block(client, key) - spend_at_block = client.gateway.key_info(key).spend or 0.0 + spend_at_block = client.proxy.key_info(key).spend or 0.0 deadline = time.monotonic() + RESET_DEADLINE_SECONDS while time.monotonic() < deadline: @@ -156,7 +156,7 @@ def test_multi_window_key_resets_each_window_independently( assert elapsed < WINDOW_SECONDS + 90, ( f"tight window reset took {elapsed:.0f}s - too long for {WINDOW_SECONDS}s" ) - assert (client.gateway.key_info(key).spend or 0.0) >= spend_at_block, ( + assert (client.proxy.key_info(key).spend or 0.0) >= spend_at_block, ( "roomy window spend was wiped when only the tight window should reset" ) return diff --git a/tests/e2e/quota_management/budgets/test_spend_counter_reseed_e2e.py b/tests/e2e/quota_management/budgets/test_spend_counter_reseed_e2e.py index 52fe112ed29..d9f9830ee20 100644 --- a/tests/e2e/quota_management/budgets/test_spend_counter_reseed_e2e.py +++ b/tests/e2e/quota_management/budgets/test_spend_counter_reseed_e2e.py @@ -141,7 +141,7 @@ def test_cold_counter_reseed_keeps_counter_equal_to_db_spend( "the spend counter never went cold; default_redis_ttl must be short enough for it " "to expire, otherwise the burst reads a warm counter and the reseed is never exercised" ) - db_spend = client.gateway.key_info(key).spend or 0.0 + db_spend = client.proxy.key_info(key).spend or 0.0 assert db_spend > 0, f"no DB spend accumulated from real calls: {db_spend}" burst_results = [] diff --git a/tests/e2e/quota_management/budgets/test_team_member_budget_e2e.py b/tests/e2e/quota_management/budgets/test_team_member_budget_e2e.py index 2717b23d9dc..0fd0a545660 100644 --- a/tests/e2e/quota_management/budgets/test_team_member_budget_e2e.py +++ b/tests/e2e/quota_management/budgets/test_team_member_budget_e2e.py @@ -46,7 +46,7 @@ def member(client: BudgetClient) -> Iterator[_Member]: Cleanups register progressively and run LIFO best-effort through ResourceManager, so a partial-setup failure still releases what came before and one failed delete never strands the rest on the shared proxy.""" - resources = ResourceManager(client=client.gateway) + resources = ResourceManager(client=client.proxy) try: marker = unique_marker() team_id = client.create_team(alias=f"e2e-team-member-{marker}", max_budget=TEAM_BUDGET) @@ -64,7 +64,7 @@ def member(client: BudgetClient) -> Iterator[_Member]: def _send(client: BudgetClient, key: str) -> str | None: """One member call; its response id (== the spend-log request_id) if it went through, else None.""" - match client.gateway.chat( + match client.proxy.chat( key, ChatBody( model=MODEL, @@ -83,7 +83,7 @@ class TestTeamMemberBudget: sent = frozenset(rid for rid in (_send(client, member.key) for _ in range(BURST)) if rid) assert sent, "no member call went through; cannot check attribution" - rows = client.gateway.poll_logs_for_key( + rows = client.proxy.poll_logs_for_key( member.key, predicate=lambda rs: bool(sent & {r.request_id for r in rs}) ) logged = [row for row in rows if row.request_id in sent] diff --git a/tests/e2e/quota_management/budgets/test_team_member_budget_isolation_e2e.py b/tests/e2e/quota_management/budgets/test_team_member_budget_isolation_e2e.py index 87855a9a1c1..f03518f8a17 100644 --- a/tests/e2e/quota_management/budgets/test_team_member_budget_isolation_e2e.py +++ b/tests/e2e/quota_management/budgets/test_team_member_budget_isolation_e2e.py @@ -43,7 +43,7 @@ def pair(client: BudgetClient) -> Iterator[_Pair]: """One team with a large budget and two members on it: a tight member capped at a tiny per-team budget and a roomy member with headroom, each with their own key. Shared across the class and torn down LIFO best-effort when it finishes.""" - resources = ResourceManager(client=client.gateway) + resources = ResourceManager(client=client.proxy) try: marker = unique_marker() team_id = client.create_team(alias=f"e2e-member-iso-{marker}", max_budget=TEAM_BUDGET) @@ -71,7 +71,7 @@ def pair(client: BudgetClient) -> Iterator[_Pair]: def _roomy_send(client: BudgetClient, key: str) -> str: """One roomy-member call that must go through; returns its request id.""" - match client.gateway.chat( + match client.proxy.chat( key, ChatBody( model=MODEL, @@ -105,7 +105,7 @@ class TestTeamMemberBudgetIsolation: client.chat(pair.tight_key, MODEL, f"tight {unique_marker()}", max_tokens=16) ), "tight member stopped being blocked once the peer spent" - rows = client.gateway.poll_logs_for_key( + rows = client.proxy.poll_logs_for_key( pair.roomy_key, predicate=lambda rs: bool(sent & {r.request_id for r in rs}) ) logged = [row for row in rows if row.request_id in sent] diff --git a/tests/e2e/quota_management/ratelimit/conftest.py b/tests/e2e/quota_management/ratelimit/conftest.py index 59dee5e65b3..0b00c87c45c 100644 --- a/tests/e2e/quota_management/ratelimit/conftest.py +++ b/tests/e2e/quota_management/ratelimit/conftest.py @@ -1,15 +1,16 @@ """Quota-management suite's `client` fixture. The shared lifecycle (resources/scoped_key), proxy liveness gate, and e2e marker -live in the parent tests/e2e/conftest.py. QuotaClient holds the shared Gateway, +live in the parent tests/e2e/conftest.py. QuotaClient holds the shared ProxyClient, so the `resources` fixture cleans up keys through it. """ import pytest from quota_client import QuotaClient, build_client +from proxy_client import ProxyClient @pytest.fixture(scope="session") -def client() -> QuotaClient: - return build_client() +def client(proxy: ProxyClient) -> QuotaClient: + return build_client(proxy) diff --git a/tests/e2e/quota_management/ratelimit/quota_client.py b/tests/e2e/quota_management/ratelimit/quota_client.py index 806ab1d1557..a3a467a1d71 100644 --- a/tests/e2e/quota_management/ratelimit/quota_client.py +++ b/tests/e2e/quota_management/ratelimit/quota_client.py @@ -1,4 +1,4 @@ -"""Client for the quota-management suite: the shared Gateway plus raw chat +"""Client for the quota-management suite: the shared ProxyClient plus raw chat calls judged by HTTP status, body, and headers (a rate-limit block is a 429 whose body and retry-after header carry the contract, not a typed success model).""" @@ -7,19 +7,19 @@ from __future__ import annotations from dataclasses import dataclass -from e2e_gateway import Gateway, build_gateway +from proxy_client import ProxyClient from e2e_http import StreamingResponse from models import ChatBody, ChatMessage @dataclass(frozen=True, slots=True) class QuotaClient: - gateway: Gateway + proxy: ProxyClient def chat(self, key: str, model: str, content: str, *, max_tokens: int = 16) -> StreamingResponse: - return self.gateway.transport.send( + return self.proxy.transport.send( "/chat/completions", - headers=self.gateway.transport.bearer(key), + headers=self.proxy.transport.bearer(key), json=ChatBody( model=model, messages=[ChatMessage(role="user", content=content)], @@ -28,5 +28,5 @@ class QuotaClient: ) -def build_client() -> QuotaClient: - return QuotaClient(gateway=build_gateway()) +def build_client(proxy: ProxyClient) -> QuotaClient: + return QuotaClient(proxy=proxy) diff --git a/tests/e2e/quota_management/ratelimit/test_rate_limit_e2e.py b/tests/e2e/quota_management/ratelimit/test_rate_limit_e2e.py index a6c15b79bb1..7d87686b06c 100644 --- a/tests/e2e/quota_management/ratelimit/test_rate_limit_e2e.py +++ b/tests/e2e/quota_management/ratelimit/test_rate_limit_e2e.py @@ -131,8 +131,8 @@ def _limited_key( rpm_limit: int | None = None, tpm_limit: int | None = None, ) -> str: - key = client.gateway.generate_key(KeyGenerateBody(models=[MODEL], rpm_limit=rpm_limit, tpm_limit=tpm_limit)) - resources.defer(lambda: client.gateway.delete_key(key)) + key = client.proxy.generate_key(KeyGenerateBody(models=[MODEL], rpm_limit=rpm_limit, tpm_limit=tpm_limit)) + resources.defer(lambda: client.proxy.delete_key(key)) return key @@ -147,7 +147,7 @@ def _first_ok(client: QuotaClient, key: str) -> _FirstOk: cache picks it up, so retry on 401 to a deadline; a 401 never reaches the rate limiter, so only the successful call consumes budget. Any other failure is behavior under test and fails hard.""" - deadline = time.monotonic() + client.gateway.poll_timeout + deadline = time.monotonic() + client.proxy.poll_timeout while True: sent_at = time.monotonic() outcome = _chat(client, key) @@ -155,7 +155,7 @@ def _first_ok(client: QuotaClient, key: str) -> _FirstOk: return _FirstOk(sent_at=sent_at, response=outcome) if outcome.status_code != 401 or time.monotonic() >= deadline: require_successful_call(outcome) - time.sleep(client.gateway.poll_interval) + time.sleep(client.proxy.poll_interval) def _assert_rate_limited(outcome: StreamingResponse, limit_type: str) -> None: @@ -178,7 +178,7 @@ class TestKeyRateLimits: @pytest.mark.covers("quota_management.ratelimit.rpm.blocks_over_limit") def test_rpm_limit_blocks_over_limit(self, client: QuotaClient, resources: ResourceManager) -> None: key = _limited_key(client, resources, rpm_limit=3) - info = client.gateway.key_info(key) + info = client.proxy.key_info(key) assert info.rpm_limit == 3, f"/key/info reports rpm_limit {info.rpm_limit}, configured 3" _ = _first_ok(client, key) @@ -190,7 +190,7 @@ class TestKeyRateLimits: @pytest.mark.covers("quota_management.ratelimit.tpm.blocks_over_limit") def test_tpm_limit_blocks_over_limit(self, client: QuotaClient, resources: ResourceManager) -> None: key = _limited_key(client, resources, tpm_limit=TPM_LIMIT) - info = client.gateway.key_info(key) + info = client.proxy.key_info(key) assert info.tpm_limit == TPM_LIMIT, f"/key/info reports tpm_limit {info.tpm_limit}, configured {TPM_LIMIT}" first = _first_ok(client, key) @@ -213,7 +213,7 @@ class TestKeyRateLimits: first = _first_ok(client, key) _assert_rate_limited(_chat(client, key), "requests") - deadline = time.monotonic() + client.gateway.poll_timeout + deadline = time.monotonic() + client.proxy.poll_timeout while time.monotonic() < deadline: attempt_sent_at = time.monotonic() outcome = _chat(client, key) @@ -228,7 +228,7 @@ class TestKeyRateLimits: assert outcome.status_code == 429, ( f"while the window drains only 429s are acceptable, got {outcome.status_code}: {outcome.body[:300]}" ) - time.sleep(client.gateway.poll_interval) + time.sleep(client.proxy.poll_interval) pytest.fail("a blocked key never recovered after the rate-limit window elapsed") @pytest.mark.covers("quota_management.ratelimit.rpm.headers_report_remaining") diff --git a/tests/e2e/quota_management/spend_tracking/conftest.py b/tests/e2e/quota_management/spend_tracking/conftest.py index 434af15b182..c31e6b3c090 100644 --- a/tests/e2e/quota_management/spend_tracking/conftest.py +++ b/tests/e2e/quota_management/spend_tracking/conftest.py @@ -1,8 +1,8 @@ """Spend-tracking suite's `client` fixture and driver-model registration. The shared lifecycle (resources/scoped_key), proxy liveness gate, and e2e marker -live in the parent tests/e2e/conftest.py. SpendClient exposes the shared Gateway -(GatewayProvider), so the `resources` fixture cleans up keys and customers this +live in the parent tests/e2e/conftest.py. SpendClient exposes the shared ProxyClient +(ProxyClientProvider), so the `resources` fixture cleans up keys and customers this suite creates. The suite drives real calls through three deployments. On the stage gateway they @@ -21,6 +21,7 @@ import pytest from models import LiteLLMParamsBody from spend_e2e_client import SpendClient, build_client +from proxy_client import ProxyClient def _driver_params(provider_model: str, env_var: str) -> LiteLLMParamsBody: @@ -38,18 +39,18 @@ DRIVER_MODELS: tuple[tuple[str, str, str], ...] = ( @pytest.fixture(scope="session") -def client() -> SpendClient: - return build_client() +def client(proxy: ProxyClient) -> SpendClient: + return build_client(proxy) @pytest.fixture(scope="session", autouse=True) def driver_models(client: SpendClient) -> Iterator[None]: - existing = frozenset(entry.model_name for entry in client.gateway.model_info()) + existing = frozenset(entry.model_name for entry in client.proxy.model_info()) created = tuple( - client.gateway.create_model(name, _driver_params(provider_model, env_var)) + client.proxy.create_model(name, _driver_params(provider_model, env_var)) for name, provider_model, env_var in DRIVER_MODELS if name not in existing ) yield for model_id in created: - client.gateway.delete_model(model_id) + client.proxy.delete_model(model_id) diff --git a/tests/e2e/quota_management/spend_tracking/spend_e2e_client.py b/tests/e2e/quota_management/spend_tracking/spend_e2e_client.py index c4991199187..29ca5eb2ce6 100644 --- a/tests/e2e/quota_management/spend_tracking/spend_e2e_client.py +++ b/tests/e2e/quota_management/spend_tracking/spend_e2e_client.py @@ -1,7 +1,7 @@ -"""Spend-tracking e2e client: a Gateway plus the spend-specific read endpoints. +"""Spend-tracking e2e client: a ProxyClient plus the spend-specific read endpoints. Generic proxy operations (keys, customers, chat/embed, route probing, SpendLogs -polling) come from the shared Gateway, DI'd in (composition, not inheritance). +polling) come from the shared ProxyClient, DI'd in (composition, not inheritance). This client adds only the spend surface: /spend/calculate, /spend/tags, key-spend polling, and the route probes the breadth test uses. @@ -27,7 +27,7 @@ from e2e_http import ( is_ok, unwrap, ) -from e2e_gateway import Gateway, build_gateway +from proxy_client import ProxyClient from models import ( ChatBody, ChatMessage, @@ -96,7 +96,7 @@ def _chat_body( @dataclass(frozen=True, slots=True) class SpendClient: - gateway: Gateway + proxy: ProxyClient def chat( self, @@ -108,19 +108,19 @@ class SpendClient: tags: list[str] | None = None, user: str | None = None, ) -> Result[ChatResponse]: - return self.gateway.chat( + return self.proxy.chat( key, _chat_body(model, content, max_tokens=max_tokens, tags=tags, user=user) ) def chat_stream( self, key: str, model: str, content: str, *, max_tokens: int | None = None ) -> StreamingResponse: - return self.gateway.chat_stream( + return self.proxy.chat_stream( key, _chat_body(model, content, max_tokens=max_tokens, stream=True) ) def embed(self, key: str, model: str, content: str) -> Result[EmbedResponse]: - return self.gateway.embed(key, EmbedBody(model=model, input=content)) + return self.proxy.embed(key, EmbedBody(model=model, input=content)) def poll_logs_for_key( self, @@ -129,15 +129,15 @@ class SpendClient: min_rows: int = 1, predicate: Callable[[list[SpendLogRow]], bool] | None = None, ) -> list[SpendLogRow]: - return self.gateway.poll_logs_for_key( + return self.proxy.poll_logs_for_key( key, min_rows=min_rows, predicate=predicate ) def calculate_spend(self, model: str, content: str) -> float: return unwrap( - self.gateway.transport.post( + self.proxy.transport.post( "/spend/calculate", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, json=SpendCalculateBody( model=model, messages=[ChatMessage(role="user", content=content)] ), @@ -146,9 +146,9 @@ class SpendClient: ).cost def spend_by_tags(self) -> list[TagSpend]: - result = self.gateway.transport.get( + result = self.proxy.transport.get( "/spend/tags", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, params=NoBody(), response_type=SpendTagsResponse, ) @@ -160,7 +160,7 @@ class SpendClient: def poll_tag_spend(self, tag: str, *, minimum: float = 0.0) -> TagSpend | None: """Poll /spend/tags until the tag's aggregate reaches `minimum`; last seen.""" - deadline = time.monotonic() + self.gateway.poll_timeout + deadline = time.monotonic() + self.proxy.poll_timeout entry: TagSpend | None = None while time.monotonic() < deadline: matches = [ @@ -170,17 +170,17 @@ class SpendClient: entry = matches[0] if (entry.total_spend or 0.0) >= minimum: return entry - time.sleep(self.gateway.poll_interval) + time.sleep(self.proxy.poll_interval) return entry def poll_key_spend(self, key: str, *, minimum: float = 0.0) -> float: - deadline = time.monotonic() + self.gateway.poll_timeout + deadline = time.monotonic() + self.proxy.poll_timeout spend = 0.0 while time.monotonic() < deadline: - spend = self.gateway.key_info(key).spend or 0.0 + spend = self.proxy.key_info(key).spend or 0.0 if spend > minimum: return spend - time.sleep(self.gateway.poll_interval) + time.sleep(self.proxy.poll_interval) return spend def spend_logs_page( @@ -191,9 +191,9 @@ class SpendClient: now = datetime.now(timezone.utc) fmt = "%Y-%m-%d %H:%M:%S" return unwrap( - self.gateway.transport.get( + self.proxy.transport.get( "/spend/logs/v2", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, params=SpendLogsPageParams( start_date=(now - timedelta(days=1)).strftime(fmt), end_date=(now + timedelta(days=1)).strftime(fmt), @@ -206,18 +206,18 @@ class SpendClient: ) def probe(self, path: str, *, params: DateRangeParams) -> ProbeResult: - return self.gateway.transport.probe(path, params=params) + return self.proxy.transport.probe(path, params=params) def openapi(self) -> OpenAPISchema: return unwrap( - self.gateway.transport.get( + self.proxy.transport.get( "/openapi.json", - headers=self.gateway.transport.master, + headers=self.proxy.transport.master, params=NoBody(), response_type=OpenAPISchema, ) ) -def build_client() -> SpendClient: - return SpendClient(gateway=build_gateway()) +def build_client(proxy: ProxyClient) -> SpendClient: + return SpendClient(proxy=proxy) diff --git a/tests/e2e/quota_management/spend_tracking/test_spend_tracking_e2e.py b/tests/e2e/quota_management/spend_tracking/test_spend_tracking_e2e.py index 2f0ffae44e3..465046e89af 100644 --- a/tests/e2e/quota_management/spend_tracking/test_spend_tracking_e2e.py +++ b/tests/e2e/quota_management/spend_tracking/test_spend_tracking_e2e.py @@ -475,12 +475,12 @@ def test_spend_logs_endpoint_returns_spend( ) ) - gateway = client.gateway - deadline = time.monotonic() + gateway.poll_timeout + proxy = client.proxy + deadline = time.monotonic() + proxy.poll_timeout while True: - result = gateway.transport.get( + result = proxy.transport.get( "/spend/logs", - headers=gateway.transport.master, + headers=proxy.transport.master, params=SpendLogsParams(api_key=scoped_key), response_type=SpendLogs, ) @@ -493,4 +493,4 @@ def test_spend_logs_endpoint_returns_spend( f"/spend/logs never surfaced the key's spend before the deadline; " f"saw {_summarize(rows)}" ) - time.sleep(gateway.poll_interval) + time.sleep(proxy.poll_interval) diff --git a/tests/e2e/router/complexity_router_client.py b/tests/e2e/router/complexity_router_client.py index 929acbb3461..0093fc7480d 100644 --- a/tests/e2e/router/complexity_router_client.py +++ b/tests/e2e/router/complexity_router_client.py @@ -1,20 +1,20 @@ """Client for the complexity auto-router e2e tests. -The suite drives the shared /chat/completions and spend-log reads on the Gateway, -so this client only carries the Gateway the shared lifecycle needs for cleanup. +The suite drives the shared /chat/completions and spend-log reads on the ProxyClient, +so this client only carries the ProxyClient the shared lifecycle needs for cleanup. """ from __future__ import annotations from dataclasses import dataclass -from e2e_gateway import Gateway, build_gateway +from proxy_client import ProxyClient @dataclass(frozen=True, slots=True) class ComplexityRouterClient: - gateway: Gateway + proxy: ProxyClient -def build_client() -> ComplexityRouterClient: - return ComplexityRouterClient(gateway=build_gateway()) +def build_client(proxy: ProxyClient) -> ComplexityRouterClient: + return ComplexityRouterClient(proxy=proxy) diff --git a/tests/e2e/router/conftest.py b/tests/e2e/router/conftest.py index 344d8ab5c13..8ddc19aa94f 100644 --- a/tests/e2e/router/conftest.py +++ b/tests/e2e/router/conftest.py @@ -2,7 +2,7 @@ The shared lifecycle (resources/scoped_key), proxy liveness gate, and e2e marker live in the parent tests/e2e/conftest.py. ComplexityRouterClient holds the shared -Gateway, so the `resources` fixture cleans up keys this suite creates. +ProxyClient, so the `resources` fixture cleans up keys this suite creates. Also registers `complexity-smart-router` via management /model/new when the proxy does not already list it (compose has it in static config; stage does not). @@ -16,7 +16,7 @@ import pytest from requests import RequestException from complexity_router_client import ComplexityRouterClient, build_client -from e2e_gateway import Gateway +from proxy_client import ProxyClient from e2e_http import NoBody, Success from lifecycle import ResourceManager from models import ( @@ -46,27 +46,27 @@ ROUTER_KEY_MODELS = [ROUTER_MODEL, "gpt-5.5", "claude-haiku-4-5"] @pytest.fixture(scope="session") -def client() -> ComplexityRouterClient: - return build_client() +def client(proxy: ProxyClient) -> ComplexityRouterClient: + return build_client(proxy) -def _model_is_servable(gateway: Gateway, model_name: str) -> bool: - result = gateway.transport.get( +def _model_is_servable(proxy: ProxyClient, model_name: str) -> bool: + result = proxy.transport.get( "/v1/models", - headers=gateway.transport.master, + headers=proxy.transport.master, params=NoBody(), response_type=ModelsListResponse, ) return isinstance(result, Success) and any(entry.id == model_name for entry in result.data.data) -def _router_is_callable(gateway: Gateway) -> bool: +def _router_is_callable(proxy: ProxyClient) -> bool: """True only when a short chat against the virtual router succeeds; every error (the Invalid-model-name reload race, but also 401, 5xx, and network) counts as not-callable so infra/auth blips can't be mistaken for a working router.""" - key = gateway.generate_key(KeyGenerateBody(models=ROUTER_KEY_MODELS, user_id="e2e-complexity-probe")) + key = proxy.generate_key(KeyGenerateBody(models=ROUTER_KEY_MODELS, user_id="e2e-complexity-probe")) try: - result = gateway.chat( + result = proxy.chat( key, ChatBody( model=ROUTER_MODEL, @@ -75,7 +75,7 @@ def _router_is_callable(gateway: Gateway) -> bool: ), ) finally: - gateway.delete_key(key) + proxy.delete_key(key) return isinstance(result, Success) @@ -86,18 +86,18 @@ def _ensure_complexity_smart_router( # pyright: ignore[reportUnusedFunction] # """Ensure the complexity router virtual model exists for this session. Compose already declares it in docker-compose.yml; stage does not. Register - via Gateway.create_model (waits for data-plane /v1/models) when missing, then + via ProxyClient.create_model (waits for data-plane /v1/models) when missing, then probe a real chat so a list-only false positive cannot pass the fixture. """ - gateway = client.gateway - if _model_is_servable(gateway, ROUTER_MODEL) and _router_is_callable(gateway): + proxy = client.proxy + if _model_is_servable(proxy, ROUTER_MODEL) and _router_is_callable(proxy): yield return try: - model_id = gateway.create_model(ROUTER_MODEL, ROUTER_PARAMS) + model_id = proxy.create_model(ROUTER_MODEL, ROUTER_PARAMS) except (AssertionError, RequestException) as exc: - if _model_is_servable(gateway, ROUTER_MODEL) and _router_is_callable(gateway): + if _model_is_servable(proxy, ROUTER_MODEL) and _router_is_callable(proxy): yield return raise AssertionError( @@ -106,7 +106,7 @@ def _ensure_complexity_smart_router( # pyright: ignore[reportUnusedFunction] # ) from exc try: - if not _router_is_callable(gateway): + if not _router_is_callable(proxy): raise AssertionError( f"{ROUTER_MODEL!r} registered as {model_id!r} and listed on " f"/v1/models but chat still returns Invalid model name; " @@ -114,14 +114,14 @@ def _ensure_complexity_smart_router( # pyright: ignore[reportUnusedFunction] # ) yield finally: - gateway.delete_model(model_id) + proxy.delete_model(model_id) @pytest.fixture def complexity_key(resources: ResourceManager, client: ComplexityRouterClient) -> str: """Per-test key allowed to call the complexity router and its tier backends.""" - key = client.gateway.generate_key( + key = client.proxy.generate_key( KeyGenerateBody(models=ROUTER_KEY_MODELS, user_id="e2e-complexity-router") ) - resources.defer(lambda: client.gateway.delete_key(key)) + resources.defer(lambda: client.proxy.delete_key(key)) return key diff --git a/tests/e2e/router/test_complexity_router_e2e.py b/tests/e2e/router/test_complexity_router_e2e.py index e9ec020994c..a495e2fdf4d 100644 --- a/tests/e2e/router/test_complexity_router_e2e.py +++ b/tests/e2e/router/test_complexity_router_e2e.py @@ -48,7 +48,7 @@ class TestComplexityRouterLlmClassifier: self, client: ComplexityRouterClient, complexity_key: str ) -> None: chat = unwrap( - client.gateway.chat( + client.proxy.chat( complexity_key, ChatBody( model=ROUTER_MODEL, @@ -59,7 +59,7 @@ class TestComplexityRouterLlmClassifier: ) assert chat.choices, f"router returned no choices: {chat}" - rows = client.gateway.poll_logs_for_key(complexity_key, min_rows=1) + rows = client.proxy.poll_logs_for_key(complexity_key, min_rows=1) served = [row.model for row in rows] # Exactly one spend row for the routed completion (not the classifier sub-call). # Membership allows alias vs provider-prefixed forms across compose and stage. diff --git a/tests/test_litellm/anthropic_interface/test_rust_bridge_messages.py b/tests/test_litellm/anthropic_interface/test_rust_bridge_messages.py new file mode 100644 index 00000000000..26ca7d27210 --- /dev/null +++ b/tests/test_litellm/anthropic_interface/test_rust_bridge_messages.py @@ -0,0 +1,354 @@ +"""Tests for the optional Rust-backed Anthropic Messages path.""" + +import importlib +from typing import cast + +import httpx +import pytest + +import litellm +from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler +from litellm.types.llms.anthropic_messages.anthropic_response import ( + AnthropicMessagesResponse, +) +from litellm.types.router import GenericLiteLLMParams + +rust_messages = importlib.import_module("litellm.rust_bridge.messages") +rust_bridge_loader = importlib.import_module("litellm.rust_bridge.loader") + +FAKE_MESSAGES_RESPONSE: dict[str, object] = { + "id": "msg_123", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5-20250929", + "content": [{"type": "text", "text": "hello world"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 5, "output_tokens": 3}, +} + +REQUEST_BODY: dict[str, object] = { + "model": "claude-sonnet-4-5", + "max_tokens": 64, + "messages": [{"role": "user", "content": "hi"}], +} + + +class RecordingMessages: + def __init__(self) -> None: + self.calls: list[dict[str, object]] = [] + + def __call__( + self, + model: str, + body: dict[str, object], + api_key: str | None, + api_base: str | None, + custom_llm_provider: str | None, + extra_headers: dict[str, object] | None, + timeout_seconds: float | None, + ) -> dict[str, object]: + self.calls.append( + { + "model": model, + "body": body, + "api_key": api_key, + "api_base": api_base, + "custom_llm_provider": custom_llm_provider, + "extra_headers": extra_headers, + "timeout_seconds": timeout_seconds, + } + ) + return dict(FAKE_MESSAGES_RESPONSE) + + +class RecordingAsyncMessages: + def __init__(self) -> None: + self.calls: list[dict[str, object]] = [] + + async def __call__( + self, + model: str, + body: dict[str, object], + api_key: str | None, + api_base: str | None, + custom_llm_provider: str | None, + extra_headers: dict[str, object] | None, + timeout_seconds: float | None, + ) -> dict[str, object]: + self.calls.append( + { + "model": model, + "body": body, + "api_key": api_key, + "api_base": api_base, + "custom_llm_provider": custom_llm_provider, + "extra_headers": extra_headers, + "timeout_seconds": timeout_seconds, + } + ) + return dict(FAKE_MESSAGES_RESPONSE) + + +class ExplodingAsyncMessages: + def __init__(self) -> None: + self.calls = 0 + + async def __call__(self, **kwargs: object) -> dict[str, object]: + self.calls += 1 + raise AssertionError("bridge must not be called") + + +class RaisingAsyncMessages: + def __init__(self) -> None: + self.calls = 0 + + async def __call__(self, **kwargs: object) -> dict[str, object]: + self.calls += 1 + raise RuntimeError("upstream request failed with status 400: bad request") + + +@pytest.fixture(autouse=True) +def _reset_rust_flag(): + litellm.use_litellm_rust(False, messages=None, amessages=None) + rust_bridge_loader._cached_bridge = rust_bridge_loader._BRIDGE_SENTINEL + yield + litellm.use_litellm_rust(False, messages=None, amessages=None) + rust_bridge_loader._cached_bridge = rust_bridge_loader._BRIDGE_SENTINEL + + +def test_load_rust_messages_returns_injected_impl(): + bridge = RecordingMessages() + litellm.use_litellm_rust(True, messages=bridge) + assert rust_messages.load_rust_messages() is bridge + + +def test_configuring_messages_does_not_enable_ocr(): + from litellm.rust_bridge.ocr import rust_ocr_enabled + + litellm.use_litellm_rust(False) + assert rust_ocr_enabled() is False + + litellm.use_litellm_rust(True, messages=RecordingMessages()) + + assert rust_ocr_enabled() is False + + +def test_bare_use_litellm_rust_still_toggles_ocr(): + from litellm.rust_bridge.ocr import rust_ocr_enabled + + litellm.use_litellm_rust(True) + assert rust_ocr_enabled() is True + + litellm.use_litellm_rust(False) + assert rust_ocr_enabled() is False + + +def test_load_rust_amessages_returns_injected_impl(): + bridge = RecordingAsyncMessages() + litellm.use_litellm_rust(True, amessages=bridge) + assert rust_messages.load_rust_amessages() is bridge + + +def test_messages_wrapper_returns_none_when_bridge_absent(monkeypatch): + monkeypatch.setattr( + importlib.import_module("litellm.rust_bridge"), + "get_native_bridge", + lambda: None, + ) + litellm.use_litellm_rust(True) + assert rust_messages.load_rust_messages() is None + result = rust_messages.messages( + model="claude", + body=REQUEST_BODY, + api_key="k", + api_base="b", + custom_llm_provider="azure_ai", + extra_headers={}, + timeout=30.0, + ) + assert result is None + + +def test_messages_wrapper_forwards_args_and_converts_timeout(): + bridge = RecordingMessages() + litellm.use_litellm_rust(True, messages=bridge) + + response = rust_messages.messages( + model="claude-sonnet-4-5", + body=REQUEST_BODY, + api_key="sk-azure", + api_base="https://resource.services.ai.azure.com/anthropic", + custom_llm_provider="azure_ai", + extra_headers={"anthropic-beta": "token-efficient-tools-2025-02-19"}, + timeout=httpx.Timeout(600.0, read=42.0), + ) + + assert response == FAKE_MESSAGES_RESPONSE + assert bridge.calls[0] == { + "model": "claude-sonnet-4-5", + "body": REQUEST_BODY, + "api_key": "sk-azure", + "api_base": "https://resource.services.ai.azure.com/anthropic", + "custom_llm_provider": "azure_ai", + "extra_headers": {"anthropic-beta": "token-efficient-tools-2025-02-19"}, + "timeout_seconds": 42.0, + } + + +@pytest.mark.asyncio +async def test_amessages_wrapper_forwards_args(): + bridge = RecordingAsyncMessages() + litellm.use_litellm_rust(True, amessages=bridge) + + response = await rust_messages.amessages( + model="claude-sonnet-4-5", + body=REQUEST_BODY, + api_key="sk-azure", + api_base="https://resource.services.ai.azure.com/anthropic", + custom_llm_provider="azure_ai", + extra_headers=None, + timeout=12.5, + ) + + assert response == FAKE_MESSAGES_RESPONSE + assert bridge.calls[0]["model"] == "claude-sonnet-4-5" + assert bridge.calls[0]["timeout_seconds"] == 12.5 + + +def _gate(**overrides): + kwargs = { + "custom_llm_provider": "azure_ai", + "litellm_params": GenericLiteLLMParams(api_key="sk-azure", rust=True), + "stream": False, + "rust_stream_eligible": False, + "model": "claude-sonnet-4-5", + "api_key": "sk-azure", + "api_base": "https://resource.services.ai.azure.com/anthropic", + "headers": {"x-api-key": "sk-azure", "anthropic-version": "2023-06-01"}, + "request_body": dict(REQUEST_BODY), + "timeout": 30.0, + } + kwargs.update(overrides) + return BaseLLMHTTPHandler._maybe_rust_anthropic_messages(**kwargs) + + +@pytest.mark.asyncio +async def test_gate_invokes_rust_and_marks_response_header(): + bridge = RecordingAsyncMessages() + litellm.use_litellm_rust(True, amessages=bridge) + + response = await _gate() + + assert response is not None + assert response["id"] == "msg_123" + assert response["_hidden_params"]["additional_headers"] == {"x-litellm-rust": "true"} + call = bridge.calls[0] + assert call["model"] == "claude-sonnet-4-5" + assert call["body"] == REQUEST_BODY + assert call["api_key"] == "sk-azure" + assert call["api_base"] == "https://resource.services.ai.azure.com/anthropic" + assert call["extra_headers"] == {"x-api-key": "sk-azure", "anthropic-version": "2023-06-01"} + assert call["timeout_seconds"] == 30.0 + + +@pytest.mark.asyncio +async def test_gate_falls_back_to_python_when_bridge_raises(): + bridge = RaisingAsyncMessages() + litellm.use_litellm_rust(True, amessages=bridge) + + response = await _gate() + + assert response is None + assert bridge.calls == 1 + + +@pytest.mark.asyncio +async def test_gate_skips_rust_when_flag_absent(): + bridge = ExplodingAsyncMessages() + litellm.use_litellm_rust(True, amessages=bridge) + + response = await _gate(litellm_params=GenericLiteLLMParams(api_key="sk-azure")) + + assert response is None + assert bridge.calls == 0 + + +@pytest.mark.asyncio +async def test_gate_skips_rust_when_flag_false(): + bridge = ExplodingAsyncMessages() + litellm.use_litellm_rust(True, amessages=bridge) + + response = await _gate(litellm_params=GenericLiteLLMParams(api_key="sk-azure", rust=False)) + + assert response is None + assert bridge.calls == 0 + + +@pytest.mark.asyncio +async def test_gate_skips_rust_for_non_azure_provider(): + bridge = ExplodingAsyncMessages() + litellm.use_litellm_rust(True, amessages=bridge) + + response = await _gate(custom_llm_provider="anthropic") + + assert response is None + assert bridge.calls == 0 + + +@pytest.mark.asyncio +async def test_gate_skips_rust_when_streaming_but_not_eligible(): + bridge = ExplodingAsyncMessages() + litellm.use_litellm_rust(True, amessages=bridge) + + response = await _gate(stream=True, rust_stream_eligible=False) + + assert response is None + assert bridge.calls == 0 + + +@pytest.mark.asyncio +async def test_gate_streams_through_rust_when_eligible_and_strips_stream_flag(): + bridge = RecordingAsyncMessages() + litellm.use_litellm_rust(True, amessages=bridge) + + streaming_body = {**REQUEST_BODY, "stream": True} + response = await _gate( + stream=True, + rust_stream_eligible=True, + request_body=streaming_body, + ) + + assert response is not None + assert response["_hidden_params"]["additional_headers"] == {"x-litellm-rust": "true"} + assert "stream" not in bridge.calls[0]["body"] + assert bridge.calls[0]["body"] == REQUEST_BODY + + +@pytest.mark.asyncio +async def test_fake_stream_wraps_rust_response_as_anthropic_sse(): + response = cast(AnthropicMessagesResponse, dict(FAKE_MESSAGES_RESPONSE)) + stream = BaseLLMHTTPHandler._rust_anthropic_messages_fake_stream(response) + + assert stream._hidden_params["additional_headers"] == {"x-litellm-rust": "true"} + + chunks = [chunk async for chunk in stream] + joined = b"".join(chunks) + + assert b"event: message_start" in joined + assert b"event: content_block_delta" in joined + assert b"hello world" in joined + assert b"event: message_stop" in joined + + +@pytest.mark.asyncio +async def test_gate_falls_back_when_bridge_unavailable(monkeypatch): + monkeypatch.setattr( + importlib.import_module("litellm.rust_bridge"), + "get_native_bridge", + lambda: None, + ) + litellm.use_litellm_rust(True) + + response = await _gate() + + assert response is None diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py b/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py index 48190a798da..6b1da4c2952 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_emitter.py @@ -16,10 +16,12 @@ from litellm.integrations.otel import ( # noqa: E402 from litellm.integrations.otel.plumbing import context as ctx_mod # noqa: E402 from litellm.integrations.otel.plumbing import providers # noqa: E402 from litellm.integrations.otel.emitter import SpanEmitter # noqa: E402 +from litellm.integrations.otel.emitter import stamp_error # noqa: E402 from litellm.integrations.otel.model.payloads import ( # noqa: E402 GuardrailSpanData, LLMCallSpanData, ServiceSpanData, + SpanError, ) from litellm.integrations.otel.model.spans import SPAN_REGISTRY, SpanRole # noqa: E402 @@ -155,6 +157,46 @@ def test_error_span_sets_status_and_error_type(): assert span.attributes["error.type"] == "RateLimitError" +def test_stamp_error_writes_full_attribute_set_and_event(): + engine, exporter = _engine() + span = engine.start_span(SpanRole.PROXY_REQUEST, "POST /chat/completions") + result = stamp_error( + span, SpanError("ProxyException", "boom", code="401", stack_trace="tb", llm_provider="anthropic") + ) + span.end() + (s,) = exporter.get_finished_spans() + assert result == ("ProxyException", "boom") + assert s.attributes["error.type"] == "ProxyException" + assert s.attributes["error.message"] == "boom" + assert s.attributes["litellm.provider.error.code"] == "401" + assert s.attributes["litellm.provider.error.stack_trace"] == "tb" + assert s.attributes["litellm.provider.error.llm_provider"] == "anthropic" + assert s.status.status_code is StatusCode.ERROR + assert [e.name for e in s.events] == ["exception"] + + +def test_stamp_error_opt_outs_skip_status_and_event(): + engine, exporter = _engine() + span = engine.start_span(SpanRole.PROXY_REQUEST, "POST /chat/completions") + stamp_error(span, SpanError("ProxyException", "boom", code="401"), record_event=False, set_status=False) + span.end() + (s,) = exporter.get_finished_spans() + assert s.attributes["error.type"] == "ProxyException" + assert s.attributes["litellm.provider.error.code"] == "401" + assert s.status.status_code is StatusCode.UNSET + assert s.events == () + + +def test_stamp_error_without_type_or_message_is_noop(): + engine, exporter = _engine() + span = engine.start_span(SpanRole.PROXY_REQUEST, "POST /chat/completions") + assert stamp_error(span, SpanError()) is None + span.end() + (s,) = exporter.get_finished_spans() + assert "error.type" not in s.attributes + assert s.status.status_code is StatusCode.UNSET + + def test_hierarchy_and_kinds_match_registry(): engine, exporter = _engine() data = LLMCallSpanData.from_standard_logging_payload(_payload()) diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_logger.py b/tests/test_litellm/integrations/otel/test_otel_v2_logger.py index b5e077e3561..5f6002f4cdf 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_logger.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_logger.py @@ -860,6 +860,120 @@ def test_guardrail_span_anchors_to_root_inside_active_phase_span(): assert guard.parent.span_id != auth_span.get_span_context().span_id +# --------------------------------------------------------------------------- # +# LIT-4179 — proxy-level failures that never reach an LLM call must still stamp +# the structured error.* attributes onto the request's spans, restoring the v1 +# behavior v2 dropped when it stopped subclassing ``OpenTelemetry``. +# --------------------------------------------------------------------------- # + + +def _proxy_exc(message, code): + from litellm.proxy._types import ProxyException + + return ProxyException(message=message, type="bad_request_error", param=None, code=code) + + +def test_async_post_call_failure_hook_stamps_error_on_root_span(): + """PATH B: an endpoint-level failure (empty body rejected before dispatch) + reaches ``async_post_call_failure_hook``; it must stamp error.* + an exception + event on the anchored request root span.""" + from litellm.proxy._types import UserAPIKeyAuth + + logger, exporter = _logger() + server = logger._emitter.start_span(SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME) + set_request_root_span(server) + exc = _proxy_exc("litellm.BadRequestError: messages is required", 400) + result = asyncio.run( + logger.async_post_call_failure_hook( + request_data={}, original_exception=exc, user_api_key_dict=UserAPIKeyAuth() + ) + ) + server.end() + assert result is None + (span,) = exporter.get_finished_spans() + assert span.attributes["error.type"] == "ProxyException" + assert "messages is required" in span.attributes["error.message"] + assert span.attributes["litellm.provider.error.code"] == "400" + assert span.status.status_code is StatusCode.ERROR + assert any(e.name == "exception" for e in span.events) + + +def test_async_post_call_failure_hook_falls_back_to_user_api_key_parent_span(): + """With no anchor set (a path that never captured the root), the hook must fall + back to ``user_api_key_dict.parent_otel_span`` rather than dropping the error.""" + from litellm.proxy._types import UserAPIKeyAuth + + logger, exporter = _logger() + server = logger._emitter.start_span(SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME) + asyncio.run( + logger.async_post_call_failure_hook( + request_data={}, + original_exception=_proxy_exc("boom", 401), + user_api_key_dict=UserAPIKeyAuth(parent_otel_span=server), + ) + ) + server.end() + (span,) = exporter.get_finished_spans() + assert span.attributes["error.type"] == "ProxyException" + assert span.attributes["litellm.provider.error.code"] == "401" + + +def test_record_error_attributes_on_span_decorates_without_ending(): + """PATH A: a failure that dies before any LLM-call span (malformed body, + validation) is stamped onto the instrumentor-owned SERVER span. The method must + not end the span or emit a duplicate exception event, and must pin error.code + to the real response status (not the exception's own code).""" + logger, exporter = _logger() + server = logger._emitter.start_span(SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME) + logger.record_error_attributes_on_span(server, _proxy_exc("Invalid JSON body", 400), 422) + assert server.is_recording() + server.end() + (span,) = exporter.get_finished_spans() + assert span.attributes["error.type"] == "ProxyException" + assert span.attributes["error.message"] == "Invalid JSON body" + assert span.attributes["litellm.provider.error.code"] == "422" + assert all(e.name != "exception" for e in span.events) + + +def test_record_error_attributes_on_span_ignores_below_400_and_missing_span(): + logger, _ = _logger() + server = logger._emitter.start_span(SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME) + logger.record_error_attributes_on_span(None, _proxy_exc("boom", 400), 400) # no span → no-op + logger.record_error_attributes_on_span(server, None, 400) # no exception → no-op + server.end() + assert "error.type" not in (server.attributes or {}) + + +def test_start_phase_span_stamps_error_attributes_on_failure(): + """An ``auth`` phase span that dies (expired key) must carry the structured + error.* attributes, not only the exception event ``use_span`` records.""" + logger, exporter = _logger() + server = logger._emitter.start_span(SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME) + set_request_root_span(server) + exc = _proxy_exc("Authentication Error, ExpiredToken", 401) + with trace.use_span(server, end_on_exit=False): + with contextlib.suppress(Exception): + with logger.start_phase_span("auth /chat/completions"): + raise exc + server.end() + by_name = {s.name: s for s in exporter.get_finished_spans()} + auth = by_name["auth /chat/completions"] + assert auth.attributes["error.type"] == "ProxyException" + assert "ExpiredToken" in auth.attributes["error.message"] + assert auth.attributes["litellm.provider.error.code"] == "401" + assert auth.status.status_code is StatusCode.ERROR + assert any(e.name == "exception" for e in auth.events) + + +def test_start_phase_span_success_carries_no_error(): + logger, exporter = _logger() + with logger.start_phase_span("auth /chat/completions"): + pass + (span,) = exporter.get_finished_spans() + assert "error.type" not in span.attributes + assert span.status.status_code is not StatusCode.ERROR + + def test_real_logging_pre_call_opens_span_end_to_end(): """Regression guard: a real ``LiteLLMLoggingObj.pre_call`` must fire ``log_pre_api_call`` on the V2 logger (via ``litellm.input_callback``), so the diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py index 17960e917a4..707374e7061 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py @@ -21,9 +21,12 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( ApiKeyConfig, AuthorizationCodeConfig, + ClientSecretAuth, CredError, + IdJagConfig, NoneConfig, PassthroughConfig, + PrivateKeyJwtAuth, SharedKey, TokenExchangeConfig, ) @@ -35,6 +38,21 @@ def _server(**kwargs) -> MCPServer: return MCPServer(server_id="s", name="n", transport=MCPTransport.http, **kwargs) +def _id_jag_server(**overrides) -> MCPServer: + defaults = dict( + auth_type=MCPAuth.oauth2_id_jag, + url="https://mcp.example.com/mcp", + client_id="litellm-client-id", + client_secret="litellm-client-secret", + token_exchange_endpoint="https://idp.example.com/token", + id_jag_resource_token_endpoint="https://mcp-as.example.com/token", + audience="api://mcp-server", + scopes=["mcp.read", "mcp.write"], + ) + defaults.update(overrides) + return _server(**defaults) + + def test_none_maps_to_none_config(): spec = to_server_spec(_server(auth_type=None)) assert spec is not None @@ -413,3 +431,63 @@ def test_raise_token_exchange_challenge_uses_insufficient_claims_with_claims_pre assert 'error="invalid_token"' not in www assert f'claims="{base64.b64encode(claims.encode()).decode()}"' in www assert claims not in www # raw JSON never appears; only the base64 form + + +def test_id_jag_client_secret_maps_to_config(): + spec = to_server_spec(_id_jag_server()) + assert spec is not None and isinstance(spec.config, IdJagConfig) + assert spec.config.org_token_endpoint == "https://idp.example.com/token" + assert spec.config.resource_token_endpoint == "https://mcp-as.example.com/token" + assert spec.config.client_id == "litellm-client-id" + assert spec.config.audience == "api://mcp-server" + assert spec.config.scopes == ("mcp.read", "mcp.write") + # ID-JAG asserts the user's id_token; the access_token default maps to id_token. + assert spec.config.subject_token_type == "urn:ietf:params:oauth:token-type:id_token" + assert isinstance(spec.config.client_auth, ClientSecretAuth) + assert spec.config.client_auth.client_secret.get_secret_value() == ( + "litellm-client-secret" + ) + + +def test_id_jag_private_key_maps_to_private_key_jwt_auth(): + spec = to_server_spec( + _id_jag_server( + client_secret=None, + client_private_key="PEM-DATA", + client_private_key_id="kid-1", + client_assertion_signing_alg="RS384", + ) + ) + assert spec is not None and isinstance(spec.config, IdJagConfig) + assert isinstance(spec.config.client_auth, PrivateKeyJwtAuth) + assert spec.config.client_auth.private_key.get_secret_value() == "PEM-DATA" + assert spec.config.client_auth.key_id == "kid-1" + assert spec.config.client_auth.signing_alg == "RS384" + + +def test_id_jag_private_key_wins_over_client_secret(): + spec = to_server_spec(_id_jag_server(client_private_key="PEM-DATA")) + assert spec is not None and isinstance(spec.config, IdJagConfig) + assert isinstance(spec.config.client_auth, PrivateKeyJwtAuth) + + +def test_id_jag_honors_explicit_subject_token_type(): + spec = to_server_spec( + _id_jag_server(subject_token_type="urn:ietf:params:oauth:token-type:saml2") + ) + assert spec is not None and isinstance(spec.config, IdJagConfig) + assert spec.config.subject_token_type == "urn:ietf:params:oauth:token-type:saml2" + + +@pytest.mark.parametrize( + "server", + [ + _id_jag_server(token_exchange_endpoint=None), + _id_jag_server(id_jag_resource_token_endpoint=None), + _id_jag_server(client_id=None), + _id_jag_server(client_secret=None, client_private_key=None), + ], +) +def test_id_jag_half_configured_defers_to_v1(server): + # A half-configured server must defer (None) rather than 500 at IdJagConfig construction. + assert to_server_spec(server) is None diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py index c88027abcd4..ba7720ffd51 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py @@ -16,8 +16,10 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials import ( AwsSigV4Config, Byok, ClientCredentialsConfig, + ClientSecretAuth, CredError, Error, + IdJagConfig, NoneConfig, NoOpAuth, Ok, @@ -34,10 +36,40 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_sto OAuthToken, TokenStoreUnavailable, ) +from litellm.proxy._experimental.mcp_server.outbound_credentials.token_endpoint import ( + ExchangedToken, +) _SUBJECT = Subject(tenant_id="", subject_id="") +def _id_jag_config() -> IdJagConfig: + return IdJagConfig( + org_token_endpoint="https://idp.example.com/token", + resource_token_endpoint="https://mcp-as.example.com/token", + client_id="litellm", + client_auth=ClientSecretAuth(client_secret=SecretStr("s")), + audience="api://mcp", + scopes=("mcp.read",), + ) + + +class _FakeTokenEndpoint: + """Records each fetch and returns the next canned Result, leg by leg.""" + + def __init__(self, results: list[Result[ExchangedToken, CredError]]) -> None: + self._results = list(results) + self.calls: list[tuple[str, str, dict[str, str]]] = [] + + async def fetch(self, endpoint, client_id, grant_params, client_auth): + self.calls.append((endpoint, client_id, dict(grant_params))) + return self._results.pop(0) + + +def _with_inbound(token: str) -> Subject: + return Subject(tenant_id="", subject_id="alice", inbound_token=SecretStr(token)) + + def _spec(config): return ServerSpec(server_id="s", resource="https://upstream.example.com", config=config) @@ -305,3 +337,183 @@ async def test_unbuilt_arms_fail_closed_with_not_implemented(label, config): result = await UpstreamCredentialProvider().resolve_credentials(_SUBJECT, _spec(config)) assert isinstance(result, Error) assert result.error.tag == "not_implemented" + + +@pytest.mark.asyncio +async def test_id_jag_runs_both_legs_and_returns_the_leg2_bearer(): + endpoint = _FakeTokenEndpoint( + [ + Ok(ExchangedToken(access_token="the-id-jag", expires_in=300)), + Ok(ExchangedToken(access_token="final-access", expires_in=3600)), + ] + ) + provider = UpstreamCredentialProvider(token_endpoint=endpoint) + result = await provider.resolve_credentials( + _with_inbound("user-id-token"), _spec(_id_jag_config()) + ) + + assert isinstance(result, Ok) + assert _emitted(result.ok)["Authorization"] == "Bearer final-access" + + leg1_endpoint, _, leg1_params = endpoint.calls[0] + assert leg1_endpoint == "https://idp.example.com/token" + assert ( + leg1_params["grant_type"] == "urn:ietf:params:oauth:grant-type:token-exchange" + ) + assert ( + leg1_params["requested_token_type"] == "urn:ietf:params:oauth:token-type:id-jag" + ) + assert leg1_params["subject_token"] == "user-id-token" + + leg2_endpoint, _, leg2_params = endpoint.calls[1] + assert leg2_endpoint == "https://mcp-as.example.com/token" + assert leg2_params["grant_type"] == "urn:ietf:params:oauth:grant-type:jwt-bearer" + # The leg-1 token is forwarded verbatim as the leg-2 assertion. + assert leg2_params["assertion"] == "the-id-jag" + + +@pytest.mark.asyncio +async def test_id_jag_without_inbound_token_is_precondition_required_no_http(): + endpoint = _FakeTokenEndpoint([]) + provider = UpstreamCredentialProvider(token_endpoint=endpoint) + result = await provider.resolve_credentials( + Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()) + ) + + assert isinstance(result, Error) + assert result.error.tag == "precondition_required" + assert endpoint.calls == [] + + +@pytest.mark.asyncio +async def test_id_jag_propagates_a_leg1_error_without_calling_leg2(): + endpoint = _FakeTokenEndpoint( + [Error(CredError.of_upstream_unavailable("leg1 down"))] + ) + provider = UpstreamCredentialProvider(token_endpoint=endpoint) + result = await provider.resolve_credentials( + _with_inbound("user-id-token"), _spec(_id_jag_config()) + ) + + assert isinstance(result, Error) + assert result.error.tag == "upstream_unavailable" + assert "leg1 down" in result.error.summary + assert len(endpoint.calls) == 1 + + +@pytest.mark.asyncio +async def test_id_jag_propagates_a_leg2_error(): + endpoint = _FakeTokenEndpoint( + [ + Ok(ExchangedToken(access_token="the-id-jag", expires_in=300)), + Error(CredError.of_upstream_unavailable("leg2 forbidden")), + ] + ) + provider = UpstreamCredentialProvider(token_endpoint=endpoint) + result = await provider.resolve_credentials( + _with_inbound("user-id-token"), _spec(_id_jag_config()) + ) + + assert isinstance(result, Error) + assert result.error.tag == "upstream_unavailable" + assert "leg2 forbidden" in result.error.summary + assert len(endpoint.calls) == 2 + + +def _two_leg_ok(bearer: str) -> list: + return [ + Ok(ExchangedToken(access_token="the-id-jag", expires_in=300)), + Ok(ExchangedToken(access_token=bearer, expires_in=3600)), + ] + + +@pytest.mark.asyncio +async def test_id_jag_reuses_the_cached_bearer_for_an_unchanged_config(): + endpoint = _FakeTokenEndpoint(_two_leg_ok("first-bearer")) + provider = UpstreamCredentialProvider(token_endpoint=endpoint) + + first = await provider.resolve_credentials(_with_inbound("user-id-token"), _spec(_id_jag_config())) + second = await provider.resolve_credentials(_with_inbound("user-id-token"), _spec(_id_jag_config())) + + assert isinstance(first, Ok) and isinstance(second, Ok) + assert _emitted(second.ok)["Authorization"] == "Bearer first-bearer" + assert len(endpoint.calls) == 2 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "changed", + [ + _id_jag_config().model_copy(update={"audience": "api://other"}), + _id_jag_config().model_copy(update={"resource": "https://other.example.com/mcp"}), + _id_jag_config().model_copy(update={"scopes": ("mcp.read", "mcp.write")}), + _id_jag_config().model_copy(update={"org_token_endpoint": "https://idp.example.com/v2/token"}), + _id_jag_config().model_copy(update={"resource_token_endpoint": "https://mcp-as.example.com/v2/token"}), + _id_jag_config().model_copy(update={"client_id": "litellm-rotated"}), + _id_jag_config().model_copy(update={"client_auth": ClientSecretAuth(client_secret=SecretStr("rotated"))}), + _id_jag_config().model_copy(update={"subject_token_type": "urn:ietf:params:oauth:token-type:saml2"}), + ], + ids=[ + "audience", + "resource", + "scopes", + "org_token_endpoint", + "resource_token_endpoint", + "client_id", + "client_auth", + "subject_token_type", + ], +) +async def test_id_jag_config_change_forces_a_fresh_exchange(changed): + endpoint = _FakeTokenEndpoint(_two_leg_ok("old-policy-bearer") + _two_leg_ok("new-policy-bearer")) + provider = UpstreamCredentialProvider(token_endpoint=endpoint) + + before = await provider.resolve_credentials(_with_inbound("user-id-token"), _spec(_id_jag_config())) + after = await provider.resolve_credentials(_with_inbound("user-id-token"), _spec(changed)) + + assert isinstance(before, Ok) and isinstance(after, Ok) + assert _emitted(after.ok)["Authorization"] == "Bearer new-policy-bearer" + assert len(endpoint.calls) == 4 + + +@pytest.mark.asyncio +async def test_id_jag_does_not_share_the_cached_bearer_across_caller_tokens(): + endpoint = _FakeTokenEndpoint(_two_leg_ok("alice-bearer") + _two_leg_ok("bob-bearer")) + provider = UpstreamCredentialProvider(token_endpoint=endpoint) + + alice = await provider.resolve_credentials(_with_inbound("alice-id-token"), _spec(_id_jag_config())) + bob = await provider.resolve_credentials(_with_inbound("bob-id-token"), _spec(_id_jag_config())) + + assert isinstance(alice, Ok) and isinstance(bob, Ok) + assert _emitted(bob.ok)["Authorization"] == "Bearer bob-bearer" + assert len(endpoint.calls) == 4 + + +@pytest.mark.asyncio +async def test_invalidate_credentials_evicts_the_id_jag_bearer_so_the_next_resolve_re_exchanges(): + endpoint = _FakeTokenEndpoint(_two_leg_ok("rejected-bearer") + _two_leg_ok("fresh-bearer")) + provider = UpstreamCredentialProvider(token_endpoint=endpoint) + subject = _with_inbound("user-id-token") + + first = await provider.resolve_credentials(subject, _spec(_id_jag_config())) + await provider.invalidate_credentials(subject, _spec(_id_jag_config())) + second = await provider.resolve_credentials(subject, _spec(_id_jag_config())) + + assert isinstance(first, Ok) and isinstance(second, Ok) + assert _emitted(second.ok)["Authorization"] == "Bearer fresh-bearer" + assert len(endpoint.calls) == 4 + + +@pytest.mark.asyncio +async def test_invalidate_credentials_for_id_jag_is_a_noop_without_a_caller_token(): + endpoint = _FakeTokenEndpoint(_two_leg_ok("cached-bearer")) + provider = UpstreamCredentialProvider(token_endpoint=endpoint) + subject = _with_inbound("user-id-token") + + first = await provider.resolve_credentials(subject, _spec(_id_jag_config())) + await provider.invalidate_credentials(Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config())) + second = await provider.resolve_credentials(subject, _spec(_id_jag_config())) + + assert isinstance(first, Ok) and isinstance(second, Ok) + assert _emitted(second.ok)["Authorization"] == "Bearer cached-bearer" + assert len(endpoint.calls) == 2 diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_token_endpoint.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_token_endpoint.py new file mode 100644 index 00000000000..f100bd56f8f --- /dev/null +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_token_endpoint.py @@ -0,0 +1,408 @@ +"""Tests for the v2 token-endpoint collaborator. + +`TokenEndpointClient.fetch` makes one authenticated POST and returns the minted token as a value; +`ExchangedTokenCache` memoizes it with per-key single-flight. These pin the grant/client-auth wire +shape, the private-key-JWT vs client_secret authentication, the error-as-value mapping, and the +cache's hit/single-flight behavior. Each assertion fails under a real mutation of the feature. +""" + +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import jwt +import litellm +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + +from litellm.proxy._experimental.mcp_server.outbound_credentials.result import ( + Error, + Ok, + Result, +) +from litellm.proxy._experimental.mcp_server.outbound_credentials.token_endpoint import ( + CLIENT_ASSERTION_TYPE, + ExchangedToken, + ExchangedTokenCache, + TokenEndpointClient, +) +from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( + ClientSecretAuth, + CredError, + PrivateKeyJwtAuth, +) +from pydantic import SecretStr + +_PATCH_TARGET = ( + "litellm.proxy._experimental.mcp_server.outbound_credentials." + "token_endpoint.get_async_httpx_client" +) + +_ENDPOINT = "https://idp.example.com/oauth2/token" +_CLIENT_ID = "litellm-client-id" + +_RSA_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) +_PRIVATE_PEM = _RSA_KEY.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), +).decode() +_PUBLIC_PEM = ( + _RSA_KEY.public_key() + .public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode() +) + + +def _resp(token="access", expires_in=3600): + resp = MagicMock() + resp.json.return_value = {"access_token": token, "expires_in": expires_in} + resp.raise_for_status = MagicMock() + return resp + + +def _client(response): + client = AsyncMock() + client.post.return_value = response + return client + + +def _posted_data(client): + return client.post.call_args.kwargs["data"] + + +@pytest.mark.asyncio +async def test_fetch_forwards_grant_params_and_client_secret(): + client = _client(_resp("the-token", expires_in=1200)) + with patch(_PATCH_TARGET, return_value=client): + result = await TokenEndpointClient().fetch( + _ENDPOINT, + _CLIENT_ID, + {"grant_type": "g", "subject_token": "user-jwt"}, + ClientSecretAuth(client_secret=SecretStr("shhh")), + ) + + assert isinstance(result, Ok) + assert result.ok == ExchangedToken(access_token="the-token", expires_in=1200) + assert client.post.call_args.args[0] == _ENDPOINT + data = _posted_data(client) + assert data["grant_type"] == "g" + assert data["subject_token"] == "user-jwt" + assert data["client_id"] == _CLIENT_ID + assert data["client_secret"] == "shhh" + assert "client_assertion" not in data + + +@pytest.mark.asyncio +async def test_fetch_private_key_jwt_client_assertion(): + client = _client(_resp()) + with patch(_PATCH_TARGET, return_value=client): + await TokenEndpointClient().fetch( + _ENDPOINT, + _CLIENT_ID, + {"grant_type": "g"}, + PrivateKeyJwtAuth( + private_key=SecretStr(_PRIVATE_PEM), + key_id="kid-1", + signing_alg="RS256", + ), + ) + + data = _posted_data(client) + assert data["client_assertion_type"] == CLIENT_ASSERTION_TYPE + assert "client_secret" not in data + decoded = jwt.decode( + data["client_assertion"], + _PUBLIC_PEM, + algorithms=["RS256"], + audience=_ENDPOINT, + ) + assert decoded["iss"] == _CLIENT_ID + assert decoded["sub"] == _CLIENT_ID + assert decoded["aud"] == _ENDPOINT + assert "exp" in decoded + assert jwt.get_unverified_header(data["client_assertion"])["kid"] == "kid-1" + + +@pytest.mark.asyncio +async def test_fetch_http_error_maps_to_upstream_unavailable_with_status(): + error_resp = MagicMock() + error_resp.status_code = 403 + error_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "Forbidden", request=MagicMock(), response=error_resp + ) + with patch(_PATCH_TARGET, return_value=_client(error_resp)): + result = await TokenEndpointClient().fetch( + _ENDPOINT, + _CLIENT_ID, + {"grant_type": "g"}, + ClientSecretAuth(client_secret=SecretStr("s")), + ) + + assert isinstance(result, Error) + assert result.error.tag == "upstream_unavailable" + assert "403" in result.error.summary + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "raised", + [ + httpx.ConnectError("connection refused", request=MagicMock()), + httpx.ReadTimeout("timed out", request=MagicMock()), + litellm.Timeout( + message="Connection timed out", + model="default-model-name", + llm_provider="litellm-httpx-handler", + ), + ], +) +async def test_fetch_network_error_maps_to_upstream_unavailable(raised): + client = AsyncMock() + client.post.side_effect = raised + with patch(_PATCH_TARGET, return_value=client): + result = await TokenEndpointClient().fetch( + _ENDPOINT, + _CLIENT_ID, + {"grant_type": "g"}, + ClientSecretAuth(client_secret=SecretStr("s")), + ) + + assert isinstance(result, Error) + assert result.error.tag == "upstream_unavailable" + assert _ENDPOINT not in result.error.summary + assert "idp.example.com" not in result.error.summary + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "auth", + [ + PrivateKeyJwtAuth(private_key=SecretStr("not-a-pem-key"), signing_alg="RS256"), + PrivateKeyJwtAuth(private_key=SecretStr(_PRIVATE_PEM), signing_alg="XX999"), + ], + ids=["garbage-key", "unknown-alg"], +) +async def test_fetch_unsignable_client_assertion_is_misconfigured_not_a_crash(auth): + client = AsyncMock() + with patch(_PATCH_TARGET, return_value=client): + result = await TokenEndpointClient().fetch( + _ENDPOINT, + _CLIENT_ID, + {"grant_type": "g"}, + auth, + ) + + assert isinstance(result, Error) + assert result.error.tag == "misconfigured" + client.post.assert_not_called() + assert _ENDPOINT not in result.error.summary + assert "idp.example.com" not in result.error.summary + + +@pytest.mark.asyncio +async def test_fetch_invalid_json_maps_to_upstream_unavailable(): + bad = MagicMock() + bad.raise_for_status = MagicMock() + bad.json.side_effect = json.JSONDecodeError("Expecting value", "", 0) + with patch(_PATCH_TARGET, return_value=_client(bad)): + result = await TokenEndpointClient().fetch( + _ENDPOINT, + _CLIENT_ID, + {"grant_type": "g"}, + ClientSecretAuth(client_secret=SecretStr("s")), + ) + + assert isinstance(result, Error) + assert result.error.tag == "upstream_unavailable" + assert _ENDPOINT not in result.error.summary + assert "idp.example.com" not in result.error.summary + + +@pytest.mark.asyncio +async def test_fetch_none_response_is_upstream_unavailable(): + with patch(_PATCH_TARGET, return_value=_client(None)): + result = await TokenEndpointClient().fetch( + _ENDPOINT, + _CLIENT_ID, + {"grant_type": "g"}, + ClientSecretAuth(client_secret=SecretStr("s")), + ) + + assert isinstance(result, Error) + assert result.error.tag == "upstream_unavailable" + + +@pytest.mark.asyncio +async def test_fetch_missing_access_token_is_upstream_unavailable(): + bad = MagicMock() + bad.json.return_value = {"token_type": "Bearer"} + bad.raise_for_status = MagicMock() + with patch(_PATCH_TARGET, return_value=_client(bad)): + result = await TokenEndpointClient().fetch( + _ENDPOINT, + _CLIENT_ID, + {"grant_type": "g"}, + ClientSecretAuth(client_secret=SecretStr("s")), + ) + + assert isinstance(result, Error) + assert result.error.tag == "upstream_unavailable" + + +@pytest.mark.asyncio +async def test_fetch_http_error_does_not_leak_endpoint_url(): + error_resp = MagicMock() + error_resp.status_code = 403 + error_resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "Forbidden", request=MagicMock(), response=error_resp + ) + with patch(_PATCH_TARGET, return_value=_client(error_resp)): + result = await TokenEndpointClient().fetch( + _ENDPOINT, + _CLIENT_ID, + {"grant_type": "g"}, + ClientSecretAuth(client_secret=SecretStr("s")), + ) + + assert isinstance(result, Error) + assert _ENDPOINT not in result.error.summary + assert "idp.example.com" not in result.error.summary + + +@pytest.mark.asyncio +async def test_fetch_none_response_does_not_leak_endpoint_url(): + with patch(_PATCH_TARGET, return_value=_client(None)): + result = await TokenEndpointClient().fetch( + _ENDPOINT, + _CLIENT_ID, + {"grant_type": "g"}, + ClientSecretAuth(client_secret=SecretStr("s")), + ) + + assert isinstance(result, Error) + assert _ENDPOINT not in result.error.summary + assert "idp.example.com" not in result.error.summary + + +@pytest.mark.asyncio +async def test_fetch_missing_access_token_does_not_leak_endpoint_url(): + bad = MagicMock() + bad.json.return_value = {"token_type": "Bearer"} + bad.raise_for_status = MagicMock() + with patch(_PATCH_TARGET, return_value=_client(bad)): + result = await TokenEndpointClient().fetch( + _ENDPOINT, + _CLIENT_ID, + {"grant_type": "g"}, + ClientSecretAuth(client_secret=SecretStr("s")), + ) + + assert isinstance(result, Error) + assert _ENDPOINT not in result.error.summary + assert "idp.example.com" not in result.error.summary + + +def _ok_token(value="cached") -> Result[ExchangedToken, CredError]: + return Ok(ExchangedToken(access_token=value, expires_in=3600)) + + +@pytest.mark.asyncio +async def test_cache_hit_skips_the_second_compute(): + cache = ExchangedTokenCache() + calls = 0 + + async def compute(): + nonlocal calls + calls += 1 + return _ok_token("tok") + + first = await cache.get_or_compute("k", compute) + second = await cache.get_or_compute("k", compute) + + assert isinstance(first, Ok) and first.ok == "tok" + assert isinstance(second, Ok) and second.ok == "tok" + assert calls == 1 + + +@pytest.mark.asyncio +async def test_cache_single_flights_concurrent_misses(): + cache = ExchangedTokenCache() + calls = 0 + + async def compute(): + nonlocal calls + calls += 1 + await asyncio.sleep(0.01) + return _ok_token("shared") + + results = await asyncio.gather( + cache.get_or_compute("k", compute), + cache.get_or_compute("k", compute), + ) + + assert [r.ok for r in results] == ["shared", "shared"] + assert calls == 1 + + +@pytest.mark.asyncio +async def test_cache_invalidate_forces_the_next_compute(): + cache = ExchangedTokenCache() + calls = 0 + + async def compute(): + nonlocal calls + calls += 1 + return _ok_token(f"tok-{calls}") + + first = await cache.get_or_compute("k", compute) + cache.invalidate("k") + second = await cache.get_or_compute("k", compute) + + assert isinstance(first, Ok) and first.ok == "tok-1" + assert isinstance(second, Ok) and second.ok == "tok-2" + assert calls == 2 + + +@pytest.mark.asyncio +async def test_cache_invalidate_only_evicts_the_named_key(): + cache = ExchangedTokenCache() + calls = 0 + + async def compute(): + nonlocal calls + calls += 1 + return _ok_token(f"tok-{calls}") + + await cache.get_or_compute("keep", compute) + await cache.get_or_compute("evict", compute) + cache.invalidate("evict") + kept = await cache.get_or_compute("keep", compute) + + assert isinstance(kept, Ok) and kept.ok == "tok-1" + assert calls == 2 + + +@pytest.mark.asyncio +async def test_cache_does_not_store_a_failed_compute(): + cache = ExchangedTokenCache() + calls = 0 + + async def compute(): + nonlocal calls + calls += 1 + if calls == 1: + return Error(CredError.of_upstream_unavailable("down")) + return _ok_token("recovered") + + first = await cache.get_or_compute("k", compute) + second = await cache.get_or_compute("k", compute) + + assert isinstance(first, Error) + assert isinstance(second, Ok) and second.ok == "recovered" + assert calls == 2 diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_types.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_types.py index 43b3612a5f2..bb25ab6bd3c 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_types.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_types.py @@ -17,10 +17,13 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials import ( AuthSpecKind, AwsSigV4Config, Byok, + ClientSecretAuth, CredError, Error, + IdJagConfig, NoneConfig, Ok, + PrivateKeyJwtAuth, ServerSpec, SharedKey, StaticKeys, @@ -29,6 +32,14 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials import ( _AUTH_CONFIG = TypeAdapter(AuthConfig) +_ID_JAG_MINIMAL = { + "kind": "id_jag", + "org_token_endpoint": "https://idp.example.com/token", + "resource_token_endpoint": "https://mcp-as.example.com/token", + "client_id": "litellm", + "client_auth": {"source": "client_secret", "client_secret": "s"}, +} + def test_parse_auth_spec_kind_accepts_known_mode(): result = parse_auth_spec_kind("token_exchange") @@ -148,3 +159,73 @@ def test_secrets_do_not_leak_in_repr(): key = SharedKey(value=SecretStr("super-secret")) assert "super-secret" not in repr(key) assert key.value.get_secret_value() == "super-secret" + + +@pytest.mark.parametrize( + "missing", + ["org_token_endpoint", "resource_token_endpoint", "client_id", "client_auth"], +) +def test_id_jag_config_requires_each_endpoint_client_and_auth(missing): + payload = {k: v for k, v in _ID_JAG_MINIMAL.items() if k != missing} + with pytest.raises(ValidationError): + _AUTH_CONFIG.validate_python(payload) + + +def test_id_jag_client_auth_discriminates_on_source(): + by_secret = _AUTH_CONFIG.validate_python(_ID_JAG_MINIMAL) + assert isinstance(by_secret, IdJagConfig) + assert isinstance(by_secret.client_auth, ClientSecretAuth) + assert by_secret.client_auth.client_secret.get_secret_value() == "s" + + by_key = _AUTH_CONFIG.validate_python( + { + **_ID_JAG_MINIMAL, + "client_auth": { + "source": "private_key_jwt", + "private_key": "PEM", + "key_id": "kid-1", + "signing_alg": "RS384", + }, + } + ) + assert isinstance(by_key, IdJagConfig) + assert isinstance(by_key.client_auth, PrivateKeyJwtAuth) + assert by_key.client_auth.private_key.get_secret_value() == "PEM" + assert by_key.client_auth.key_id == "kid-1" + assert by_key.client_auth.signing_alg == "RS384" + + +def test_id_jag_client_auth_rejects_unknown_source(): + with pytest.raises(ValidationError): + _AUTH_CONFIG.validate_python( + {**_ID_JAG_MINIMAL, "client_auth": {"source": "mystery"}} + ) + + +def test_id_jag_config_defaults_id_token_subject_and_empty_optionals(): + config = _AUTH_CONFIG.validate_python(_ID_JAG_MINIMAL) + assert isinstance(config, IdJagConfig) + assert config.subject_token_type == "urn:ietf:params:oauth:token-type:id_token" + assert config.audience is None + assert config.resource is None + assert config.scopes == () + + +def test_id_jag_secrets_do_not_leak_in_repr(): + config = IdJagConfig( + org_token_endpoint="https://idp.example.com/token", + resource_token_endpoint="https://mcp-as.example.com/token", + client_id="litellm", + client_auth=PrivateKeyJwtAuth(private_key=SecretStr("super-secret-pem")), + ) + assert "super-secret-pem" not in repr(config) + + +def test_id_jag_server_spec_derives_auth_spec_kind(): + config = _AUTH_CONFIG.validate_python(_ID_JAG_MINIMAL) + spec = ServerSpec( + server_id="s", + resource="https://mcp.example.com/mcp", + config=config, + ) + assert spec.auth_spec_kind is AuthSpecKind.id_jag diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py index 7269774442b..56ca855c814 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_db_credentials.py @@ -19,6 +19,8 @@ import pytest from litellm.proxy._experimental.mcp_server.db import ( _decode_user_credential, _prepare_mcp_server_data, + decrypt_credentials, + encrypt_credentials, get_user_credential, get_user_oauth_credential, is_oauth_credential_expired, @@ -332,6 +334,29 @@ def _stored_value(prisma) -> str: return create_value +# ── MCP server credentials at rest ────────────────────────────────────────────── + + +def test_client_private_key_encrypted_at_rest(): + """An ID-JAG client_private_key is a secret and must be encrypted in the stored + credentials blob, never persisted in plaintext, and must round-trip back. The + pre-fix code left client_private_key out of encrypt_credentials, so it was stored + verbatim.""" + private_key = ( + "-----BEGIN PRIVATE KEY-----\nsensitive-rsa-material\n-----END PRIVATE KEY-----" + ) + credentials = {"client_secret": "shh", "client_private_key": private_key} + + encrypted = encrypt_credentials(dict(credentials), encryption_key=None) + assert encrypted["client_private_key"] != private_key + assert private_key not in encrypted["client_private_key"] + assert encrypted["client_secret"] != "shh" + + decrypted = decrypt_credentials(dict(encrypted)) + assert decrypted["client_private_key"] == private_key + assert decrypted["client_secret"] == "shh" + + # ── BYOK round-trip ─────────────────────────────────────────────────────────── @@ -978,3 +1003,67 @@ def test_prepare_mcp_server_data_update_carries_token_exchange_columns(): assert data["audience"] == "https://upstream.example.com" assert data["subject_token_type"] == "urn:ietf:params:oauth:token-type:jwt" assert data["token_exchange_profile"] == "entra_obo" + + +@pytest.mark.asyncio +async def test_master_key_rotation_reencrypts_oauth_client_store(monkeypatch): + """The server-scoped DCR client store (LiteLLM_MCPServerOAuthClient) is encrypted at rest, so a + master-key rotation must re-encrypt it alongside the server rows. Skipping it leaves + config-declared DCR clients under the retired key, where they decrypt back to ciphertext and + force a full re-authorization.""" + import litellm.proxy.common_utils.encrypt_decrypt_utils as enc + from litellm.litellm_core_utils.safe_json_dumps import safe_dumps + from litellm.proxy._experimental.mcp_server.db import ( + decrypt_credentials, + encrypt_credentials, + rotate_mcp_server_credentials_master_key, + ) + + key_old, key_new = "salt-old-key", "salt-new-key" + + blob_old = safe_dumps( + encrypt_credentials( + credentials={"client_id": "cid-123", "client_secret": "sec-456"}, + encryption_key=key_old, + ) + ) + + monkeypatch.setattr(enc, "_get_salt_key", lambda: key_old) + + prisma = MagicMock() + prisma.db.litellm_mcpservertable.find_many = AsyncMock(return_value=[]) + prisma.db.litellm_mcpserveroauthclient.find_many = AsyncMock( + return_value=[SimpleNamespace(server_id="config_faros", credentials=blob_old)] + ) + store_update = AsyncMock() + prisma.db.litellm_mcpserveroauthclient.update = store_update + + await rotate_mcp_server_credentials_master_key(prisma, touched_by="test", new_master_key=key_new) + + store_update.assert_awaited_once() + assert store_update.await_args.kwargs["where"] == {"server_id": "config_faros"} + rotated_blob = store_update.await_args.kwargs["data"]["credentials"] + + monkeypatch.setattr(enc, "_get_salt_key", lambda: key_new) + recovered = decrypt_credentials(credentials=json.loads(rotated_blob)) + assert recovered["client_id"] == "cid-123" + assert recovered["client_secret"] == "sec-456" + + +@pytest.mark.asyncio +async def test_delete_mcp_server_cleans_oauth_client_store(): + """Deleting a server must remove its server-scoped DCR client store entry alongside the per-user + credential and env-var rows, or a re-created server reusing the same server_id would inherit the + deleted server's OAuth client.""" + from litellm.proxy._experimental.mcp_server.db import delete_mcp_server + + prisma = MagicMock() + prisma.db.litellm_mcpservertable.delete = AsyncMock(return_value=SimpleNamespace(server_id="s1")) + prisma.db.litellm_mcpusercredentials.find_many = AsyncMock(return_value=[]) + prisma.db.litellm_mcpusercredentials.delete_many = AsyncMock() + prisma.db.litellm_mcpuserenvvars.delete_many = AsyncMock() + prisma.db.litellm_mcpserveroauthclient.delete_many = AsyncMock() + + await delete_mcp_server(prisma, "s1", invalidate_token_cache=AsyncMock()) + + prisma.db.litellm_mcpserveroauthclient.delete_many.assert_awaited_once_with(where={"server_id": "s1"}) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index f5ac229d119..6f2f24df8fa 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -7130,3 +7130,373 @@ async def test_token_exchange_unreadable_body_still_renders_oauth_fault(): assert response.status_code == 502 body = json.loads(response.body) assert body == {"error": "server_error", "error_description": "upstream token endpoint returned HTTP 400"} + + +@pytest.mark.asyncio +async def test_persist_dcr_client_for_config_server_uses_side_store(): + """A config.yaml-declared OAuth2 DCR server has no LiteLLM_MCPServerTable row, so + update_mcp_server returns None. The minted client must then persist to the server-scoped + OAuth-client store keyed by server_id (never a shadow server row), overlay onto the in-memory + server so refresh can authenticate this process, and never call update_server(None) (which + previously raised AttributeError on .approval_status, was swallowed, and reported a 200 that + persisted nothing).""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + _persist_dcr_client_registration, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.proxy._types import MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + config_server = MCPServer( + server_id="config_faros", + name="config_faros", + server_name="config_faros", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + client_id=None, + client_secret=None, + authorization_url="https://provider.example/oauth/authorize", + token_url="https://provider.example/oauth/token", + registration_url="https://provider.example/oauth/register", + ) + + mock_upsert = AsyncMock() + mock_update_server = AsyncMock() + + with ( + patch.object(global_mcp_server_manager, "is_config_declared_server", return_value=True), + patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()), + patch( + "litellm.proxy._experimental.mcp_server.db.update_mcp_server", + new=AsyncMock(return_value=None), + ), + patch( + "litellm.proxy._experimental.mcp_server.db.get_mcp_server", + new=AsyncMock(return_value=None), + ), + patch( + "litellm.proxy._experimental.mcp_server.db.get_mcp_server_oauth_client_credentials", + new=AsyncMock(return_value=None), + ), + patch( + "litellm.proxy._experimental.mcp_server.db.upsert_mcp_server_oauth_client_credentials", + new=mock_upsert, + ), + patch.object(global_mcp_server_manager, "update_server", new=mock_update_server), + ): + result = await _persist_dcr_client_registration( + mcp_server=config_server, + registration_response={ + "client_id": "minted-client", + "client_secret": "minted-secret", + "token_endpoint_auth_method": "client_secret_basic", + }, + current_redirect_uri="https://proxy.litellm.example/callback", + ) + + assert result == "persisted" + + mock_upsert.assert_called_once() + assert mock_upsert.call_args.kwargs["server_id"] == "config_faros" + stored = mock_upsert.call_args.kwargs["credentials"] + assert stored["client_id"] == "minted-client" + assert stored["client_secret"] == "minted-secret" + assert stored["token_endpoint_auth_method"] == "client_secret_basic" + assert stored["redirect_uris"] == ["https://proxy.litellm.example/callback"] + + assert config_server.client_id == "minted-client" + assert config_server.client_secret == "minted-secret" + assert config_server.token_endpoint_auth_method == "client_secret_basic" + + mock_update_server.assert_not_called() + + +@pytest.mark.asyncio +async def test_hydrate_config_server_applies_stored_dcr_client(monkeypatch): + """On restart a config server's in-memory object has no client_id; hydration overlays the + persisted DCR client from the server-scoped store, decrypting the encrypted-at-rest blob, so the + refresh_token grant can authenticate as the registered client instead of re-authenticating.""" + import litellm.proxy.common_utils.encrypt_decrypt_utils as enc + from litellm.litellm_core_utils.safe_json_dumps import safe_dumps + from litellm.proxy._experimental.mcp_server.db import encrypt_credentials + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + hydrate_config_server_dcr_client, + ) + from litellm.proxy._types import MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server = MCPServer( + server_id="config_faros", + name="config_faros", + server_name="config_faros", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + client_id=None, + ) + + monkeypatch.setattr(enc, "_get_salt_key", lambda: "salt-hydrate-key") + stored_blob = safe_dumps( + encrypt_credentials( + credentials={ + "client_id": "stored-client", + "client_secret": "stored-secret", + "token_endpoint_auth_method": "client_secret_basic", + "redirect_uris": ["https://proxy.litellm.example/callback"], + }, + encryption_key="salt-hydrate-key", + ) + ) + assert "stored-client" not in stored_blob and "stored-secret" not in stored_blob + + with ( + patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()), + patch( + "litellm.proxy._experimental.mcp_server.db.get_mcp_server_oauth_client_credentials", + new=AsyncMock(return_value=stored_blob), + ), + ): + applied = await hydrate_config_server_dcr_client(server) + + assert applied is True + assert server.client_id == "stored-client" + assert server.client_secret == "stored-secret" + assert server.token_endpoint_auth_method == "client_secret_basic" + + +@pytest.mark.asyncio +async def test_reuse_config_server_reads_store_with_real_crypto(monkeypatch): + """A config-declared server (rowless) keeps its DCR client in the store, so the reuse read + resolves it from the store and decrypts the encrypted-at-rest client, mirroring the write path so + a re-authorize reuses the client instead of re-minting one.""" + import litellm.proxy.common_utils.encrypt_decrypt_utils as enc + from litellm.litellm_core_utils.safe_json_dumps import safe_dumps + from litellm.proxy._experimental.mcp_server.db import encrypt_credentials + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + _reuse_persisted_dcr_client_if_available, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.proxy._types import MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server = MCPServer( + server_id="config_faros", + name="config_faros", + server_name="config_faros", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + client_id=None, + ) + + monkeypatch.setattr(enc, "_get_salt_key", lambda: "salt-reuse-key") + blob = safe_dumps( + encrypt_credentials( + credentials={"client_id": "stored-client", "client_secret": "sec", "redirect_uris": ["https://x/callback"]}, + encryption_key="salt-reuse-key", + ) + ) + assert "stored-client" not in blob + store_lookup = AsyncMock(return_value=blob) + with ( + patch.object(global_mcp_server_manager, "is_config_declared_server", return_value=True), + patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()), + patch("litellm.proxy._experimental.mcp_server.db.get_mcp_server", new=AsyncMock(return_value=None)), + patch( + "litellm.proxy._experimental.mcp_server.db.get_mcp_server_oauth_client_credentials", + new=store_lookup, + ), + ): + result = await _reuse_persisted_dcr_client_if_available(server, current_redirect_uri="https://x/callback") + + assert result is True + assert server.client_id == "stored-client" + store_lookup.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_temp_server_is_not_persisted_to_store(): + """A rowless server that is NOT config-declared (a throwaway /server/oauth/session server) must + not leave a permanent store row on persist, and the read must never consult the store for it. Its + minted client is overlaid in memory for the session only.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + _persist_dcr_client_registration, + _reuse_persisted_dcr_client_if_available, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.proxy._types import MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + temp = MCPServer( + server_id="temp-uuid", + name="temp", + server_name="temp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + client_id=None, + authorization_url="https://p.example/authorize", + token_url="https://p.example/token", + registration_url="https://p.example/register", + ) + + upsert = AsyncMock() + store_read = AsyncMock(return_value=None) + with ( + patch.object(global_mcp_server_manager, "is_config_declared_server", return_value=False), + patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()), + patch("litellm.proxy._experimental.mcp_server.db.update_mcp_server", new=AsyncMock(return_value=None)), + patch("litellm.proxy._experimental.mcp_server.db.get_mcp_server", new=AsyncMock(return_value=None)), + patch("litellm.proxy._experimental.mcp_server.db.upsert_mcp_server_oauth_client_credentials", new=upsert), + patch( + "litellm.proxy._experimental.mcp_server.db.get_mcp_server_oauth_client_credentials", + new=store_read, + ), + patch.object(global_mcp_server_manager, "update_server", new=AsyncMock()), + ): + result = await _persist_dcr_client_registration( + temp, {"client_id": "temp-client", "client_secret": "s"}, "https://x/callback" + ) + reused = await _reuse_persisted_dcr_client_if_available( + MCPServer( + server_id="temp-uuid", + name="temp", + server_name="temp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + client_id=None, + ), + current_redirect_uri="https://x/callback", + ) + + assert result == "persisted" + assert temp.client_id == "temp-client" + upsert.assert_not_called() + store_read.assert_not_called() + assert reused is False + + +@pytest.mark.asyncio +async def test_hydrate_does_not_overwrite_explicit_config_client_id(): + """An explicit client_id set in config.yaml wins: hydration must not overwrite it with a stale + persisted store client, and must not even read the store when config already supplied a client.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + hydrate_config_server_dcr_client, + ) + from litellm.proxy._types import MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server = MCPServer( + server_id="config_static", + name="config_static", + server_name="config_static", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + client_id="explicit-from-config", + ) + store_read = AsyncMock( + return_value={"client_id": "stale-store-client", "client_secret": "x", "redirect_uris": []} + ) + with ( + patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()), + patch( + "litellm.proxy._experimental.mcp_server.db.get_mcp_server_oauth_client_credentials", + new=store_read, + ), + ): + applied = await hydrate_config_server_dcr_client(server) + + assert applied is False + assert server.client_id == "explicit-from-config" + store_read.assert_not_called() + + +@pytest.mark.asyncio +async def test_reuse_does_not_inherit_store_client_when_a_row_exists(): + """Security: a server that HAS a LiteLLM_MCPServerTable row reads its DCR client only from that + row, never from the server-scoped store. server_id is caller-settable on create, so a submitted + server whose id collides with a config-declared server must not be able to load that config + server's client from the store and send it to its own token endpoint. A row that exists but has + no client_id yields no reusable client and must not fall back to the store.""" + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + _reuse_persisted_dcr_client_if_available, + ) + from litellm.proxy._types import MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + submitted = MCPServer( + server_id="collides_with_config", + name="submitted", + server_name="submitted", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + client_id=None, + ) + + row_without_client = MagicMock() + row_without_client.credentials = None + row_without_client.server_id = "collides_with_config" + store_lookup = AsyncMock( + return_value={"client_id": "config-secret-client", "client_secret": "leak", "redirect_uris": []} + ) + with ( + patch("litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock()), + patch( + "litellm.proxy._experimental.mcp_server.db.get_mcp_server", + new=AsyncMock(return_value=row_without_client), + ), + patch( + "litellm.proxy._experimental.mcp_server.db.get_mcp_server_oauth_client_credentials", + new=store_lookup, + ), + ): + result = await _reuse_persisted_dcr_client_if_available(submitted, current_redirect_uri="https://x/callback") + + assert result is False + assert submitted.client_id is None + store_lookup.assert_not_called() + + +@pytest.mark.asyncio +async def test_load_servers_from_config_hydrates_dcr_clients(): + """load_servers_from_config must invoke DCR-client hydration so config servers pick up their + persisted client on startup; deleting the call site leaves a restarted server with no client_id + and forces re-authentication on every token expiry.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + hydrate_spy = AsyncMock() + with patch.object(global_mcp_server_manager, "_hydrate_config_servers_dcr_clients", new=hydrate_spy): + await global_mcp_server_manager.load_servers_from_config({}) + + hydrate_spy.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_reload_servers_from_database_hydrates_dcr_clients(): + """load_servers_from_config runs before the DB connects at startup, so its hydration no-ops; + reload_servers_from_database runs after the DB connects and must hydrate config servers' persisted + DCR clients too, or a fresh pod has no client_id for a config server and forces re-authentication + on the first token refresh.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + prisma = MagicMock() + prisma.db.litellm_mcpservertable.find_many = AsyncMock(return_value=[]) + + hydrate_spy = AsyncMock() + with ( + patch( + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", + return_value=prisma, + ), + patch.object(global_mcp_server_manager, "_hydrate_config_servers_dcr_clients", new=hydrate_spy), + ): + await global_mcp_server_manager.reload_servers_from_database() + + hydrate_spy.assert_awaited_once() diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 55b6bbbdbc2..491fa023031 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -176,6 +176,89 @@ class TestMCPServerManager: assert calls == [("", "authz-srv")] assert client is not None + @pytest.mark.asyncio + async def test_caller_auth_header_cannot_bypass_id_jag_exchange(self): + """A caller-supplied per-request override must not disable the ID-JAG exchange and forward an + arbitrary bearer upstream: _create_mcp_client keeps the v2 spec and resolves through the + injected provider rather than deferring to the v1 caller-override path.""" + 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 + + calls = [] + + class _FakeProvider: + async def resolve_credentials(self, subject, server): + calls.append((subject.subject_id, server.server_id)) + return Ok(StaticHeaderAuth("Bearer minted-id-jag-token")) + + manager = MCPServerManager(cred_provider=_FakeProvider()) + server = MCPServer( + server_id="id-jag-srv", + name="id-jag", + url="https://upstream.example/mcp", + transport=MCPTransport.sse, + auth_type=MCPAuth.oauth2_id_jag, + client_id="gateway-client", + client_secret="gateway-secret", + token_exchange_endpoint="https://org-idp.example/oauth2/token", + id_jag_resource_token_endpoint="https://resource-as.example/oauth2/token", + ) + + client = await manager._create_mcp_client( + server, + mcp_auth_header="Bearer caller-supplied-token", + subject_token="caller-id-token", + ) + + assert calls == [("", "id-jag-srv")] + assert client is not None + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "missing_field", + ["token_exchange_endpoint", "id_jag_resource_token_endpoint", "client_id", "client_secret"], + ) + async def test_half_configured_id_jag_fails_closed_instead_of_deferring_to_v1(self, missing_field): + """ID-JAG has no v1 arm, so a half-configured oauth2_id_jag server must not silently fall + through to resolve_mcp_auth, where a caller x-mcp-* override or the static + authentication_token would bypass the per-user identity assertion. It must be refused as an + operator misconfiguration (HTTP 500) before any client is built.""" + from fastapi import HTTPException + + from litellm.types.mcp import MCPAuth + + fields = { + "client_id": "gateway-client", + "client_secret": "gateway-secret", + "token_exchange_endpoint": "https://org-idp.example/oauth2/token", + "id_jag_resource_token_endpoint": "https://resource-as.example/oauth2/token", + } + fields.pop(missing_field) + server = MCPServer( + server_id="id-jag-srv", + name="id-jag", + url="https://upstream.example/mcp", + transport=MCPTransport.sse, + auth_type=MCPAuth.oauth2_id_jag, + authentication_token="static-server-secret", + **fields, + ) + + with pytest.raises(HTTPException) as exc_info: + await MCPServerManager()._create_mcp_client( + server, + mcp_auth_header="Bearer caller-supplied-token", + subject_token="caller-id-token", + ) + + assert exc_info.value.status_code == 500 + assert "oauth2_id_jag" in str(exc_info.value.detail) + async def test_create_mcp_client_stdio_injects_npm_config_cache(self): """Test that _create_mcp_client injects NPM_CONFIG_CACHE when not already set, and preserves user-provided NPM_CONFIG_CACHE when present.""" @@ -257,6 +340,35 @@ class TestMCPServerManager: assert env == {} @pytest.mark.asyncio + async def test_load_servers_from_config_debug_dump_redacts_secrets(self, caplog): + """The registry debug dump must not leak long-lived credentials: the ID-JAG signing key, + client secret, and static token are masked while non-secret fields stay readable.""" + + manager = MCPServerManager() + config = { + "idjag": { + "url": "https://example.com/mcp", + "transport": MCPTransport.http, + "auth_type": MCPAuth.oauth2_id_jag, + "client_id": "gateway-client", + "client_secret": "SECRET-CLIENT-SECRET", + "client_private_key": "-----BEGIN PRIVATE KEY-----SECRET-PEM-----END PRIVATE KEY-----", + "token_exchange_endpoint": "https://org-idp.example/oauth2/token", + "id_jag_resource_token_endpoint": "https://resource-as.example/oauth2/token", + "authentication_token": "SECRET-STATIC-TOKEN", + } + } + + with caplog.at_level(logging.DEBUG, logger="LiteLLM"): + await manager.load_servers_from_config(config) + + dump = next(m for m in caplog.messages if "Loaded MCP Servers" in m) + assert "SECRET-PEM" not in dump + assert "SECRET-CLIENT-SECRET" not in dump + assert "SECRET-STATIC-TOKEN" not in dump + assert "gateway-client" in dump + assert "https://org-idp.example/oauth2/token" in dump + async def test_load_servers_from_config_warns_on_invalid_alias(self, caplog): """Invalid aliases from config should emit warnings during load.""" @@ -8122,6 +8234,86 @@ class TestOBOCallToolRetry: manager._create_mcp_client.assert_awaited_once() assert first.attempts == 1 and retry.attempts == 1 + @pytest.mark.asyncio + async def test_upstream_401_on_id_jag_evicts_the_cached_bearer_and_retries(self): + """The retry path must invalidate the ID-JAG leg-2 bearer too: without eviction the rebuilt + client resolves the same rejected token from the cache and the retry 401s identically.""" + from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( + IdJagConfig, + ) + + manager = self._manager() + success = CallToolResult(content=[], isError=False) + first = _RetryFakeClient(raises=_UpstreamAuthError(401)) + retry = _RetryFakeClient(result=success) + manager._create_mcp_client = AsyncMock(return_value=retry) + server = MCPServer( + server_id="id-jag-srv", + name="id-jag", + url="https://upstream.example/mcp", + transport=MCPTransport.sse, + auth_type=MCPAuth.oauth2_id_jag, + client_id="gateway-client", + client_secret="gateway-secret", + token_exchange_endpoint="https://org-idp.example/oauth2/token", + id_jag_resource_token_endpoint="https://resource-as.example/oauth2/token", + ) + + result = await manager._obo_call_tool_with_retry( + client=first, + call_tool_params=MagicMock(), + host_progress_callback=None, + mcp_server=server, + server_auth_header=None, + extra_headers=None, + stdio_env=None, + subject_token="caller-id-token", + user_api_key_auth=None, + ) + + assert result is success + manager._cred_provider.invalidate_credentials.assert_awaited_once() + invalidated_spec = manager._cred_provider.invalidate_credentials.await_args.args[1] + assert isinstance(invalidated_spec.config, IdJagConfig) + assert first.attempts == 1 and retry.attempts == 1 + + @pytest.mark.asyncio + async def test_call_regular_routes_id_jag_through_the_retry_path(self): + """An oauth2_id_jag tool call with a subject token must take the invalidate-and-retry branch + of _call_regular_mcp_tool, not the plain single call, so an upstream 401 re-exchanges.""" + manager = self._manager() + success = CallToolResult(content=[], isError=False) + first = _RetryFakeClient(raises=_UpstreamAuthError(401)) + retry = _RetryFakeClient(result=success) + manager._create_mcp_client = AsyncMock(side_effect=[first, retry]) + server = MCPServer( + server_id="id-jag-srv", + name="id-jag", + url="https://upstream.example/mcp", + transport=MCPTransport.sse, + auth_type=MCPAuth.oauth2_id_jag, + client_id="gateway-client", + client_secret="gateway-secret", + token_exchange_endpoint="https://org-idp.example/oauth2/token", + id_jag_resource_token_endpoint="https://resource-as.example/oauth2/token", + ) + + result = await manager._call_regular_mcp_tool( + mcp_server=server, + original_tool_name="tool", + arguments={}, + tasks=[], + mcp_auth_header=None, + mcp_server_auth_headers=None, + oauth2_headers={"Authorization": "Bearer caller-id-token"}, + raw_headers=None, + proxy_logging_obj=None, + ) + + assert result is success + manager._cred_provider.invalidate_credentials.assert_awaited_once() + assert first.attempts == 1 and retry.attempts == 1 + @pytest.mark.asyncio async def test_non_auth_error_does_not_retry(self): manager = self._manager() diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sigv4_auth.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sigv4_auth.py index 5992fd1814f..f6b61c1d9f7 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sigv4_auth.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_sigv4_auth.py @@ -989,6 +989,7 @@ class TestRotateCredentials: mock_prisma = MagicMock() mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock(return_value=[server]) mock_prisma.db.litellm_mcpservertable.update = AsyncMock() + mock_prisma.db.litellm_mcpserveroauthclient.find_many = AsyncMock(return_value=[]) with ( patch( @@ -1036,6 +1037,7 @@ class TestRotateCredentials: mock_prisma = MagicMock() mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock(return_value=[server]) mock_prisma.db.litellm_mcpservertable.update = AsyncMock() + mock_prisma.db.litellm_mcpserveroauthclient.find_many = AsyncMock(return_value=[]) with ( patch( diff --git a/tests/test_litellm/proxy/proxy_server/test_exception_handlers.py b/tests/test_litellm/proxy/proxy_server/test_exception_handlers.py index cf92f9cd12b..e4bf06991b4 100644 --- a/tests/test_litellm/proxy/proxy_server/test_exception_handlers.py +++ b/tests/test_litellm/proxy/proxy_server/test_exception_handlers.py @@ -124,6 +124,47 @@ def test_close_dangling_otel_server_span_records_status_and_ends(monkeypatch): } +def test_close_dangling_otel_server_span_v2_stamps_error_without_ending(monkeypatch): + """LIT-4179: under OTel v2 the FastAPI instrumentor owns the SERVER span, so + the handler must only stamp error.* on it (via record_error_attributes_on_span) + and must NOT set status, end the span, or clear request state — otherwise the + instrumentor's http.* attributes and span close are lost.""" + import litellm.integrations.otel.model.config as otel_config + import litellm.proxy.proxy_server as ps + + span = MagicMock() + fake_logger = MagicMock() + monkeypatch.setattr(ps, "open_telemetry_logger", fake_logger, raising=False) + monkeypatch.setattr(otel_config, "is_otel_v2_enabled", lambda: True) + request = _make_request(parent_otel_span=span) + exc = ProxyException(message="bad", type="bad_request_error", param=None, code=400) + + _close_dangling_otel_server_span(request=request, status_code=422, exc=exc) + + fake_logger.record_error_attributes_on_span.assert_called_once_with(span, exc, 422) + assert not span.end.called + assert not span.set_status.called + assert not fake_logger.set_response_status_code_attribute.called + assert request.state.parent_otel_span is span + + +def test_close_dangling_otel_server_span_v2_success_does_not_stamp(monkeypatch): + """Under v2 a sub-400 status must not stamp an error onto the SERVER span.""" + import litellm.integrations.otel.model.config as otel_config + import litellm.proxy.proxy_server as ps + + span = MagicMock() + fake_logger = MagicMock() + monkeypatch.setattr(ps, "open_telemetry_logger", fake_logger, raising=False) + monkeypatch.setattr(otel_config, "is_otel_v2_enabled", lambda: True) + request = _make_request(parent_otel_span=span) + + _close_dangling_otel_server_span(request=request, status_code=200) + + assert not fake_logger.record_error_attributes_on_span.called + assert not span.end.called + + def test_close_dangling_otel_server_span_missing_span_is_noop_error(): """When parent_otel_span is missing the call short-circuits — no error.""" request = _make_request(parent_otel_span=None) diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 55c09e6cac4..76a6e3c1bbe 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -2864,6 +2864,185 @@ def test_pre_call_checks_counts_once_and_filters_on_max_input_tokens(monkeypatch assert calls == [1] +def test_pre_call_checks_counts_tokens_from_responses_input_string(monkeypatch): + """ + Responses API calls pass `input` (str) instead of `messages`. Context-window + checks must count tokens from `input` and filter deployments over the limit. Uses + the real token_counter so the transform + counting path is a true regression guard. + """ + router = litellm.Router( + model_list=[ + {"model_name": "m", "litellm_params": {"model": "gpt-3.5-turbo"}}, + ], + enable_pre_call_checks=True, + ) + monkeypatch.setattr( + router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 1} + ) + + deployments = [ + {"litellm_params": {"model": "gpt-3.5-turbo"}, "model_info": {"id": "d1"}}, + ] + with pytest.raises(litellm.ContextWindowExceededError): + router._pre_call_checks( + model="m", + healthy_deployments=deployments, + input="a very long prompt that exceeds the tiny context window", + ) + + +def test_pre_call_checks_counts_tokens_from_responses_input_list(monkeypatch): + """ + Responses API `input` can be a list of input items. It must be normalized to + chat messages and counted so oversized requests are filtered out. Uses the real + token_counter (no mock) so the transform + counting path is a true regression guard. + """ + router = litellm.Router( + model_list=[ + {"model_name": "m", "litellm_params": {"model": "gpt-3.5-turbo"}}, + ], + enable_pre_call_checks=True, + ) + monkeypatch.setattr( + router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 1} + ) + + deployments = [ + {"litellm_params": {"model": "gpt-3.5-turbo"}, "model_info": {"id": "d1"}}, + ] + with pytest.raises(litellm.ContextWindowExceededError): + router._pre_call_checks( + model="m", + healthy_deployments=deployments, + input=[ + {"role": "user", "content": "count these tokens against the one token limit please"}, + ], + ) + + +def test_pre_call_checks_counts_responses_instructions_tokens(monkeypatch): + """ + Responses API `instructions` become a system message the model receives, so their + tokens must be counted too. A request whose `input` alone fits under the limit but + whose `input` + `instructions` exceeds it must be filtered (regression for the + context-window check under-filtering when instructions were ignored). + """ + router = litellm.Router( + model_list=[ + {"model_name": "m", "litellm_params": {"model": "gpt-3.5-turbo"}}, + ], + enable_pre_call_checks=True, + ) + + deployments = [ + {"litellm_params": {"model": "gpt-3.5-turbo"}, "model_info": {"id": "d1"}}, + ] + + short_input = "hi" + long_instructions = "you are a helpful assistant. " * 20 + + input_only_tokens = router._count_pre_call_check_tokens(messages=None, input=short_input) + with_instructions_tokens = router._count_pre_call_check_tokens( + messages=None, input=short_input, instructions=long_instructions + ) + assert with_instructions_tokens > input_only_tokens + + monkeypatch.setattr( + router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": input_only_tokens} + ) + with pytest.raises(litellm.ContextWindowExceededError): + router._pre_call_checks( + model="m", + healthy_deployments=deployments, + input=short_input, + request_kwargs={"instructions": long_instructions}, + ) + + +def test_count_pre_call_check_tokens_across_api_surfaces(): + """ + _count_pre_call_check_tokens must count tokens from chat `messages`, a Responses + API string `input`, and a Responses API list `input`, and raise when given neither. + """ + router = litellm.Router( + model_list=[ + {"model_name": "m", "litellm_params": {"model": "gpt-3.5-turbo"}}, + ], + ) + + messages_tokens = router._count_pre_call_check_tokens( + messages=[{"role": "user", "content": "hello world"}], input=None + ) + string_input_tokens = router._count_pre_call_check_tokens(messages=None, input="hello world") + list_input_tokens = router._count_pre_call_check_tokens( + messages=None, input=[{"role": "user", "content": "hello world"}] + ) + + assert messages_tokens > 0 + assert string_input_tokens > 0 + assert list_input_tokens > 0 + + with pytest.raises(ValueError): + router._count_pre_call_check_tokens(messages=None, input=None) + + +def test_pre_call_checks_no_messages_or_input_does_not_crash(monkeypatch): + """ + When neither messages nor input is provided (e.g. endpoints without prompt text), + token counting is skipped gracefully and all deployments are returned. + """ + router = litellm.Router( + model_list=[ + {"model_name": "m", "litellm_params": {"model": "gpt-3.5-turbo"}}, + ], + enable_pre_call_checks=True, + ) + monkeypatch.setattr( + router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5} + ) + + counted: list[dict] = [] + original = router._count_pre_call_check_tokens + monkeypatch.setattr( + router, + "_count_pre_call_check_tokens", + lambda **kwargs: counted.append(kwargs) or original(**kwargs), + ) + + deployments = [ + {"litellm_params": {"model": "gpt-3.5-turbo"}, "model_info": {"id": "d1"}}, + ] + result = router._pre_call_checks(model="m", healthy_deployments=deployments) + assert len(result) == 1 + assert counted == [] # token counting skipped entirely, so no misleading error is logged + + +@pytest.mark.asyncio +async def test_aresponses_enforces_context_window_pre_call_check(): + """ + End-to-end router regression: a Responses API call whose `input` exceeds the + deployment's max_input_tokens must be filtered by the pre-call check, raising + ContextWindowExceededError instead of being silently routed. This guards the + wiring that forwards `input` from the generic-call path into deployment selection + (the deployment uses mock_response, so the check must trip before any real call). + """ + router = litellm.Router( + model_list=[ + { + "model_name": "small-ctx", + "litellm_params": {"model": "gpt-3.5-turbo", "mock_response": "hi"}, + "model_info": {"max_input_tokens": 5}, + } + ], + enable_pre_call_checks=True, + ) + with pytest.raises(litellm.ContextWindowExceededError): + await router.aresponses( + model="small-ctx", + input="this responses input is definitely much longer than five tokens for sure", + ) + + def test_get_deployment_model_info_base_model_flow(): """Test that get_deployment_model_info correctly handles the base model flow""" from unittest.mock import patch diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 87b4c96e323..0482f47e5bc 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -15,7 +15,7 @@ "limit": 0 }, "LIT006": { - "limit": 1113 + "limit": 1111 }, "LIT007": { "limit": 0 diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 90b0c84244e..c775af81ba8 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -881,19 +881,6 @@ "count": 1 } }, - "src/app/(dashboard)/policies/_components/attachment_table.test.tsx": { - "react/display-name": { - "count": 1 - } - }, - "src/app/(dashboard)/policies/_components/attachment_table.tsx": { - "no-nested-ternary": { - "count": 1 - }, - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/policies/_components/guardrail_selection_modal.tsx": { "no-nested-ternary": { "count": 1 @@ -1885,19 +1872,11 @@ "count": 1 } }, - "src/components/model_add/AddCredentialModal.tsx": { + "src/components/model_add/CredentialModal.tsx": { "no-restricted-imports": { "count": 1 } }, - "src/components/model_add/EditCredentialModal.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/components/model_add/credentials.tsx": { "no-restricted-imports": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/attachment_table.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTable.test.tsx similarity index 55% rename from ui/litellm-dashboard/src/app/(dashboard)/policies/_components/attachment_table.test.tsx rename to ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTable.test.tsx index c53881e5cce..b544c44d190 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/attachment_table.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTable.test.tsx @@ -1,63 +1,17 @@ import React from "react"; -import { screen } from "@testing-library/react"; +import { screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "@/../tests/test-utils"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import AttachmentTable from "./attachment_table"; +import AttachmentTable from "./AttachmentTable"; import { PolicyAttachment } from "@/components/policies/types"; vi.mock("./impact_popover", () => ({ - default: () => - + ); -}; - -export default AddCredentialsModal; +} diff --git a/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.test.tsx b/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.test.tsx deleted file mode 100644 index def3b4f6cd7..00000000000 --- a/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.test.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; -import { Providers } from "../provider_info_helpers"; -import { CredentialItem } from "../networking"; -import EditCredentialModal from "./EditCredentialModal"; - -vi.mock("../networking", async () => { - const actual = await vi.importActual("../networking"); - return { - ...actual, - getProviderCreateMetadata: vi.fn().mockResolvedValue([ - { - provider: "OpenAI", - provider_display_name: Providers.OpenAI, - litellm_provider: "openai", - default_model_placeholder: "gpt-3.5-turbo", - credential_fields: [ - { - key: "api_key", - label: "OpenAI API Key", - field_type: "password", - required: true, - }, - { - key: "api_base", - label: "API Base", - field_type: "text", - placeholder: "https://api.openai.com/v1", - }, - ], - }, - { - provider: "Anthropic", - provider_display_name: Providers.Anthropic, - litellm_provider: "anthropic", - default_model_placeholder: "claude-3-opus-20240229", - credential_fields: [ - { - key: "api_key", - label: "Anthropic API Key", - field_type: "password", - required: true, - }, - ], - }, - ]), - }; -}); - -const createQueryClient = () => - new QueryClient({ - defaultOptions: { - queries: { - retry: false, - gcTime: 0, - }, - }, - }); - -const mockUploadProps = { - beforeUpload: vi.fn(), - onChange: vi.fn(), -}; - -const mockCredential: CredentialItem = { - credential_name: "test-credential", - credential_values: { - api_key: "test-api-key", - api_base: "https://api.test.com", - }, - credential_info: { - custom_llm_provider: Providers.OpenAI, - }, -}; - -describe("EditCredentialModal", () => { - it("should render", () => { - const queryClient = createQueryClient(); - const onCancel = vi.fn(); - const onUpdateCredential = vi.fn(); - - render( - - - , - ); - - expect(screen.getByText("Edit Credential")).toBeInTheDocument(); - expect(screen.getByLabelText("Credential Name:")).toBeInTheDocument(); - expect(screen.getByLabelText("Provider:")).toBeInTheDocument(); - }); - - it("should render initial values", async () => { - const queryClient = createQueryClient(); - const onCancel = vi.fn(); - const onUpdateCredential = vi.fn(); - - render( - - - , - ); - - await waitFor(() => { - const credentialNameInput = screen.getByLabelText("Credential Name:") as HTMLInputElement; - expect(credentialNameInput.value).toBe("test-credential"); - expect(credentialNameInput.disabled).toBe(true); - }); - }); -}); diff --git a/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.tsx b/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.tsx deleted file mode 100644 index d087edc1069..00000000000 --- a/ui/litellm-dashboard/src/components/model_add/EditCredentialModal.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { TextInput } from "@tremor/react"; -import { Select as AntdSelect, Button, Form, Modal, Tooltip, Typography } from "antd"; -import type { UploadProps } from "antd/es/upload"; -import { useEffect, useState } from "react"; -import ProviderSpecificFields from "../add_model/provider_specific_fields"; -import { CredentialItem } from "../networking"; -import { Providers, providerLogoMap } from "../provider_info_helpers"; -import { resolveLogoSrc } from "@/lib/assetPaths"; -import { resetCredentialFormOnProviderChange } from "./credential_form_helpers"; -const { Link } = Typography; - -interface EditCredentialsModalProps { - open: boolean; - onCancel: () => void; - onUpdateCredential: (values: any) => void; - uploadProps: UploadProps; - existingCredential: CredentialItem | null; -} - -export default function EditCredentialsModal({ - open, - onCancel, - onUpdateCredential, - uploadProps, - existingCredential, -}: EditCredentialsModalProps) { - const [form] = Form.useForm(); - const [selectedProvider, setSelectedProvider] = useState(Providers.Anthropic); - - const handleSubmit = (values: any) => { - const filteredValues = Object.entries(values).reduce((acc, [key, value]) => { - if (value !== "" && value !== undefined && value !== null) { - acc[key] = value; - } - return acc; - }, {} as any); - onUpdateCredential(filteredValues); - form.resetFields(); - }; - - useEffect(() => { - if (existingCredential) { - // Spread all credential_values dynamically, converting undefined/null to null for form compatibility - const credentialValues = Object.entries(existingCredential.credential_values || {}).reduce( - (acc, [key, value]) => { - acc[key] = value ?? null; - return acc; - }, - {} as Record, - ); - - form.setFieldsValue({ - credential_name: existingCredential.credential_name, - custom_llm_provider: existingCredential.credential_info.custom_llm_provider, - ...credentialValues, - }); - setSelectedProvider(existingCredential.credential_info.custom_llm_provider as Providers); - } - }, [existingCredential]); - - return ( - { - onCancel(); - form.resetFields(); - }} - footer={null} - width={600} - destroyOnHidden={true} - > -
- {/* Credential Name */} - - - - - {/* Provider Selection */} - - { - resetCredentialFormOnProviderChange(form, value as Providers, setSelectedProvider); - }} - > - {Object.entries(Providers).map(([providerEnum, providerDisplayName]) => ( - -
- {`${providerEnum} { - const target = e.target as HTMLImageElement; - const parent = target.parentElement; - if (parent) { - const fallbackDiv = document.createElement("div"); - fallbackDiv.className = - "w-5 h-5 rounded-full bg-gray-200 flex items-center justify-center text-xs"; - fallbackDiv.textContent = providerDisplayName.charAt(0); - parent.replaceChild(fallbackDiv, target); - } - }} - /> - {providerDisplayName} -
-
- ))} -
-
- - - - {/* Modal Footer */} -
- - Need Help? - - -
- - -
-
- -
- ); -} diff --git a/ui/litellm-dashboard/src/components/model_add/credentials.tsx b/ui/litellm-dashboard/src/components/model_add/credentials.tsx index 82320b7ff8d..9289888c1ed 100644 --- a/ui/litellm-dashboard/src/components/model_add/credentials.tsx +++ b/ui/litellm-dashboard/src/components/model_add/credentials.tsx @@ -22,8 +22,7 @@ import { UploadProps } from "antd/es/upload"; import { useState } from "react"; import DeleteResourceModal from "../common_components/DeleteResourceModal"; import NotificationsManager from "../molecules/notifications_manager"; -import AddCredentialsTab from "./AddCredentialModal"; -import EditCredentialsModal from "./EditCredentialModal"; +import CredentialModal from "./CredentialModal"; import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { isProxyAdminRole } from "@/utils/roles"; @@ -201,18 +200,20 @@ const CredentialsPanel: React.FC = ({ uploadProps }) => { {isAddModalOpen && ( - setIsAddModalOpen(false)} uploadProps={uploadProps} /> )} {isUpdateModalOpen && ( - setIsUpdateModalOpen(false)} /> diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 6dc63762e5c..daba5639a4f 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -27288,7 +27288,7 @@ export interface components { /** Alias */ alias?: string | null; /** Auth Type */ - auth_type?: ("none" | "api_key" | "bearer_token" | "basic" | "authorization" | "oauth2" | "aws_sigv4" | "token" | "oauth2_token_exchange" | "true_passthrough" | "oauth_delegate") | null; + auth_type?: ("none" | "api_key" | "bearer_token" | "basic" | "authorization" | "oauth2" | "aws_sigv4" | "token" | "oauth2_token_exchange" | "oauth2_id_jag" | "true_passthrough" | "oauth_delegate") | null; /** Mcp Info */ mcp_info?: { [key: string]: unknown; diff --git a/uv.lock b/uv.lock index 081066f8df0..e1574d594c5 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-07-15T14:06:38.760469712Z" +exclude-newer = "2026-07-15T19:00:01.873034869Z" exclude-newer-span = "P3D" [manifest] @@ -651,6 +651,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, ] +[[package]] +name = "bidict" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" }, +] + [[package]] name = "blinker" version = "1.9.0" @@ -712,6 +721,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/ca/f017727b11895908c5dedc829cf2ec35e0c4b2a26ba875db325fef2cefdf/botocore_stubs-1.43.14-py3-none-any.whl", hash = "sha256:fb98f1475c92fd718644e786b5c543a20f1b1f610e89e0a7191c3f1f429c75aa", size = 67093, upload-time = "2026-05-25T06:06:34.532Z" }, ] +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/10/a090475284fc4a71aed40a96f32e44a7fe5bda39687353dd977720b211b6/brotli-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b90b767916ac44e93a8e28ce6adf8d551e43affb512f2377c732d486ac6514e", size = 863089, upload-time = "2025-11-05T18:38:01.181Z" }, + { url = "https://files.pythonhosted.org/packages/03/41/17416630e46c07ac21e378c3464815dd2e120b441e641bc516ac32cc51d2/brotli-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6be67c19e0b0c56365c6a76e393b932fb0e78b3b56b711d180dd7013cb1fd984", size = 445442, upload-time = "2025-11-05T18:38:02.434Z" }, + { url = "https://files.pythonhosted.org/packages/24/31/90cc06584deb5d4fcafc0985e37741fc6b9717926a78674bbb3ce018957e/brotli-1.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bbd5b5ccd157ae7913750476d48099aaf507a79841c0d04a9db4415b14842de", size = 1532658, upload-time = "2025-11-05T18:38:03.588Z" }, + { url = "https://files.pythonhosted.org/packages/62/17/33bf0c83bcbc96756dfd712201d87342732fad70bb3472c27e833a44a4f9/brotli-1.2.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3f3c908bcc404c90c77d5a073e55271a0a498f4e0756e48127c35d91cf155947", size = 1631241, upload-time = "2025-11-05T18:38:04.582Z" }, + { url = "https://files.pythonhosted.org/packages/48/10/f47854a1917b62efe29bc98ac18e5d4f71df03f629184575b862ef2e743b/brotli-1.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1b557b29782a643420e08d75aea889462a4a8796e9a6cf5621ab05a3f7da8ef2", size = 1424307, upload-time = "2025-11-05T18:38:05.587Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b7/f88eb461719259c17483484ea8456925ee057897f8e64487d76e24e5e38d/brotli-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:81da1b229b1889f25adadc929aeb9dbc4e922bd18561b65b08dd9343cfccca84", size = 1488208, upload-time = "2025-11-05T18:38:06.613Z" }, + { url = "https://files.pythonhosted.org/packages/26/59/41bbcb983a0c48b0b8004203e74706c6b6e99a04f3c7ca6f4f41f364db50/brotli-1.2.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ff09cd8c5eec3b9d02d2408db41be150d8891c5566addce57513bf546e3d6c6d", size = 1597574, upload-time = "2025-11-05T18:38:07.838Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e6/8c89c3bdabbe802febb4c5c6ca224a395e97913b5df0dff11b54f23c1788/brotli-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a1778532b978d2536e79c05dac2d8cd857f6c55cd0c95ace5b03740824e0e2f1", size = 1492109, upload-time = "2025-11-05T18:38:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/ed/9a/4b19d4310b2dbd545c0c33f176b0528fa68c3cd0754e34b2f2bcf56548ae/brotli-1.2.0-cp310-cp310-win32.whl", hash = "sha256:b232029d100d393ae3c603c8ffd7e3fe6f798c5e28ddca5feabb8e8fdb732997", size = 334461, upload-time = "2025-11-05T18:38:10.729Z" }, + { url = "https://files.pythonhosted.org/packages/ac/39/70981d9f47705e3c2b95c0847dfa3e7a37aa3b7c6030aedc4873081ed005/brotli-1.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:ef87b8ab2704da227e83a246356a2b179ef826f550f794b2c52cddb4efbd0196", size = 369035, upload-time = "2025-11-05T18:38:11.827Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" }, + { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" }, + { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" }, + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + [[package]] name = "bytecode" version = "0.17.0" @@ -1049,6 +1116,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6d/c1/e419ef3723a074172b68aaa89c9f3de486ed4c2399e2dbd8113a4fdcaf9e/colorlog-6.10.1-py3-none-any.whl", hash = "sha256:2d7e8348291948af66122cff006c9f8da6255d224e7cf8e37d8de2df3bad8c9c", size = 11743, upload-time = "2025-10-16T16:14:10.512Z" }, ] +[[package]] +name = "configargparse" +version = "1.7.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/0b/30328302903c55218ffc5199646d0e9d28348ff26c02ba77b2ffc58d294a/configargparse-1.7.5.tar.gz", hash = "sha256:e3f9a7bb6be34d66b2e3c4a2f58e3045f8dfae47b0dc039f87bcfaa0f193fb0f", size = 53548, upload-time = "2026-03-11T02:19:38.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/19/3ba5e1b0bcc7b91aeab6c258afd70e4907d220fed3972febe38feb40db30/configargparse-1.7.5-py3-none-any.whl", hash = "sha256:1e63fdffedf94da9cd435fc13a1cd24777e76879dd2343912c1f871d4ac8c592", size = 27692, upload-time = "2026-03-11T02:19:36.442Z" }, +] + [[package]] name = "contourpy" version = "1.3.2" @@ -1869,6 +1945,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/af/72ad54402e599152de6d067324c46fe6a4f531c7c65baf7e96c63db55eaf/flask_cors-6.0.2-py3-none-any.whl", hash = "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", size = 13257, upload-time = "2025-12-12T20:31:41.3Z" }, ] +[[package]] +name = "flask-login" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" }, +] + [[package]] name = "fonttools" version = "4.62.1" @@ -2062,6 +2151,138 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/0c/043d5e551459da400957a1395e0febbf771446ff34291afcbe3d8be2a279/fsspec-2026.4.0-py3-none-any.whl", hash = "sha256:11ef7bb35dab8a394fde6e608221d5cf3e8499401c249bebaeaad760a1a8dec2", size = 203402, upload-time = "2026-04-29T20:42:36.842Z" }, ] +[[package]] +name = "gevent" +version = "25.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, + { name = "greenlet", marker = "platform_python_implementation == 'CPython'" }, + { name = "zope-event" }, + { name = "zope-interface" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/48/b3ef2673ffb940f980966694e40d6d32560f3ffa284ecaeb5ea3a90a6d3f/gevent-25.9.1.tar.gz", hash = "sha256:adf9cd552de44a4e6754c51ff2e78d9193b7fa6eab123db9578a210e657235dd", size = 5059025, upload-time = "2025-09-17T16:15:34.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/c7/2c60fc4e5c9144f2b91e23af8d87c626870ad3183cfd09d2b3ba6d699178/gevent-25.9.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:856b990be5590e44c3a3dc6c8d48a40eaccbb42e99d2b791d11d1e7711a4297e", size = 1831980, upload-time = "2025-09-17T15:41:22.597Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ae/49bf0a01f95a1c92c001d7b3f482a2301626b8a0617f448c4cd14ca9b5d4/gevent-25.9.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fe1599d0b30e6093eb3213551751b24feeb43db79f07e89d98dd2f3330c9063e", size = 1918777, upload-time = "2025-09-17T15:48:57.223Z" }, + { url = "https://files.pythonhosted.org/packages/88/3f/266d2eb9f5d75c184a55a39e886b53a4ea7f42ff31f195220a363f0e3f9e/gevent-25.9.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:f0d8b64057b4bf1529b9ef9bd2259495747fba93d1f836c77bfeaacfec373fd0", size = 1869235, upload-time = "2025-09-17T15:49:18.255Z" }, + { url = "https://files.pythonhosted.org/packages/76/24/c0c7c7db70ca74c7b1918388ebda7c8c2a3c3bff0bbfbaa9280ed04b3340/gevent-25.9.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b56cbc820e3136ba52cd690bdf77e47a4c239964d5f80dc657c1068e0fe9521c", size = 2177334, upload-time = "2025-09-17T15:15:10.073Z" }, + { url = "https://files.pythonhosted.org/packages/4c/1e/de96bd033c03955f54c455b51a5127b1d540afcfc97838d1801fafce6d2e/gevent-25.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c5fa9ce5122c085983e33e0dc058f81f5264cebe746de5c401654ab96dddfca8", size = 1847708, upload-time = "2025-09-17T15:52:38.475Z" }, + { url = "https://files.pythonhosted.org/packages/26/8b/6851e9cd3e4f322fa15c1d196cbf1a8a123da69788b078227dd13dd4208f/gevent-25.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:03c74fec58eda4b4edc043311fca8ba4f8744ad1632eb0a41d5ec25413581975", size = 2234274, upload-time = "2025-09-17T15:24:07.797Z" }, + { url = "https://files.pythonhosted.org/packages/0f/d8/b1178b70538c91493bec283018b47c16eab4bac9ddf5a3d4b7dd905dab60/gevent-25.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a8ae9f895e8651d10b0a8328a61c9c53da11ea51b666388aa99b0ce90f9fdc27", size = 1695326, upload-time = "2025-09-17T20:10:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/81/86/03f8db0704fed41b0fa830425845f1eb4e20c92efa3f18751ee17809e9c6/gevent-25.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5aff9e8342dc954adb9c9c524db56c2f3557999463445ba3d9cbe3dada7b7", size = 1792418, upload-time = "2025-09-17T15:41:24.384Z" }, + { url = "https://files.pythonhosted.org/packages/5f/35/f6b3a31f0849a62cfa2c64574bcc68a781d5499c3195e296e892a121a3cf/gevent-25.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1cdf6db28f050ee103441caa8b0448ace545364f775059d5e2de089da975c457", size = 1875700, upload-time = "2025-09-17T15:48:59.652Z" }, + { url = "https://files.pythonhosted.org/packages/66/1e/75055950aa9b48f553e061afa9e3728061b5ccecca358cef19166e4ab74a/gevent-25.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:812debe235a8295be3b2a63b136c2474241fa5c58af55e6a0f8cfc29d4936235", size = 1831365, upload-time = "2025-09-17T15:49:19.426Z" }, + { url = "https://files.pythonhosted.org/packages/31/e8/5c1f6968e5547e501cfa03dcb0239dff55e44c3660a37ec534e32a0c008f/gevent-25.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b28b61ff9216a3d73fe8f35669eefcafa957f143ac534faf77e8a19eb9e6883a", size = 2122087, upload-time = "2025-09-17T15:15:12.329Z" }, + { url = "https://files.pythonhosted.org/packages/c0/2c/ebc5d38a7542af9fb7657bfe10932a558bb98c8a94e4748e827d3823fced/gevent-25.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5e4b6278b37373306fc6b1e5f0f1cf56339a1377f67c35972775143d8d7776ff", size = 1808776, upload-time = "2025-09-17T15:52:40.16Z" }, + { url = "https://files.pythonhosted.org/packages/e6/26/e1d7d6c8ffbf76fe1fbb4e77bdb7f47d419206adc391ec40a8ace6ebbbf0/gevent-25.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d99f0cb2ce43c2e8305bf75bee61a8bde06619d21b9d0316ea190fc7a0620a56", size = 2179141, upload-time = "2025-09-17T15:24:09.895Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6c/bb21fd9c095506aeeaa616579a356aa50935165cc0f1e250e1e0575620a7/gevent-25.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:72152517ecf548e2f838c61b4be76637d99279dbaa7e01b3924df040aa996586", size = 1677941, upload-time = "2025-09-17T19:59:50.185Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/e55930ba5259629eb28ac7ee1abbca971996a9165f902f0249b561602f24/gevent-25.9.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:46b188248c84ffdec18a686fcac5dbb32365d76912e14fda350db5dc0bfd4f86", size = 2955991, upload-time = "2025-09-17T14:52:30.568Z" }, + { url = "https://files.pythonhosted.org/packages/aa/88/63dc9e903980e1da1e16541ec5c70f2b224ec0a8e34088cb42794f1c7f52/gevent-25.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f2b54ea3ca6f0c763281cd3f96010ac7e98c2e267feb1221b5a26e2ca0b9a692", size = 1808503, upload-time = "2025-09-17T15:41:25.59Z" }, + { url = "https://files.pythonhosted.org/packages/7a/8d/7236c3a8f6ef7e94c22e658397009596fa90f24c7d19da11ad7ab3a9248e/gevent-25.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7a834804ac00ed8a92a69d3826342c677be651b1c3cd66cc35df8bc711057aa2", size = 1890001, upload-time = "2025-09-17T15:49:01.227Z" }, + { url = "https://files.pythonhosted.org/packages/4f/63/0d7f38c4a2085ecce26b50492fc6161aa67250d381e26d6a7322c309b00f/gevent-25.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:323a27192ec4da6b22a9e51c3d9d896ff20bc53fdc9e45e56eaab76d1c39dd74", size = 1855335, upload-time = "2025-09-17T15:49:20.582Z" }, + { url = "https://files.pythonhosted.org/packages/95/18/da5211dfc54c7a57e7432fd9a6ffeae1ce36fe5a313fa782b1c96529ea3d/gevent-25.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6ea78b39a2c51d47ff0f130f4c755a9a4bbb2dd9721149420ad4712743911a51", size = 2109046, upload-time = "2025-09-17T15:15:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/a6/5a/7bb5ec8e43a2c6444853c4a9f955f3e72f479d7c24ea86c95fb264a2de65/gevent-25.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:dc45cd3e1cc07514a419960af932a62eb8515552ed004e56755e4bf20bad30c5", size = 1827099, upload-time = "2025-09-17T15:52:41.384Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d4/b63a0a60635470d7d986ef19897e893c15326dd69e8fb342c76a4f07fe9e/gevent-25.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34e01e50c71eaf67e92c186ee0196a039d6e4f4b35670396baed4a2d8f1b347f", size = 2172623, upload-time = "2025-09-17T15:24:12.03Z" }, + { url = "https://files.pythonhosted.org/packages/d5/98/caf06d5d22a7c129c1fb2fc1477306902a2c8ddfd399cd26bbbd4caf2141/gevent-25.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:4acd6bcd5feabf22c7c5174bd3b9535ee9f088d2bbce789f740ad8d6554b18f3", size = 1682837, upload-time = "2025-09-17T19:48:47.318Z" }, + { url = "https://files.pythonhosted.org/packages/5a/77/b97f086388f87f8ad3e01364f845004aef0123d4430241c7c9b1f9bde742/gevent-25.9.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:4f84591d13845ee31c13f44bdf6bd6c3dbf385b5af98b2f25ec328213775f2ed", size = 2973739, upload-time = "2025-09-17T14:53:30.279Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/9d5f204ead343e5b27bbb2fedaec7cd0009d50696b2266f590ae845d0331/gevent-25.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9cdbb24c276a2d0110ad5c978e49daf620b153719ac8a548ce1250a7eb1b9245", size = 1809165, upload-time = "2025-09-17T15:41:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/10/3e/791d1bf1eb47748606d5f2c2aa66571f474d63e0176228b1f1fd7b77ab37/gevent-25.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:88b6c07169468af631dcf0fdd3658f9246d6822cc51461d43f7c44f28b0abb82", size = 1890638, upload-time = "2025-09-17T15:49:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5c/9ad0229b2b4d81249ca41e4f91dd8057deaa0da6d4fbe40bf13cdc5f7a47/gevent-25.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b7bb0e29a7b3e6ca9bed2394aa820244069982c36dc30b70eb1004dd67851a48", size = 1857118, upload-time = "2025-09-17T15:49:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/49/2a/3010ed6c44179a3a5c5c152e6de43a30ff8bc2c8de3115ad8733533a018f/gevent-25.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2951bb070c0ee37b632ac9134e4fdaad70d2e660c931bb792983a0837fe5b7d7", size = 2111598, upload-time = "2025-09-17T15:15:15.226Z" }, + { url = "https://files.pythonhosted.org/packages/08/75/6bbe57c19a7aa4527cc0f9afcdf5a5f2aed2603b08aadbccb5bf7f607ff4/gevent-25.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4e17c2d57e9a42e25f2a73d297b22b60b2470a74be5a515b36c984e1a246d47", size = 1829059, upload-time = "2025-09-17T15:52:42.596Z" }, + { url = "https://files.pythonhosted.org/packages/06/6e/19a9bee9092be45679cb69e4dd2e0bf5f897b7140b4b39c57cc123d24829/gevent-25.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d94936f8f8b23d9de2251798fcb603b84f083fdf0d7f427183c1828fb64f117", size = 2173529, upload-time = "2025-09-17T15:24:13.897Z" }, + { url = "https://files.pythonhosted.org/packages/ca/4f/50de9afd879440e25737e63f5ba6ee764b75a3abe17376496ab57f432546/gevent-25.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:eb51c5f9537b07da673258b4832f6635014fee31690c3f0944d34741b69f92fa", size = 1681518, upload-time = "2025-09-17T19:39:47.488Z" }, + { url = "https://files.pythonhosted.org/packages/15/1a/948f8167b2cdce573cf01cec07afc64d0456dc134b07900b26ac7018b37e/gevent-25.9.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:1a3fe4ea1c312dbf6b375b416925036fe79a40054e6bf6248ee46526ea628be1", size = 2982934, upload-time = "2025-09-17T14:54:11.302Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ec/726b146d1d3aad82e03d2e1e1507048ab6072f906e83f97f40667866e582/gevent-25.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0adb937f13e5fb90cca2edf66d8d7e99d62a299687400ce2edee3f3504009356", size = 1813982, upload-time = "2025-09-17T15:41:28.506Z" }, + { url = "https://files.pythonhosted.org/packages/35/5d/5f83f17162301662bd1ce702f8a736a8a8cac7b7a35e1d8b9866938d1f9d/gevent-25.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:427f869a2050a4202d93cf7fd6ab5cffb06d3e9113c10c967b6e2a0d45237cb8", size = 1894902, upload-time = "2025-09-17T15:49:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/83/cd/cf5e74e353f60dab357829069ffc300a7bb414c761f52cf8c0c6e9728b8d/gevent-25.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c049880175e8c93124188f9d926af0a62826a3b81aa6d3074928345f8238279e", size = 1861792, upload-time = "2025-09-17T15:49:23.279Z" }, + { url = "https://files.pythonhosted.org/packages/dd/65/b9a4526d4a4edce26fe4b3b993914ec9dc64baabad625a3101e51adb17f3/gevent-25.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5a67a0974ad9f24721034d1e008856111e0535f1541499f72a733a73d658d1c", size = 2113215, upload-time = "2025-09-17T15:15:16.34Z" }, + { url = "https://files.pythonhosted.org/packages/e5/be/7d35731dfaf8370795b606e515d964a0967e129db76ea7873f552045dd39/gevent-25.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1d0f5d8d73f97e24ea8d24d8be0f51e0cf7c54b8021c1fddb580bf239474690f", size = 1833449, upload-time = "2025-09-17T15:52:43.75Z" }, + { url = "https://files.pythonhosted.org/packages/65/58/7bc52544ea5e63af88c4a26c90776feb42551b7555a1c89c20069c168a3f/gevent-25.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddd3ff26e5c4240d3fbf5516c2d9d5f2a998ef87cfb73e1429cfaeaaec860fa6", size = 2176034, upload-time = "2025-09-17T15:24:15.676Z" }, + { url = "https://files.pythonhosted.org/packages/c2/69/a7c4ba2ffbc7c7dbf6d8b4f5d0f0a421f7815d229f4909854266c445a3d4/gevent-25.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:bb63c0d6cb9950cc94036a4995b9cc4667b8915366613449236970f4394f94d7", size = 1703019, upload-time = "2025-09-17T19:30:55.272Z" }, +] + +[[package]] +name = "geventhttpclient" +version = "2.3.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "brotli" }, + { name = "certifi" }, + { name = "gevent" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/ff/cb3db11fca4223b2753ae170d1a09c9d32bfbfa3e8d4a6181324db686830/geventhttpclient-2.3.9.tar.gz", hash = "sha256:16807578dc4a175e8d97e6e39d65a10b04b5237a8c55f7a5ef39044e869baeb8", size = 84353, upload-time = "2026-03-03T08:09:03.336Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/04/ab0f19f591bd9e93121c857ef310f1c02930aca024e25784127da93ce39f/geventhttpclient-2.3.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25c03073a1136c2b93189488bb1bfc0868d90aa106dd49f15ac964d2454296c6", size = 70141, upload-time = "2026-03-03T08:07:47.714Z" }, + { url = "https://files.pythonhosted.org/packages/ab/e9/0133bd17574ec647d2a754204c18ae241a1ecb93eb7808dd1865972f96f7/geventhttpclient-2.3.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e1e711eb91085585f61445c7313e1a0acb159b5dc11327930e673b4899ebd84f", size = 51745, upload-time = "2026-03-03T08:07:48.726Z" }, + { url = "https://files.pythonhosted.org/packages/33/a5/aaf42c13002c7f52b1d9985bec69718cc697303cb1610629519be76b60e6/geventhttpclient-2.3.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:829454480d001f43bce4a8373bfe282a418b09817c32ce9b369ce637ae5240ab", size = 51564, upload-time = "2026-03-03T08:07:49.517Z" }, + { url = "https://files.pythonhosted.org/packages/62/96/82895ba3cbc61f6ca125894449a8d164ce730393dae01f81460df95ba72a/geventhttpclient-2.3.9-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f7cf60062d3aebd5e83f4d197a59609194effe25a25bcab01ae3775be18c877e", size = 114606, upload-time = "2026-03-03T08:07:50.533Z" }, + { url = "https://files.pythonhosted.org/packages/36/3c/cab8117e80eb7e7ac4da94bbceff44d96f07d294409d88d02f5e50235e6b/geventhttpclient-2.3.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b9587beaccac950619f1defe0e1b9499a275edf8d912095f041060c62cb1aa3", size = 115543, upload-time = "2026-03-03T08:07:51.347Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/db3e16dcd28ce1e72282733cceb358f67a134c7e92d20bf520f073f2c4c3/geventhttpclient-2.3.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c71796fda35bfe5b4ae93cdca62fd4932ee95c2b36812ce65878183ca7da517", size = 121386, upload-time = "2026-03-03T08:07:52.456Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ad/aa8042a623dd69f9847a08ac491e2136b0d88158a4a413bf4c0e354e6a5f/geventhttpclient-2.3.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f74053954f4599afb48b2c7765532c7e0cb5b0f1d0a62da8342ac4b5aadb76f9", size = 111411, upload-time = "2026-03-03T08:07:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/3f/40/e86f63af699409eb5546046c4c3ffb3be812f17201e04cffcf830c2fea57/geventhttpclient-2.3.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:09b82815247a1044c648bac1cee1e766e03e762950cae49cf61efffaeff667c4", size = 118043, upload-time = "2026-03-03T08:07:54.371Z" }, + { url = "https://files.pythonhosted.org/packages/3d/64/e3e8e82c45f5d78d1046e45ca26df98706628836bfd7502abb78f815beea/geventhttpclient-2.3.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ecd2e843d1649cb5fba678240bb9778f6229b7315faa07a3696ccabcf289f609", size = 111690, upload-time = "2026-03-03T08:07:55.246Z" }, + { url = "https://files.pythonhosted.org/packages/25/2f/3c0a0f7ca7bef0d6fac256c152a4896bb938c44507b7c67806bd1084704f/geventhttpclient-2.3.9-cp310-cp310-win32.whl", hash = "sha256:1d0c2af2aff5b802cdec4b6b216348a32a2452f4e5f5f2e19fc5f84d77443649", size = 48725, upload-time = "2026-03-03T08:07:56.177Z" }, + { url = "https://files.pythonhosted.org/packages/cd/cb/109450f861af0aba887b0a9fc9f839fabadde44222715b99151a16129d9a/geventhttpclient-2.3.9-cp310-cp310-win_amd64.whl", hash = "sha256:d980c54f98bc623e10f94595de633690bbf690b915e6ef2298df6728b31f0285", size = 49395, upload-time = "2026-03-03T08:07:57.226Z" }, + { url = "https://files.pythonhosted.org/packages/07/9e/17f086d8529582e2c0dcc8ec238f6250eaee1f38d8a315a0a1b4b84aeb49/geventhttpclient-2.3.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cf18417cabb210be64d1b610ced94387f4222fa4e0942486d5d5a6237d2dd9fa", size = 70149, upload-time = "2026-03-03T08:07:57.99Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3a/f075d390d17117dfc8cfe36528ae5ffafbc86181d9c24b545705f396b9b2/geventhttpclient-2.3.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3fc084a475eca84257b1f77dd584678c7e4bdc625f66b0279f2cfa54901a5ef8", size = 51746, upload-time = "2026-03-03T08:07:58.804Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/41c47b47c30457eb3091b1f46879092364bb6fcd37d141274b8df4103a28/geventhttpclient-2.3.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c840b05ec56d16783f24926de25ce38d3453673ce4786896c63febd2fb34a6cf", size = 51566, upload-time = "2026-03-03T08:07:59.569Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ca/e22ca8eb5977f5ee04c51c77e0388727f01aa94411264b50224195c75fe0/geventhttpclient-2.3.9-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4f35a5adbb0770824e98372dcec6805180c3ee99287e52598a4fb3b5d1a2b8aa", size = 114666, upload-time = "2026-03-03T08:08:00.388Z" }, + { url = "https://files.pythonhosted.org/packages/95/c4/8152b481bf431b159fe14688c3b1228505466d4264dace10a00e9b287aaa/geventhttpclient-2.3.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0658266fa594931e5260f17c6f52f867597e5cb257e85f73990b2f61bad58ec7", size = 115606, upload-time = "2026-03-03T08:08:01.221Z" }, + { url = "https://files.pythonhosted.org/packages/dd/77/2bdfbfdb63b439fd7bd5b93a59701bc056e986ead05d18c598bcb70ccb21/geventhttpclient-2.3.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:83da9a0cab4c990ac48316bed696aa1ffc0e678cbca725c3e904b84ee9c5d3e1", size = 121488, upload-time = "2026-03-03T08:08:02.059Z" }, + { url = "https://files.pythonhosted.org/packages/42/ae/ffb2502049b6040e0e9b31a9944672dcd34e6a88a52a49e47b0b208795c4/geventhttpclient-2.3.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e3d120c2dbaf931fb1690ede4b7022bcaad82fa181e288b04d2f8a5e2d3d7eab", size = 111518, upload-time = "2026-03-03T08:08:02.879Z" }, + { url = "https://files.pythonhosted.org/packages/75/00/82a1f8a214c9f33dacc963fd08fa14d23fcc44e74beacbe95abc19e1d118/geventhttpclient-2.3.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1dac4df42a954e19d3e737c4c4351332cf27e415c0e7b8850070fd8056237a04", size = 118191, upload-time = "2026-03-03T08:08:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/f5/71/c85c739a94f80384ede1070870514033754ef5208a4afbb802ca632efb18/geventhttpclient-2.3.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:269e861e7fc38994b315b50469c8e629e3a78321a049598c4f4a0f21053e5503", size = 111793, upload-time = "2026-03-03T08:08:04.546Z" }, + { url = "https://files.pythonhosted.org/packages/70/b5/b8495d93046e0dcaf59622fb6f70d6a319cc9eea49679ec4da3b98209b31/geventhttpclient-2.3.9-cp311-cp311-win32.whl", hash = "sha256:e8b30889ee4d5629904321da2a068ffb3a6114c7bcd46416051e869911b20a90", size = 48723, upload-time = "2026-03-03T08:08:05.636Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4d/3b96953db366d912180bc04904fe92398823a5ddf62982adfa5b2cdbadba/geventhttpclient-2.3.9-cp311-cp311-win_amd64.whl", hash = "sha256:224e4a959ece6673f4c57113013fc20ed020e661d6de3c820aa3afe2f1cf2e99", size = 49396, upload-time = "2026-03-03T08:08:06.385Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8f/a6a787443af8defaae8bab96e056f2e0d48ffcb4eb2724d83727c465335f/geventhttpclient-2.3.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39afb8046fe04358a85956555aa6a1d931710bac386a2fceb5c24bbd4d7c10e7", size = 70141, upload-time = "2026-03-03T08:08:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/18/c6/043e74e5ce9ce7cfd206f2ba572ded6bfe7278fb73d0d81aef0e913e9b5d/geventhttpclient-2.3.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:04b8feec69fd662eb46b4f81013206f5a23d179b195cbaf590d4a59f641ed0fc", size = 51776, upload-time = "2026-03-03T08:08:09.206Z" }, + { url = "https://files.pythonhosted.org/packages/16/b2/f65c47ec71278f02c22e5d7120db855fe9c1d8034994c6c4ac8ed9b2328a/geventhttpclient-2.3.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5b542025a0c9905c847d1459e598ccbdcd21dc0dd050cc1d3813ce7e01bd350f", size = 51523, upload-time = "2026-03-03T08:08:10.119Z" }, + { url = "https://files.pythonhosted.org/packages/5a/12/bed280730e754bc8f1691481a58cff71eca16f439dc6629ad6843ffc9988/geventhttpclient-2.3.9-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c582a6697c82a948d3d42094da941544606a0ebee31fc0aa6731e248eeba0e9b", size = 115393, upload-time = "2026-03-03T08:08:11.239Z" }, + { url = "https://files.pythonhosted.org/packages/3c/eb/def9770ae049a64b698c78186ebe1281900da581401a043c206efe2957ca/geventhttpclient-2.3.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39011c8cdd7ef8b6ab07592525f83018cd1504e8133cce5114bfcee5547b9bb5", size = 116043, upload-time = "2026-03-03T08:08:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/62/50/877f2ddd8ebebb0e060bfc34ed5d3721689e96c6debb218f5bac9fa04339/geventhttpclient-2.3.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b91fb31523725ddc777c14b444ccedaf2043dcb9af0ede29056a9b8146c79a7", size = 122061, upload-time = "2026-03-03T08:08:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/4c/00/6ef955f9eb1cfd7412ee20e5f7bee2459f3a9aad3330b4c193729a968ee2/geventhttpclient-2.3.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d91139a4fafd77fa985535966d7a6c2e64753f340ab1395508ee83cd8de70c38", size = 111963, upload-time = "2026-03-03T08:08:15.33Z" }, + { url = "https://files.pythonhosted.org/packages/28/10/965899a6c557055974b2aca048d478451aa144876171fbe023959fd8f42a/geventhttpclient-2.3.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0ff40ca5b848f96c6390bd8cc3a4c4598c119be08125cf1c30103201adc00940", size = 118841, upload-time = "2026-03-03T08:08:16.173Z" }, + { url = "https://files.pythonhosted.org/packages/67/e1/e559f1ee8b0d26870cabae401bb32706390ade2f4e540695fbb6ed908bdb/geventhttpclient-2.3.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9091db18eeb53626a81e9280d602ae9e29706ee4c1e7a05edc8b07cc632b3fc", size = 112618, upload-time = "2026-03-03T08:08:17.301Z" }, + { url = "https://files.pythonhosted.org/packages/a6/25/03b06f42ddb202f2a7da0f8dc3759ac4a09213bad6122e218397268832d0/geventhttpclient-2.3.9-cp312-cp312-win32.whl", hash = "sha256:4110273531fc9ac2ec197a44a90d9c7b4266b51a070747368e38213be281d5c2", size = 48750, upload-time = "2026-03-03T08:08:18.204Z" }, + { url = "https://files.pythonhosted.org/packages/fe/32/918822056ecc395a1f4e81e292a4549779fd255dd533b036aac80023b691/geventhttpclient-2.3.9-cp312-cp312-win_amd64.whl", hash = "sha256:98f3582a1c9effb56bc2db4f43d382cedd921217a139d5737eeaad3a1e307047", size = 49383, upload-time = "2026-03-03T08:08:19.157Z" }, + { url = "https://files.pythonhosted.org/packages/12/0c/ec3e7926e5a24780ad0f2d422799966f2f13342c793ed9f37f0c03282f58/geventhttpclient-2.3.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9d0568d38cf74cecd37fd1ef65459f60ecd26dbc0d33bc2a1e0d8df4af24f07d", size = 70144, upload-time = "2026-03-03T08:08:19.932Z" }, + { url = "https://files.pythonhosted.org/packages/63/d7/d28f76482880f9233de07fb9422db26b983a901cad4670bba8bc1170f988/geventhttpclient-2.3.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:02e06a2f78a225b70e616b493317073f3e2fddd4e51ddfc44569d188f368bd8d", size = 51779, upload-time = "2026-03-03T08:08:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/35/ff/930be8f0e4f84d1b229b1ec394463ea36701991d888f4856904e292a6b0b/geventhttpclient-2.3.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3eec8e442214d4086e40a3ae7fe1e1e3ecbc422157d8d2118059cf9977336d9f", size = 51516, upload-time = "2026-03-03T08:08:21.802Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ac/952c51392527f707c1f08401d0b477cdd1840a487dffa6e9fce444d54122/geventhttpclient-2.3.9-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a18b28d2f8bc7fcfc721227733bccb647602399db6b0fd093c00ff9699717b74", size = 115412, upload-time = "2026-03-03T08:08:22.615Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1f/1b61f8dae1efb670f7728cd727c35ff294b89af727db268f9e2d90102a97/geventhttpclient-2.3.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b16e30dbbc528453a4130210d83638444229357c073eb911421eb44e3367359", size = 116088, upload-time = "2026-03-03T08:08:23.474Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/941c05d483fe8a95672f8f39e7410292f4b617020d1d595b88da5660b132/geventhttpclient-2.3.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06df5597edf65d4c691052fce3e37620cbc037879a3b872bc16a7b2a0941d59a", size = 122068, upload-time = "2026-03-03T08:08:24.495Z" }, + { url = "https://files.pythonhosted.org/packages/fa/75/84400d58934f774cef259c8b49292542313c02224c2f11b1b116d720b464/geventhttpclient-2.3.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:47a303bcac3d69569f025d0c81781c5f0c1a48c9f225e43082d1b56e4c0440f8", size = 112054, upload-time = "2026-03-03T08:08:25.663Z" }, + { url = "https://files.pythonhosted.org/packages/12/ae/12821cad292235d4db8532f58c8bc93db4211862845bf76a4c06e6ed1416/geventhttpclient-2.3.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e73b25415e83064f5a334e83495d97b138e66f67a98cfcad154068c257733973", size = 118837, upload-time = "2026-03-03T08:08:26.816Z" }, + { url = "https://files.pythonhosted.org/packages/4f/d3/34e80569f3563eb26f5d7bb971677de0b53b16d720f87373cc7aeee51c04/geventhttpclient-2.3.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:98ff3350d8be75586076140bde565c35ccdd72a6840b88f94037ec6595407383", size = 112643, upload-time = "2026-03-03T08:08:27.666Z" }, + { url = "https://files.pythonhosted.org/packages/b1/79/94ace94281e40f7258ba4e7166ae846394d2a673dbe47a0a255eb0d53ca8/geventhttpclient-2.3.9-cp313-cp313-win32.whl", hash = "sha256:af7931f55522cddedf84e837769c66d9ceb130b29182ad1e2d0201f501df899f", size = 48741, upload-time = "2026-03-03T08:08:28.507Z" }, + { url = "https://files.pythonhosted.org/packages/89/67/15b1ba79dfbab515c0d42a01b6545adef7dad00968eaa89ec21cca030c2e/geventhttpclient-2.3.9-cp313-cp313-win_amd64.whl", hash = "sha256:14daf2f0361f19b0221f900d7e9d563c184bb7186676e61fe848495b1f2483d3", size = 49371, upload-time = "2026-03-03T08:08:29.319Z" }, + { url = "https://files.pythonhosted.org/packages/16/9f/57d5acd0d95417a29661dfa91a8657be8026a9df17cafc6ba4f20bc2a687/geventhttpclient-2.3.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c06e243de53f54942b098f81622917f4a33c16f44733c9371ea98a2cd5ce12e", size = 70423, upload-time = "2026-03-03T08:08:30.106Z" }, + { url = "https://files.pythonhosted.org/packages/29/8b/ad6eb43b136fdb2f4954dc21073911d7703ea95fd88a3cc7512714508ce3/geventhttpclient-2.3.9-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:549155d557de403612336ca36cd93a049e67acbf9a29e6b6b971d0f4cb56786d", size = 51902, upload-time = "2026-03-03T08:08:30.892Z" }, + { url = "https://files.pythonhosted.org/packages/1a/64/2d2cfd9dd9ae0a6d4138b8a88f0b4524657a48a7c81ead6986a3e955deda/geventhttpclient-2.3.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:31b463324d5fde983657247b2faea77f8f8a40f3f7ac0c2897a2fe3afa27d610", size = 51564, upload-time = "2026-03-03T08:08:31.717Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f3/4585fabea4f45c4c21cd128d61ebbf43a78c73d520c70471734d41177b1d/geventhttpclient-2.3.9-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e1ac3a39e3c4ae36024ddf1694eb82b0cc22c4516f176477f94f98bcd56ce6cf", size = 115449, upload-time = "2026-03-03T08:08:32.75Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e8/d7d82f527c632cbdeffa34858557db3da238f68f2fbb9bd80f2ec2c64510/geventhttpclient-2.3.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3d24480c3a2cc88311c41a042bc12ab8e4104dad6029591ecbf5a1e933e8a44", size = 116152, upload-time = "2026-03-03T08:08:33.941Z" }, + { url = "https://files.pythonhosted.org/packages/ea/63/25ee53c3efa9ded9976e4f5ac8c6f8e8cef941bbbc847290e7f5c0254c40/geventhttpclient-2.3.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b244adcbf5814a29d5cea8b2fc079f9242d92765191faa4dc5eccc0421840ae", size = 122145, upload-time = "2026-03-03T08:08:34.809Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8c/15c5d71e6011f317f7decb26fd15e1e6caf780b09297af3018599311e6df/geventhttpclient-2.3.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:83dc6f037a50b7d2dc45af58a7e7978016a06320a5f823d1bd544c85d69f2058", size = 112134, upload-time = "2026-03-03T08:08:35.716Z" }, + { url = "https://files.pythonhosted.org/packages/02/5b/fd7b17c37a9f9002a5fd8d690c97ada372393fcfb9358dd62026e089ae96/geventhttpclient-2.3.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:caf8779ca686497e0fab1048b026b4e48fb14fb9e88ddbfd14ca1a1a4c4bfa89", size = 118879, upload-time = "2026-03-03T08:08:36.953Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/c1559f43ef56100d64bf1b227844bf229101fdb58b556e2336b4307bda0d/geventhttpclient-2.3.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cd4efebba798c7f585aa1ceb9aba9524b12ebc51b26ad62de5234b8264d9b94d", size = 112593, upload-time = "2026-03-03T08:08:37.842Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a61a9cb76feede0d4429154d391c275debde78b6602f52c95aeed6dce1ff/geventhttpclient-2.3.9-cp314-cp314-win32.whl", hash = "sha256:7b60c0b650c77d2644374149c38dfee34510e88e569ca85f38fe15f40ecaea1c", size = 49390, upload-time = "2026-03-03T08:08:38.996Z" }, + { url = "https://files.pythonhosted.org/packages/fd/05/b8d71edd82c9b07b9be0c3e5d6faf94583c6a098e2e9e5ee2b14a6312c5e/geventhttpclient-2.3.9-cp314-cp314-win_amd64.whl", hash = "sha256:c4d5e1b9b1ac9baab42a1789bbfae7e97e40e8e83e09a32b353c6eb985f36071", size = 49881, upload-time = "2026-03-03T08:08:40.228Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/2f7f8f63968d26d7233fb9b9e5b1a5015989b90f95e997e9dc98283b0a86/geventhttpclient-2.3.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ae44cec808193bb70b634fabdfdd89f0850744ace5668dc98063d633cf50c417", size = 70812, upload-time = "2026-03-03T08:08:41.073Z" }, + { url = "https://files.pythonhosted.org/packages/5f/89/7887f802adee5990c10dd9c44b20a3205e046773061266ce5cffb99e30b9/geventhttpclient-2.3.9-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:53977ca41809eaef73cf38af170484baa53bde5f16bafbca7b77b670c343f48f", size = 52087, upload-time = "2026-03-03T08:08:42.063Z" }, + { url = "https://files.pythonhosted.org/packages/5d/07/23cc505111abb65cb5a68e5cd123b1ffc1ad7893a1bc46945b9ed3d03245/geventhttpclient-2.3.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0f66a33c95e4d6d343fc6ace458b13c613684bf7cfd6832b61cc9c42eaf394f3", size = 51772, upload-time = "2026-03-03T08:08:42.926Z" }, + { url = "https://files.pythonhosted.org/packages/d9/97/461fd5c73858b2daaaba2ecefd2ff64aa8f2242c48c939e75caba9ec3cb2/geventhttpclient-2.3.9-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cfe23d419aa676492677374bdd37e364c921895d1090a180173be5d5f87f82b9", size = 118329, upload-time = "2026-03-03T08:08:44.07Z" }, + { url = "https://files.pythonhosted.org/packages/c2/08/0ede3d90ab92a105f24b758b0bfb2d5e7f34c017d22d76d87524e75e93cc/geventhttpclient-2.3.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e3b279da39ad3eee69a5df9e1b602f87bcd2cec7eb258d3cc801e2170682383", size = 119974, upload-time = "2026-03-03T08:08:45.231Z" }, + { url = "https://files.pythonhosted.org/packages/98/8f/0ef02946bbbbd91ba4c3da99657d90e250c00409710ed377e4e4540b90c3/geventhttpclient-2.3.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:38535589a564822c64d1b4c2a5d6dcc27159d0d7d76500f2c8c8d21d9dd54880", size = 125764, upload-time = "2026-03-03T08:08:46.446Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e1/d8f385fd6a3538cf1fd57a3fd47b133fba2e32c6be86e75805117d96ff1f/geventhttpclient-2.3.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a6436cd77885a8ef7cdc6d225cddd732560a17e92969c74e997836cf3135baa0", size = 115599, upload-time = "2026-03-03T08:08:47.393Z" }, + { url = "https://files.pythonhosted.org/packages/cb/55/ec651647ee2f7fdfee8d7a75ba682064e0e5012696f9aa83c0392d54fdeb/geventhttpclient-2.3.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5000c9fb0553818c4e4c1de248ee4e9a56de0a245a30ef76b687542a935f4645", size = 122254, upload-time = "2026-03-03T08:08:48.566Z" }, + { url = "https://files.pythonhosted.org/packages/77/7d/20606d1a4ae085eb3935e4d3625e7208911d0f1a0006c9fd962d88254d92/geventhttpclient-2.3.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:52516d5c153fcef0d3d2447e533244dc6360e8c2a190b958861137db6f227605", size = 115383, upload-time = "2026-03-03T08:08:49.454Z" }, + { url = "https://files.pythonhosted.org/packages/85/16/d20ac6ac73d63fe326ffe357a8e91c4f43b9e790faeeb9b15774eecf2550/geventhttpclient-2.3.9-cp314-cp314t-win32.whl", hash = "sha256:14eaa836bde26a70952e95ca462018f3a47c1c92642327315aa6502e54141016", size = 49749, upload-time = "2026-03-03T08:08:50.339Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/95d1ed1ace7902d8e1ce698db31931cb87d4abe621ec2df24e69daf49ae9/geventhttpclient-2.3.9-cp314-cp314t-win_amd64.whl", hash = "sha256:b9bbcbc7d5d875e5180f2b1f1c6fa8e092ef80d9debfb6ba22a4ec28f0565395", size = 50300, upload-time = "2026-03-03T08:08:51.482Z" }, +] + [[package]] name = "gitdb" version = "4.0.12" @@ -3909,6 +4130,7 @@ dev = [ { name = "vcrpy" }, ] e2e-dev = [ + { name = "locust" }, { name = "playwright" }, { name = "websockets" }, ] @@ -4084,6 +4306,7 @@ dev = [ { name = "vcrpy", specifier = "==8.2.1" }, ] e2e-dev = [ + { name = "locust", specifier = "==2.45.0" }, { name = "playwright", specifier = "==1.61.0" }, { name = "websockets", specifier = ">=15.0.1,<16.0" }, ] @@ -4125,6 +4348,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/cf/c0419980643daca64173cadda2edad6addc8965b0e694ab0e18a8f77a2c6/llm_sandbox-0.3.39-py3-none-any.whl", hash = "sha256:4fd22a4ae175695de8d9e151f6b12e270859d65208f946b8abec8d5ae2e29b66", size = 108859, upload-time = "2026-04-20T16:59:44.02Z" }, ] +[[package]] +name = "locust" +version = "2.45.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "configargparse" }, + { name = "flask" }, + { name = "flask-cors" }, + { name = "flask-login" }, + { name = "gevent" }, + { name = "geventhttpclient" }, + { name = "msgpack" }, + { name = "psutil" }, + { name = "pytest" }, + { name = "python-engineio" }, + { name = "python-socketio", extra = ["client"] }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "pyzmq" }, + { name = "requests" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/4e/62c3fee4af4720aceac2c102a6a1d69707b7b28e83fc44b82581e86155d7/locust-2.45.0.tar.gz", hash = "sha256:9fa840a4ef8c2624d7719f6e642bce3e19079aa4eea755c99c5282087903e13a", size = 1477628, upload-time = "2026-07-09T08:20:46.931Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/a5/c138eda829a34f00d1dbaa90bffdf2b31defcc55a722978e1d5774500d78/locust-2.45.0-py3-none-any.whl", hash = "sha256:a8b4d059296a38fa79432be60fb6904f70367e7d6fb824da64b744c45d8073ae", size = 1497273, upload-time = "2026-07-09T08:20:45.423Z" }, +] + [[package]] name = "logfire" version = "4.6.0" @@ -4610,6 +4861,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5e/75/bd9b7bb966668920f06b200e84454c8f3566b102183bc55c5473d96cb2b9/msal_extensions-1.3.1-py3-none-any.whl", hash = "sha256:96d3de4d034504e969ac5e85bae8106c8373b5c6568e4c8fa7af2eca9dbe6bca", size = 20583, upload-time = "2025-03-14T23:51:03.016Z" }, ] +[[package]] +name = "msgpack" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/16/f70100614b69feb3ade7285f08c9c52d6cda0a5c03f3f5e2facd63acb211/msgpack-1.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8c7b398c56ff125feae96c2737abfec5595f1fa0aa186df60c56040b8accb95c", size = 82926, upload-time = "2026-06-18T16:12:31.531Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3c/08ecd5cdfe4e2de43aec79062028ad0f7b2d9b1fea5430068c198ba570da/msgpack-1.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1548006a91aa93c5da81f3bdcebc1a0d10cea2d25969754fbe848da622b2b895", size = 82730, upload-time = "2026-06-18T16:12:32.894Z" }, + { url = "https://files.pythonhosted.org/packages/19/9f/a70c9cb1a04ecc134005149367dcfe35d167284e8f65035a1e4156ad17b5/msgpack-1.2.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1dabedcd0f23559f3596428c6589c1cd8c6eaed3a0d720795b07b0225d769203", size = 400729, upload-time = "2026-06-18T16:12:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7f/5ce020168cf0439041526e95aa068c722c016aee21624e331aeabeee2e8e/msgpack-1.2.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83efa1c898e0fc5380fc0cabbf75164c52e3b5cbb45973710d75821928380c73", size = 407625, upload-time = "2026-06-18T16:12:35.239Z" }, + { url = "https://files.pythonhosted.org/packages/79/70/fb7668ce0386819303047057aef6fc1da73b584291d9cff82b821744e2ef/msgpack-1.2.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01e2dd6c9b19d333a00282330cc8a73d38d8dabc306dc5b42cd668c3ac82e833", size = 377891, upload-time = "2026-06-18T16:12:36.684Z" }, + { url = "https://files.pythonhosted.org/packages/3d/dc/9ebe654a73c3aed2e40aa6b52e3c2a02b5f53ef0085fa235a45d5b367f87/msgpack-1.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:350cb813d0af6e65d2f7ef0d729f7ff5be5a8bce03665892f43e5883d4ecc1b8", size = 391987, upload-time = "2026-06-18T16:12:37.839Z" }, + { url = "https://files.pythonhosted.org/packages/42/eb/b67cf64218a2fa25e1c671fe1d3dbb06cbeb973e71bc4b822da079862d0b/msgpack-1.2.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ee1d9ed27d0497b848923746cf762ed2e7db24f4be7eec8e5cbe8c766aa707b7", size = 374603, upload-time = "2026-06-18T16:12:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2e/9ee200cde32fd1a0101b4006202fde554c1860adfb9bf7bff31ea4c08df8/msgpack-1.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:633727297ed063441fd1cda2288865487f33ad14eeb8831afb5f0c396a62cfce", size = 405121, upload-time = "2026-06-18T16:12:40.524Z" }, + { url = "https://files.pythonhosted.org/packages/43/b6/f10117be7ca7a51e8feed699a907b8e663a8cd66e115ae6b4fb30cc7945c/msgpack-1.2.1-cp310-cp310-win32.whl", hash = "sha256:298872ecf9e61950f1c6af4ca969b859ee91783bb920ef6e6172697d0c8aad74", size = 64088, upload-time = "2026-06-18T16:12:41.762Z" }, + { url = "https://files.pythonhosted.org/packages/ba/93/89976c696fb0224662239d952c47b4d1661b34d79a332ef5584facaa8579/msgpack-1.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:2ff164c1b0bcb740b073b99e945234d0212852fa378e44a208c425379140dbeb", size = 70113, upload-time = "2026-06-18T16:12:42.78Z" }, + { url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" }, + { url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" }, + { url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" }, + { url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" }, + { url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" }, + { url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" }, + { url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" }, + { url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" }, + { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" }, + { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, + { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, + { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, + { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" }, + { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, + { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, + { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, + { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, + { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, + { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, + { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, + { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, + { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, + { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, + { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, + { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, + { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, + { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, + { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, + { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, + { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, +] + [[package]] name = "multidict" version = "6.7.1" @@ -7015,6 +7339,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-engineio" +version = "4.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "simple-websocket" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/a0/f75491f942184d9960b15e763270f765fe9f239745ca5f9e16289011aed4/python_engineio-4.13.3.tar.gz", hash = "sha256:572b7783e341fed21edbc7cea297ccd378dad79265fdde96aa4664420a7c06c9", size = 79734, upload-time = "2026-06-20T22:53:52.197Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/96/82f6328e410515fab21d5602ba35b9377a47b5a141a0c1f9efa00ce21eb4/python_engineio-4.13.3-py3-none-any.whl", hash = "sha256:1f60ecaf1358190f0e26c48c578a60428dc02a8f1295bc3dbf53d1b31116821f", size = 59993, upload-time = "2026-06-20T22:53:50.775Z" }, +] + [[package]] name = "python-multipart" version = "0.0.32" @@ -7024,6 +7360,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, ] +[[package]] +name = "python-socketio" +version = "5.16.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bidict" }, + { name = "python-engineio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/2d/ffce71017c106b75099fea569df6518c63fee5d6202ce0cfe7b01e6f22c3/python_socketio-5.16.3.tar.gz", hash = "sha256:89b136f677ae65607a84cecda9b4d6c5377b40a97582c504c25df89af16d520e", size = 128095, upload-time = "2026-06-15T22:07:04.003Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/38/8c5e72d53ff8eb27497c4f268a7f6d9121e727a50b65248288ad79a93053/python_socketio-5.16.3-py3-none-any.whl", hash = "sha256:e7ad14202a5e6448824c7c2f86161d04e13dec05992257df5c709e6a2798c041", size = 82087, upload-time = "2026-06-15T22:07:02.498Z" }, +] + +[package.optional-dependencies] +client = [ + { name = "requests" }, + { name = "websocket-client" }, +] + [[package]] name = "python-ulid" version = "3.1.0" @@ -7128,6 +7483,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/b9/52aa9ec2867528b54f1e60846728d8b4d84726630874fee3a91e66c7df81/pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4", size = 1329850, upload-time = "2025-09-08T23:07:26.274Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/5653e7b7425b169f994835a2b2abf9486264401fdef18df91ddae47ce2cc/pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556", size = 906380, upload-time = "2025-09-08T23:07:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/73/78/7d713284dbe022f6440e391bd1f3c48d9185673878034cfb3939cdf333b2/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf7b38f9fd7b81cb6d9391b2946382c8237fd814075c6aa9c3b746d53076023b", size = 666421, upload-time = "2025-09-08T23:07:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/30/76/8f099f9d6482450428b17c4d6b241281af7ce6a9de8149ca8c1c649f6792/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03ff0b279b40d687691a6217c12242ee71f0fba28bf8626ff50e3ef0f4410e1e", size = 854149, upload-time = "2025-09-08T23:07:33.17Z" }, + { url = "https://files.pythonhosted.org/packages/59/f0/37fbfff06c68016019043897e4c969ceab18bde46cd2aca89821fcf4fb2e/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:677e744fee605753eac48198b15a2124016c009a11056f93807000ab11ce6526", size = 1655070, upload-time = "2025-09-08T23:07:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/47/14/7254be73f7a8edc3587609554fcaa7bfd30649bf89cd260e4487ca70fdaa/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd2fec2b13137416a1c5648b7009499bcc8fea78154cd888855fa32514f3dad1", size = 2033441, upload-time = "2025-09-08T23:07:37.432Z" }, + { url = "https://files.pythonhosted.org/packages/22/dc/49f2be26c6f86f347e796a4d99b19167fc94503f0af3fd010ad262158822/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08e90bb4b57603b84eab1d0ca05b3bbb10f60c1839dc471fc1c9e1507bef3386", size = 1891529, upload-time = "2025-09-08T23:07:39.047Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3e/154fb963ae25be70c0064ce97776c937ecc7d8b0259f22858154a9999769/pyzmq-27.1.0-cp310-cp310-win32.whl", hash = "sha256:a5b42d7a0658b515319148875fcb782bbf118dd41c671b62dae33666c2213bda", size = 567276, upload-time = "2025-09-08T23:07:40.695Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/f4ab56c8c595abcb26b2be5fd9fa9e6899c1e5ad54964e93ae8bb35482be/pyzmq-27.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f", size = 632208, upload-time = "2025-09-08T23:07:42.298Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e3/be2cc7ab8332bdac0522fdb64c17b1b6241a795bee02e0196636ec5beb79/pyzmq-27.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:9a916f76c2ab8d045b19f2286851a38e9ac94ea91faf65bd64735924522a8b32", size = 559766, upload-time = "2025-09-08T23:07:43.869Z" }, + { url = "https://files.pythonhosted.org/packages/06/5d/305323ba86b284e6fcb0d842d6adaa2999035f70f8c38a9b6d21ad28c3d4/pyzmq-27.1.0-cp311-cp311-macosx_10_15_universal2.whl", hash = "sha256:226b091818d461a3bef763805e75685e478ac17e9008f49fce2d3e52b3d58b86", size = 1333328, upload-time = "2025-09-08T23:07:45.946Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a0/fc7e78a23748ad5443ac3275943457e8452da67fda347e05260261108cbc/pyzmq-27.1.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0790a0161c281ca9723f804871b4027f2e8b5a528d357c8952d08cd1a9c15581", size = 908803, upload-time = "2025-09-08T23:07:47.551Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/37d15eb05f3bdfa4abea6f6d96eb3bb58585fbd3e4e0ded4e743bc650c97/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c895a6f35476b0c3a54e3eb6ccf41bf3018de937016e6e18748317f25d4e925f", size = 668836, upload-time = "2025-09-08T23:07:49.436Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c4/2a6fe5111a01005fc7af3878259ce17684fabb8852815eda6225620f3c59/pyzmq-27.1.0-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bbf8d3630bf96550b3be8e1fc0fea5cbdc8d5466c1192887bd94869da17a63e", size = 857038, upload-time = "2025-09-08T23:07:51.234Z" }, + { url = "https://files.pythonhosted.org/packages/cb/eb/bfdcb41d0db9cd233d6fb22dc131583774135505ada800ebf14dfb0a7c40/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15c8bd0fe0dabf808e2d7a681398c4e5ded70a551ab47482067a572c054c8e2e", size = 1657531, upload-time = "2025-09-08T23:07:52.795Z" }, + { url = "https://files.pythonhosted.org/packages/ab/21/e3180ca269ed4a0de5c34417dfe71a8ae80421198be83ee619a8a485b0c7/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bafcb3dd171b4ae9f19ee6380dfc71ce0390fefaf26b504c0e5f628d7c8c54f2", size = 2034786, upload-time = "2025-09-08T23:07:55.047Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b1/5e21d0b517434b7f33588ff76c177c5a167858cc38ef740608898cd329f2/pyzmq-27.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e829529fcaa09937189178115c49c504e69289abd39967cd8a4c215761373394", size = 1894220, upload-time = "2025-09-08T23:07:57.172Z" }, + { url = "https://files.pythonhosted.org/packages/03/f2/44913a6ff6941905efc24a1acf3d3cb6146b636c546c7406c38c49c403d4/pyzmq-27.1.0-cp311-cp311-win32.whl", hash = "sha256:6df079c47d5902af6db298ec92151db82ecb557af663098b92f2508c398bb54f", size = 567155, upload-time = "2025-09-08T23:07:59.05Z" }, + { url = "https://files.pythonhosted.org/packages/23/6d/d8d92a0eb270a925c9b4dd039c0b4dc10abc2fcbc48331788824ef113935/pyzmq-27.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:190cbf120fbc0fc4957b56866830def56628934a9d112aec0e2507aa6a032b97", size = 633428, upload-time = "2025-09-08T23:08:00.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/14/01afebc96c5abbbd713ecfc7469cfb1bc801c819a74ed5c9fad9a48801cb/pyzmq-27.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:eca6b47df11a132d1745eb3b5b5e557a7dae2c303277aa0e69c6ba91b8736e07", size = 559497, upload-time = "2025-09-08T23:08:02.15Z" }, + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, + { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, + { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, + { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, + { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, + { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, + { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, + { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, + { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, + { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, + { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, + { url = "https://files.pythonhosted.org/packages/f3/81/a65e71c1552f74dec9dff91d95bafb6e0d33338a8dfefbc88aa562a20c92/pyzmq-27.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6", size = 836266, upload-time = "2025-09-08T23:09:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/58/ed/0202ca350f4f2b69faa95c6d931e3c05c3a397c184cacb84cb4f8f42f287/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90", size = 800206, upload-time = "2025-09-08T23:09:41.902Z" }, + { url = "https://files.pythonhosted.org/packages/47/42/1ff831fa87fe8f0a840ddb399054ca0009605d820e2b44ea43114f5459f4/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62", size = 567747, upload-time = "2025-09-08T23:09:43.741Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/5c4d6807434751e3f21231bee98109aa57b9b9b55e058e450d0aef59b70f/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:448f9cb54eb0cee4732b46584f2710c8bc178b0e5371d9e4fc8125201e413a74", size = 747371, upload-time = "2025-09-08T23:09:45.575Z" }, + { url = "https://files.pythonhosted.org/packages/26/af/78ce193dbf03567eb8c0dc30e3df2b9e56f12a670bf7eb20f9fb532c7e8a/pyzmq-27.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:05b12f2d32112bf8c95ef2e74ec4f1d4beb01f8b5e703b38537f8849f92cb9ba", size = 544862, upload-time = "2025-09-08T23:09:47.448Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c6/c4dcdecdbaa70969ee1fdced6d7b8f60cfabe64d25361f27ac4665a70620/pyzmq-27.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:18770c8d3563715387139060d37859c02ce40718d1faf299abddcdcc6a649066", size = 836265, upload-time = "2025-09-08T23:09:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/3e/79/f38c92eeaeb03a2ccc2ba9866f0439593bb08c5e3b714ac1d553e5c96e25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:ac25465d42f92e990f8d8b0546b01c391ad431c3bf447683fdc40565941d0604", size = 800208, upload-time = "2025-09-08T23:09:51.073Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/3f0d0d335c6b3abb9b7b723776d0b21fa7f3a6c819a0db6097059aada160/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53b40f8ae006f2734ee7608d59ed661419f087521edbfc2149c3932e9c14808c", size = 567747, upload-time = "2025-09-08T23:09:52.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cf/f2b3784d536250ffd4be70e049f3b60981235d70c6e8ce7e3ef21e1adb25/pyzmq-27.1.0-pp311-pypy311_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f605d884e7c8be8fe1aa94e0a783bf3f591b84c24e4bc4f3e7564c82ac25e271", size = 747371, upload-time = "2025-09-08T23:09:54.563Z" }, + { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, +] + [[package]] name = "redis" version = "5.3.1" @@ -7889,6 +8317,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "simple-websocket" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -9016,6 +9456,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, ] +[[package]] +name = "websocket-client" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, +] + [[package]] name = "websockets" version = "15.0.1" @@ -9474,6 +9923,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, ] +[[package]] +name = "zope-event" +version = "6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/93/41/faa10af34d48d9cd6fa0249a1162943ad84a9590bd1a06939981e6640416/zope_event-6.2.tar.gz", hash = "sha256:b97d5d6327067ee6b9dfcbdf606ade9ade70991e19c162e808ea39e5fcf0f8d3", size = 18958, upload-time = "2026-04-28T06:24:10.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/33/848922889e946d4befc415c219fe516af75c49555d8e736e183bfd30db42/zope_event-6.2-py3-none-any.whl", hash = "sha256:5e755153ac4faf64c10a4b6dd3307680166a3edf65b38df22df592610f8fa874", size = 6525, upload-time = "2026-04-28T06:24:09.176Z" }, +] + +[[package]] +name = "zope-interface" +version = "8.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/dc/50550cfcbb2ea3cbca5f1d7ed05c8aa840f831a0f2d63aec0a953f7c590e/zope_interface-8.5.tar.gz", hash = "sha256:7a3ba1c5877f0f3e3906b02ddf793abed2becc2948116414ce0e1dd820b68d6d", size = 257957, upload-time = "2026-05-26T06:50:14.574Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/43/9cd98bee951d23848de690ba2809f87e3b22c67c370987acc960da15ad37/zope_interface-8.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0c8aa2bf8f3911ef37b87deb1bbe225a310e6eb6522a16d77f5d8330c4f6fbe", size = 210951, upload-time = "2026-05-26T06:49:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/17/0f/8f1a29966bcf863e3a2121edcafb81c55715de7886bcc9544749cc79e7da/zope_interface-8.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:efe234a0fafb4b6b1602e9be9245b97c2bf06d67c07af5a4bc3c0438978b555c", size = 211309, upload-time = "2026-05-26T06:49:02.732Z" }, + { url = "https://files.pythonhosted.org/packages/9f/9f/37e564eaaf85e3abc1ada40a79fa43f2ab45bdb67431b0ec0fe29e4763e2/zope_interface-8.5-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:dabeb6fe1228d411994f300811edc6866fff0cdcbc9cef98a78f05ea0da42e37", size = 254881, upload-time = "2026-05-26T06:49:04.303Z" }, + { url = "https://files.pythonhosted.org/packages/06/61/e6501d8ea7a2cac3217e03f404e1f98c1df7191d83cfe86b1895fbba5dac/zope_interface-8.5-cp310-cp310-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:147a9442dcc2b7339ecdb1be2b3cdb098e90462e39425054053ebfb50d99125a", size = 259811, upload-time = "2026-05-26T06:49:06.373Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/bfa25ef480b02af6e9452c478483fec75e87c9e2b60c407fd0b1f6054b9c/zope_interface-8.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a17e681224267880707c9ec9e730ad9a1ad2d65c371256843efba6cf48711b58", size = 260358, upload-time = "2026-05-26T06:49:08.317Z" }, + { url = "https://files.pythonhosted.org/packages/64/51/2b518072fea76242da64451d501c69b7b5ccdef9b57fead584ccf1c180d5/zope_interface-8.5-cp310-cp310-win_amd64.whl", hash = "sha256:d178968a1a611df30549a717d1624cb38ca810347339e3e37b7baa6f6781a170", size = 214822, upload-time = "2026-05-26T06:49:10.441Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/83ad110fb847413affe71609bb50e59e1aa082e1236030122227c7c283d3/zope_interface-8.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:afc66ccaef2a3c0bef6ca02aad40d29a39276389dad16a8eac36f9f385e4d057", size = 211426, upload-time = "2026-05-26T06:49:12.595Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a7/6b6e0c31ac240cb9fc015ae9ed45ca54be886c18fcf7bfa2377a4d7a8785/zope_interface-8.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c28044972187245d7a309e4699319bfdbd2ffcbf7176d1d4ddf5adffb2dea80f", size = 211850, upload-time = "2026-05-26T06:49:14.474Z" }, + { url = "https://files.pythonhosted.org/packages/37/36/7599ecabcf80ce4fef2e1ef3c5ac0d4696b61f03f724cc44022f4d226af9/zope_interface-8.5-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:03bbecc7982af713d7499d4084bc03916413d17ffd45f89009348cc0c1d9e376", size = 260711, upload-time = "2026-05-26T06:49:16.568Z" }, + { url = "https://files.pythonhosted.org/packages/03/3e/1774b0ee46ccbb5498ee3c33ece40315b6ef58bc71957be94bd345340bc1/zope_interface-8.5-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bf917009a4a7457c7290225a019f4a0aa706d96accd2cfdba2418d3bc1fcde2f", size = 265277, upload-time = "2026-05-26T06:49:18.656Z" }, + { url = "https://files.pythonhosted.org/packages/b6/09/e533b2ffabaae4e5d5730d6768a591cf335defe8e37bec2ad905d09be656/zope_interface-8.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:31cff25b2aaedb5267e6e77b1e9be6b0ec4f622032de8a069202b8ffacda7dc2", size = 266369, upload-time = "2026-05-26T06:49:20.174Z" }, + { url = "https://files.pythonhosted.org/packages/49/4a/3ebe6a4c122b2d5340db45cbe7e490663d3228b172710ec71060cd5d541e/zope_interface-8.5-cp311-cp311-win_amd64.whl", hash = "sha256:17a3114bbdddb5e75e5784cdf318944636190cbbc72d357ef9fb1a8b0351f955", size = 215161, upload-time = "2026-05-26T06:49:21.799Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/056ad97af5b16db1975ee98ec7ab03d2ce3f3355efad904ced1dbce0e39f/zope_interface-8.5-cp311-cp311-win_arm64.whl", hash = "sha256:aab6bb5bee10f38ea688b95ba054396b67f613552d2c8378be7fcb2d2fba7646", size = 213481, upload-time = "2026-05-26T06:49:25.085Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/b84123a948f3162a34623e188922827cd845244fdd043ed20f8d02228caa/zope_interface-8.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8e6ee90c2e6de7c37058d5fa41f123c8b13a312db8d1e0fb5840d7f4bcdff9c9", size = 212165, upload-time = "2026-05-26T06:49:26.566Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/cbceec44f1b27208a76c1a688c131302685852406a23df5aab68324109cc/zope_interface-8.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c1adc90d3576b3b4c4de4953e6002c37bef28b78d7fa54c1bbfd0c50f022fe7c", size = 212341, upload-time = "2026-05-26T06:49:28.182Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c3/005032195ff3b210c139b7c560ed5c534e844b0907d8e44d2b3d8919305e/zope_interface-8.5-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:e6347b8d8d12c5eca6502450a92be30079b7acfade2c4f693efa0deb8871b06e", size = 265296, upload-time = "2026-05-26T06:49:29.741Z" }, + { url = "https://files.pythonhosted.org/packages/c5/66/1036543d6a66bc04c19df3cf650f3ad938a002ab0a443c24e23e8de5e8b9/zope_interface-8.5-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5e970dabea777a24b0b0bbf9dae3ab75ce8b2d8e948edf4875627034b21f3560", size = 270689, upload-time = "2026-05-26T06:49:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/30/4c/8b56259558cace4414e753ca6740396a1f59d4a95ddb55b4658600408670/zope_interface-8.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f0b48ccadaa9839e09ff81e969703cecb3f402c813bfe8b958652e699bea69f5", size = 270280, upload-time = "2026-05-26T06:49:33.489Z" }, + { url = "https://files.pythonhosted.org/packages/f9/ea/649908c83aa8fdb7faf2ddca4d3cf6fb8f2157121267dc56e8f72681e26c/zope_interface-8.5-cp312-cp312-win_amd64.whl", hash = "sha256:e0e311f1277468c08fd59a2b41f71b43d25dff639789d364747acd1705c0df6e", size = 215019, upload-time = "2026-05-26T06:49:35.607Z" }, + { url = "https://files.pythonhosted.org/packages/9f/97/da13037b4c563e4df32eedbc819f8c00b754af494f68211e3dffd48d52da/zope_interface-8.5-cp312-cp312-win_arm64.whl", hash = "sha256:652b73107a04159ec6c020db6c1543d4f1e8f4d069bd2aac88a947820923517b", size = 213569, upload-time = "2026-05-26T06:49:37.317Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8c/4c15755d701f2ec0e80d64a18e1ebaf5be2c584c0ec153fd516f5d13eada/zope_interface-8.5-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:28e80457c134d1fa57a7d758004dece348654e1b1467ac22dcdc20fc1d127c52", size = 212512, upload-time = "2026-05-26T06:49:38.996Z" }, + { url = "https://files.pythonhosted.org/packages/9a/2e/4360c54c465db042cc8fbeeec92abac28b4cedbf6ba63c1f092fd08a190f/zope_interface-8.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:09495ce9d559c06b70f2d4855b3e4f48a822a9ddc8be1d30c5b4e5be14ae1ace", size = 212541, upload-time = "2026-05-26T06:49:41.186Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a5/692a2b8d70f78e848793231d5fae5fecbf8d0cccd73430fdc34802a6d3c1/zope_interface-8.5-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:7849ad8fa90763cc1087f4dda78ca3a233e950b3e08fac7079297c9cafbbd7bb", size = 265191, upload-time = "2026-05-26T06:49:43.449Z" }, + { url = "https://files.pythonhosted.org/packages/70/8d/454a9cfc7a050c394ab4f11b3371f7897828b7415e096afff724637e65e0/zope_interface-8.5-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5578c9421ca409a1f39f153d6f7803e4cde01da592ec75a9ac5e1b777d18d33b", size = 270626, upload-time = "2026-05-26T06:49:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/db8409cfa3575b8e9b4800babd7d49f8228433cd1f0c56814bd0ada49c33/zope_interface-8.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e1bd7d96b4ca5fa311f54c9eac16dce4886b428c1531dbe06067763ccdf123b4", size = 270444, upload-time = "2026-05-26T06:49:47.025Z" }, + { url = "https://files.pythonhosted.org/packages/4a/df/a386940e41469ef615e100a216d8b386521e9e598817147f87932ca203c4/zope_interface-8.5-cp313-cp313-win_amd64.whl", hash = "sha256:0c8123d2a4dfde2a613c7cb772605477724782c20bc2e0ad1d9435376a6a44a3", size = 215021, upload-time = "2026-05-26T06:49:48.478Z" }, + { url = "https://files.pythonhosted.org/packages/89/75/477eb5669b6b2a7a843decd1a075e9b1971a8720017654143a7183abd3d9/zope_interface-8.5-cp313-cp313-win_arm64.whl", hash = "sha256:6d02be14f3173c6c7288bc2fdf530090c01c3cf8764ad46c68024686f364278e", size = 213610, upload-time = "2026-05-26T06:49:50.01Z" }, + { url = "https://files.pythonhosted.org/packages/d4/19/5032e954827fdf02db2d2f49737ac4378bb9cfc2cd95a8f2e2a5ae2ec01a/zope_interface-8.5-cp314-cp314-macosx_10_9_x86_64.whl", hash = "sha256:ffaecf013251a89d0de6feb49a46eba48ad8cbbf8a40aeb6045e459e7bec6784", size = 212597, upload-time = "2026-05-26T06:49:51.63Z" }, + { url = "https://files.pythonhosted.org/packages/f1/53/3ef644012cf8a6a234a2d6134aab5a5c65ac5467c86296865501d4fbc406/zope_interface-8.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:126fa9d1c52295ae076d4cf968634f0a1826afa408a20808b57ff72877b8f69f", size = 212626, upload-time = "2026-05-26T06:49:53.236Z" }, + { url = "https://files.pythonhosted.org/packages/32/67/bc8b4f465d388039255003e230c284a175cedf1203c692f23cb7bff64efe/zope_interface-8.5-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:3090e3a663d20194756a59a272e0c8508b889341e31d5894223331fe6b4f9b21", size = 266827, upload-time = "2026-05-26T06:49:54.873Z" }, + { url = "https://files.pythonhosted.org/packages/a7/eb/37d05b935ede53d79690fecc8d201440084418e590bcfc05f384451c7593/zope_interface-8.5-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9342fb74e2afefdb081bf1df727d209ea56995c6e13f5a0540e6d7aff4beafb8", size = 270139, upload-time = "2026-05-26T06:49:57.116Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/fd0c54579e2ce8dc6cf1a757903f3374bc6fbda929a46af9e0f53cb0e5f0/zope_interface-8.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6c54725d818f1b57a7efb8b16528326e1f3c257b602b32393fd255c45af8799d", size = 270338, upload-time = "2026-05-26T06:49:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1d/c420dcd777bb761067ea92879ac766694a5ca78608185f1aecea64cbfc11/zope_interface-8.5-cp314-cp314-win_amd64.whl", hash = "sha256:29d74febbae1afeb6834c4ccbf42e242a673c860060f09e53142825270456140", size = 215789, upload-time = "2026-05-26T06:50:00.405Z" }, + { url = "https://files.pythonhosted.org/packages/62/94/50b5eb8f94e527edceac14f9955e58917424ea79bb572ddc18548561cbc2/zope_interface-8.5-cp314-cp314-win_arm64.whl", hash = "sha256:633c8c49396f38df030340797c533e9fe460d1b5d1e42d88e55e938e525f548c", size = 213757, upload-time = "2026-05-26T06:50:01.973Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/5d5f32c4dfcdb16ce2ec5363da686840f13c13e1a1214cb70b49e1cd6d9f/zope_interface-8.5-cp314-cp314t-macosx_10_9_x86_64.whl", hash = "sha256:133999820fdbae513c36c03d6f29ef87317aaa3edef39112222b155083664714", size = 213591, upload-time = "2026-05-26T06:50:03.529Z" }, + { url = "https://files.pythonhosted.org/packages/f3/55/de0c3459ff717fce3342f9a29464c281fdeb0d36c3171ee88d119d5f0650/zope_interface-8.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8bd75c96966e573232f0599deaff717564828031c7f05563ccc1ac35c5ee0304", size = 213733, upload-time = "2026-05-26T06:50:05.101Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/d97430abd5ae9677e8b9295b58720c0064a5b557dbb6b8bf5928484cf0d8/zope_interface-8.5-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:14b0e9799351d4c34fe99afd67f0cdd76e55ba15c66a98699d5fc22ea8241e08", size = 294905, upload-time = "2026-05-26T06:50:07.384Z" }, + { url = "https://files.pythonhosted.org/packages/41/ec/a0f8f3dad6e74992f4654bdd94802be0929eabca7b871cac3b6fbb5e961b/zope_interface-8.5-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cd6a732ac84b94eb1ef9222a117347a27efd294ee16810ffdf7ecd307677ed5", size = 300885, upload-time = "2026-05-26T06:50:08.997Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/6881b48803a0ee8d23eb5efa30fce3ed218a2bd9de5758ce489d224fee81/zope_interface-8.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:798b7c87d0e59a7d5d086d642208d0d8700ff0d55c4029134b3c479c3bfb110f", size = 304672, upload-time = "2026-05-26T06:50:10.563Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0e/b4c01320859ff1d585438bc231fd60bd258d096359bccf6654fecdf0cffb/zope_interface-8.5-cp314-cp314t-win_amd64.whl", hash = "sha256:0fc3a9d45f114d27eaa1e53beeb144533689edca8a9f66505b1e8e8b3f075e42", size = 217241, upload-time = "2026-05-26T06:50:12.171Z" }, +] + [[package]] name = "zstandard" version = "0.25.0"