mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-12 21:04:10 +00:00
d52fbfb458
* fix(mcp): handle OAuth IdP error responses in /callback (LIT-2750) Per RFC 6749 section 4.1.2.1, when the IdP rejects an OAuth authorization request it redirects back to the client with ?error=...&error_description=... and no code. The MCP /callback handler declared code and state as required query params, so FastAPI rejected such error responses with a 422 before the handler ran -- stranding the MCP client waiting on the loopback. This change: - Makes code and state optional and accepts the RFC-defined error, error_description, and error_uri params. - When state decodes to a trusted client redirect_uri, propagates the error params back to that URI with the client's original (un-wrapped) state preserved, so the client's OAuth library can surface the failure. - When state is missing/undecryptable or the encoded redirect_uri is no longer trusted, renders a 400 HTML page with the (HTML-escaped) error details instead of leaking to an attacker-controlled redirect. - Preserves the existing success path (code + state -> 302 to validated client redirect_uri with original state). Fixes LIT-2750. * test(mcp): regression tests for /callback handling IdP error responses (LIT-2750) Adds a new test module covering the LIT-2750 fix: the MCP OAuth /callback endpoint must accept IdP error responses (e.g. ?error=access_denied) per RFC 6749 section 4.1.2.1 instead of returning a 422 because ``code`` is missing. Coverage: - IdP error with no state -> 400 HTML page surfacing the error. - HTML escaping of user-controlled error / error_description fields. - IdP error with a trusted (loopback) state -> 302 propagating error / error_description / original client state to the client. - IdP error with an untrusted redirect_uri encoded in state -> 400 inline (no open-redirect to attacker-controlled origin). - IdP error with an undecryptable state -> 400 HTML fallback. - Bare GET /callback with no params -> 400 HTML (not Pydantic 422). - Success path (code + state) still 302 to validated client redirect_uri with the original (un-wrapped) state preserved. * refactor(mcp): drop unused _OAUTH_ERROR_PARAMS constant (Greptile P2) The tuple was leftover scaffolding from an earlier draft of the LIT-2750 fix; nothing references it. The explanatory RFC 6749 §4.1.2.1 comment block above the callback handler covers the same intent. * fix(mcp/oauth): preserve empty original_state and clarify missing-param error in /callback Co-authored-by: Yassin Kortam <yassin@berri.ai> * fix(thinking): handle None thinking param in is_thinking_enabled (#28598) Squash-merged by litellm-agent from Terrajlz's PR. * feat(helm): support tpl rendering in podAnnotations (#28609) Squash-merged by litellm-agent from devauxbr's PR. * fix: apply black formatting to base_llm chat transformation Fix CI black --check failure on is_thinking_enabled return formatting. Co-authored-by: Cursor <cursoragent@cursor.com> * merge main (#28836) * fix(proxy): Bedrock Knowledge Base pass-through: preserve SigV4 headers and signed request body (#27526) * Fix Bedrock KB pass-through SigV4 headers and signed body Coerce botocore HeadersDict to a dict for pass-through routes. When forward_headers is true, drop request headers that collide case-insensitively with signed headers so client Bearer auth does not shadow AWS SigV4. Send prepped.body as raw content so the outbound payload matches the signature after logging hooks mutate the parsed dict. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify pass-through raw body handling Read the SigV4-signed bytes directly from request.state inside pass_through_request instead of threading a custom_raw_body argument through three functions. Helper methods are restored to their original signatures, and the new branch lives in one place at each httpx call site. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden pass-through raw body read from request.state Guard missing request.state (test fixtures) and ignore non-bytes/str values so MagicMock does not trigger the SigV4 raw-body path. Co-authored-by: Cursor <cursoragent@cursor.com> * Test pass_through_request state_raw_body uses httpx content= Cover non-streaming (async_client.request) and streaming (build_request) paths so SigV4 bytes on request.state are not replaced by json= of a hook-mutated dict. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> * chore(tests): migrate Bedrock CI to AWS account 941277531214 (#28728) * chore(tests): migrate Bedrock CI from AWS account 888602223428 to 941277531214 The original account (888602223428) was put under a security restriction by AWS after a root access key leaked in a PR comment. While that account works its way through the AWS Support unlock process, Bedrock-touching CI tests have been migrated to a fresh account (941277531214). Changes: - Replace 26 hardcoded references to 888602223428 with 941277531214 across 8 files (provisioned-model ARNs, imported-model ARNs, AgentCore runtime ARNs, batch execution role ARN, and example proxy config). - The provisioned-model and imported-model ARNs are referenced only from mocked unit tests — no AWS resources to recreate. - The batch execution IAM role has been recreated in the new account with the same name and equivalent permissions. - The two AgentCore runtimes (hosted_agent_r9jvp-3ySZuRHjLC, hosted_agent_13sf6-cALnp38iZD) are being recreated in the new account under the same names — see tools/agentcore-deploy/ in a follow-up. CircleCI env vars AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_REGION_NAME were updated separately via the CircleCI API to point at the new account. Smoke-tested locally against the new account: aws bedrock-runtime converse --region us-west-2 \ --model-id us.anthropic.claude-sonnet-4-5-20250929-v1:0 \ --messages '[{"role":"user","content":[{"text":"ping"}]}]' → 200, model returned 'pong' Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(tests): refresh AgentCore ARN suffixes to match newly-deployed runtimes The first migration commit replaced just the account ID, but AgentCore auto-assigns a random 10-char suffix to every runtime on creation — we can't reuse the original suffixes (`3ySZuRHjLC`, `cALnp38iZD`) in the new account. Updated the AgentCore-runtime ARNs in the three files that reference real runtime IDs (not the mock-based unit-test ARNs). Deployed runtimes: arn:aws:bedrock-agentcore:us-west-2:941277531214:runtime/hosted_agent_r9jvp-Rq79QFC2fp arn:aws:bedrock-agentcore:us-west-2:941277531214:runtime/hosted_agent_13sf6-4046UzHSwy Both runtimes are status=READY and pass a smoke invoke: $ aws bedrock-agentcore invoke-agent-runtime --agent-runtime-arn ... --payload '{"prompt":"ping"}' → 200, {"result": "echo: ping"} The agent is a minimal echo (see /tmp/agentcore_deploy/agent.py for the deploy artifacts). Tests that only verify the SDK wiring will pass; if any test asserts on agent output content, swap the echo for the real agent. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore(tests): point Bedrock batch tests at new-account S3 bucket The account migration (888602223428 -> 941277531214) was a flat account-ID swap, which only rewrites ARNs that embed the account number. S3 bucket names carry no account ID, so the live Bedrock batch tests still uploaded to `litellm-proxy` — a bucket that lives in the old account. S3 names are globally unique, and the old account still holds that name, so it can't be recreated in the new account. Rename to `litellm-proxy-941277531214` (account-ID suffix guarantees global uniqueness). The bucket must be created in 941277531214 and the batch execution role granted s3:GetObject/PutObject/ListBucket on it before this job is run in CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(tests): point live S3 logging test at new-account bucket Same account-ID-free blind spot as the batch bucket: `load-testing-oct` lives in the old account and its name can't be reused globally. The `logging_testing` CI job is wired into the workflow and runs test_basic_s3_logging, which uploads to this bucket with the CI env creds, then lists and deletes objects — a live dependency. Rename to `load-testing-oct-941277531214`. The bucket must exist in the new account with the CI IAM principal granted s3:PutObject/GetObject/ListBucket/DeleteObject before this job runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(tests): repoint Bedrock guardrail IDs to new-account guardrails The migration left guardrail IDs untouched (no account ID in them), so all live guardrail tests failed with "guardrail identifier or version does not exist" against 941277531214. Recreated both guardrails in the new account and updated the hardcoded IDs: - wf0hkdb5x07f -> zgkmukebruil (PII mask: PHONE + CREDIT_DEBIT_CARD, with explicit inputAction=ANONYMIZE so masking applies to INPUT, which is the source litellm's moderation hook sends) - ff6ujrregl1q -> 4w3d1di3snt5 (blocks "coffee"; blocked message set to the exact string the tests assert on) Updated test_bedrock_guardrails.py, otel_test_config.yaml, and the guardrailConfig in test_bedrock_completion.py. Verified locally: the 5 previously-failing guardrail tests now pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(bedrock): migrate legacy models to current inference profiles The new CI account (941277531214) cannot invoke legacy Bedrock models (AWS gates them: "marked by provider as Legacy... not actively using in the last 30 days"). Migrated the live-call tests: - anthropic.claude-3-sonnet-20240229 -> us.anthropic.claude-sonnet-4-5-20250929-v1:0 - anthropic.claude-3-haiku-20240307 -> us.anthropic.claude-haiku-4-5-20251001-v1:0 Current Claude models on Bedrock require the us. inference-profile prefix (bare on-demand ids are rejected). cohere.command-r-plus has no working replacement (all Cohere is legacy- gated in the new account): swapped to claude-haiku-4-5 in provider- agnostic param lists. amazon.titan-image-generator skipped (no working replacement). Mocked/transformation/cost tests that reference the legacy strings are intentionally left unchanged. Verified live against the new account. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(bedrock): repoint SageMaker + Knowledge Base to new-account resources These referenced account-scoped resources by hardcoded id that only existed in the old account, so the migration's account-ID swap missed them. Recreated in 941277531214 and repointed: - SageMaker endpoint jumpstart-dft-hf-textgeneration1-mp-20240815-185614 -> litellm-ci-textgen (gpt2 on a TGI container, ml.g5.xlarge) - Bedrock Knowledge Base T37J8R4WTM -> LCYXFBR2TU (OpenSearch Serverless vector store + titan-embed-text-v2, seeded with a LiteLLM doc) Verified live: test_sagemaker.py (12 passed) and test_bedrock_knowledgebase_hook.py (12 passed). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(reasoning_effort_grid): skip bedrock claude-opus-4-7 cells (not entitled on 941277531214) claude-opus-4-7 is listed in the new Bedrock CI account's foundation models but invoke is denied (AccessDeniedException: "not available for this account"). Bedrock access to the flagship Opus requires an AWS Sales request, not the self-serve model-access toggle, so it can't be enabled inline with the rest of the account migration. Add an optional `skip_reason` to ModelEntry and set it on the bedrock-claude-opus-4-7 entry; the grid test honors it via pytest.skip. Cell count (231) and route coverage are unchanged, so the structural asserts still pass. Restore coverage by deleting the one skip_reason line once access is granted. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(bedrock): swap/skip legacy-gated models unavailable on new CI account The migrated AWS account (941277531214) cannot access several models that the old account could, so the remaining red CI jobs were hitting real Bedrock "Access denied / Legacy" and "account not authorized" errors: - image_gen: skip both Nova Canvas test classes (amazon.nova-canvas-v1:0 is legacy-gated), matching the existing titan skip. - batches: skip test_async_file_and_batch (Bedrock batch inference is not authorized on the new account; requires an AWS support case). - litellm_overhead: swap legacy claude-3-5-haiku for the active us.anthropic.claude-haiku-4-5 inference profile. - test_completion_claude_3_function_call: swap legacy claude-3-sonnet for the active us.anthropic.claude-sonnet-4-5 inference profile. https://claude.ai/code/session_01Y7zgHYu9GX29YRwV4yiWAa * test(bedrock): fix remaining e2e legacy-model + batch failures on new CI account - e2e_openai_endpoints: skip test_bedrock_batches_api (Bedrock batch inference is not authorized on account 941277531214) and migrate the missed s3_bucket_name in oai_misc_config.yaml to litellm-proxy-941277531214. - build_and_test: swap legacy bedrock claude-3-sonnet for the active us.anthropic.claude-sonnet-4-5 inference profile in the proxy structured output e2e test. https://claude.ai/code/session_01Y7zgHYu9GX29YRwV4yiWAa * test(bedrock): make opus-4-7 + batch cells fail loudly and mock image-gen (#28791) Replace the silent skips added for the new CI account with noisier behavior: - reasoning-effort grid: opus-4-7 cells now fail (when AWS creds are present) instead of skipping, so the missing entitlement stays visible in CI; they still skip when AWS creds are absent (local dev) - Bedrock batch inference tests: drop the skip so they run and fail until batch access is granted - Titan + Nova Canvas image-gen tests: mock the Bedrock HTTP call so the transform + cost-tracking path stays under test without live model access https://claude.ai/code/session_01MT7SWDnXUjv6e6EPG7BDjT Co-authored-by: Claude <noreply@anthropic.com> * test(bedrock): use pytest.xfail for known-failing opus-4-7 cells Replace pytest.fail with pytest.xfail when a model has a fail_reason, so known-broken cells stay visible as XFAIL without keeping CI red. Co-authored-by: Yassin Kortam <yassin@berri.ai> --------- Co-authored-by: Mateo <mateo@Mateos-MacBook-Pro.local> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Yassin Kortam <yassin@berri.ai> * fix(otel): export SERVER span on management-endpoint success without http_request (#28794) Co-authored-by: Yassin Kortam <yassinkortam@Yassins-MacBook-Pro.local> * chore(ci): merge dev branch (#28801) * chore(proxy): route path-dependent call sites through get_request_route Replace direct ``request.url.path`` reads in auth, ACL, routing, and audit-log decisions with ``get_request_route(request)`` — the helper already added in ``auth/auth_utils.py`` that returns the ASGI ``scope["path"]`` with ``root_path`` stripped. Starlette reconstructs ``url.path`` from the Host header; ``scope["path"]`` is uvicorn's parse of the request line and matches what FastAPI dispatches on, so it's the authoritative route for any decision that should agree with the actual handler. Sites: - _experimental/mcp_server/auth/user_api_key_auth_mcp.py - management_endpoints/mcp_management_endpoints.py - vector_store_endpoints/utils.py - pass_through_endpoints/pass_through_endpoints.py - auth/route_checks.py - litellm_pre_call_utils.py - spend_tracking/spend_management_endpoints.py - common_utils/http_parsing_utils.py - management_helpers/utils.py - health_endpoints/_health_endpoints.py Adds regression tests in tests/proxy_unit_tests/test_proxy_routes.py that construct a Request with scope["path"] set to a benign route and the Host header crafted so url.path would resolve differently; each site's decision is asserted against scope["path"]. * chore(proxy): make get_request_route imports lazy at call sites Move the ``from litellm.proxy.auth.auth_utils import get_request_route`` imports added in the prior commit back to the function bodies that use them. The module-level form participates in a long-standing import cycle through ``auth_utils -> _types -> ...`` and was flagged by CodeQL on the PR; the lazy form matches the pattern the proxy already uses for ``user_api_key_auth`` and related helpers elsewhere in these files. Also drop the ``RouteChecks._is_assistants_api_request`` delegation in ``_get_metadata_variable_name`` introduced in the prior commit — the delegation pulled ``RouteChecks`` into the same cycle, and the call site reuses the resolved route for its other branches, so inlining the substring check is both cycle-free and avoids a redundant second ``get_request_route`` call. Comment in test_proxy_routes.py acknowledges that the two MCP table entries exercise ``get_request_route`` directly rather than the full production handler (which needs ASGI scope + MCP state to invoke). --------- Co-authored-by: shin-berri <shin-laptop@berri.ai> Co-authored-by: user <70670632+stuxf@users.noreply.github.com> * chore(ci): merge dev branch (#28657) * feat(dashboard): navbar hierarchy + Agent Platform notifications (#27543) * feat(dashboard): refine navbar zones and Agent Platform notice Restructure the admin navbar for production users: clear product vs community vs personal columns with vertical dividers, icon-only Slack/GitHub in a shared chip, and Docs/Blog typography aligned on an 8px rhythm. Add a notifications bell with popover linking to the LiteLLM Agent Platform repo and optional mark-as-read persistence. Promote the account control with initials avatar, single-line display name, and navDisplayName mapping for placeholder user ids (e.g. default_user_id). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(dashboard): address PR review — AntD buttons, public page guard, dedupe regex - Replace raw <button> with AntD Button in BlogDropdown, NotificationsBell, UserDropdown, and test mock - Guard NotificationsBell + container behind !isPublicPage to avoid rendering on public pages - Remove redundant equality checks in navDisplayName (regex already covers them) - Remove unused `lower` variable after simplification Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: yuneng-jiang <yuneng@berri.ai> * fix(dashboard): drop dead useHealthReadiness import in navbar The module was removed in #27896 (replaced by useHealthReadinessDetails), but the import survived the rebase. The symbol is unused — only useHealthReadinessDetails is consumed in the file. Removing the dead import unblocks the UI TypeScript build. * fix(dashboard): align CommunityEngagementButtons test with icon-only aria-labels The component was refactored to an icon-only chip with aria-label='LiteLLM on GitHub' (squash #27543), but the test still asserted /star us on github/i. Update the query to match the rendered accessible name. * refactor(dashboard): drop unused props from NavbarProps The navbar refactor moved user identity + dark-mode state to internal hooks (useAuthorized, useWorker), but the NavbarProps interface still declared userID, userEmail, userRole, premiumUser, isDarkMode, and toggleDarkMode as required, forcing every caller to thread them through. Drop them from the interface and all four call sites (page.tsx, (dashboard)/layout.tsx, public_model_hub.tsx, navbar.test.tsx). Also shrinks the destructure in layout.tsx so the now-unused locals stop being pulled out of useAuthorized(). * refactor(dashboard): use useSyncExternalStore for NotificationsBell dismiss flag Reads/writes of the litellmHideAgentPlatformBanner key were done directly inside NotificationsBell via a useEffect + useState pair. Every other localStorage-backed flag in the dashboard (Disable ShowPrompts, DisableBouncingIcon, DisableShowNewBadge, DisableUsageIndicator, DisableBlogPosts) is wrapped in a useSyncExternalStore hook over localStorageUtils so all mounted components stay in sync. Extract useHideAgentPlatformBanner to follow the same shape, swap NotificationsBell to consume it, and add a regression test that two sibling bells stay in sync without a remount when one is dismissed. * refactor: mask credential fields in proxy settings GET responses (#28682) * refactor: mask credential fields in proxy settings GET responses Brings SSO settings, cache settings, and the email/Slack alerting view in /get/config/callbacks in line with the HashiCorp Vault config-override pattern, so persisted credentials are not transported back to the UI in plaintext. * refactor: harden short-value masking and hoist alerting var constant Closes two review observations: - mask_sensitive_keys now replaces short values (below the visible prefix+suffix length) with an all-mask string instead of returning them unchanged, so a 1-7 character credential is no longer round-tripped verbatim. - _ALERTING_SENSITIVE_VARS is moved out of get_config() to a module-level constant, matching the analogous _SSO_SENSITIVE_FIELDS and _CACHE_SENSITIVE_FIELDS in the SSO and cache endpoint files. --------- Co-authored-by: Krrish Dholakia <krrish+github@berri.ai> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * fix(ui): show 2-decimal precision for max_budget on key overview (#28809) The Key Info Overview tab's Spend card truncated sub-dollar budgets to "$0" because formatNumberWithCommas defaults to 0 decimals. The Settings tab passes 2; align the overview so a $0.10 budget renders as "$0.10". Resolves LIT-2845 * feat(proxy): allow `llm_api_routes` virtual keys to list MCP servers (#28442) * feat(proxy): allow llm_api_routes virtual keys to list MCP servers Add a new `mcp_discovery_routes` group (GET /v1/mcp/server and GET /v1/mcp/server/{server_id}) and include it in `llm_api_routes` so that virtual keys configured with `allowed_routes=["llm_api_routes"]` can discover the MCP servers they have access to. Previously these calls failed with 'Virtual key is not allowed to call this route. Only allowed to call routes: [llm_api_routes]'. The GET handlers already sanitize the response for restricted virtual keys via `_sanitize_mcp_server_list_for_virtual_key`, stripping credential-bearing fields (url, headers, env). Write methods (POST/PUT/DELETE) on the same paths remain gated by the existing handler-level admin role checks. The new discovery list is intentionally kept OUT of `mcp_inference_routes`, so `is_llm_api_route()` still returns False for these paths — this preserves the existing contract that DISABLE_LLM_API_ENDPOINTS must not block the Admin UI from listing MCP servers. Co-authored-by: ryan-crabbe-berri <ryan-crabbe-berri@users.noreply.github.com> * refactor(proxy): make MCP discovery carve-out method-aware Replace the `mcp_discovery_routes` group in `llm_api_routes` with a method-aware special case inside `is_virtual_key_allowed_to_call_route`. Virtual keys with allowed_routes=["llm_api_routes"] are now permitted to call only GET /v1/mcp/server and GET /v1/mcp/server/{server_id} — non-GET methods and multi-segment admin sub-paths fall through to the existing 403. This keeps the general llm_api_routes list free of management paths and avoids accidentally exposing POST/PUT/DELETE writes through the route-check layer. --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: ryan-crabbe-berri <ryan-crabbe-berri@users.noreply.github.com> * chore(ci): merge dev branch (#28807) * chore(proxy): route path-dependent call sites through get_request_route Replace direct ``request.url.path`` reads in auth, ACL, routing, and audit-log decisions with ``get_request_route(request)`` — the helper already added in ``auth/auth_utils.py`` that returns the ASGI ``scope["path"]`` with ``root_path`` stripped. Starlette reconstructs ``url.path`` from the Host header; ``scope["path"]`` is uvicorn's parse of the request line and matches what FastAPI dispatches on, so it's the authoritative route for any decision that should agree with the actual handler. Sites: - _experimental/mcp_server/auth/user_api_key_auth_mcp.py - management_endpoints/mcp_management_endpoints.py - vector_store_endpoints/utils.py - pass_through_endpoints/pass_through_endpoints.py - auth/route_checks.py - litellm_pre_call_utils.py - spend_tracking/spend_management_endpoints.py - common_utils/http_parsing_utils.py - management_helpers/utils.py - health_endpoints/_health_endpoints.py Adds regression tests in tests/proxy_unit_tests/test_proxy_routes.py that construct a Request with scope["path"] set to a benign route and the Host header crafted so url.path would resolve differently; each site's decision is asserted against scope["path"]. * chore(proxy): make get_request_route imports lazy at call sites Move the ``from litellm.proxy.auth.auth_utils import get_request_route`` imports added in the prior commit back to the function bodies that use them. The module-level form participates in a long-standing import cycle through ``auth_utils -> _types -> ...`` and was flagged by CodeQL on the PR; the lazy form matches the pattern the proxy already uses for ``user_api_key_auth`` and related helpers elsewhere in these files. Also drop the ``RouteChecks._is_assistants_api_request`` delegation in ``_get_metadata_variable_name`` introduced in the prior commit — the delegation pulled ``RouteChecks`` into the same cycle, and the call site reuses the resolved route for its other branches, so inlining the substring check is both cycle-free and avoids a redundant second ``get_request_route`` call. Comment in test_proxy_routes.py acknowledges that the two MCP table entries exercise ``get_request_route`` directly rather than the full production handler (which needs ASGI scope + MCP state to invoke). --------- Co-authored-by: shin-berri <shin-laptop@berri.ai> Co-authored-by: user <70670632+stuxf@users.noreply.github.com> * fix(team): keep team_alias cache in sync on _cache_team_object writes (#28737) * fix(team): keep team_alias cache in sync on _cache_team_object writes _cache_team_object wrote only to the team_id:<id> cache key, but the JWT auth path that uses team_alias_jwt_field reads from a separate team_alias:<alias> key (get_team_object_by_alias caches under both keys on miss, but reads only the alias-keyed one). After any team-mutation endpoint (team_model_add, team_model_delete, update_team, the two access-group writes) the team_id cache was refreshed but the team_alias cache stayed stale until TTL — JWT callers using team_alias_jwt_field kept seeing the pre-mutation team for the full cache window. Mirror the write under the alias key inside _cache_team_object so every existing caller stays in sync without further changes. Skip the alias write when team_alias is None/empty so we don't collide across alias-less teams. Surfaced testing the LIT-3244 cherry-pick on patch/1.86.0: the LIT-3244 fix correctly invalidated the team_id cache but the customer's JWT used team_alias_jwt_field, so they kept hitting the stale alias-keyed entry. * fix(team): delete (not overwrite) team_alias cache on _cache_team_object The prior shape of this PR wrote both team_id:<id> AND team_alias:<alias> from _cache_team_object. team_alias is NOT unique in the schema (no @unique on LiteLLM_TeamTable.team_alias), and get_team_object_by_alias enforces uniqueness on its own DB-fetch path (len(teams) > 1 raises). Writing the alias-keyed cache from the generic refresh path bypassed that check: a team admin renaming their team to collide with another team's alias could silently overwrite the cached team for JWT-by-alias auth, swapping the resolved team under that alias for the cache window. Switch the alias-keyed operation from a write to a delete (mirroring the dual-cache delete pattern in _delete_cache_key_object). After every team write, the next JWT-by-alias reader cache-misses and falls through to get_team_object_by_alias, which (a) re-fetches the fresh team from DB, closing the LIT-3244 staleness gap that motivated this PR, and (b) enforces alias uniqueness before populating either cache key. team_id:<id> writes are unchanged — team_id is the table PK and is guaranteed unique. Surfaced in veria-ai review on #28739. * fix(managed-files): anchor model_id regex so it doesn't match llm_output_file_model_id extract_model_id_from_unified_id used `re.search(r"model_id,([^;]+)", ...)` which substring-matches the `model_id,` inside the file-ID encoding's `llm_output_file_model_id,<deployment_uuid>` field. parse_unified_id then fed that deployment UUID back into the auth path as a model candidate via _extract_models_from_managed_resource_id, and every team-BYOK file attach 403'd with: team not allowed to access model. This team can only access models=['openai/*']. Tried to access <deployment-uuid> The team's models list correctly contains the public name (`openai/*`) that target_model_names matches, but the bogus UUID candidate fails the wildcard check first. Anchor the regex to a field boundary (`(?:^|;)model_id,`) so it matches the legitimate top-level `model_id,<value>` field on vector_store unified IDs and skips substring matches inside other fields. File-IDs (which have no top-level `model_id` field) now return None and contribute no spurious UUID candidate. Surfaced reproducing LIT-3244 on patch/1.86.0 with the customer's exact flow: team with openai/* BYOK deployment, JWT-scoped user, POST /v1/vector_stores/{id}/files attaching a file uploaded with target_model_names=openai/gpt-4o. * fix(proxy): hydrate wildcard discovery credentials (#28284) (#28822) * fix(proxy): hydrate wildcard discovery credentials * fix(proxy): constrain wildcard credential hydration Co-authored-by: Dibyo Mukherjee <dibyo@adobe.com> * ci: add daily oss-agent-shin branch creation workflow (#28829) Creates litellm_oss_agent_shin_MM_DD_YYYY from main every day at 00:00 UTC. Lets us retarget oss-agent-shin fork PRs onto a canonical branch so CircleCI runs with secrets, without granting the agent write access. Co-authored-by: shin-berri <shin-laptop@berri.ai> Co-authored-by: yuneng-jiang <yuneng@berri.ai> Co-authored-by: Ishaan Jaffer <ishaanjaffer0324@gmail.com> * test(proxy): add harness for proxy_server.py behavior-pinning (#28827) * test(proxy): add harness for proxy_server.py behavior-pinning Creates tests/test_litellm/proxy/proxy_server/ with: - conftest.py: 11 shared fixtures (app, client, mock_prisma, auth_as, mock_router with parametrized response builders, normalize, etc.) - _coverage_check.py: per-PR coverage gate (line + branch) against a baseline, self-selects target by inspecting which placeholder files have been filled - _pin_check.py: AST-based gate that verifies every pin-list item has >=1 happy + >=1 error test with a real assertion (no status-only) - test_harness_smoke.py: 19 smoke tests covering every fixture + both scripts end-to-end - 26 placeholder test files (one docstring each) reserved for follow-up PRs per the directory ownership in the Notion plan - .coverage_baseline pinned at 0% so future PRs measure deltas against new-tests-only and aren't entangled with the broader scattered test suite Adds a dedicated proxy-server job to test-unit-proxy-endpoints.yml so this directory's runtime + coverage are tracked independently. Plan: https://www.notion.so/36c43b8acdab81ee845fd5365128a2fc * ci(proxy-endpoints): allow workflow_dispatch Lets the workflow be triggered manually on a branch via `gh workflow run`, which is needed for the verify-first flow on workflow changes before opening a PR. * test(proxy): address review feedback on proxy_server harness - conftest.py: anchor sys.path insert to __file__ (Path(__file__).resolve().parents[4]) instead of CWD-relative os.path.abspath("../../../../") which resolved to the wrong directory when pytest is launched from the repo root. - _coverage_check.py: actually read .coverage_baseline and use it as the floor (line_min = max(target, baseline)). Closes the gap between the PR description's "delta semantics" and what the script was doing. With baseline=0.0 today this is a no-op; future PRs that update the baseline cause regressions (test deletions etc.) to trip the gate even if the static PR target is still met. - _pin_check.py: drop unreachable startswith("_") guard (test_*.py glob never yields underscore-prefixed names) and read each test file once instead of twice. * feat(openai): apply regional-processing cost uplift for EU/US data residency (#28626) * feat(openai): apply regional-processing cost uplift for EU/US data residency OpenAI charges a 10% uplift on the latest GPT models when requests are served from a regionalized hostname (eu./us.api.openai.com). Infer the region from `api_base`, expose it on `kwargs["litellm_params"]["data_residency"]`, and multiply the computed cost by a per-model `regional_processing_uplift_multiplier_<region>` field. https://claude.ai/code/session_012ebH44s7ohYxjoix5CXzTW * test: allow regional_processing_uplift_multiplier_{eu,us} in model_prices schema * fix(cost): tighten data_residency inference and restore model_cost in tests - Only infer OpenAI data_residency when custom_llm_provider == "openai"; drop the implicit None fallback so non-OpenAI callers can't accidentally pick up a regional tag from a stray OpenAI hostname. - _local_model_cost_map fixture now snapshots and restores litellm.model_cost and LITELLM_LOCAL_MODEL_COST_MAP so tests don't leak state across the session. * refactor(openai): move data_residency helper under llms/openai * fix: thread data_residency through realtime stream cost calculation Co-authored-by: Yassin Kortam <yassin@berri.ai> * fix(cost): thread data_residency through batch_cost_calculator Apply the OpenAI regional-processing uplift multiplier to retrieve_batch cost paths so Batch API requests served via eu./us.api.openai.com are priced at the same uplifted token rates as completions/transcriptions. * refactor(openai): encapsulate provider check inside infer_openai_data_residency Move the custom_llm_provider == "openai" guard from get_litellm_params into the helper itself so the core utility no longer carries provider-specific dispatch logic. Callers pass through the provider unconditionally; the helper returns None for any non-OpenAI provider. * fix(responses): thread data_residency through Responses logging params The Responses API paths build their logging litellm_params dict after provider resolution but did not include data_residency, so cost calc saw None even when the effective api_base was a regional OpenAI host. --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Yassin Kortam <yassin@berri.ai> --------- Co-authored-by: milan-berri <milan@berri.ai> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: Mateo <mateo@Mateos-MacBook-Pro.local> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Yassin Kortam <yassin@berri.ai> Co-authored-by: Yassin Kortam <yassinkortam@Yassins-MacBook-Pro.local> Co-authored-by: yuneng-jiang <yuneng@berri.ai> Co-authored-by: shin-berri <shin-laptop@berri.ai> Co-authored-by: user <70670632+stuxf@users.noreply.github.com> Co-authored-by: Krrish Dholakia <krrish+github@berri.ai> Co-authored-by: ryan-crabbe-berri <ryan@berri.ai> Co-authored-by: ryan-crabbe-berri <ryan-crabbe-berri@users.noreply.github.com> Co-authored-by: Dibyo Mukherjee <dibyo@adobe.com> Co-authored-by: ishaan-berri <155045088+ishaan-berri@users.noreply.github.com> Co-authored-by: Ishaan Jaffer <ishaanjaffer0324@gmail.com> * fix: preserve OTEL response payload and remove duplicate constant - _emit_management_endpoint_otel_span now passes result as response on success - remove duplicate _CREDENTIAL_LITELLM_PARAM_FIELDS assignment in model_checks Co-authored-by: Yassin Kortam <yassin@berri.ai> * fix: address bug detection findings - pass_through_endpoints: use request.method instead of hardcoded POST in streaming SigV4-signed request path for consistency with the non-streaming branch - llm_cost_calc/utils: hoist DataResidency value set to a module-level frozenset to avoid rebuilding it on every cost calculation - example_config_yaml/oai_misc_config: replace real-looking AWS account ID with placeholder 123456789012 in example bucket and role ARN Co-authored-by: Yassin Kortam <yassin@berri.ai> * chore(github_copilot): refresh model catalog from upstream /models API (#28055) Aligns the github_copilot catalog with values returned by Copilot's public /models endpoint (capabilities.limits + capabilities.supports + model.supported_endpoints). - Adds 10 new model entries: claude-opus-4.7, claude-sonnet-4.6, gemini-3-flash-preview, gemini-3.1-pro-preview, gpt-4-0125-preview, gpt-5.2-codex, gpt-5.4, gpt-5.4-mini, gpt-5.5, oswe-vscode-prime. - Updates max_input_tokens for existing entries to reflect each model's true context window (e.g. gpt-4o-mini 64000 -> 128000, gpt-5-mini 128000 -> 264000, gpt-5.3-codex 128000 -> 400000, claude-haiku-4.5 128000 -> 200000). - Adds supports_reasoning, supports_response_schema, supports_function_calling, supports_parallel_function_calling, supports_vision based on capabilities.supports. - Declares supported_endpoints for entries missing it (e.g. gpt-3.5-turbo, gpt-4o, embeddings). - For responses-only models (gpt-5.2-codex, gpt-5.4, gpt-5.4-mini, gpt-5.5), sets mode to 'responses'. - gpt-41-copilot.mode changes from 'completion' to 'chat' because Copilot reports capabilities.type = 'chat'. Revertible on request. Pricing fields and other manually-curated values are preserved. * feat(datadog): emit litellm.overhead.latency as a standalone Datadog metric (#28831) Adds a new `litellm.overhead.latency` gauge metric to `DatadogMetricsLogger` (the `/api/v2/series` path). The value is sourced from `hidden_params["litellm_overhead_time_ms"]` already computed in `ResponseMetadata` and exposed in `StandardLoggingPayload`. Matches the Prometheus integration which exposes the same value via `litellm_overhead_latency_metric`. Emitted in seconds (ms ÷ 1000) for consistency with the other latency series. Co-authored-by: shin-berri <shin-laptop@berri.ai> Co-authored-by: yuneng-jiang <yuneng@berri.ai> Co-authored-by: Shin <shin@litellm.ai> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: ishaan-berri <155045088+ishaan-berri@users.noreply.github.com> * feat(arize): route Phoenix traces via per-project TracerProviders (#28876) Use LRU-cached TracerProviders with project-scoped OTEL Resources so team/key metadata routes traces correctly. On the proxy, project selection is limited to server-controlled user_api_key_auth_metadata; client metadata fields stay banned. * fix(arize_phoenix): skip _emit_semantic_logs on failure path Co-authored-by: Yassin Kortam <yassin@berri.ai> * fix(arize_phoenix): skip raw request logging and metrics on failure path Restores pre-refactor behavior: _handle_failure no longer emits raw-request sub-spans or records OTEL metrics, matching the original _handle_failure that did not call these helpers. Co-authored-by: Yassin Kortam <yassin@berri.ai> * fix(security): close two medium telemetry trust-boundary issues Issue 1 (arize_phoenix.py — caller-controlled telemetry routing): - _is_proxy_request no longer detects proxy mode by checking user_api_key_auth_metadata in request metadata. That field is user-supplied, so an authenticated caller could fake proxy-mode detection and have _project_from_metadata_dict read their own dict for project selection, routing telemetry to arbitrary Arize/Phoenix projects. Proxy mode is now determined solely by the server-set proxy_server_request field in litellm_params. - auth_utils.py adds user_api_key_auth_metadata to the banned request body params list so the proxy rejects any attempt to supply the field at the HTTP layer. The field is server-reserved: it is written exclusively by add_user_api_key_auth_to_request_metadata from the authenticated key's database record after the ban check runs. Issue 2 (management_helpers/utils.py — API key in OTEL span): - _emit_management_endpoint_otel_span stripped plaintext credential fields (key, token, api_key, secret, …) from the response dict before passing it to the OTEL success hook. dict(result) on a Pydantic GenerateKeyResponse includes the freshly-generated key field, which would previously be written as a span attribute to every configured OTEL collector/backend. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: shin-berri <shin-laptop@berri.ai> Co-authored-by: yuneng-jiang <yuneng@berri.ai> Co-authored-by: oss-agent-shin <ext-agent-shin@berri.ai> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Yassin Kortam <yassin@berri.ai> Co-authored-by: Terrajlz <info@jouleselectrictech.com> Co-authored-by: Bruno Devaux <devaux.br@gmail.com> Co-authored-by: milan-berri <milan@berri.ai> Co-authored-by: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: Mateo <mateo@Mateos-MacBook-Pro.local> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Yassin Kortam <yassinkortam@Yassins-MacBook-Pro.local> Co-authored-by: user <70670632+stuxf@users.noreply.github.com> Co-authored-by: Krrish Dholakia <krrish+github@berri.ai> Co-authored-by: ryan-crabbe-berri <ryan@berri.ai> Co-authored-by: ryan-crabbe-berri <ryan-crabbe-berri@users.noreply.github.com> Co-authored-by: Dibyo Mukherjee <dibyo@adobe.com> Co-authored-by: ishaan-berri <155045088+ishaan-berri@users.noreply.github.com> Co-authored-by: Ishaan Jaffer <ishaanjaffer0324@gmail.com> Co-authored-by: rinto <54238243+ririnto@users.noreply.github.com> Co-authored-by: Shin <shin@litellm.ai> Co-authored-by: mubashir1osmani <mubashir.osmani777@gmail.com>
1929 lines
73 KiB
Python
1929 lines
73 KiB
Python
"""
|
|
Unit tests for auth_utils functions related to rate limiting and customer ID extraction.
|
|
"""
|
|
|
|
import base64
|
|
from typing import Optional
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from litellm.proxy._types import UserAPIKeyAuth
|
|
from litellm.proxy.auth.auth_utils import (
|
|
_get_customer_id_from_standard_headers,
|
|
abbreviate_api_key,
|
|
check_complete_credentials,
|
|
get_end_user_id_from_request_body,
|
|
get_key_model_rpm_limit,
|
|
get_key_model_tpm_limit,
|
|
get_model_from_request,
|
|
get_project_model_rpm_limit,
|
|
get_project_model_tpm_limit,
|
|
get_request_route_template,
|
|
is_request_body_safe,
|
|
)
|
|
|
|
|
|
class TestGetKeyModelRpmLimit:
|
|
"""Tests for get_key_model_rpm_limit function."""
|
|
|
|
def test_returns_key_metadata_when_present(self):
|
|
"""Key metadata takes priority over team metadata."""
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-123",
|
|
metadata={"model_rpm_limit": {"gpt-4": 100}},
|
|
team_metadata={"model_rpm_limit": {"gpt-4": 50}},
|
|
)
|
|
result = get_key_model_rpm_limit(user_api_key_dict)
|
|
assert result == {"gpt-4": 100}
|
|
|
|
def test_falls_back_to_team_metadata_when_key_has_other_metadata(self):
|
|
"""Should fall back to team metadata when key metadata exists but has no model_rpm_limit."""
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-123",
|
|
metadata={
|
|
"some_other_key": "value"
|
|
}, # Has metadata, but not model_rpm_limit
|
|
team_metadata={"model_rpm_limit": {"gpt-4": 50}},
|
|
)
|
|
result = get_key_model_rpm_limit(user_api_key_dict)
|
|
assert result == {"gpt-4": 50}
|
|
|
|
def test_extracts_from_model_max_budget(self):
|
|
"""Should extract rpm_limit from model_max_budget when metadata is empty."""
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-123",
|
|
model_max_budget={
|
|
"gpt-4": {"rpm_limit": 100, "tpm_limit": 1000},
|
|
"gpt-3.5-turbo": {"rpm_limit": 200},
|
|
},
|
|
)
|
|
result = get_key_model_rpm_limit(user_api_key_dict)
|
|
assert result == {"gpt-4": 100, "gpt-3.5-turbo": 200}
|
|
|
|
def test_skips_models_without_rpm_limit(self):
|
|
"""Should skip models that don't have rpm_limit in model_max_budget."""
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-123",
|
|
model_max_budget={
|
|
"gpt-4": {"rpm_limit": 100},
|
|
"gpt-3.5-turbo": {"tpm_limit": 1000}, # No rpm_limit
|
|
},
|
|
)
|
|
result = get_key_model_rpm_limit(user_api_key_dict)
|
|
assert result == {"gpt-4": 100}
|
|
|
|
def test_returns_none_when_no_limits_configured(self):
|
|
"""Should return None when no rate limits are configured."""
|
|
user_api_key_dict = UserAPIKeyAuth(api_key="sk-123")
|
|
result = get_key_model_rpm_limit(user_api_key_dict)
|
|
assert result is None
|
|
|
|
def test_team_metadata_empty_rpm_dict_falls_through_to_deployment_default(self):
|
|
"""Explicitly empty team model_rpm_limit ({}) should be returned as-is, not fallen through."""
|
|
# An empty dict is a valid team limit map (no per-model limits configured).
|
|
# It should be returned directly rather than falling through to deployment defaults,
|
|
# so a team with an empty map is treated as unconstrained at the team level.
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-123",
|
|
team_metadata={"model_rpm_limit": {}},
|
|
)
|
|
result = get_key_model_rpm_limit(user_api_key_dict)
|
|
assert result == {}
|
|
|
|
|
|
class TestGetKeyModelTpmLimit:
|
|
"""Tests for get_key_model_tpm_limit function."""
|
|
|
|
def test_returns_key_metadata_when_present(self):
|
|
"""Key metadata takes priority over team metadata."""
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-123",
|
|
metadata={"model_tpm_limit": {"gpt-4": 10000}},
|
|
team_metadata={"model_tpm_limit": {"gpt-4": 5000}},
|
|
)
|
|
result = get_key_model_tpm_limit(user_api_key_dict)
|
|
assert result == {"gpt-4": 10000}
|
|
|
|
def test_falls_back_to_team_metadata_when_key_has_other_metadata(self):
|
|
"""Should fall back to team metadata when key metadata exists but has no model_tpm_limit."""
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-123",
|
|
metadata={
|
|
"some_other_key": "value"
|
|
}, # Has metadata, but not model_tpm_limit
|
|
team_metadata={"model_tpm_limit": {"gpt-4": 5000}},
|
|
)
|
|
result = get_key_model_tpm_limit(user_api_key_dict)
|
|
assert result == {"gpt-4": 5000}
|
|
|
|
def test_extracts_from_model_max_budget(self):
|
|
"""Should extract tpm_limit from model_max_budget when metadata is empty."""
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-123",
|
|
model_max_budget={
|
|
"gpt-4": {"tpm_limit": 10000, "rpm_limit": 100},
|
|
"gpt-3.5-turbo": {"tpm_limit": 20000},
|
|
},
|
|
)
|
|
result = get_key_model_tpm_limit(user_api_key_dict)
|
|
assert result == {"gpt-4": 10000, "gpt-3.5-turbo": 20000}
|
|
|
|
def test_skips_models_without_tpm_limit(self):
|
|
"""Should skip models that don't have tpm_limit in model_max_budget."""
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-123",
|
|
model_max_budget={
|
|
"gpt-4": {"tpm_limit": 10000},
|
|
"gpt-3.5-turbo": {"rpm_limit": 100}, # No tpm_limit
|
|
},
|
|
)
|
|
result = get_key_model_tpm_limit(user_api_key_dict)
|
|
assert result == {"gpt-4": 10000}
|
|
|
|
def test_returns_none_when_no_limits_configured(self):
|
|
"""Should return None when no rate limits are configured."""
|
|
user_api_key_dict = UserAPIKeyAuth(api_key="sk-123")
|
|
result = get_key_model_tpm_limit(user_api_key_dict)
|
|
assert result is None
|
|
|
|
def test_model_max_budget_priority_over_team(self):
|
|
"""model_max_budget should take priority over team_metadata."""
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-123",
|
|
model_max_budget={"gpt-4": {"tpm_limit": 10000}},
|
|
team_metadata={"model_tpm_limit": {"gpt-4": 5000}},
|
|
)
|
|
result = get_key_model_tpm_limit(user_api_key_dict)
|
|
assert result == {"gpt-4": 10000}
|
|
|
|
def test_team_metadata_empty_tpm_dict_falls_through_to_deployment_default(self):
|
|
"""Explicitly empty team model_tpm_limit ({}) should be returned as-is, not fallen through."""
|
|
# An empty dict is a valid team limit map (no per-model limits configured).
|
|
# It should be returned directly rather than falling through to deployment defaults,
|
|
# so a team with an empty map is treated as unconstrained at the team level.
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-123",
|
|
team_metadata={"model_tpm_limit": {}},
|
|
)
|
|
result = get_key_model_tpm_limit(user_api_key_dict)
|
|
assert result == {}
|
|
|
|
def test_skips_deployments_with_malformed_limit_value(self):
|
|
"""Deployments with non-integer-parseable limit values are skipped without raising."""
|
|
user_api_key_dict = UserAPIKeyAuth(api_key="sk-123")
|
|
mock_router = MagicMock()
|
|
mock_router.get_model_list.return_value = [
|
|
{
|
|
"model_name": "model1",
|
|
"litellm_params": {"default_api_key_tpm_limit": "not-a-number"},
|
|
},
|
|
_make_deployment_dict("model1", tpm=500),
|
|
]
|
|
with patch(_ROUTER_PATCH, mock_router):
|
|
result = get_key_model_tpm_limit(user_api_key_dict, model_name="model1")
|
|
# The malformed deployment is skipped; the valid one provides 500
|
|
assert result == {"model1": 500}
|
|
|
|
|
|
class TestGetCustomerIdFromStandardHeaders:
|
|
"""Tests for _get_customer_id_from_standard_headers helper function."""
|
|
|
|
def test_should_return_customer_id_from_x_litellm_customer_id_header(self):
|
|
"""Should extract customer ID from x-litellm-customer-id header."""
|
|
headers = {"x-litellm-customer-id": "customer-123"}
|
|
result = _get_customer_id_from_standard_headers(request_headers=headers)
|
|
assert result == "customer-123"
|
|
|
|
def test_should_return_customer_id_from_x_litellm_end_user_id_header(self):
|
|
"""Should extract customer ID from x-litellm-end-user-id header."""
|
|
headers = {"x-litellm-end-user-id": "end-user-456"}
|
|
result = _get_customer_id_from_standard_headers(request_headers=headers)
|
|
assert result == "end-user-456"
|
|
|
|
def test_should_return_none_when_headers_is_none(self):
|
|
"""Should return None when headers is None."""
|
|
result = _get_customer_id_from_standard_headers(request_headers=None)
|
|
assert result is None
|
|
|
|
def test_should_return_none_when_no_standard_headers_present(self):
|
|
"""Should return None when no standard customer ID headers are present."""
|
|
headers = {"x-other-header": "some-value"}
|
|
result = _get_customer_id_from_standard_headers(request_headers=headers)
|
|
assert result is None
|
|
|
|
|
|
class TestGetEndUserIdFromRequestBodyWithStandardHeaders:
|
|
"""Tests for get_end_user_id_from_request_body with standard customer ID headers."""
|
|
|
|
def test_should_prioritize_standard_header_over_body_user(self):
|
|
"""Standard customer ID header should take precedence over body user field."""
|
|
headers = {"x-litellm-customer-id": "header-customer"}
|
|
request_body = {"user": "body-user"}
|
|
|
|
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
|
result = get_end_user_id_from_request_body(
|
|
request_body=request_body, request_headers=headers
|
|
)
|
|
assert result == "header-customer"
|
|
|
|
def test_should_fall_back_to_body_when_no_standard_header(self):
|
|
"""Should fall back to body user when no standard headers are present."""
|
|
headers = {"x-other-header": "value"}
|
|
request_body = {"user": "body-user"}
|
|
|
|
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
|
result = get_end_user_id_from_request_body(
|
|
request_body=request_body, request_headers=headers
|
|
)
|
|
assert result == "body-user"
|
|
|
|
|
|
def test_get_model_from_request_supports_google_model_names_with_slashes():
|
|
assert (
|
|
get_model_from_request(
|
|
request_data={},
|
|
route="/v1beta/models/bedrock/claude-sonnet-3.7:generateContent",
|
|
)
|
|
== "bedrock/claude-sonnet-3.7"
|
|
)
|
|
assert (
|
|
get_model_from_request(
|
|
request_data={},
|
|
route="/models/hosted_vllm/gpt-oss-20b:generateContent",
|
|
)
|
|
== "hosted_vllm/gpt-oss-20b"
|
|
)
|
|
|
|
|
|
def test_get_model_from_request_vertex_passthrough_still_works():
|
|
route = "/vertex_ai/v1/projects/p/locations/l/publishers/google/models/gemini-1.5-pro:generateContent"
|
|
assert get_model_from_request(request_data={}, route=route) == "gemini-1.5-pro"
|
|
|
|
|
|
def test_get_model_from_request_openai_deployment_route_still_works():
|
|
assert (
|
|
get_model_from_request(
|
|
request_data={},
|
|
route="/openai/deployments/my-azure-deployment/chat/completions",
|
|
)
|
|
== "my-azure-deployment"
|
|
)
|
|
|
|
|
|
def test_get_model_from_request_includes_file_endpoint_header_model():
|
|
assert (
|
|
get_model_from_request(
|
|
request_data={},
|
|
route="/v1/files",
|
|
request_headers={"X-LiteLLM-Model": "restricted-model"},
|
|
)
|
|
== "restricted-model"
|
|
)
|
|
|
|
|
|
def test_get_model_from_request_ignores_routing_header_on_standard_llm_routes():
|
|
assert (
|
|
get_model_from_request(
|
|
request_data={"model": "allowed-model"},
|
|
route="/v1/chat/completions",
|
|
request_headers={"x-litellm-model": "restricted-model"},
|
|
)
|
|
== "allowed-model"
|
|
)
|
|
|
|
|
|
def test_get_model_from_request_authorizes_all_file_routing_model_sources():
|
|
models = get_model_from_request(
|
|
request_data={"model": "body-model"},
|
|
route="/v1/files",
|
|
request_headers={"x-litellm-model": "header-model"},
|
|
request_query_params={"target_model_names": "query-model-a,query-model-b"},
|
|
)
|
|
assert isinstance(models, list)
|
|
assert set(models) == {
|
|
"body-model",
|
|
"query-model-a",
|
|
"query-model-b",
|
|
"header-model",
|
|
}
|
|
|
|
|
|
def test_get_model_from_request_extracts_simple_encoded_file_id_model():
|
|
from litellm.proxy.openai_files_endpoints.common_utils import (
|
|
encode_file_id_with_model,
|
|
)
|
|
|
|
file_id = encode_file_id_with_model(
|
|
file_id="file-provider-id",
|
|
model="restricted-model",
|
|
)
|
|
|
|
assert (
|
|
get_model_from_request(
|
|
request_data={"file_id": file_id},
|
|
route="/v1/files/{file_id}",
|
|
)
|
|
== "restricted-model"
|
|
)
|
|
|
|
|
|
def test_get_model_from_request_extracts_unified_file_id_models():
|
|
raw_unified_file_id = (
|
|
"litellm_proxy:application/octet-stream;unified_id,test-id;"
|
|
"target_model_names,model-a,model-b;llm_output_file_id,file-provider-id"
|
|
)
|
|
encoded_unified_file_id = (
|
|
base64.urlsafe_b64encode(raw_unified_file_id.encode()).decode().rstrip("=")
|
|
)
|
|
|
|
assert get_model_from_request(
|
|
request_data={"file_id": encoded_unified_file_id},
|
|
route="/v1/files/{file_id}",
|
|
) == ["model-a", "model-b"]
|
|
|
|
|
|
def test_get_model_from_request_extracts_eval_completion_model():
|
|
assert (
|
|
get_model_from_request(
|
|
request_data={"completion": {"model": "judge-model"}},
|
|
route="/v1/evals/{eval_id}/runs",
|
|
)
|
|
== "judge-model"
|
|
)
|
|
|
|
|
|
def test_get_model_from_request_includes_fine_tuning_target_model_query():
|
|
assert (
|
|
get_model_from_request(
|
|
request_data={},
|
|
route="/v1/fine_tuning/jobs",
|
|
request_query_params={"target_model_names": "fine-tune-model"},
|
|
)
|
|
== "fine-tune-model"
|
|
)
|
|
|
|
|
|
def test_get_model_from_request_extracts_video_id_model():
|
|
from litellm.types.videos.utils import encode_video_id_with_provider
|
|
|
|
video_id = encode_video_id_with_provider(
|
|
video_id="video-provider-id",
|
|
provider="openai",
|
|
model_id="video-model",
|
|
)
|
|
|
|
assert (
|
|
get_model_from_request(
|
|
request_data={"video_id": video_id},
|
|
route="/v1/videos/{video_id}",
|
|
)
|
|
== "video-model"
|
|
)
|
|
|
|
|
|
def test_get_model_from_request_only_runs_media_decoders_for_matching_fields():
|
|
with (
|
|
patch(
|
|
"litellm.types.videos.utils.decode_video_id_with_provider",
|
|
return_value={"model_id": "video-model"},
|
|
) as video_decoder,
|
|
patch(
|
|
"litellm.types.videos.utils.decode_character_id_with_provider",
|
|
return_value={"model_id": "character-model"},
|
|
) as character_decoder,
|
|
):
|
|
assert (
|
|
get_model_from_request(
|
|
request_data={"file_id": "file-provider-id"},
|
|
route="/v1/files/{file_id}",
|
|
)
|
|
is None
|
|
)
|
|
video_decoder.assert_not_called()
|
|
character_decoder.assert_not_called()
|
|
|
|
assert (
|
|
get_model_from_request(
|
|
request_data={"video_id": "video-provider-id"},
|
|
route="/v1/videos/{video_id}",
|
|
)
|
|
== "video-model"
|
|
)
|
|
video_decoder.assert_called_once_with("video-provider-id")
|
|
character_decoder.assert_not_called()
|
|
|
|
video_decoder.reset_mock()
|
|
character_decoder.reset_mock()
|
|
assert (
|
|
get_model_from_request(
|
|
request_data={"character_id": "character-provider-id"},
|
|
route="/v1/videos/{character_id}",
|
|
)
|
|
== "character-model"
|
|
)
|
|
video_decoder.assert_not_called()
|
|
character_decoder.assert_called_once_with("character-provider-id")
|
|
|
|
|
|
def test_get_model_from_request_handles_managed_id_decoder_failures():
|
|
with (
|
|
patch(
|
|
"litellm.proxy.openai_files_endpoints.common_utils.decode_model_from_file_id",
|
|
side_effect=Exception("decode failed"),
|
|
),
|
|
patch(
|
|
"litellm.llms.base_llm.managed_resources.utils.parse_unified_id",
|
|
side_effect=Exception("parse failed"),
|
|
),
|
|
patch(
|
|
"litellm.types.videos.utils.decode_video_id_with_provider",
|
|
side_effect=Exception("video decode failed"),
|
|
),
|
|
):
|
|
assert (
|
|
get_model_from_request(
|
|
request_data={"file_id": "not-a-managed-resource-id"},
|
|
route="/v1/files/{file_id}",
|
|
)
|
|
is None
|
|
)
|
|
assert (
|
|
get_model_from_request(
|
|
request_data={"video_id": "not-a-managed-resource-id"},
|
|
route="/v1/videos/{video_id}",
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
def test_abbreviate_api_key():
|
|
assert abbreviate_api_key("sk-test-1234") == "sk-...1234"
|
|
|
|
|
|
def test_get_customer_user_header_returns_none_when_no_customer_role():
|
|
from litellm.proxy.auth.auth_utils import get_customer_user_header_from_mapping
|
|
|
|
mappings = [
|
|
{"header_name": "X-OpenWebUI-User-Id", "litellm_user_role": "internal_user"}
|
|
]
|
|
result = get_customer_user_header_from_mapping(mappings)
|
|
assert result is None
|
|
|
|
|
|
def test_get_customer_user_header_returns_none_for_single_non_customer_mapping():
|
|
from litellm.proxy.auth.auth_utils import get_customer_user_header_from_mapping
|
|
|
|
mapping = {"header_name": "X-Only-Internal", "litellm_user_role": "internal_user"}
|
|
result = get_customer_user_header_from_mapping(mapping)
|
|
assert result is None
|
|
|
|
|
|
def test_get_customer_user_header_from_mapping_returns_customer_header():
|
|
from litellm.proxy.auth.auth_utils import get_customer_user_header_from_mapping
|
|
|
|
mappings = [
|
|
{"header_name": "X-OpenWebUI-User-Id", "litellm_user_role": "internal_user"},
|
|
{"header_name": "X-OpenWebUI-User-Email", "litellm_user_role": "customer"},
|
|
]
|
|
result = get_customer_user_header_from_mapping(mappings)
|
|
assert result == ["x-openwebui-user-email"]
|
|
|
|
|
|
def test_get_customer_user_header_returns_customers_header_in_config_order_when_multiple_exist():
|
|
from litellm.proxy.auth.auth_utils import get_customer_user_header_from_mapping
|
|
|
|
mappings = [
|
|
{"header_name": "X-OpenWebUI-User-Id", "litellm_user_role": "internal_user"},
|
|
{"header_name": "X-OpenWebUI-User-Email", "litellm_user_role": "customer"},
|
|
{"header_name": "X-User-Id", "litellm_user_role": "customer"},
|
|
]
|
|
result = get_customer_user_header_from_mapping(mappings)
|
|
assert result == ["x-openwebui-user-email", "x-user-id"]
|
|
|
|
|
|
def test_get_end_user_id_returns_id_from_user_header_mappings():
|
|
from litellm.proxy.auth.auth_utils import get_end_user_id_from_request_body
|
|
|
|
mappings = [
|
|
{"header_name": "x-openwebui-user-id", "litellm_user_role": "internal_user"},
|
|
{"header_name": "x-openwebui-user-email", "litellm_user_role": "customer"},
|
|
]
|
|
general_settings = {"user_header_mappings": mappings}
|
|
headers = {"x-openwebui-user-email": "1234"}
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.auth.auth_utils._get_customer_id_from_standard_headers",
|
|
return_value=None,
|
|
),
|
|
patch("litellm.proxy.proxy_server.general_settings", general_settings),
|
|
):
|
|
result = get_end_user_id_from_request_body(
|
|
request_body={}, request_headers=headers
|
|
)
|
|
|
|
assert result == "1234"
|
|
|
|
|
|
def test_get_end_user_id_returns_first_customer_header_when_multiple_mappings_exist():
|
|
from litellm.proxy.auth.auth_utils import get_end_user_id_from_request_body
|
|
|
|
mappings = [
|
|
{"header_name": "x-openwebui-user-id", "litellm_user_role": "internal_user"},
|
|
{"header_name": "x-user-id", "litellm_user_role": "customer"},
|
|
{"header_name": "x-openwebui-user-email", "litellm_user_role": "customer"},
|
|
]
|
|
general_settings = {"user_header_mappings": mappings}
|
|
headers = {
|
|
"x-user-id": "user-456",
|
|
"x-openwebui-user-email": "user@example.com",
|
|
}
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.auth.auth_utils._get_customer_id_from_standard_headers",
|
|
return_value=None,
|
|
),
|
|
patch("litellm.proxy.proxy_server.general_settings", general_settings),
|
|
):
|
|
result = get_end_user_id_from_request_body(
|
|
request_body={}, request_headers=headers
|
|
)
|
|
|
|
assert result == "user-456"
|
|
|
|
|
|
def test_get_end_user_id_returns_none_when_no_customer_role_in_mappings():
|
|
from litellm.proxy.auth.auth_utils import get_end_user_id_from_request_body
|
|
|
|
mappings = [
|
|
{"header_name": "x-openwebui-user-id", "litellm_user_role": "internal_user"},
|
|
]
|
|
general_settings = {"user_header_mappings": mappings}
|
|
headers = {"x-openwebui-user-id": "user-789"}
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.auth.auth_utils._get_customer_id_from_standard_headers",
|
|
return_value=None,
|
|
),
|
|
patch("litellm.proxy.proxy_server.general_settings", general_settings),
|
|
):
|
|
result = get_end_user_id_from_request_body(
|
|
request_body={}, request_headers=headers
|
|
)
|
|
|
|
assert result is None
|
|
|
|
|
|
def test_get_end_user_id_falls_back_to_deprecated_user_header_name():
|
|
from litellm.proxy.auth.auth_utils import get_end_user_id_from_request_body
|
|
|
|
general_settings = {"user_header_name": "x-custom-user-id"}
|
|
headers = {"x-custom-user-id": "user-legacy"}
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.auth.auth_utils._get_customer_id_from_standard_headers",
|
|
return_value=None,
|
|
),
|
|
patch("litellm.proxy.proxy_server.general_settings", general_settings),
|
|
):
|
|
result = get_end_user_id_from_request_body(
|
|
request_body={}, request_headers=headers
|
|
)
|
|
|
|
assert result == "user-legacy"
|
|
|
|
|
|
class TestCoerceUserIdToStr:
|
|
"""Unit tests for the _coerce_user_id_to_str helper."""
|
|
|
|
def test_plain_string_is_returned_verbatim(self):
|
|
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
|
|
|
assert _coerce_user_id_to_str("alice@example.com") == "alice@example.com"
|
|
|
|
def test_string_is_stripped(self):
|
|
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
|
|
|
assert _coerce_user_id_to_str(" bob ") == "bob"
|
|
|
|
def test_codex_opaque_identifier_is_preserved(self):
|
|
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
|
|
|
codex_id = (
|
|
"user_8a4a360c36621665b341e06fb76041d9b6def732bb183eea148d4abc9d97c1de"
|
|
"_account__session_a2bce4a5-8887-44ef-b491-fbf0a55c6569"
|
|
)
|
|
assert _coerce_user_id_to_str(codex_id) == codex_id
|
|
|
|
def test_none_returns_none(self):
|
|
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
|
|
|
assert _coerce_user_id_to_str(None) is None
|
|
|
|
def test_empty_string_returns_none(self):
|
|
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
|
|
|
assert _coerce_user_id_to_str("") is None
|
|
assert _coerce_user_id_to_str(" ") is None
|
|
|
|
def test_dict_returns_none(self):
|
|
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
|
|
|
payload = {
|
|
"device_id": "abc",
|
|
"account_uuid": "",
|
|
"session_id": "c284b8cb",
|
|
}
|
|
assert _coerce_user_id_to_str(payload) is None
|
|
|
|
def test_list_returns_none(self):
|
|
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
|
|
|
assert _coerce_user_id_to_str(["a", "b"]) is None
|
|
|
|
def test_json_encoded_dict_string_passes_through_by_default(self):
|
|
"""JSON-encoded dict strings are preserved unless opt-in flag is on.
|
|
|
|
This preserves backwards compatibility: existing deployments that
|
|
intentionally pass JSON-encoded user identifiers keep working.
|
|
"""
|
|
import litellm
|
|
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
|
|
|
blob = (
|
|
'{"device_id":"d5abe9199ee7759a0558974e9371e78c7b38d7621aae26d6609c1de61af6afb0",'
|
|
'"account_uuid":"","session_id":"c284b8cb-a050-4278-8599-cc4e016a10ab"}'
|
|
)
|
|
original = litellm.validate_end_user_id_in_db
|
|
litellm.validate_end_user_id_in_db = False
|
|
try:
|
|
assert _coerce_user_id_to_str(blob) == blob
|
|
finally:
|
|
litellm.validate_end_user_id_in_db = original
|
|
|
|
def test_json_encoded_dict_string_returns_none_when_validation_enabled(self):
|
|
import litellm
|
|
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
|
|
|
# Same broken shape we saw in spend logs, but pre-stringified to JSON.
|
|
blob = (
|
|
'{"device_id":"d5abe9199ee7759a0558974e9371e78c7b38d7621aae26d6609c1de61af6afb0",'
|
|
'"account_uuid":"","session_id":"c284b8cb-a050-4278-8599-cc4e016a10ab"}'
|
|
)
|
|
original = litellm.validate_end_user_id_in_db
|
|
litellm.validate_end_user_id_in_db = True
|
|
try:
|
|
assert _coerce_user_id_to_str(blob) is None
|
|
finally:
|
|
litellm.validate_end_user_id_in_db = original
|
|
|
|
def test_json_encoded_list_string_passes_through_by_default(self):
|
|
import litellm
|
|
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
|
|
|
original = litellm.validate_end_user_id_in_db
|
|
litellm.validate_end_user_id_in_db = False
|
|
try:
|
|
assert _coerce_user_id_to_str('["a","b"]') == '["a","b"]'
|
|
finally:
|
|
litellm.validate_end_user_id_in_db = original
|
|
|
|
def test_json_encoded_list_string_returns_none_when_validation_enabled(self):
|
|
import litellm
|
|
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
|
|
|
original = litellm.validate_end_user_id_in_db
|
|
litellm.validate_end_user_id_in_db = True
|
|
try:
|
|
assert _coerce_user_id_to_str('["a","b"]') is None
|
|
finally:
|
|
litellm.validate_end_user_id_in_db = original
|
|
|
|
def test_int_returns_str(self):
|
|
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
|
|
|
assert _coerce_user_id_to_str(12345) == "12345"
|
|
|
|
def test_bool_returns_none(self):
|
|
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
|
|
|
# bool is an int subclass — reject explicitly, never produce "True"/"False".
|
|
assert _coerce_user_id_to_str(True) is None
|
|
assert _coerce_user_id_to_str(False) is None
|
|
|
|
def test_brace_string_that_isnt_json_is_kept(self):
|
|
"""A string starting with `{` but failing to parse stays as-is."""
|
|
from litellm.proxy.auth.auth_utils import _coerce_user_id_to_str
|
|
|
|
assert _coerce_user_id_to_str("{not json") == "{not json"
|
|
|
|
|
|
class TestGetEndUserIdDropsMalformedBodyValues:
|
|
"""Tests that get_end_user_id_from_request_body drops dict-shaped values
|
|
rather than stringifying them into spend logs."""
|
|
|
|
def test_dict_user_falls_through_to_litellm_metadata(self):
|
|
request_body = {
|
|
"user": {
|
|
"device_id": "abc",
|
|
"session_id": "c284b8cb",
|
|
},
|
|
"litellm_metadata": {"user": "alice@example.com"},
|
|
}
|
|
|
|
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
|
result = get_end_user_id_from_request_body(
|
|
request_body=request_body, request_headers={}
|
|
)
|
|
|
|
assert result == "alice@example.com"
|
|
|
|
def test_dict_user_with_no_other_sources_returns_none(self):
|
|
request_body = {
|
|
"user": {"device_id": "abc", "session_id": "xyz"},
|
|
}
|
|
|
|
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
|
result = get_end_user_id_from_request_body(
|
|
request_body=request_body, request_headers={}
|
|
)
|
|
|
|
assert result is None
|
|
|
|
def test_json_encoded_user_string_passes_through_by_default(self):
|
|
"""JSON-encoded user strings pass through unless validation is opted in.
|
|
|
|
Gating behind ``litellm.validate_end_user_id_in_db`` keeps existing
|
|
deployments that send JSON-encoded identifiers working until they
|
|
explicitly opt into the stricter extraction.
|
|
"""
|
|
import litellm
|
|
|
|
blob = (
|
|
'{"device_id":"d5abe9199ee7759a","account_uuid":"",'
|
|
'"session_id":"c284b8cb-a050-4278-8599-cc4e016a10ab"}'
|
|
)
|
|
request_body = {"user": blob}
|
|
|
|
original = litellm.validate_end_user_id_in_db
|
|
litellm.validate_end_user_id_in_db = False
|
|
try:
|
|
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
|
result = get_end_user_id_from_request_body(
|
|
request_body=request_body, request_headers={}
|
|
)
|
|
finally:
|
|
litellm.validate_end_user_id_in_db = original
|
|
|
|
assert result == blob
|
|
|
|
def test_json_encoded_user_string_returns_none_when_validation_enabled(self):
|
|
import litellm
|
|
|
|
request_body = {
|
|
"user": (
|
|
'{"device_id":"d5abe9199ee7759a","account_uuid":"",'
|
|
'"session_id":"c284b8cb-a050-4278-8599-cc4e016a10ab"}'
|
|
),
|
|
}
|
|
|
|
original = litellm.validate_end_user_id_in_db
|
|
litellm.validate_end_user_id_in_db = True
|
|
try:
|
|
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
|
result = get_end_user_id_from_request_body(
|
|
request_body=request_body, request_headers={}
|
|
)
|
|
finally:
|
|
litellm.validate_end_user_id_in_db = original
|
|
|
|
assert result is None
|
|
|
|
def test_plain_string_user_is_preserved(self):
|
|
request_body = {"user": "alice@example.com"}
|
|
|
|
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
|
result = get_end_user_id_from_request_body(
|
|
request_body=request_body, request_headers={}
|
|
)
|
|
|
|
assert result == "alice@example.com"
|
|
|
|
def test_codex_opaque_user_is_preserved(self):
|
|
codex_id = (
|
|
"user_8a4a360c36621665b341e06fb76041d9b6def732bb183eea148d4abc9d97c1de"
|
|
"_account__session_a2bce4a5-8887-44ef-b491-fbf0a55c6569"
|
|
)
|
|
request_body = {"user": codex_id}
|
|
|
|
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
|
result = get_end_user_id_from_request_body(
|
|
request_body=request_body, request_headers={}
|
|
)
|
|
|
|
assert result == codex_id
|
|
|
|
def test_int_user_is_coerced_to_string(self):
|
|
request_body = {"user": 12345}
|
|
|
|
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
|
result = get_end_user_id_from_request_body(
|
|
request_body=request_body, request_headers={}
|
|
)
|
|
|
|
assert result == "12345"
|
|
|
|
def test_list_user_falls_through(self):
|
|
request_body = {
|
|
"user": ["a", "b"],
|
|
"safety_identifier": "alice@example.com",
|
|
}
|
|
|
|
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
|
result = get_end_user_id_from_request_body(
|
|
request_body=request_body, request_headers={}
|
|
)
|
|
|
|
assert result == "alice@example.com"
|
|
|
|
def test_dict_safety_identifier_returns_none(self):
|
|
request_body = {
|
|
"safety_identifier": {"device_id": "abc"},
|
|
}
|
|
|
|
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
|
result = get_end_user_id_from_request_body(
|
|
request_body=request_body, request_headers={}
|
|
)
|
|
|
|
assert result is None
|
|
|
|
def test_dict_metadata_user_id_returns_none(self):
|
|
request_body = {
|
|
"metadata": {"user_id": {"device_id": "abc"}},
|
|
}
|
|
|
|
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
|
result = get_end_user_id_from_request_body(
|
|
request_body=request_body, request_headers={}
|
|
)
|
|
|
|
assert result is None
|
|
|
|
def test_whitespace_user_falls_through(self):
|
|
request_body = {"user": " ", "safety_identifier": "alice@example.com"}
|
|
|
|
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
|
result = get_end_user_id_from_request_body(
|
|
request_body=request_body, request_headers={}
|
|
)
|
|
|
|
assert result == "alice@example.com"
|
|
|
|
def test_dict_user_header_falls_through_to_body(self):
|
|
"""A dict-shaped value in a configured user-id header is dropped, not stringified."""
|
|
general_settings = {"user_header_name": "x-custom-user-id"}
|
|
# A header value will normally be a str, but be defensive: the coercion
|
|
# must drop anything that isn't a usable identifier.
|
|
headers = {"x-custom-user-id": {"device_id": "abc"}}
|
|
request_body = {"user": "alice@example.com"}
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.auth.auth_utils._get_customer_id_from_standard_headers",
|
|
return_value=None,
|
|
),
|
|
patch("litellm.proxy.proxy_server.general_settings", general_settings),
|
|
):
|
|
result = get_end_user_id_from_request_body(
|
|
request_body=request_body, request_headers=headers
|
|
)
|
|
|
|
assert result == "alice@example.com"
|
|
|
|
|
|
def _make_deployment_dict(
|
|
model_name: str, tpm: Optional[int] = None, rpm: Optional[int] = None
|
|
) -> dict:
|
|
"""Helper to build a minimal deployment dict as returned by router.get_model_list."""
|
|
litellm_params: dict = {"model": model_name}
|
|
if tpm is not None:
|
|
litellm_params["default_api_key_tpm_limit"] = tpm
|
|
if rpm is not None:
|
|
litellm_params["default_api_key_rpm_limit"] = rpm
|
|
return {"model_name": model_name, "litellm_params": litellm_params}
|
|
|
|
|
|
_ROUTER_PATCH = "litellm.proxy.proxy_server.llm_router"
|
|
|
|
|
|
class TestDeploymentDefaultRpmLimit:
|
|
"""Tests for deployment default_api_key_rpm_limit fallback in get_key_model_rpm_limit."""
|
|
|
|
def test_returns_deployment_default_when_key_has_no_limits(self):
|
|
"""Case 2 from spec: key has no model-specific limits, falls back to deployment default."""
|
|
user_api_key_dict = UserAPIKeyAuth(api_key="sk-123")
|
|
mock_router = MagicMock()
|
|
mock_router.get_model_list.return_value = [
|
|
_make_deployment_dict("model1", rpm=200)
|
|
]
|
|
with patch(_ROUTER_PATCH, mock_router):
|
|
result = get_key_model_rpm_limit(user_api_key_dict, model_name="model1")
|
|
assert result == {"model1": 200}
|
|
|
|
def test_key_model_limit_takes_priority_over_deployment_default(self):
|
|
"""Case 1 from spec: key model-specific limit wins over deployment default."""
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-123",
|
|
metadata={"model_rpm_limit": {"model1": 10}},
|
|
)
|
|
mock_router = MagicMock()
|
|
mock_router.get_model_list.return_value = [
|
|
_make_deployment_dict("model1", rpm=200)
|
|
]
|
|
with patch(_ROUTER_PATCH, mock_router):
|
|
result = get_key_model_rpm_limit(user_api_key_dict, model_name="model1")
|
|
assert result == {"model1": 10}
|
|
|
|
def test_returns_none_when_no_deployment_default_and_no_key_limits(self):
|
|
"""Returns None when neither the key nor the deployment has any rpm limit."""
|
|
user_api_key_dict = UserAPIKeyAuth(api_key="sk-123")
|
|
mock_router = MagicMock()
|
|
mock_router.get_model_list.return_value = [
|
|
_make_deployment_dict("model1") # no rpm default
|
|
]
|
|
with patch(_ROUTER_PATCH, mock_router):
|
|
result = get_key_model_rpm_limit(user_api_key_dict, model_name="model1")
|
|
assert result is None
|
|
|
|
def test_returns_none_without_model_name_even_when_deployment_has_default(self):
|
|
"""No model_name means deployment fallback is skipped."""
|
|
user_api_key_dict = UserAPIKeyAuth(api_key="sk-123")
|
|
mock_router = MagicMock()
|
|
mock_router.get_model_list.return_value = [
|
|
_make_deployment_dict("model1", rpm=200)
|
|
]
|
|
with patch(_ROUTER_PATCH, mock_router):
|
|
result = get_key_model_rpm_limit(user_api_key_dict)
|
|
assert result is None
|
|
|
|
def test_returns_none_when_llm_router_is_none(self):
|
|
"""No router means deployment fallback returns None gracefully."""
|
|
user_api_key_dict = UserAPIKeyAuth(api_key="sk-123")
|
|
with patch(_ROUTER_PATCH, None):
|
|
result = get_key_model_rpm_limit(user_api_key_dict, model_name="model1")
|
|
assert result is None
|
|
|
|
def test_returns_minimum_across_multiple_deployments(self):
|
|
"""When multiple deployments share a model name, the minimum rpm limit is used."""
|
|
user_api_key_dict = UserAPIKeyAuth(api_key="sk-123")
|
|
mock_router = MagicMock()
|
|
mock_router.get_model_list.return_value = [
|
|
_make_deployment_dict("model1", rpm=200),
|
|
_make_deployment_dict("model1", rpm=50),
|
|
_make_deployment_dict("model1", rpm=150),
|
|
]
|
|
with patch(_ROUTER_PATCH, mock_router):
|
|
result = get_key_model_rpm_limit(user_api_key_dict, model_name="model1")
|
|
assert result == {"model1": 50}
|
|
|
|
def test_ignores_deployments_without_default_when_others_have_it(self):
|
|
"""Deployments missing the field are skipped; min is taken over those that have it."""
|
|
user_api_key_dict = UserAPIKeyAuth(api_key="sk-123")
|
|
mock_router = MagicMock()
|
|
mock_router.get_model_list.return_value = [
|
|
_make_deployment_dict("model1"), # no rpm default
|
|
_make_deployment_dict("model1", rpm=75),
|
|
]
|
|
with patch(_ROUTER_PATCH, mock_router):
|
|
result = get_key_model_rpm_limit(user_api_key_dict, model_name="model1")
|
|
assert result == {"model1": 75}
|
|
|
|
def test_skips_deployments_with_malformed_limit_value(self):
|
|
"""Deployments with non-integer-parseable limit values are skipped without raising."""
|
|
user_api_key_dict = UserAPIKeyAuth(api_key="sk-123")
|
|
mock_router = MagicMock()
|
|
mock_router.get_model_list.return_value = [
|
|
{
|
|
"model_name": "model1",
|
|
"litellm_params": {"default_api_key_rpm_limit": "not-a-number"},
|
|
},
|
|
_make_deployment_dict("model1", rpm=100),
|
|
]
|
|
with patch(_ROUTER_PATCH, mock_router):
|
|
result = get_key_model_rpm_limit(user_api_key_dict, model_name="model1")
|
|
# The malformed deployment is skipped; the valid one provides 100
|
|
assert result == {"model1": 100}
|
|
|
|
|
|
class TestDeploymentDefaultTpmLimit:
|
|
"""Tests for deployment default_api_key_tpm_limit fallback in get_key_model_tpm_limit."""
|
|
|
|
def test_returns_deployment_default_when_key_has_no_limits(self):
|
|
"""Case 2 from spec: key has no model-specific limits, falls back to deployment default."""
|
|
user_api_key_dict = UserAPIKeyAuth(api_key="sk-123")
|
|
mock_router = MagicMock()
|
|
mock_router.get_model_list.return_value = [
|
|
_make_deployment_dict("model1", tpm=100)
|
|
]
|
|
with patch(_ROUTER_PATCH, mock_router):
|
|
result = get_key_model_tpm_limit(user_api_key_dict, model_name="model1")
|
|
assert result == {"model1": 100}
|
|
|
|
def test_key_model_limit_takes_priority_over_deployment_default(self):
|
|
"""Case 1 from spec: key model-specific limit wins over deployment default."""
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-123",
|
|
metadata={"model_tpm_limit": {"model1": 20}},
|
|
)
|
|
mock_router = MagicMock()
|
|
mock_router.get_model_list.return_value = [
|
|
_make_deployment_dict("model1", tpm=100)
|
|
]
|
|
with patch(_ROUTER_PATCH, mock_router):
|
|
result = get_key_model_tpm_limit(user_api_key_dict, model_name="model1")
|
|
assert result == {"model1": 20}
|
|
|
|
def test_returns_none_when_no_deployment_default_and_no_key_limits(self):
|
|
"""Returns None when neither the key nor the deployment has any tpm limit."""
|
|
user_api_key_dict = UserAPIKeyAuth(api_key="sk-123")
|
|
mock_router = MagicMock()
|
|
mock_router.get_model_list.return_value = [
|
|
_make_deployment_dict("model1") # no tpm default
|
|
]
|
|
with patch(_ROUTER_PATCH, mock_router):
|
|
result = get_key_model_tpm_limit(user_api_key_dict, model_name="model1")
|
|
assert result is None
|
|
|
|
def test_returns_none_without_model_name_even_when_deployment_has_default(self):
|
|
"""No model_name means deployment fallback is skipped."""
|
|
user_api_key_dict = UserAPIKeyAuth(api_key="sk-123")
|
|
mock_router = MagicMock()
|
|
mock_router.get_model_list.return_value = [
|
|
_make_deployment_dict("model1", tpm=100)
|
|
]
|
|
with patch(_ROUTER_PATCH, mock_router):
|
|
result = get_key_model_tpm_limit(user_api_key_dict)
|
|
assert result is None
|
|
|
|
def test_returns_none_when_llm_router_is_none(self):
|
|
"""No router means deployment fallback returns None gracefully."""
|
|
user_api_key_dict = UserAPIKeyAuth(api_key="sk-123")
|
|
with patch(_ROUTER_PATCH, None):
|
|
result = get_key_model_tpm_limit(user_api_key_dict, model_name="model1")
|
|
assert result is None
|
|
|
|
def test_returns_minimum_across_multiple_deployments(self):
|
|
"""When multiple deployments share a model name, the minimum tpm limit is used."""
|
|
user_api_key_dict = UserAPIKeyAuth(api_key="sk-123")
|
|
mock_router = MagicMock()
|
|
mock_router.get_model_list.return_value = [
|
|
_make_deployment_dict("model1", tpm=1000),
|
|
_make_deployment_dict("model1", tpm=300),
|
|
_make_deployment_dict("model1", tpm=700),
|
|
]
|
|
with patch(_ROUTER_PATCH, mock_router):
|
|
result = get_key_model_tpm_limit(user_api_key_dict, model_name="model1")
|
|
assert result == {"model1": 300}
|
|
|
|
def test_ignores_deployments_without_default_when_others_have_it(self):
|
|
"""Deployments missing the field are skipped; min is taken over those that have it."""
|
|
user_api_key_dict = UserAPIKeyAuth(api_key="sk-123")
|
|
mock_router = MagicMock()
|
|
mock_router.get_model_list.return_value = [
|
|
_make_deployment_dict("model1"), # no tpm default
|
|
_make_deployment_dict("model1", tpm=400),
|
|
]
|
|
with patch(_ROUTER_PATCH, mock_router):
|
|
result = get_key_model_tpm_limit(user_api_key_dict, model_name="model1")
|
|
assert result == {"model1": 400}
|
|
|
|
|
|
class TestGetProjectModelRpmLimit:
|
|
"""Tests for get_project_model_rpm_limit function."""
|
|
|
|
def test_returns_project_metadata_rpm_limit(self):
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-123",
|
|
project_metadata={"model_rpm_limit": {"gpt-4": 200}},
|
|
)
|
|
result = get_project_model_rpm_limit(user_api_key_dict)
|
|
assert result == {"gpt-4": 200}
|
|
|
|
def test_returns_none_when_no_project_metadata(self):
|
|
user_api_key_dict = UserAPIKeyAuth(api_key="sk-123")
|
|
result = get_project_model_rpm_limit(user_api_key_dict)
|
|
assert result is None
|
|
|
|
def test_returns_none_when_project_metadata_missing_key(self):
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-123",
|
|
project_metadata={"other_key": "value"},
|
|
)
|
|
result = get_project_model_rpm_limit(user_api_key_dict)
|
|
assert result is None
|
|
|
|
|
|
class TestGetProjectModelTpmLimit:
|
|
"""Tests for get_project_model_tpm_limit function."""
|
|
|
|
def test_returns_project_metadata_tpm_limit(self):
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-123",
|
|
project_metadata={"model_tpm_limit": {"gpt-4": 50000}},
|
|
)
|
|
result = get_project_model_tpm_limit(user_api_key_dict)
|
|
assert result == {"gpt-4": 50000}
|
|
|
|
def test_returns_none_when_no_project_metadata(self):
|
|
user_api_key_dict = UserAPIKeyAuth(api_key="sk-123")
|
|
result = get_project_model_tpm_limit(user_api_key_dict)
|
|
assert result is None
|
|
|
|
def test_returns_none_when_project_metadata_missing_key(self):
|
|
user_api_key_dict = UserAPIKeyAuth(
|
|
api_key="sk-123",
|
|
project_metadata={"other_key": "value"},
|
|
)
|
|
result = get_project_model_tpm_limit(user_api_key_dict)
|
|
assert result is None
|
|
|
|
|
|
class TestCheckCompleteCredentials:
|
|
"""Tests for the api_key validation in check_complete_credentials."""
|
|
|
|
def test_returns_false_when_api_key_missing(self):
|
|
result = check_complete_credentials({"model": "gpt-4"})
|
|
assert result is False
|
|
|
|
def test_returns_false_when_api_key_is_none(self):
|
|
result = check_complete_credentials({"model": "gpt-4", "api_key": None})
|
|
assert result is False
|
|
|
|
def test_returns_false_when_api_key_is_empty_string(self):
|
|
result = check_complete_credentials({"model": "gpt-4", "api_key": ""})
|
|
assert result is False
|
|
|
|
def test_returns_false_when_api_key_is_whitespace(self):
|
|
result = check_complete_credentials({"model": "gpt-4", "api_key": " "})
|
|
assert result is False
|
|
|
|
def test_returns_true_when_api_key_is_valid(self):
|
|
result = check_complete_credentials({"model": "gpt-4", "api_key": "sk-valid"})
|
|
assert result is True
|
|
|
|
|
|
class TestCheckCompleteCredentialsBlocksSSRF:
|
|
"""
|
|
Even with credentials supplied, ``api_base`` / ``base_url`` must not
|
|
point at private / internal / cloud-metadata addresses. Without this
|
|
the gate accepts ``api_key=anything`` plus a malicious target and the
|
|
proxy is used as an SSRF pivot.
|
|
|
|
The check only runs when ``litellm.user_url_validation`` is True, so
|
|
every test in this class flips the toggle. Tests stay mock-only — no
|
|
real DNS is performed.
|
|
"""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _enable_url_validation(self, monkeypatch):
|
|
import litellm
|
|
|
|
monkeypatch.setattr(litellm, "user_url_validation", True, raising=False)
|
|
|
|
@pytest.mark.parametrize(
|
|
"url_field",
|
|
["api_base", "base_url"],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"blocked_url",
|
|
[
|
|
"http://169.254.169.254/latest/meta-data/iam/security-credentials/",
|
|
"http://metadata.google.internal/computeMetadata/v1/",
|
|
"http://127.0.0.1:8080/admin",
|
|
"http://10.0.0.1/",
|
|
"http://192.168.1.1/",
|
|
],
|
|
)
|
|
def test_rejects_private_or_metadata_targets(self, url_field, blocked_url):
|
|
from litellm.litellm_core_utils.url_utils import SSRFError
|
|
|
|
with patch(
|
|
"litellm.proxy.auth.auth_utils.validate_url",
|
|
side_effect=SSRFError(f"blocked: {blocked_url}"),
|
|
):
|
|
with pytest.raises(ValueError) as exc_info:
|
|
check_complete_credentials(
|
|
{
|
|
"model": "gpt-4",
|
|
"api_key": "sk-some-clientside-key",
|
|
url_field: blocked_url,
|
|
}
|
|
)
|
|
assert url_field in str(exc_info.value)
|
|
assert "SSRF" in str(exc_info.value)
|
|
|
|
def test_allows_public_target_when_validate_url_passes(self):
|
|
# ``validate_url`` is mocked so no real DNS is performed.
|
|
with patch(
|
|
"litellm.proxy.auth.auth_utils.validate_url",
|
|
return_value=("https://api.openai.com/v1", "api.openai.com"),
|
|
):
|
|
result = check_complete_credentials(
|
|
{
|
|
"model": "gpt-4",
|
|
"api_key": "sk-some-clientside-key",
|
|
"api_base": "https://api.openai.com/v1",
|
|
}
|
|
)
|
|
assert result is True
|
|
|
|
def test_skips_url_validation_when_toggle_is_off(self, monkeypatch):
|
|
# Admins who disable ``user_url_validation`` (default) should not
|
|
# have requests rejected at the proxy boundary even if the URL
|
|
# would fail the SSRF guard.
|
|
import litellm
|
|
|
|
monkeypatch.setattr(litellm, "user_url_validation", False, raising=False)
|
|
with patch(
|
|
"litellm.proxy.auth.auth_utils.validate_url",
|
|
) as mocked:
|
|
result = check_complete_credentials(
|
|
{
|
|
"model": "gpt-4",
|
|
"api_key": "sk-some-clientside-key",
|
|
"api_base": "http://127.0.0.1:8080/admin",
|
|
}
|
|
)
|
|
assert result is True
|
|
mocked.assert_not_called()
|
|
|
|
|
|
class TestGetDynamicLitellmParamsClearsAdminConfigOnBaseOverride:
|
|
"""
|
|
When the caller redirects ``api_base`` / ``base_url`` to their own
|
|
server, admin-set fields like ``OpenAI-Organization``, ``extra_body``,
|
|
AWS / Vertex / Azure tokens, and per-deployment ``api_version`` must
|
|
NOT flow through to that destination.
|
|
"""
|
|
|
|
def test_clears_admin_organization_and_extra_body_on_base_override(self):
|
|
from litellm.router_utils.clientside_credential_handler import (
|
|
get_dynamic_litellm_params,
|
|
)
|
|
|
|
admin_params = {
|
|
"model": "gpt-4",
|
|
"api_key": "sk-admin-key",
|
|
"api_base": "https://admin.upstream/v1",
|
|
"organization": "org-admin-corp",
|
|
"extra_body": {"x-admin-secret": "super-secret"},
|
|
"api_version": "2026-04-01",
|
|
}
|
|
out = get_dynamic_litellm_params(
|
|
litellm_params=dict(admin_params),
|
|
request_kwargs={
|
|
"api_key": "sk-attacker",
|
|
"api_base": "https://attacker.example",
|
|
},
|
|
)
|
|
assert out["api_base"] == "https://attacker.example"
|
|
assert out["api_key"] == "sk-attacker"
|
|
assert "organization" not in out
|
|
assert "extra_body" not in out
|
|
assert "api_version" not in out
|
|
|
|
def test_clears_aws_and_vertex_secrets_on_base_override(self):
|
|
from litellm.router_utils.clientside_credential_handler import (
|
|
get_dynamic_litellm_params,
|
|
)
|
|
|
|
admin_params = {
|
|
"model": "bedrock/claude-3",
|
|
"aws_access_key_id": "AKIA-EXAMPLE",
|
|
"aws_secret_access_key": "secret-example",
|
|
"aws_session_token": "session-example",
|
|
"vertex_credentials": '{"private_key":"-----BEGIN..."}',
|
|
"vertex_project": "admin-gcp-project",
|
|
}
|
|
out = get_dynamic_litellm_params(
|
|
litellm_params=dict(admin_params),
|
|
request_kwargs={"base_url": "https://attacker.example"},
|
|
)
|
|
assert "aws_access_key_id" not in out
|
|
assert "aws_secret_access_key" not in out
|
|
assert "aws_session_token" not in out
|
|
assert "vertex_credentials" not in out
|
|
assert "vertex_project" not in out
|
|
|
|
def test_caller_resupplied_value_overrides_admin_value_on_base_override(self):
|
|
# When the caller redirects ``api_base`` and *also* supplies their
|
|
# own value for one of the admin fields (e.g. ``organization``),
|
|
# the caller's value must win — never the admin's. The naive
|
|
# ``if field not in request_kwargs: pop`` shape lets a caller echo
|
|
# the field name with any value (or empty string) to keep the
|
|
# admin's value forwarded, which is the exfiltration vector this
|
|
# test guards against.
|
|
from litellm.router_utils.clientside_credential_handler import (
|
|
get_dynamic_litellm_params,
|
|
)
|
|
|
|
out = get_dynamic_litellm_params(
|
|
litellm_params={
|
|
"api_base": "https://admin.upstream/v1",
|
|
"organization": "org-admin",
|
|
"extra_body": {"admin": "value"},
|
|
},
|
|
request_kwargs={
|
|
"api_base": "https://attacker.example",
|
|
"organization": "org-attacker",
|
|
"extra_body": {"attacker": "value"},
|
|
},
|
|
)
|
|
assert out["organization"] == "org-attacker"
|
|
assert out["extra_body"] == {"attacker": "value"}
|
|
|
|
def test_field_echo_does_not_preserve_admin_value(self):
|
|
# Regression: a caller that echoes an admin-config field name with
|
|
# an *empty* value (or any value) must not be able to keep the
|
|
# admin's value in ``litellm_params``.
|
|
from litellm.router_utils.clientside_credential_handler import (
|
|
get_dynamic_litellm_params,
|
|
)
|
|
|
|
out = get_dynamic_litellm_params(
|
|
litellm_params={
|
|
"api_base": "https://admin.upstream/v1",
|
|
"organization": "org-admin-secret",
|
|
"extra_body": {"x-admin-only": "secret"},
|
|
},
|
|
request_kwargs={
|
|
"api_base": "https://attacker.example",
|
|
"organization": "",
|
|
"extra_body": "",
|
|
},
|
|
)
|
|
assert out["organization"] == ""
|
|
assert out["extra_body"] == ""
|
|
assert "org-admin-secret" not in str(out)
|
|
|
|
def test_no_clearing_when_only_api_key_overridden(self):
|
|
from litellm.router_utils.clientside_credential_handler import (
|
|
get_dynamic_litellm_params,
|
|
)
|
|
|
|
# Caller only overrides api_key (BYOK pattern); admin's organization /
|
|
# extra_body / region still apply because the destination is unchanged.
|
|
out = get_dynamic_litellm_params(
|
|
litellm_params={
|
|
"api_base": "https://admin.upstream/v1",
|
|
"organization": "org-admin",
|
|
"api_version": "2026-04-01",
|
|
},
|
|
request_kwargs={"api_key": "sk-byok"},
|
|
)
|
|
assert out["organization"] == "org-admin"
|
|
assert out["api_version"] == "2026-04-01"
|
|
assert out["api_base"] == "https://admin.upstream/v1"
|
|
|
|
|
|
class TestIsRequestBodySafeBlocksEndpointTargetingFields:
|
|
"""
|
|
``is_request_body_safe`` rejects request-body fields that retarget the
|
|
outbound request to a caller-controlled host. Beyond the original
|
|
``api_base`` / ``base_url``, the same protection must apply to:
|
|
|
|
* ``aws_bedrock_runtime_endpoint`` — Bedrock endpoint redirect; an
|
|
attacker-controlled value coerces the proxy to authenticate against
|
|
their host with the admin's AWS creds.
|
|
* ``langsmith_base_url`` — Langsmith callback host; attacker-controlled
|
|
values exfiltrate the entire request payload (incl. message content)
|
|
via the observability hook.
|
|
* ``langfuse_host`` — same exfil vector via the Langfuse hook.
|
|
"""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _disable_url_validation(self, monkeypatch):
|
|
# The new banned-params entries should be rejected even when
|
|
# ``user_url_validation`` is off — the gate isn't the URL guard,
|
|
# it's the banned-params list.
|
|
import litellm
|
|
|
|
monkeypatch.setattr(litellm, "user_url_validation", False, raising=False)
|
|
|
|
@pytest.mark.parametrize(
|
|
"field",
|
|
[
|
|
"aws_bedrock_runtime_endpoint",
|
|
"langsmith_base_url",
|
|
"langfuse_host",
|
|
"posthog_host",
|
|
"braintrust_host",
|
|
"slack_webhook_url",
|
|
"s3_endpoint_url",
|
|
"sagemaker_base_url",
|
|
"deployment_url",
|
|
],
|
|
)
|
|
def test_endpoint_targeting_field_in_request_body_is_rejected(self, field):
|
|
with pytest.raises(ValueError) as exc:
|
|
is_request_body_safe(
|
|
request_body={"model": "gpt-4", field: "https://attacker.example"},
|
|
general_settings={},
|
|
llm_router=None,
|
|
model="gpt-4",
|
|
)
|
|
# The function lists the offending param name in the error.
|
|
assert field in str(exc.value)
|
|
|
|
@pytest.mark.parametrize(
|
|
"field",
|
|
["api_base", "base_url", "user_config", "langfuse_host", "slack_webhook_url"],
|
|
)
|
|
def test_api_key_does_not_bypass_blocklist(self, field):
|
|
# Regression: the historical ``check_complete_credentials`` clause
|
|
# made the entire blocklist a no-op for any caller that supplied
|
|
# a non-empty ``api_key``. That bypass turned every missing entry
|
|
# on the blocklist into an SSRF / credential-exfil hole. Verify
|
|
# that supplying an api_key (alongside the banned param) does NOT
|
|
# bypass the gate — it can only be opened by an admin opt-in.
|
|
with pytest.raises(ValueError) as exc:
|
|
is_request_body_safe(
|
|
request_body={
|
|
"model": "gpt-4",
|
|
"api_key": "sk-anything",
|
|
field: "https://attacker.example",
|
|
},
|
|
general_settings={},
|
|
llm_router=None,
|
|
model="gpt-4",
|
|
)
|
|
assert field in str(exc.value)
|
|
|
|
def test_admin_opt_in_proxy_wide_still_allows(self):
|
|
# ``general_settings.allow_client_side_credentials = True`` remains
|
|
# the documented proxy-wide BYOK opt-in.
|
|
assert (
|
|
is_request_body_safe(
|
|
request_body={"model": "gpt-4", "api_base": "https://my-byok.example"},
|
|
general_settings={"allow_client_side_credentials": True},
|
|
llm_router=None,
|
|
model="gpt-4",
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
# ── is_request_body_safe nested-config recursion (VERIA-6) ────────────────────
|
|
|
|
|
|
class TestIsRequestBodySafeNestedConfig:
|
|
"""The Milvus vector store transformer unpacks
|
|
``litellm_embedding_config`` as ``**kwargs`` into ``litellm.embedding(...)``
|
|
— same SSRF / credential-exfil surface as a top-level ``api_base`` in
|
|
the request body. ``is_request_body_safe`` must recurse into this
|
|
nested dict so a banned param can't be smuggled in via nesting."""
|
|
|
|
def test_root_level_api_base_blocked_when_no_opt_in(self):
|
|
"""Sanity check: pre-existing root-level enforcement still works."""
|
|
with pytest.raises(ValueError, match="api_base"):
|
|
is_request_body_safe(
|
|
request_body={"api_base": "https://attacker.example.com"},
|
|
general_settings={},
|
|
llm_router=None,
|
|
model="gpt-4",
|
|
)
|
|
|
|
def test_nested_api_base_in_embedding_config_blocked(self):
|
|
"""Smuggling ``api_base`` inside ``litellm_embedding_config`` is
|
|
the VERIA-6 bypass — must be blocked by the recursive check."""
|
|
with pytest.raises(ValueError, match="api_base"):
|
|
is_request_body_safe(
|
|
request_body={
|
|
"litellm_embedding_config": {
|
|
"api_base": "https://attacker.example.com",
|
|
"api_key": "leaked-key",
|
|
}
|
|
},
|
|
general_settings={},
|
|
llm_router=None,
|
|
model="milvus-store",
|
|
)
|
|
|
|
def test_nested_langfuse_host_in_embedding_config_blocked(self):
|
|
"""The recursion uses the *full* banned-param list, not a special
|
|
subset — so any flag that's banned at the root is also banned
|
|
when nested."""
|
|
with pytest.raises(ValueError, match="langfuse_host"):
|
|
is_request_body_safe(
|
|
request_body={
|
|
"litellm_embedding_config": {
|
|
"langfuse_host": "https://attacker.example.com"
|
|
}
|
|
},
|
|
general_settings={},
|
|
llm_router=None,
|
|
model="milvus-store",
|
|
)
|
|
|
|
def test_nested_api_base_allowed_when_admin_opts_in(self):
|
|
"""Admins who explicitly enable client-side credential passthrough
|
|
keep the existing escape hatch — same UX as for root-level."""
|
|
assert (
|
|
is_request_body_safe(
|
|
request_body={
|
|
"litellm_embedding_config": {
|
|
"api_base": "https://my-azure.example.com"
|
|
}
|
|
},
|
|
general_settings={"allow_client_side_credentials": True},
|
|
llm_router=None,
|
|
model="milvus-store",
|
|
)
|
|
is True
|
|
)
|
|
|
|
def test_safe_nested_config_accepted(self):
|
|
"""A nested config without any banned params passes — there's no
|
|
false-positive on legitimate ``api_version`` / model params."""
|
|
assert (
|
|
is_request_body_safe(
|
|
request_body={
|
|
"litellm_embedding_config": {
|
|
"api_version": "2024-02-15-preview",
|
|
}
|
|
},
|
|
general_settings={},
|
|
llm_router=None,
|
|
model="milvus-store",
|
|
)
|
|
is True
|
|
)
|
|
|
|
def test_non_dict_nested_config_does_not_break_check(self):
|
|
"""A bogus type for ``litellm_embedding_config`` (string, list,
|
|
None) must not crash the validator — it should just fall through."""
|
|
assert (
|
|
is_request_body_safe(
|
|
request_body={"litellm_embedding_config": "not-a-dict"},
|
|
general_settings={},
|
|
llm_router=None,
|
|
model="x",
|
|
)
|
|
is True
|
|
)
|
|
|
|
def test_deeply_nested_config_does_not_recurse(self):
|
|
"""Greptile P1: ``is_request_body_safe`` is iterative single-level —
|
|
a deeply-nested ``litellm_embedding_config`` cannot exhaust the
|
|
Python call stack to trigger a 500 ``RecursionError``. Build a
|
|
body 1000 levels deep; the validator must complete in O(1)
|
|
descent."""
|
|
body = {"litellm_embedding_config": {}}
|
|
cur = body["litellm_embedding_config"]
|
|
for _ in range(1000):
|
|
cur["litellm_embedding_config"] = {}
|
|
cur = cur["litellm_embedding_config"]
|
|
# Banned param at the deepest level shouldn't be reached — single
|
|
# level only.
|
|
cur["api_base"] = "https://attacker.example.com"
|
|
|
|
# No exception raised: deeper levels aren't checked.
|
|
assert (
|
|
is_request_body_safe(
|
|
request_body=body,
|
|
general_settings={},
|
|
llm_router=None,
|
|
model="x",
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
# ── observability-callback ban (root + metadata) ───────────────────────────
|
|
|
|
|
|
class TestObservabilityCallbackBans:
|
|
"""The proxy must reject observability credentials, hosts, and project
|
|
identifiers regardless of whether they arrive at the request body root,
|
|
in ``metadata`` / ``litellm_metadata``, or in a JSON-string-encoded
|
|
metadata blob (multipart/``extra_body`` path).
|
|
|
|
The ban list is derived from
|
|
``litellm.litellm_core_utils.initialize_dynamic_callback_params._supported_callback_params``
|
|
minus a small ``_SAFE_CLIENT_CALLBACK_PARAMS`` allow-list, plus
|
|
``_EXTRA_BANNED_OBSERVABILITY_PARAMS`` for fields integrations read but
|
|
that are not yet in the canonical allow-list. The derivation keeps the
|
|
proxy in sync as new integrations are added.
|
|
"""
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _disable_url_validation(self, monkeypatch):
|
|
import litellm
|
|
|
|
monkeypatch.setattr(litellm, "user_url_validation", False, raising=False)
|
|
|
|
@pytest.mark.parametrize(
|
|
"field",
|
|
[
|
|
"langfuse_public_key",
|
|
"langfuse_secret",
|
|
"langfuse_secret_key",
|
|
"langsmith_api_key",
|
|
"langsmith_project",
|
|
"langsmith_tenant_id",
|
|
"arize_api_key",
|
|
"arize_space_key",
|
|
"arize_space_id",
|
|
"posthog_api_key",
|
|
"posthog_api_url",
|
|
"braintrust_api_key",
|
|
"braintrust_project",
|
|
"phoenix_project_name",
|
|
"phoenix_project_name_override",
|
|
"wandb_api_key",
|
|
"weave_project_id",
|
|
"gcs_bucket_name",
|
|
"gcs_path_service_account",
|
|
"humanloop_api_key",
|
|
"lunary_public_key",
|
|
],
|
|
)
|
|
def test_observability_field_in_request_body_root_is_rejected(self, field):
|
|
with pytest.raises(ValueError) as exc:
|
|
is_request_body_safe(
|
|
request_body={"model": "gpt-4", field: "attacker-value"},
|
|
general_settings={},
|
|
llm_router=None,
|
|
model="gpt-4",
|
|
)
|
|
assert field in str(exc.value)
|
|
|
|
@pytest.mark.parametrize(
|
|
"metadata_key",
|
|
["metadata", "litellm_metadata"],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"field",
|
|
[
|
|
"langfuse_host",
|
|
"langfuse_secret_key",
|
|
"langsmith_api_key",
|
|
"posthog_api_url",
|
|
"braintrust_project",
|
|
"phoenix_project_name",
|
|
"phoenix_project_name_override",
|
|
],
|
|
)
|
|
def test_observability_field_in_metadata_dict_is_rejected(
|
|
self, metadata_key, field
|
|
):
|
|
# Verifies the metadata walk: a value smuggled inside ``metadata``
|
|
# or ``litellm_metadata`` is just as dangerous as the same field
|
|
# at the body root, and must hit the same gate.
|
|
with pytest.raises(ValueError) as exc:
|
|
is_request_body_safe(
|
|
request_body={
|
|
"model": "gpt-4",
|
|
metadata_key: {field: "attacker-value"},
|
|
},
|
|
general_settings={},
|
|
llm_router=None,
|
|
model="gpt-4",
|
|
)
|
|
assert field in str(exc.value)
|
|
|
|
@pytest.mark.parametrize(
|
|
"metadata_key",
|
|
["metadata", "litellm_metadata"],
|
|
)
|
|
def test_observability_field_in_json_string_metadata_is_rejected(
|
|
self, metadata_key
|
|
):
|
|
# Multipart/form-data and ``extra_body`` callers send metadata as a
|
|
# JSON-encoded string. The bouncer parses it before applying the
|
|
# banned-params check so the JSON-string path can't smuggle past
|
|
# the ``isinstance(dict)`` guard.
|
|
import json
|
|
|
|
with pytest.raises(ValueError) as exc:
|
|
is_request_body_safe(
|
|
request_body={
|
|
"model": "gpt-4",
|
|
metadata_key: json.dumps(
|
|
{"langfuse_host": "https://attacker.example"}
|
|
),
|
|
},
|
|
general_settings={},
|
|
llm_router=None,
|
|
model="gpt-4",
|
|
)
|
|
assert "langfuse_host" in str(exc.value)
|
|
|
|
def test_admin_opt_in_allows_metadata_credential_passthrough(self):
|
|
# The opt-in gate covers the metadata path the same way it covers
|
|
# the root path — operators running BYO observability with
|
|
# clientside creds flip a single flag and both paths work.
|
|
assert (
|
|
is_request_body_safe(
|
|
request_body={
|
|
"model": "gpt-4",
|
|
"metadata": {
|
|
"langfuse_host": "https://my-langfuse.example",
|
|
"langfuse_public_key": "pk-mine",
|
|
"langfuse_secret_key": "sk-mine",
|
|
},
|
|
},
|
|
general_settings={"allow_client_side_credentials": True},
|
|
llm_router=None,
|
|
model="gpt-4",
|
|
)
|
|
is True
|
|
)
|
|
|
|
def test_safe_per_request_observability_metadata_is_allowed(self):
|
|
# Informational fields (sampling rate, prompt version) describe
|
|
# the request being logged — they don't choose the destination or
|
|
# credentials, so they must remain accepted from clients without
|
|
# the opt-in flag.
|
|
assert (
|
|
is_request_body_safe(
|
|
request_body={
|
|
"model": "gpt-4",
|
|
"metadata": {
|
|
"langfuse_prompt_version": "v2",
|
|
"langsmith_sampling_rate": 0.1,
|
|
},
|
|
},
|
|
general_settings={},
|
|
llm_router=None,
|
|
model="gpt-4",
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
def test_model_level_allow_does_not_skip_subsequent_banned_params(monkeypatch):
|
|
"""Greptile P1: ``_check_banned_params`` previously ``return``-ed when a
|
|
deployment's ``configurable_clientside_auth_params`` permitted one
|
|
banned field, exiting before any later banned field in the same body
|
|
was checked. The metadata walk this PR adds multiplies the surface
|
|
where that bypass matters: a body pairing a model-level-allowed
|
|
``api_base`` with an observability credential like ``langfuse_host``
|
|
must still reject on the second field, not silently pass."""
|
|
from litellm.proxy.auth import auth_utils
|
|
|
|
monkeypatch.setattr(
|
|
auth_utils,
|
|
"_allow_model_level_clientside_configurable_parameters",
|
|
lambda model, param, request_body_value, llm_router: param == "api_base",
|
|
)
|
|
|
|
with pytest.raises(ValueError) as exc:
|
|
is_request_body_safe(
|
|
request_body={
|
|
"model": "gpt-4",
|
|
"api_base": "https://allowed-by-deployment.example",
|
|
"langfuse_host": "https://attacker.example",
|
|
},
|
|
general_settings={},
|
|
llm_router=None,
|
|
model="gpt-4",
|
|
)
|
|
assert "langfuse_host" in str(exc.value)
|
|
|
|
|
|
def test_observability_ban_covers_canonical_supported_callback_params():
|
|
"""Guard test: every entry in the canonical
|
|
``_supported_callback_params`` allow-list must end up either banned by
|
|
the proxy or explicitly safe-listed. New integrations added to that
|
|
list are banned by default (the safe failure mode); flagging them as
|
|
safe is an explicit decision recorded in
|
|
``_SAFE_CLIENT_CALLBACK_PARAMS``."""
|
|
from litellm.litellm_core_utils.initialize_dynamic_callback_params import (
|
|
_request_blocked_callback_params,
|
|
_supported_callback_params,
|
|
)
|
|
from litellm.proxy.auth.auth_utils import (
|
|
_BANNED_REQUEST_BODY_PARAMS,
|
|
_SAFE_CLIENT_CALLBACK_PARAMS,
|
|
)
|
|
|
|
banned = set(_BANNED_REQUEST_BODY_PARAMS)
|
|
for param in _supported_callback_params:
|
|
assert param in banned or param in _SAFE_CLIENT_CALLBACK_PARAMS, (
|
|
f"{param} is in _supported_callback_params but neither banned nor "
|
|
f"safe-listed. Add it to _SAFE_CLIENT_CALLBACK_PARAMS if it is an "
|
|
f"informational per-request field; otherwise the derivation will "
|
|
f"ban it automatically."
|
|
)
|
|
for param in _request_blocked_callback_params:
|
|
assert param in banned, (
|
|
f"{param} is in _request_blocked_callback_params but is not banned "
|
|
"at the proxy request-body boundary."
|
|
)
|
|
|
|
|
|
# ── pricing injection (global model cost registry poisoning) ──────────────────
|
|
|
|
|
|
class TestPricingInjectionBlocked:
|
|
"""Authenticated clients must not be able to mutate the global
|
|
litellm.model_cost registry by supplying pricing fields in the request
|
|
body. Any CustomPricingLiteLLMParams field (input_cost_per_token etc.)
|
|
passed to completion() is forwarded to register_model(), which overwrites
|
|
the shared global dict for ALL users on the instance.
|
|
|
|
Fix: all CustomPricingLiteLLMParams fields are in _BANNED_REQUEST_BODY_PARAMS,
|
|
so is_request_body_safe() rejects them before they reach completion().
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
"field,value",
|
|
[
|
|
("input_cost_per_token", -0.01),
|
|
("output_cost_per_token", 0.0),
|
|
("input_cost_per_second", 999.0),
|
|
("output_cost_per_second", -1.0),
|
|
("cache_read_input_token_cost", 0.0),
|
|
("cache_creation_input_token_cost", -0.05),
|
|
],
|
|
)
|
|
def test_pricing_field_rejected_by_default(self, field, value):
|
|
with pytest.raises(ValueError) as exc:
|
|
is_request_body_safe(
|
|
request_body={"model": "gpt-4", field: value},
|
|
general_settings={},
|
|
llm_router=None,
|
|
model="gpt-4",
|
|
)
|
|
assert field in str(exc.value)
|
|
|
|
def test_all_custom_pricing_fields_are_banned(self):
|
|
from litellm.proxy.auth.auth_utils import _BANNED_REQUEST_BODY_PARAMS
|
|
from litellm.types.utils import CustomPricingLiteLLMParams
|
|
|
|
banned = set(_BANNED_REQUEST_BODY_PARAMS)
|
|
for field in CustomPricingLiteLLMParams.model_fields:
|
|
assert field in banned, (
|
|
f"CustomPricingLiteLLMParams.{field} is not in "
|
|
"_BANNED_REQUEST_BODY_PARAMS — clients can poison the global "
|
|
"model cost registry by supplying it in the request body."
|
|
)
|
|
|
|
def test_pricing_field_allowed_with_admin_opt_in(self):
|
|
assert (
|
|
is_request_body_safe(
|
|
request_body={"model": "gpt-4", "input_cost_per_token": 0.00001},
|
|
general_settings={"allow_client_side_credentials": True},
|
|
llm_router=None,
|
|
model="gpt-4",
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
class TestGetRequestRouteTemplate:
|
|
"""get_request_route_template returns the low-cardinality FastAPI route
|
|
template (e.g. /v1/threads/{thread_id}/runs) for http.route, distinct
|
|
from the literal url.path. None when unavailable."""
|
|
|
|
def _request(self, scope):
|
|
req = MagicMock()
|
|
req.scope = scope
|
|
return req
|
|
|
|
def test_returns_route_template(self):
|
|
route = MagicMock()
|
|
route.path = "/v1/threads/{thread_id}/runs"
|
|
req = self._request({"route": route, "path": "/v1/threads/abc123/runs"})
|
|
# template, not the literal path — two thread IDs share this value
|
|
assert get_request_route_template(req) == "/v1/threads/{thread_id}/runs"
|
|
|
|
def test_scope_not_dict_returns_none(self):
|
|
assert get_request_route_template(self._request("not-a-dict")) is None
|
|
|
|
def test_no_route_in_scope_returns_none(self):
|
|
assert get_request_route_template(self._request({"path": "/x"})) is None
|
|
|
|
def test_route_without_str_path_returns_none(self):
|
|
route = MagicMock()
|
|
route.path = 12345 # not a str
|
|
assert get_request_route_template(self._request({"route": route})) is None
|
|
|
|
def test_route_with_empty_path_returns_none(self):
|
|
route = MagicMock()
|
|
route.path = ""
|
|
assert get_request_route_template(self._request({"route": route})) is None
|
|
|
|
def test_exception_returns_none(self):
|
|
req = MagicMock()
|
|
type(req).scope = property(
|
|
lambda self: (_ for _ in ()).throw(RuntimeError("boom"))
|
|
)
|
|
assert get_request_route_template(req) is None
|