mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
* feat(ui): gate "Default Credentials" hint on /ui/login behind env flag (#30234) Adds LITELLM_HIDE_DEFAULT_CREDENTIALS_HINT (and an equivalent general_settings.hide_default_credentials_hint) that suppresses the "By default, Username is admin and Password is your set LiteLLM Proxy MASTER_KEY" info card rendered on /ui/login and /fallback/login. Motivation: in production deployments operators set UI_USERNAME / UI_PASSWORD (or SSO), and the hardcoded hint becomes factually incorrect and is flagged by security scanners (Tenable WAS plugin 114625) as information disclosure. There is currently no way to suppress it without forking the dashboard. Behaviour: - Default is unchanged (hint shown), so existing deployments are unaffected. - New field hide_default_credentials_hint on the well-known UI config endpoint, populated from the env var or general_settings. - LoginPage.tsx conditionally renders the Alert based on the flag. Refs: BerriAI/litellm#30232 * fix(router): clean pattern_router state on upsert/delete (#29601) * fix(router): clean pattern_router state on upsert/delete PatternMatchRouter.add_pattern was append-only, and neither Router.upsert_deployment nor Router.delete_deployment removed the existing entry. Rotated-out api_keys stayed in the routing rotation for wildcard deployments (model_name with `*`) until proxy restart, silently defeating key rotation as an admin operation. The same leak applied to provider_default_deployment_ids and per-team pattern routers, and the patterns list grew unboundedly on every edit * test(router): direct unit tests for _remove_deployment_from_wildcard_state router_code_coverage.py greps test files for AST Call nodes and flagged the helper as untested because the existing coverage only exercised it transitively through upsert/delete. Adds two direct tests that pin the helper's contract (cleans across global pattern router, per-team routers with empty-router pop, and provider_default_deployment_ids; noop on falsy model_id) * fix(router): address Greptile review on pattern_router cleanup Widen PatternMatchRouter.remove_deployment annotation to Optional[str]; the implementation already handles None via the falsy guard and the unit test exercises it directly. Move _remove_deployment_from_wildcard_state up one level in upsert_deployment so it runs whenever the prior deployment is on the router, not only when the model_id is present in the fast-mapping index. The scenario is currently unreachable (get_deployment shares the same index), but the cleanup is idempotent so this is defensive against any future divergence between those code paths. * fix(router): widen _remove_deployment_from_wildcard_state to Optional[str] Moving the call out of the inner `deployment_id in deployment_fast_mapping` block in the previous commit lost mypy's narrowing of `deployment_id` from Optional[str] to str, tripping the lint CI. The helper already handles None via its falsy guard, so widening the annotation matches the actual contract. * fix(router): make delete_deployment wildcard cleanup symmetric with upsert After the previous commit moved _remove_deployment_from_wildcard_state out of the inner index-map guard in upsert_deployment, delete_deployment was still calling it only inside `if deployment_idx is not None`. Greptile flagged the asymmetry: under a desynced index_map, delete would silently leave the stale wildcard credential in pattern_router. Moves the cleanup call to the top of the try block, mirroring the upsert path. Cleanup is idempotent so the change is a no-op on the happy path. Adds a regression test that simulates the desync by removing the entry from model_id_to_deployment_index_map and asserts delete still clears pattern_router. * fix(pricing): add 1h cache-write cost for Anthropic Sonnet 4.5/4.6 (#30474) The native anthropic claude-sonnet-4-5/4-6 price-map entries were missing cache_creation_input_token_cost_above_1hr (and the >200K long-context sub-tier for 4.5), so 1-hour-TTL cache writes were costed at the 5-minute rate. Adds 6e-06 regular (and 1.2e-05 long-context) = 2x base input, matching the vertex_ai/azure_ai/bedrock siblings and the older claude-sonnet-4-20250514 entry. Adds a regression test. * fix(proxy): cancel upstream gemini request and release httpx connection on client disconnect (#30075) * fix(proxy): cancel upstream gemini request and release httpx connection on client disconnect - add _check_request_disconnection to common_request_processing; wrap llm_call as asyncio.Task so it can be cancelled; catch CancelledError and raise HTTPException(499) when client disconnects before LLM responds (non-streaming path) - pass raw httpx.Response into ModelResponseIterator in make_call/make_sync_call so the iterator holds a reference to the underlying connection - implement ModelResponseIterator.aclose() and .close(): close the line iterator then explicitly call response.aclose()/response.close() to release the httpx connection when the client drops mid-stream; errors are debug-logged, not raised - add tests for _check_request_disconnection (cancels task, graceful on exception, does not cancel when client stays connected) and base_process_llm_request 499 behavior; add TestModelResponseIteratorCleanup verifying aclose/close propagation through CustomStreamWrapper * fix(proxy): record 499 on streaming disconnect and cancel orphaned gather tasks Wire streaming generator cleanup to log client_disconnected with error_code 499 in spend logs, cancel pending during_call_hook tasks when the LLM call is cancelled on disconnect, and align the 600s poll limit comment with proxy_server. * fix: extract client disconnect logging helper to satisfy PLR0915 * fix: resolve mypy and code-quality CI failures for client disconnect logging Cast client disconnect error_information for mypy, only await pending gather tasks to avoid masking LLM errors, and add tests for the new logging helper and gather cleanup. * fix(proxy): harden gather cleanup so finally cannot mask LLM errors * fix(proxy): shield streaming disconnect logging and strip spoofable metadata Move streaming disconnect recording into a shielded cancel scope, add gather cleanup regression coverage for guardrail-converted cancels, and strip client_disconnected/error_information from user metadata at the proxy boundary. * fix(proxy): only map CancelledError to 499 for client disconnect Track when the disconnect poller cancels the LLM task and re-raise other CancelledError paths so graceful shutdown is not reported as HTTP 499. * fix(proxy): remove dead _check_request_disconnection helper Non-streaming client disconnect is handled by staging's cancel_on_disconnect path via _await_llm_call_cancelling_on_disconnect. Drop the unused is_disconnected poller and its unit tests; rename the remaining integration tests to TestDisconnectGatherCleanup. * feat(mistral): add mistral-medium-3-5 to model_prices_and_context_wind.. (#29303) * feat(mistral): add mistral-medium-3-5 to model_prices_and_context_window.json Mistral's docs page lists mistral-medium-3-5 as a new model offering. Pricing/specs sourced from Mistral's published model metadata: - input: $1.50 / 1M tokens - output: $7.50 / 1M tokens - context: 262,144 tokens - capabilities: vision, function calling, structured outputs, assistant prefill Adds entry: `mistral/mistral-medium-3-5`, mirroring the pattern used for the rest of the Mistral family. test(mistral): add model_info test for mistral-medium-3-5 + sync backup cost map - Mirror mistral/mistral-medium-3-5 entries into litellm/model_prices_and_context_window_backup.json so the bundled model cost map matches the canonical model_prices_and_context_window.json. - Add tests/test_litellm/test_mistral_medium_3_5_model_metadata.py covering pricing tiers, capability flags, context window, provider routing, and parity between the main and backup cost maps. - Point 'source' at the live Mistral models documentation page. * fix(ui): three small UI fixes — Gemini api_base + credential form reset + Mode badge (#30419) * fix(ui): three small UI fixes — Gemini api_base field + credential form reset + Mode badge Three independent fixes; bundled because they all touch the credential-form / logging-callbacks area. 1. expose api_base field on Google AI Studio credential form The runtime gemini provider supports custom api_base via `vertex_llm_base._check_custom_proxy`; the UI just needs to expose the field. Adds api_base to the Google_AI_Studio credential form ordered before api_key (matching OpenAI/Anthropic conventions). Default value matches the canonical Google AI Studio endpoint that LiteLLM's gemini provider talks to when api_base is unset, so leaving the default in the form behaves identically to leaving it blank. 2. reset credential form state when switching providers Switching the Provider select in AddCredentialModal / EditCredentialModal left the previous provider's field values populated. The form then submitted a mixed payload (e.g. Azure deployment fields under an OpenAI credential), producing confusing failures. Extract `getProviderFieldDefaults` helper and reset the form to it on provider change. Unit-tested via the extracted helper because Antd Select's portal/dropdown behaviour is unreliable in jsdom. 3. logging callbacks table reads backend `type` for Mode badge (#35) The `/get_callbacks` proxy endpoint returns each callback as `{name, type, variables}` where `type` is `"success"` or `"failure"`. The same callback name can appear twice (one per event class) and the two entries fire on disjoint events. `LoggingCallbacksTable` ignored `type` and read `record.mode` (always undefined), so every row fell back to the "Success" badge. A `generic_api` callback registered for both classes showed up as two identical "Success" rows + React duplicate-key warning. Read `record.type` first (fall back to `record.mode` for newly- added not-yet-server-acknowledged rows). Composite rowKey `${name}-${type ?? mode ?? 'success'}`. Removed leftover debug `console.log`. * fix(ui): drop api_base default_value to preserve Gemini v1alpha auto-routing Greptile P2 (PR #30419, threads on lines 1255-1256 of provider_create_fields.json): the api_base field's `default_value` was hard-coded to "https://generativelanguage.googleapis.com/v1beta". This: 1. Bakes v1beta into every credential record saved through the form, even when the user never touched the field. If LiteLLM's internal gemini default URL ever changes, those persisted credentials keep hitting the stale path. 2. Bypasses `_get_gemini_url`'s automatic version routing for Gemini 3+ models. That helper picks v1alpha for Gemini 3+ and v1beta for older models when api_base is unset. With the default pre-filled (and `_check_custom_proxy` then taking over because api_base is non-empty), Gemini 3+ requests get pinned to v1beta and may fail or behave unexpectedly — purely because the user accepted the visible default. Fix: set `default_value` to `null` and move the canonical URL guidance into the `placeholder` (visible to the user, never persisted) and an expanded tooltip. UX is unchanged — the URL is still shown in the greyed-out input — but the auto-version-routing path stays default. Updated test_google_ai_studio_provider_fields_expose_api_base to assert the new contract (`default_value is None`, `placeholder` carries the canonical URL), with a comment pointing at the Greptile threads as the rationale so future contributors don't accidentally re-introduce the default. 26/26 tests in the file pass. JSON validates (`json.load` clean). * feat(azure_ai): add gpt-5.5 to model cost map (#30428) * feat(azure_ai): add gpt-5.5 to model cost map Adds azure_ai/gpt-5.5 and its dated snapshot azure_ai/gpt-5.5-2026-04-23 to both the canonical and bundled cost maps. gpt-5.5 is generally available on Azure AI Foundry; pricing mirrors the openai gpt-5.5 entry, matching the established azure_ai convention (verified identical for gpt-5.4), in the azure tier structure (base / above-272k / priority). supports_minimal_ reasoning_effort is false, the capability that changed from gpt-5.4. Fixes #30306 * Update tests/test_litellm/test_gpt_5_5_model_metadata.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix: guard check_and_fix_namespace against None key (#30435) * fix: guard check_and_fix_namespace against None key When user_id is None, the cache key can be None, causing AttributeError: 'NoneType' object has no attribute 'startswith' in check_and_fix_namespace. Add an early return for None key to prevent the error and the ERROR-level log noise it produces on every unauthenticated request. Fixes #30424 * fix: update type annotations for check_and_fix_namespace - key: str -> Optional[str] (now handles None input) - return: str -> Optional[str] (returns None when input is None) Addresses Greptile review concern about type signature mismatch. * fix: revert check_and_fix_namespace type signature to str to fix MyPy downstream errors * fix: update type annotations for check_and_fix_namespace - Change signature from str -> str to Optional[str] -> Optional[str] - Remove type: ignore comment on None return - Add None guard in async_set_cache_sadd before passing to helper Addresses review feedback from Sameerlite on type mismatch. * Revert "fix: update type annotations for check_and_fix_namespace" This reverts commit5272920fa0. --------- Co-authored-by: michaelxer <michaelxer@users.noreply.github.com> * fix(cost): apply service_tier suffix to above-threshold cache rates and expose priority+threshold keys in ModelInfo (#30450) * fix(cost): apply service_tier suffix to above-threshold cache rates and expose priority+threshold keys in ModelInfo Models that publish both a service_tier (e.g. priority) rate and an above-threshold tier (e.g. _above_200k_tokens) currently bill cached tokens at the standard above-threshold rate rather than the priority above-threshold rate. Affected entries in the live pricing JSON include gemini-3-pro-preview, gemini-3.1-pro-preview and their vertex_ai/ and gemini/ variants, plus azure/gpt-5.4 and azure_ai/gpt-5.4. For a 250K-token priority request with 200K cached tokens against gemini-3-pro-preview, the leak is about 44 percent of the prompt cost. Two stacked defects caused this. First, ModelInfoBase (and the ModelInfo pydantic class) and the get_model_info construction in litellm/utils.py omit the priority+above-threshold cost keys, so even if the calculator asked for them they would never reach it. Second, in _get_token_base_cost the cache_creation/cache_read tiered keys never get wrapped with _get_service_tier_cost_key, while the input/output tiered keys above and below do. The change here surfaces six new keys (input, output and cache_read at both 200k and 272k priority variants) and wraps the three cache tiered keys in _get_token_base_cost the same way input/output already are. _get_cost_per_unit's existing service_tier-to-base fallback covers models that ship the standard above-threshold rate without a priority variant. Adds one regression test in tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py that drives the actual generic_cost_per_token path for gemini-3-pro-preview at 200K cached + 50K text under priority and asserts the priority above_200k rates are picked. Verified the test fails on litellm_internal_staging without these changes and passes with them. * fix(cost): drop guard on cache tiered keys so service_tier fallback can reach standard above-threshold rate Addresses Greptile P1 on PR 30450. The previous commit wrapped cache_creation_tiered_key, cache_creation_1hr_tiered_key, and cache_read_tiered_key with _get_service_tier_cost_key (matching how the sibling input and output tiered keys are wrapped) but kept the surrounding 'if key in model_info' guards. For models that publish a standard above-threshold cache rate but no priority variant (gpt-5.4-pro, gpt-5.5-pro and their dated siblings, plus vertex_ai/claude-sonnet-4-5 for cache_creation), the guard short-circuits before _get_cost_per_unit's existing service_tier-to-base fallback can strip _priority and find the standard above-threshold key. The result on priority requests over the threshold was that those models silently dropped from the above-threshold rate back to the priority-base rate. Dropping the guard and calling _get_cost_per_unit unconditionally (mirroring how tiered_input_key and tiered_output_key are already handled) restores correct billing for that class of models while keeping the new priority+above-threshold behaviour for gemini-3-pro-preview and friends. Adds a second regression test that pins generic_cost_per_token for vertex_ai/claude-sonnet-4-5 priority + above_200k with cached and cache_creation tokens to the expected standard above-threshold rates, so the guard cannot be silently reintroduced for either the cache_read or cache_creation path. * fix(presidio): skip pre-call masking when guardrail is logging_only (#30461) The Presidio pre-call hook masked the live request unconditionally, ignoring the configured event hook. With mode: logging_only the masked request reached the model, so its response echoed anonymization tokens (e.g. <PERSON>) instead of the real output. Gate async_pre_call_hook on should_run_guardrail, matching every other guardrail; logging_only masking still happens via async_logging_hook. * fix(router): resolve list unhashable crash on model alias (#30464) * fix(router): resolve list unhashable crash on model alias Fixes the fallback parsing logic that mistakenly categorized standard array fallback definitions as override dictionaries when a deployment alias matches the literal string 'model'. Closes https://github.com/BerriAI/litellm/issues/30459 * fix(router): address greptile review for fallback parsing edge cases - Resolves ambiguity in standard vs override fallback dictionaries by iterating over all items and validating that no mapped litellm param resolves to a non-list type. - Adds regression tests in test_router_order_fallback.py to prevent unhashable type crash from silently re-entering the codebase. * chore(router): format code with black to pass CI * fix(hosted_vllm): remove thinking_blocks and convert list content to strings (#30475) * fix: hosted_vllm remove thinking_blocks and convert list content to strings vLLM endpoints reject assistant messages with thinking_blocks converted to content list blocks. This change removes thinking_blocks entirely and converts any list content back to strings. This fixes BadRequestError when using Claude Code with hosted_vllm models that pass thinking_blocks in messages. * fix(hosted_vllm): address Greptile review feedback - Join multiple text blocks with newline instead of empty string - Always set content to string (never None) to avoid vLLM validation errors * fix(hosted_vllm): update chat transformation to clean assistant messages * fix: re-raise exception instead of silently dropping MCP team permissions (#30477) * fix: re-raise exception instead of silently dropping MCP team permissions When MCPRequestHandler.get_allowed_mcp_servers raises, the broad except was swallowing the error and returning only allow_all_server_ids, silently discarding all team-level object_permission grants. Fixes #30476 * fix: log full traceback when MCP permission lookup fails Uses verbose_logger.exception() instead of warning() so operators can see the full traceback when team-level object_permission grants are dropped due to an internal error in get_allowed_mcp_servers. Fixes #30476 * fix: remove timezone date expansion in daily-activity aggregation (#29569) * fix: remove timezone date expansion in daily-activity aggregation Single-day spend queries from non-UTC timezones over-counted by ~2x because the previous implementation widened the SQL date range by a full UTC day on whichever side the offset pointed. Spend is bucketed in whole-UTC-day rows in LiteLLM_DailyUserSpend, so the expansion pulled an extra 24h of unrelated bucket data per boundary. Concretely on IST (UTC+5:30, offset -330): a single-day query for 2026-05-29 was rewritten to date >= 2026-05-28 AND date <= 2026-05-29 and returned spend across both UTC days. Sums of single-day queries across a 5-day window then exceeded the equivalent multi-day aggregate by ~50%, which is mathematically impossible. Treat the local date range as the UTC date range. The aggregation table has no hour-level granularity, so any conversion using only date arithmetic must round to whole UTC days; the previous fix turned that boundary slop into systematic over-counting. Pass-through trades a small one-time slop at each end of the range for correct, monotonic, additive results across single-day and multi-day queries. Repro from production: bedrock/global.anthropic.claude-opus-4-8 over 2026-05-29 to 2026-06-02, IST timezone: - 5-day aggregate: $701.39 / 1,831 reqs - Sum of 5 single-day queries: $1,070.94 / 2,755 reqs - Excess (was 1.527x): now matches within boundary slop Adds regression tests in TestAdjustDatesForTimezone and TestBuildAggregatedSqlQuery that pin the pass-through behavior and the additivity invariant for any future implementation. * ci: rerun checks on litellm_oss_branch base --------- Co-authored-by: Sameer Kankute <sameer@berri.ai> * fix: buffer native gemini sse frames (#30225) * fix: buffer native gemini sse frames * fix: scope native gemini sse buffering * fix: check raw sse residual buffer size * feat: updated openrouter provider to map max level to xhigh (#28881) * feat(proxy): allow use_redis_transaction_buffer without redis cache (#28764) * feat(proxy): allow use_redis_transaction_buffer without redis cache * fix(proxy): require host or url for standalone buffer redis * fix(mcp): fail closed when scope filter resolves to no servers (#30353) `_get_allowed_mcp_servers_from_mcp_server_names` returned the caller's full allowed-server set when the requested `mcp_servers` list (path- or header-derived) resolved to nothing. URL/header namespacing therefore appeared to work even when the requested name was unknown or the caller had no grant — `/mcp/<typo>/` silently exposed every server the key could reach. Fail closed instead: when `mcp_servers` is explicitly provided but nothing resolves, return an empty list. The `mcp_servers=None` path (no scope requested) keeps its existing behavior. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * fix(token-counter): handle Anthropic tool_reference blocks to stop dropped spend logs (#30302) * fix(token-counter): handle Anthropic tool_reference blocks to stop dropped spend logs `token_counter` did not know about Anthropic tool-search `tool_reference` content blocks, a lightweight pointer to a deferred tool that shows up as `{"type": "tool_reference", "tool_name": ...}`. When such a block appeared in message content, `_count_content_list` fell through to its catch-all branch and raised `Invalid content item type: tool_reference`. On the streaming `anthropic_messages` proxy path that exception nulls `response_cost`, which makes the proxy drop the entire SpendLogs row. The result is a silent cost undercount on any tool-search traffic; the request succeeds for the caller but the spend is never recorded. This adds a `tool_reference` branch that counts the referenced `tool_name` (the full tool definition is already counted via the `tools` param, so only the name is added here) and handles an empty/missing name gracefully. The catch-all error message is updated to list `tool_reference` among the expected types. A regression test asserts that a message containing a `tool_reference` block no longer raises and returns a positive token count, and that an empty `tool_name` is handled without error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(token-counter): collapse explicit None tool_name to empty string In _count_content_list, c.get("tool_name", "") returns None when the key is present with an explicit None value, and str(None) == "None" which is truthy, causing a spurious token to be counted. Use c.get("tool_name") or "" so both a missing key and an explicit None collapse to an empty string and are skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(token-counter): cover catch-all for unknown content block type Adds a regression test that calls `_count_content_list` with an unrecognized content block type and asserts it raises `ValueError` whose message names the offending type and lists `tool_reference` among the supported types. This exercises the previously uncovered catch-all branch (codecov patch gap) and pins the error contract. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(token-counter): cover tool_reference on the spend/cost and streaming paths Adds end-to-end regression tests that exercise the real public entry points (`completion_cost` and `stream_chunk_builder`), not just the private `_count_content_list` helper, for Anthropic tool-search `tool_reference` content blocks. These pin the actual bug the fix addresses: before the fix the `tool_reference` block raised out of `completion_cost` -> the proxy logging layer nulled `response_cost` and the spend callback dropped the SpendLogs row (silent cost undercount on all tool-search traffic); and `stream_chunk_builder` swallowed the same raise and collapsed prompt_tokens to 0. With the fix, cost is positive and prompt_tokens are counted. Verified: 3 fail without the fix, 3 pass with it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(cost): add cost mapping for deepseek-v4-flash and deepseek-v4-pro (#27056) * feat(cost): add cost mapping for deepseek-v4-flash and deepseek-v4-pro Adds pricing entries for the two new DeepSeek V4 models released on 2026-04-24, for both bare model names and the deepseek/ provider prefix. Prices sourced from https://api-docs.deepseek.com/quick_start/pricing: - deepseek-v4-flash: $0.14/M input, $0.28/M output - deepseek-v4-pro: $1.74/M input, $3.48/M output Cache hit price set to 1/10 of input (per DeepSeek docs). Context window: 1M tokens for both models. Closes #26709 * fix(cost): update backup registry for deepseek-v4 * style: remove print statement from deepseek-v4 test * feat(cost): add cost mapping for deepseek-v4-flash and deepseek-v4-pro Adds pricing entries for the two new DeepSeek V4 models released on 2026-04-24, for both bare model names and the deepseek/ provider prefix. Prices sourced from https://api-docs.deepseek.com/quick_start/pricing: - deepseek-v4-flash: $0.14/M input, $0.28/M output - deepseek-v4-pro: $1.74/M input, $3.48/M output Cache hit price set to 1/10 of input (per DeepSeek docs). Context window: 1M tokens for both models. Closes #26709 * fix: update deepseek-v4 prices to active discounted rates * test: update deepseek-v4 prices in tests to match active discounted rates * fix(deepseek): remove duplicate entries and update backup registry to active discounted rates * fix: update max_output_tokens to 384K for deepseek-v4 * fix: correctly restore upstream models accidentally dropped during merge * fix(tests): resolve failing claude-fable-5 and reasoning tests by safely updating cost map - Pulled the latest cost map from upstream staging - Safely appended deepseek-v4 mapping without deleting duplicate keys or formatting via json.dump * fix(tests): correct deepseek model cache prices and update JSON schema - Appended both prefixed and bare deepseek-v4 models to satisfy test assertions - Corrected deepseek-v4-pro expected cache hit and token prices based on latest review updates - Added missing realtime endpoint to test_utils.py INTENDED_SCHEMA * fix: remove accidental azure/gpt-realtime-whisper addition --------- Co-authored-by: Dushyant Acharya <dushyantacharya@Dushyants-MacBook-Pro.local> * feat(key/info): expose per-model budget usage in /key/info response (#30394) * feat(key/info): expose per-model budget usage in /key/info response Add model_max_budget_usage to /key/info and /v2/key/info responses. For each model in model_max_budget, reads current-period spend from the same DualCache used by the budget enforcer and returns it alongside the limit and time period so callers can see how much of each model budget has been consumed in the active window. * test(key/info): add coverage for model_max_budget_usage in v1 and v2 endpoints Add tests for the model_max_budget_usage enrichment in both info_key_fn and info_key_fn_v2, covering the budget-present path, the empty-budget path, and the v2 batch endpoint. * fix(key/info): source model_max_budget current_spend from SpendLogs instead of DualCache The DualCache used for enforcement is ephemeral and only populated when budget metadata is present at request time. Fall back to a direct LiteLLM_SpendLogs DB aggregation using the budget period window (budget_reset_at - budget_duration) for accurate reporting. Also fall back to litellm_budget_table.model_max_budget when the key's top-level field is empty, and round current_spend to 4 decimal places. * test(key/info): cover remaining branches in model_max_budget_usage helpers Add unit tests for: prisma_client=None early return, DB query exception swallowing, invalid budget_duration handled by _compute_budget_period_start, budget_reset_at received as a datetime object (Prisma native type), max_seconds=0 early return, and skipping models that lack a budget_duration. Also remove an unreachable except branch where fromisoformat would fail after _compute_budget_period_start already validated the same value. * test(key/info): cover except path for unparseable per-model budget_duration * fix(key/info): compute per-model rolling windows in model_max_budget_usage Each model in model_max_budget now gets its own time window derived from its own budget_duration, rather than sharing a single window computed as the max (or the budget table's reset_at). This matches what the DualCache enforcer actually tracks and prevents current_spend from being inflated for models with shorter windows. _query_model_spend_for_period is refactored to accept a model filter (handling provider-prefix variants in SQL) and return a float directly. _compute_budget_period_start and the budget_table window path are removed as they are no longer needed. * refactor(model_max_budget_limiter): remove dead get_current_period_spend method * refactor(key/info): strip synthetic formatter noise from PR diff Restore key_management_endpoints.py and test_key_management_endpoints.py to origin/litellm_internal_staging, then re-apply only the intentional additions: _query_model_spend_for_period, _build_model_max_budget_usage, the two endpoint patches (info_key_fn / info_key_fn_v2), and the new test suite. The previous commits had reformatted ~300 pre-existing lines across both files, making the functional diff unreadable. * test(key/info): cover empty-rows path in _query_model_spend_for_period * fix(model_max_budget_limiter): guard BudgetConfig construction inside try/except A malformed model entry in the DB (e.g. non-numeric max_budget from a manually edited or migrated row) caused BudgetConfig(**budget_info) to raise a Pydantic ValidationError outside any exception guard, surfacing as a 500 for the entire /key/info or /v2/key/info call. Merging both try/except blocks into one ensures bad entries are silently skipped, consistent with the existing duration_in_seconds guard. * fix: don't stack provider prefix on wildcard models with a custom prefix (#30360) * fix: don't stack provider prefix on wildcard models with a custom prefix get_known_models_from_wildcard expanded provider-prefixed model ids (e.g. "ollama/gemma3:1b" from get_provider_models) by prepending the wildcard's prefix whenever the id did not already start with it. With a custom wildcard prefix such as "ollama_server1/*" (used to distinguish multiple Ollama instances), this produced "ollama_server1/ollama/gemma3:1b", which is uncallable and breaks /v1/models. When the expanded id already carries a provider prefix, replace it with the wildcard's prefix instead of stacking both. Matching-prefix and bare-model cases are unchanged. Fixes #30358 * fix: only strip a known provider prefix when expanding custom wildcard prefixes The wildcard expansion replaced the leading slash segment of every expanded id with the wildcard prefix whenever the id did not already start with it. For ids whose first segment is an org rather than a litellm provider (for example a provider returning "meta-llama/Llama-3-8B" with no outer provider prefix), that dropped the org and produced an uncallable id Only strip the leading segment when it is a recognized provider (membership in LlmProviders); otherwise keep it and just prepend the wildcard prefix. Provider-prefixed ids like "ollama/gemma3:1b" still have their prefix replaced, so the original fix is unchanged for known providers * address greptile review feedback: log dropped non-text vLLM assistant content blocks (greploop iteration 1) * fix(ci): format credential_form_helpers test + regenerate dashboard schema.d.ts * fix(proxy): raise litellm.BadRequestError for missing model param When no model is passed, route_request now raises a litellm.BadRequestError ('Missing model parameter') instead of falling through to ProxyModelNotFoundError. This keeps the missing-param error clear and independent of router wildcard state. Unknown (non-empty) model names still raise ProxyModelNotFoundError. * Revert "fix(proxy): raise litellm.BadRequestError for missing model param" This reverts commit9240da403c. * Revert "fix(router): clean pattern_router state on upsert/delete (#29601)" This reverts commitad4e6e2395. * fix: correct streaming and key budget usage reporting * fix(hosted_vllm): type assistant tool_calls to satisfy mypy * feat: aws secret manager cross region replication (#30368) * feat(aws-secret-manager): add replica_regions cross-region replication after CreateSecret When store_virtual_keys is enabled, async_write_secret() only wrote secrets to the primary AWS region. Multi-region proxy deployments had no built-in way to synchronize virtual key secrets across regions through LiteLLM, requiring external replication mechanisms. Add replica_regions support to AWSSecretsManagerV2: - New replica_regions field in KeyManagementSettings (types/secret_managers/main.py) - New async_replicate_secret() method that calls ReplicateSecretToRegions API - async_write_secret() calls replication after successful CreateSecret - Replication failure is logged as a warning but does NOT fail key creation - load_aws_secret_manager() forwards replica_regions from key_management_settings Configuration example: key_management_settings: store_virtual_keys: true replica_regions: - us-west-2 - eu-west-1 When replica_regions is omitted or empty, behavior is unchanged. * test(aws-secret-manager): restore litellm.secret_manager_client after test to prevent state pollution * test(aws-secret-manager): add coverage for HTTP error and replication exception paths * fix: restore litellm.secret_manager_client global state in test; add replication log proof - Global state in test_load_aws_secret_manager_passes_replica_regions was already guarded with try/finally (committed in previous pass); no further change needed for Fix 1. - Fix 2: add verbose_logger.info("ReplicateSecretToRegions called …") inside async_replicate_secret so callers get an observable INFO log line whenever replication fires. - Add test_replication_fires_on_create: calls async_replicate_secret directly with caplog.at_level(INFO, logger="LiteLLM") and asserts "ReplicateSecretToRegions" appears in the captured log output, proving the code path executes. * fix: pass request to streaming generators * fix(hosted-vllm): preserve assistant structured content * fix(hosted_vllm): satisfy mypy on preserved structured content assignment * chore: resolve litellm_internal_staging merge conflicts for #30527 (#30554) * chore(codecov): add Batches, Videos, and Realtime components (#30517) * chore(codecov): add Batches, Videos, and Realtime components Define per-feature Codecov components so PR comments track coverage for batch API, video generation, and realtime streaming paths. Co-authored-by: Cursor <cursoragent@cursor.com> * chore(codecov): use wildcard path for Batches proxy component Align batches_endpoints glob with Videos, Realtime, and Proxy_Authentication. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> * test(batches): move orphan tests into tests/test_litellm for CI coverage (#30510) Four batch-related tests lived under tests/litellm/ and were never picked up by GitHub Actions. Relocate them and fix gemini multimodal e2e to use the batchEmbedContents path expected for gemini/ provider. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(guardrails): run pre_call hook once for model-level guardrails (#30543) * fix(guardrails): run pre_call hook once for model-level guardrails A CustomGuardrail attached to a deployment via litellm_params.guardrails gets its async_pre_call_hook invoked twice per request: once by the proxy pre-call loop and again by async_pre_call_deployment_hook after the router spreads the model-level guardrails into the top-level request kwargs. Record in request metadata that the proxy pre-call loop already ran a given guardrail, and have the deployment hook skip it when the marker is present. Direct-SDK usage never runs the proxy loop, so the deployment hook stays the sole invocation there and still fires exactly once. The marker key is stripped from untrusted caller metadata so a request body cannot suppress a model-only guardrail by pre-seeding it. * fix(guardrails): mark pre_call dedup on the post-hook request data Record the exactly-once marker after async_pre_call_hook runs, on the data object that flows downstream, rather than before it. A guardrail whose hook returns a brand-new request dict (instead of mutating or spreading the one it received) would otherwise discard the marker, letting the deployment hook re-run the guardrail a second time. * fix(guardrails): stop re-initializing DB guardrails on every poll (#30542) * fix(guardrails): stop re-initializing DB guardrails on every poll InMemoryGuardrailHandler._has_guardrail_params_changed compared the in-memory LitellmParams against the raw dict loaded from the DB. The in-memory side carries every field default and coerces enums via model_dump(), while the DB side only holds the keys originally stored, so the two shapes never compared equal and the guardrail was rebuilt on every poll cycle. Each rebuild created a fresh instance, but delete_in_memory_guardrail only removed the old callback from litellm.callbacks. Request handling promotes guardrail callbacks into the success/failure/async lists, so the previous instance stayed referenced there and instances accumulated. Normalize both sides through LitellmParams(...).model_dump() before diffing, and purge the callback from every callback list on delete. * refactor(guardrails): narrow params-normalization fallback to ValidationError The comparison normalizer caught a bare Exception and silently fell back to the raw dict, which hid the cause and quietly degraded the affected guardrail back to re-initializing on every poll. Catch only the ValidationError that LitellmParams construction can raise, log a warning so the offending row is diagnosable, and let any other error surface instead of being swallowed. * refactor(callbacks): add remove_callback_from_all_lists helper to manager Move the knowledge of which callback lists a callback can be promoted into out of the guardrail registry and into LoggingCallbackManager, where the rest of the callback-list bookkeeping already lives. delete_in_memory_guardrail now delegates to the new helper instead of iterating the lists itself. * chore(oss): litellm oss staging 150626 (#30463) * fix(pricing): add GitHub Copilot MAI Code Flash pricing (#30415) * fix(pricing): add GitHub Copilot MAI Code Flash pricing Add GitHub Copilot pricing entries for MAI-Code-1-Flash and the internal Copilot CLI model name so cost calculation can price input, cached input, and output tokens. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(pricing): cover GitHub Copilot MAI Code Flash pricing Add regression coverage for both GitHub Copilot MAI-Code-1-Flash model names, including cached input pricing, chat endpoint metadata, and cost_per_token arithmetic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(router/proxy): propagate completed_response through FallbackResponsesStreamWrapper for streaming /v1/responses container ownership (#30210) (#30213) * fix(router/proxy): propagate completed_response through FallbackResponsesStreamWrapper for streaming /v1/responses container ownership (#30210) #28990 added ownership recording for streaming /v1/responses via _wrap_responses_stream_for_container_ownership, which reads `getattr(stream_response, 'completed_response', None)` to extract the ResponsesAPIResponse. The unit test bypassed the Router, so it never exercised the production wrapping path. Through the Router (every proxy deployment), the stream is wrapped by FallbackResponsesStreamWrapper (router.py:2527). Its __init__ set `self.completed_response = None` and __anext__ only forwarded chunks — the inner source iterator's terminal event never bubbled up to the attribute the ownership hook reads, so the hook silently recorded nothing and every follow-up /v1/containers/<id>/files call returned 403 for non-admin keys. This commit: - router.py: pre-resolves the responses-API terminal event tuple (response.completed / .incomplete / .failed) once per _aresponses_streaming_iterator call, and has the wrapper's __anext__ sniff each forwarded chunk's .type. First terminal event hit gets stored on the wrapper's completed_response. Iterator-agnostic — works for source_iterator AND any future wrapper. - common_request_processing.py: when _extract_completed_responses_response returns None we now warn instead of silently skipping. Reporter on #30210 lost a day to this exact silent skip; the warning surfaces future regressions of the same shape directly in operator logs. Fixes #30210 * fix(router): type-ignore wrapper getattr-defaults; broaden ownership-skip warning CI lint (mypy) flagged the three pre-existing getattr(..., None) assignments in FallbackResponsesStreamWrapper.__init__: router.py:2564 self.response = getattr(source_iterator, 'response', None) router.py:2565 self.model = getattr(source_iterator, 'model', None) router.py:2566 self.logging_obj = getattr(..., None) Those lines also exist on litellm_internal_staging and pass mypy there. Adding the typed terminal-event tuple above the class made the function body more narrowable, which surfaced the pre-existing mismatch — base class declares non-Optional types but the bridge path (LiteLLMCompletionStreamingIterator) legitimately omits these. Keep the None fallback and silence with type: ignore[assignment]. Greptile 4/5 note: the ownership-skip warning hard-named code_interpreter which misleads operators when a non-code_interpreter stream aborts. Generalize to 'any tool container (e.g. code_interpreter)'. * fix(register_model): drop synthesized zero costs to preserve sparse entries (#30198) (#30201) * fix(register_model): drop synthesized zero costs to preserve sparse entries (#30198) get_model_info synthesizes input_cost_per_token / output_cost_per_token = 0 when they are absent from the raw entry (the price-unknown and free cases share the same representation). register_model then merges that result back into litellm.model_cost, which flips a sparse entry from 'no cost keys' (priced via model name) to 'cost keys = 0' (free). That defeats _is_cost_explicitly_configured (#24949) on re-registration: _is_model_cost_zero returns True, common_checks skips every tag / key / team / user / org budget check for the group, and over-budget traffic keeps returning 200. Spend keeps recording because cost calc still resolves by model name, so the symptom is silent and only triggers on the second register_model pass (router rebuild, /model/update, config sync). Mirror the existing litellm_provider-None guard one block above and pop the cost fields from the synthesized result when they are absent from the raw entry and not in the caller's value. Caller-provided zeros (genuinely free models, BYOK overrides) are preserved. Fixes #30198 * fix(register_model): switch _raw_entry to is-None checks + drop dead test assertion Greptile #30201 review notes: - the `or`-chain in the raw-entry lookup treated an empty dict (a key with no fields) as falsy and fell through to the second arm — replace with explicit `is None` checks so a present-but-empty entry is still taken at face value. - the first assertion in `test_router_double_init_keeps_db_model_entry_sparse` used `in (None, 0)` which passes under the bug condition (cost = 0 matches the tuple); the strong follow-up assertion already covers every shape, so drop the dead branch. * fix(bedrock mantle): use unique function-call id for responses->chat tool calls (#30426) * fix(bedrock mantle): use unique function-call id for responses->chat tool calls ... * fix(bedrock mantle): scope unique tool-call id fallback to degenerate call_id The previous revision preferred the Responses item id for every tool call, which broke providers (and existing tests) where call_id is a unique, canonical correlation key. Restrict the fallback to the degenerate index-based call_id that Bedrock Mantle returns (call_0, call_1, ... resetting per response) and keep call_id otherwise. Revert the change to the OUTPUT_ITEM_DONE streaming handler, whose tool_call_chunk is never emitted (dead code, per review). Extend the regression tests to assert a normal call_id is preserved. * fix(router): preserve azure_ad_token through CredentialLiteLLMParams for /v1/files + batches (#30235) (#30241) * fix(router): preserve azure_ad_token through CredentialLiteLLMParams for /v1/files + batches (#30235) Router.get_deployment_credentials_with_provider re-validates a deployment's litellm_params through CredentialLiteLLMParams before handing them to file/batch/passthrough callers: return CredentialLiteLLMParams( **deployment.litellm_params.model_dump(exclude_none=True) ).model_dump(exclude_none=True) Any field NOT declared on CredentialLiteLLMParams gets silently dropped on the way through. azure_ad_token was undeclared, so Azure deployments using OAuth/M2M (azure_ad_token instead of a static api_key) silently lost their token at the files endpoint and the proxy returned: Missing credentials. Please pass one of api_key, azure_ad_token, azure_ad_token_provider, ... Declare azure_ad_token on CredentialLiteLLMParams alongside api_key / api_base / api_version so it rides through the round-trip. Static-key deployments stay unaffected (Optional, default None, dropped by exclude_none=True). Provider-callable (azure_ad_token_provider) is a separate concern and out of scope here. Fixes #30235 * fix(ui-types): regenerate schema.d.ts for new azure_ad_token field CI's 'Verify schema.d.ts matches the proxy OpenAPI spec' check auto-detected the new field and emitted the exact diff to apply. Two schemas had `aws_secret_access_key` from CredentialLiteLLMParams, both get the new azure_ad_token marker next to it. * fix(proxy): org_admin with own user_id now sees all org teams on /v2/team/list (#30247) When the UI sends the callers own user_id (as it does for non-Admin global roles), _enforce_list_team_v2_access now nulls it out for org admins so _build_team_list_where_conditions scopes by organization_id only -- matching the legacy /team/list behavior and the documented intent. Fixes #30215 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * test(vertex_ai): multi-region regression coverage for cachedContents host (#29571) (#29707) litellm_internal_staging already routes the cachedContents URL through get_vertex_base_url, fixing the multi-region 404 reported in #29571 — but carries no test coverage for the actual regression scenario (eu/us must resolve to the REP host aiplatform.{geo}.rep.googleapis.com). Add TestContextCachingMultiRegionUrls: parametrized eu/us REP-host assertions (including absence of the old broken {geo}-aiplatform host), plus regional (us-central1) and global no-regression checks. * fix(proxy): close upstream LLM stream when client disconnects mid-stream (#30245) * fix(proxy): close upstream LLM stream when client disconnects mid-stream When a streaming client disconnects, Starlette abandons the response body iterator without calling aclose(), so the proxy's connection to the upstream backend stays open until garbage collection, which may never come. The backend (e.g. vLLM) keeps generating into a dead pipe: small responses drain invisibly into TCP buffers while large ones block the backend on a full send buffer indefinitely (observed via lsof as an ESTABLISHED proxy->backend connection minutes after the client left) create_response now returns a StreamingResponse subclass that closes both its body iterator and the wrapped upstream-facing generator in a shielded finally. The upstream generator is closed directly rather than through a cascade because aclose() on a never-started generator skips its body, which would make the cascade a no-op when the client disconnects before the first chunk is sent. async_streaming_data_generator also gains the same shielded finally-aclose that async_data_generator in proxy_server.py already had, covering the Anthropic and Google SSE paths With this, killing a streaming client causes the backend to observe the abort within about a second and free its slot, while completed streams are unaffected. No flag is needed, unlike the non-streaming opt-in cancel in #30223: this only releases resources after the client is already gone and does not change any response a client can observe Fixes #30244 * fix(proxy): close upstream even when body iterator aclose raises BaseException Addresses the Greptile finding on #30245: the cleanup loop caught only Exception while the generator-level cleanup catches BaseException, so a CancelledError or GeneratorExit escaping body_iterator.aclose() would skip closing the upstream generator. Both sites now use the same scope and a regression test pins that the upstream is closed even when the body iterator explodes with a BaseException * fix(llms): expose aclose on BaseModelResponseIterator so stream close reaches the provider connection The response-level close added for #30244 only worked for SDK-based providers (e.g. openai), whose streams expose aclose all the way down. Providers served by base_llm_http_handler (hosted_vllm and most modern transformation-based providers) wrap a bare response.aiter_lines() generator in BaseModelResponseIterator, which had no aclose or close at all, and nothing retained the httpx response object; so CustomStreamWrapper.aclose() silently did nothing and the upstream connection stayed open. Verified with a vLLM-style mock: with hosted_vllm/ the backend streamed all 100 chunks to completion after the client disconnected, while openai/ aborted at chunk 6 BaseModelResponseIterator now carries an optional http_response and an aclose() that closes it; make_async_call_stream_helper attaches the response after building the iterator. With this, hosted_vllm aborts the backend within ~1.6s of the client dropping, and completed streams are unaffected --------- Co-authored-by: kursad <kursad.lacin@brado.net> * feat(anthropic): surface compaction usage iterations data (#27065) * feat(anthropic): surface compaction usage iterations data * style: apply black formatting to fix lint checks * fix(usage): correct calculate usage with cached tokens when use ChatCompletionUsageBlock (#30422) * fix(usage): correct calculate usage with cached tokens when use ChatCompletionUsageBlock * fix(usage): optimize test imports * feat: add fastCRW search provider (#30434) * feat(provider): add LibertAI as a JSON-configured OpenAI-compatible provider (#30203) * feat(provider): add LibertAI as a JSON-configured OpenAI-compatible provider * libertai: update served endpoints backup + add mode/matrix tests Addresses review feedback: - Add libertai to litellm/provider_endpoints_support_backup.json, the file actually served by GET /public/supported_endpoints (the root provider_endpoints_support.json already had it). - Add tests asserting bge-m3 normalizes to mode='embedding' and that the served matrix lists libertai. embeddings stays false: the JSON-configured provider path only wires chat routing (OpenAILike embedding handler is reached only for literal openai_like/llamafile/lm_studio), matching the llamagate precedent; bge-m3 remains in the cost map for metadata. --------- Co-authored-by: Moshe Malawach <moshemalawach@users.noreply.github.com> * feat(provider): add ModelScope as an OpenAI-compatible provider (#28460) * add ModelScope API support * add modelscope api support * update modelscope model list * add image-genetation support * update test and multimodal * fix: address PR review feedback for modelscope provider * update README * fix(customer_endpoints): restrict /customer/daily/activity to admin-only (#28849) * fix(customer_endpoints): restrict /customer/daily/activity to admin-only * fix(customer_endpoints): check role before prisma_client guard * fix(custom_guardrail): key disable_global_guardrails takes precedence over team guardrail list (#28563) * fix(fallbacks): preserve fallback model in SDK fallback responses (#28260) * fix(fallbacks): preserve fallback model in response when using SDK-level fallbacks * fix(fallbacks): gate x-litellm-* passthrough to trusted callers only The previous patch unconditionally let `x-litellm-*` keys bypass the `llm_provider-` prefix in `process_response_headers`. That function is also called on raw upstream-provider response headers (e.g. from `llm_http_handler.py`), so a malicious provider could return `x-litellm-attempted-fallbacks` and spoof a LiteLLM-internal marker, bypassing the proxy model-override guard. Add a `preserve_litellm_internal_headers` flag (default False). Only `response_metadata.py`, which re-processes the already-built `_hidden_params["additional_headers"]` dict (LiteLLM-owned), passes True. Raw provider header callsites keep the default False, so upstream `x-litellm-*` still gets the `llm_provider-` prefix. Adds a regression test for the spoofing case and renames the existing preserve test to make the trusted-path semantics explicit. * fix(fallbacks): ignore preserve_litellm_internal_headers for raw httpx.Headers inputs * style(core_helpers): apply black formatting * fix(lint): remove banned typing.List/Dict/Any imports and suppress PLR0913 on interface overrides Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(lint): apply black formatting to modelscope chat transformation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(lint): replace noqa with proper fixes — use **kwargs and Awaitable instead of Any/List Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(lint): remove unused AllMessageValues import Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * revert: restore base_model_iterator.py to original PR state Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(lint): restore full method signatures for MyPy compatibility; bump PLR0913 budget for new provider files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(lint): use @override to suppress PLR0913 on inherited signatures instead of bumping budget The overrides keep their full base-class signatures for MyPy compatibility, but those signatures carry more than five parameters, which tripped PLR0913 on each subclass redeclaration. Since the arity is dictated by the base class and cannot be reduced, decorate the overrides with typing_extensions.override; ruff treats that as the intended signal that the parameter count is not under the author's control and skips PLR0913. This restores the PLR0913 baseline to 1813. * fix(lint): add @override to modelscope image generation overrides Apply the same typing_extensions.override treatment to the image generation config so its inherited-signature overrides do not count against PLR0913. --------- Co-authored-by: Joel Tony <github@jaytau.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: hcl <chenglunhu@gmail.com> Co-authored-by: ztko <96878659+koztkozt@users.noreply.github.com> Co-authored-by: Nahrin <nahrin@nahrinoda.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Humphrey <a739376838@gmail.com> Co-authored-by: kursadlacin <kursadlacin@gmail.com> Co-authored-by: kursad <kursad.lacin@brado.net> Co-authored-by: Dushyant Acharya <dushyantacharya873@gmail.com> Co-authored-by: Yuriy <yuriy.shuyskiy@gmail.com> Co-authored-by: Recep S <22618852+us@users.noreply.github.com> Co-authored-by: Moshe Malawach <moshe.malawach@protonmail.com> Co-authored-by: Moshe Malawach <moshemalawach@users.noreply.github.com> Co-authored-by: Rongkun Yan <2493404415@qq.com> Co-authored-by: Varshith <kvarshithgowda@gmail.com> Co-authored-by: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> * ci(lint): add blanket-noqa, dataclass-default, and unused-noqa Ruff rules (#30516) * ci(lint): enforce blanket-noqa, dataclass-default, and unused-noqa rules Enable PGH004 (blanket-noqa), RUF008 (mutable-dataclass-default), RUF009 (function-call-in-dataclass-default-argument), and RUF100 (unused-noqa) in ruff.toml, and clean up every resulting violation. RUF008/RUF009 were already clean. PGH004/RUF100 surfaced ~335 stale or blanket noqas: blanket `# noqa` are now scoped to the rule they actually suppress (mostly T201), dead directives are removed, and inapplicable codes are trimmed (e.g. F401 dropped from `import *`). lint.external lists rules enforced outside this config (the strict-rule gate via ruff-strict.toml and upstream litellm's own ruff config) so RUF100 keeps the noqa directives that protect them instead of stripping coverage this config can't see. * ci(lint): trim RUF100 external list to load-bearing codes only Drop the 9 precautionary strict-gate codes (ANN001/002/003/401, B006, PLR0913, PLW0603, RUF012, TID251) that have zero `# noqa` references in the gated source. Keep only the 11 codes with live suppressions so RUF100 doesn't flag them as unused. Future strict-gate suppressions can re-add codes here (or fix the underlying issue) as needed. * ci: ratchet lint and type-check gates (ruff preview, ANN, mypy, basedpyright) (#30379) * ci: enable ruff preview rules under the budgeted strict gate Turn on ruff preview in the strict-budget lane (ruff-strict.toml) only, leaving the clean gate (ruff.toml) untouched so make lint-ruff stays at zero. Enumerate the 118 firing codes explicitly with explicit-preview-rules so the gate is deterministic and stable across ruff upgrades rather than depending on preview auto-selecting the broad catalog. Grandfather the existing 58438 violations into ruff-strict-budget.json as per-rule baselines with headroom, so only net-new violations fail CI. The existing ten rules keep their hand-tuned slack; the new rules get slack 10 when the baseline is 50 or more and 3 otherwise. * ci: add ANN return-type rules to the budgeted strict gate Add ANN201/202/204/205/206 (missing return annotations) to the strict lane and grandfather the existing counts into ruff-strict-budget.json so the codebase ratchets toward explicit return types without breaking CI. * ci: add mypy (disallow_untyped_defs) and basedpyright strict gates with baselines Add two type-check gates, each grandfathering the current tree so only net-new violations fail CI, matching the ruff strict-budget ratchet. mypy gains disallow_untyped_defs in litellm/mypy.ini (the config the CI invocation actually reads; the root [tool.mypy] is not picked up from the litellm/ working dir). The 4885 existing missing-annotation errors are captured in litellm/.mypy-baseline.txt and the run is piped through mypy-baseline filter so new untyped defs are rejected. basedpyright runs in strict mode over litellm/, with enableTypeIgnoreComments disabled so it only honors '# pyright: ignore' and never polices mypy's '# type: ignore'. The existing strict diagnostics are grandfathered into .basedpyright/baseline.json. Both tools are pinned in the dev group and uv.lock; the lint workflow and Makefile run them filtered through their baselines, with lint-mypy-baseline-update and lint-basedpyright-baseline-update to ratchet. * ci: raise lint job timeout to 15m for the basedpyright strict pass * ci: pin pythonVersion 3.12 and regenerate baselines against merged base Merge litellm_internal_staging so the baselines cover code the CI merge includes (e.g. the cisco_ai_defense guardrail), which otherwise tripped the mypy gate with 3 ungrandfathered no-untyped-def errors. Pin pythonVersion 3.12 in pyrightconfig so basedpyright's strict analysis is reproducible across interpreter versions (CI runs 3.12). * ci: regenerate basedpyright baseline against the frozen lint env The previous baseline was generated with optional provider deps (azure, google, anthropic, mcp, numpydoc, google-genai) installed locally, so CI's dev-only env surfaced ~3500 reportUnknown*/reportMissingTypeStubs errors not in the baseline. Regenerate after uv sync --frozen so the baseline reflects the same dependency set the lint job sees. * ci: regenerate basedpyright baseline on python 3.12 frozen env The prior baseline still carried proxy-dev packages (e.g. prisma) that the lint job's dev-only, python 3.12 env lacks, leaving 2 unresolved-import errors ungrandfathered. Regenerate in a python 3.12 venv synced to the frozen lock with default groups only, so the baseline matches exactly what CI sees. * ci: replace type-check baselines with per-file count budgets The mypy and basedpyright baselines were position-sensitive (and the basedpyright one was a 27MB file), so ordinary line shifts churned them. Replace both with a per-file count gate: scripts/type_check_gate.py reduces each tool's output to errors-per-file and checks it against a committed {file: max} budget, ignoring line and column numbers. A file fails only when it gains more errors than its ceiling; debt can't be shuffled between files because each file has its own cap and new files default to zero. Budgets (mypy-file-budget.json 48K, basedpyright-file-budget.json 96K) are generated in the python 3.12 frozen lint env so they match CI. Drops the mypy-baseline dependency; basedpyright runs without its native baseline. ratchet via make lint-mypy-budget-update / lint-basedpyright-budget-update. * ci: add a small per-file slack to the type-check gate Allow each file to drift PER_FILE_SLACK (5) errors past its recorded count before failing, so a basedpyright inference ripple in an unrelated file doesn't break the build over a couple of errors. Budgets still record exact counts; the tolerance is applied at check time. * ci: move type-check slack into the budget json and trim lint timeout Make slack declarative: the budget is now {"slack": N, "files": {path: count}} so the tolerance is tuned in JSON without editing the script, mirroring how ruff-strict-budget.json carries its slack. --update preserves the existing slack. Also drop the lint job timeout from 15m to 10m; the mypy and basedpyright passes add ~2m, leaving the job around 4-5m, so 10m is a comfortable margin. * ci: collapse fully-adopted ruff categories and drop inert preview flag ANN (all nine non-removed rules) and BLE (its only rule) were spelled out code-by-code; replace each with its category selector, which is exactly equivalent in 0.15.3 (the removed ANN101/ANN102 are skipped by a category selector and error when named explicitly). explicit-preview-rules was inert: every selected rule is stable and nothing is selected by category, so the flag had nothing to gate. Verified the strict-rule counts are identical before and after (62379 each, zero per-rule drift), so no budget change. * ci: drop redundant pyright dev dependency Nothing invokes bare pyright in the Makefile, the linting workflow, or scripts; the basedpyright gate added on this branch is the only type checker that runs. basedpyright is a superset fork that reads the same pyrightconfig.json and honors the same "# pyright: ignore" comments, so pyright==1.1.408 in the ci group was dead weight. Regenerated uv.lock under the same exclude-newer cutoff so the only change is removing pyright and its package stanza * ci: un-weaken mypy and error on Any in basedpyright mypy: enable warn_return_any, drop the valid-type silencer, and stop globally ignoring missing first-party imports via [mypy-litellm.*] ignore_missing_imports = False, which surfaced eight real broken litellm.* imports the blanket ignore was hiding; third-party imports stay ignored. The per-file budget moves 4888 -> 5799 (902 no-any-return, 1 valid-type, 8 import-not-found), all grandfathered so only net-new errors fail and the ceilings ratchet down basedpyright: error on reportExplicitAny and reportAny. The per-file budget moves 117033 -> 148946 (6931 explicit-Any, 24954 Any-typed expressions), grandfathered the same way * ci: add Any-discipline gate on changed lines under litellm/ Add scripts/check_any_discipline.py, a type-aware gate that fails when a changed line holds a value typed Any -- including the X | Any unions that mypy --strict / basedpyright accept (e.g. re.Match.group() -> str | Any, json.loads() -> Any, bare dict -> dict[Any, Any]). It reuses the repo's mypyc-compiled mypy 1.19 via a custom generic AST walker (mypyc precludes subclassing TraverserVisitor), loads litellm/mypy.ini for parity with lint-mypy, and uses a dedicated incremental cache (.mypy_cache_any) with mtime+hash invalidation to force re-checks. Scope is changed-lines-only so editing a legacy file never forces cleaning its existing Any debt; suppress a genuine typed/untyped boundary with # any-ok: <reason> (ANY002 requires the reason). Wire it into the Makefile (lint-any, lint, lint-dev), a parallel any-discipline CI job with its own actions/cache, .gitignore, and the CLAUDE.md / CONTRIBUTING.md docs. * ci: move Any-gate codes into the shared LIT namespace Renumber the Any-discipline checker into the LIT*** scheme owned by scripts/check_type_discipline.py (PR #30500) so the two checkers share one rule namespace and suppression convention: ANY001 -> LIT002 (Any-typed value; LIT002 was the retired/free slot) ANY002 -> LIT005 (any-ok without a reason; the shared suppression-reason code) ANY000 -> LIT000 (setup/build/read error; the shared error code) Messages and behavior are unchanged; LIT005's text already matches the "<token> requires a reason" shape used for cast-ok/guard-ok. * ci: gate mypy and basedpyright per error rule, not per file Switch the mypy/basedpyright budget gate from per-file error counts to per-rule-code totals, mirroring the {rule: {baseline, slack}} shape of ruff-strict-budget.json. A rule fails when its codebase-wide error count exceeds baseline + slack, so violations are tracked by category rather than by file location. scripts/type_check_gate.py now parses mypy from its text output (trailing [code]) and basedpyright from --outputjson (the JSON `rule` field), since basedpyright's wrapped text diagnostics mis-attribute the rule on continuation lines. Replace the *-file-budget.json files with freshly captured *-code-budget.json baselines and update the Makefile, CI, and CLAUDE.md accordingly. * docs: prefer Pydantic validation over any-ok suppression Point the Any-discipline guidance at validating Any with Pydantic (a model or TypeAdapter that returns a typed value or raises) and frame # any-ok as a last resort that should ideally never be used. * chore: remove extraneous comment * chore: make the CLAUDE.md more concise * chore: clean up bloated CONTRIBUTING.md additions * chore: make Makefile more concise * ci: add the lint-budget-update target CLAUDE.md references CLAUDE.md tells contributors to run make lint-budget-update, but the target was never defined. Add it as an aggregate that re-captures the ruff, mypy, and basedpyright budgets in one shot. * ci: recapture mypy and basedpyright budgets in the lint env The per-rule baselines were captured in a richer dependency env than the CI lint job's uv sync --frozen, so CI resolved fewer types and reported more errors than the budgets allowed (no-any-return 902 over cap 900, plus several basedpyright reportUnknown* rules). Regenerate both in the frozen env so they grandfather the true CI debt: mypy 5786 -> 5799 (no-any-return 890 -> 902, valid-type 1 restored), basedpyright 146213 -> 148942. * ci: check out PR head sha in lint and any-discipline jobs The default pull_request checkout uses refs/pull/N/merge, which folds the latest base commits into HEAD. The diff-based gates (ruff delta, Any discipline) then diff against the event's older base.sha and blame base's own new commits on this branch; staging's otel-v2 and streaming changes (#30326, #30485) tripped the Any gate on files this branch never touched. Checking out the PR head sha makes the gates diff the real branch tip against base, and pins the tree the mypy/basedpyright budgets were captured against so their counts stay deterministic as the base advances. * ci(lint): renumber Any-typed-value rule LIT002 -> LIT009 Free up LIT002 for the sibling type-discipline gate (check_type_discipline.py, #30500), which groups its mutable-collection family at LIT001 (annotation) and LIT002 (construction). This gate's Any-typed-value rule moves to LIT009 so the shared LIT namespace stays contiguous with no holes; LIT000 and LIT005 are unchanged. * style: rename lint-strict-budget -> lint-ruff-budget * ci: harden type-check gates against silent passes (greptile review) type_check_gate.py: refuse to certify a vacuous run. The CI pipe swallows the tool's exit code ('tool || true'), so a crashed mypy/basedpyright that emits nothing would parse to zero errors, breach no ceiling, and pass. is_vacuous_run() now fails when nothing was parsed but the budget expects errors. Also wrap basedpyright's json.loads in a JSONDecodeError handler that prints the offending output instead of dumping a raw traceback. check_any_discipline.py: ALL_LINES was None, which dict.get() also returns for a path absent from the line map, so a path-normalisation mismatch could let a violation on an unchanged file pass the scope filter. Make ALL_LINES a distinct sentinel object so 'whole file' and 'path missing' are unambiguous. Adds tests for all three. --------- Co-authored-by: Sameer Kankute <sameer@berri.ai> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Yassin Kortam <yassin@berri.ai> Co-authored-by: Joel Tony <github@jaytau.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: hcl <chenglunhu@gmail.com> Co-authored-by: ztko <96878659+koztkozt@users.noreply.github.com> Co-authored-by: Nahrin <nahrin@nahrinoda.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Humphrey <a739376838@gmail.com> Co-authored-by: kursadlacin <kursadlacin@gmail.com> Co-authored-by: kursad <kursad.lacin@brado.net> Co-authored-by: Dushyant Acharya <dushyantacharya873@gmail.com> Co-authored-by: Yuriy <yuriy.shuyskiy@gmail.com> Co-authored-by: Recep S <22618852+us@users.noreply.github.com> Co-authored-by: Moshe Malawach <moshe.malawach@protonmail.com> Co-authored-by: Moshe Malawach <moshemalawach@users.noreply.github.com> Co-authored-by: Rongkun Yan <2493404415@qq.com> Co-authored-by: Varshith <kvarshithgowda@gmail.com> * chore: satisfy strict-rule and any-discipline gates for the staging bundle The strict-rule budget and any-discipline gates added in #30379 flag the bundle's new lines: blind-except (BLE001), legacy typing imports (UP006/UP035/UP045), and values typed Any on changed lines (LIT009). Type-fix the cleanly-fixable cases (function signatures, payload dicts as dict[str, object], BudgetConfig.model_validate over **kwargs, direct KeyManagementSettings attribute access over getattr, Optional[X] -> X | None) and suppress the irreducible untyped boundaries (request/streaming dicts, cache reads, httpx responses, asyncio primitives, Pydantic model_dump navigation) with # any-ok and a short reason. Also fix two any-discipline gate false positives so legitimate code is no longer flagged: the synthetic Any in Coroutine/Generator send and yield protocol slots (the awaited/returned value is still checked), and the special-form Any of a TypedDict field's TempNode rvalue placeholder. * chore: extend basedpyright slack to the two rules #30563 left at default PR #30563 raised basedpyright slack to ~10% of baseline across the noisy reportUnknown*/reportAny family so staging bundles clear the per-rule gate, but it left reportArgumentType (slack 3) and reportPrivateUsage (slack 10) at their original tight values. This bundle pushes those two 10 and 1 over their caps respectively, so apply the same ~10% policy: reportArgumentType baseline 1863 -> slack 180, reportPrivateUsage baseline 1625 -> slack 160. No baselines move; only the slack on these two rules * fix: handle duplicate tool calls and stream tail disconnects * fix(proxy): mark stream completed before tail yields, not after [DONE] Clients routinely close the connection right after the final chunk or the terminating data: [DONE] frame. Setting stream_completed only after those trailing yields made the GeneratorExit from that close fall into the disconnect branch, recording false 499 client_disconnected metadata for a response that already delivered all content and fired success logging, and double-releasing the max_parallel_requests slot the success callback had already released. Restore stream_completed before the trailing raw-SSE, error, and [DONE] yields so terminal-marker closes are treated as the successful completions they are. The tool_use dedupe guard is kept. --------- Co-authored-by: apshada <49001649+apshada@users.noreply.github.com> Co-authored-by: Aarkin Karnik <56022539+Aarkin7@users.noreply.github.com> Co-authored-by: David Bochenski <david@goincremental.com> Co-authored-by: Cai Songrui <1922909737@qq.com> Co-authored-by: Martin Honermeyer <7229+djmaze@users.noreply.github.com> Co-authored-by: songkuan-zheng <252822057+songkuan-zheng@users.noreply.github.com> Co-authored-by: fangkang <fangkangm@gmail.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Michael <52305679+michaelxer@users.noreply.github.com> Co-authored-by: michaelxer <michaelxer@users.noreply.github.com> Co-authored-by: Anuj ojha <ojhaanuj224@gmail.com> Co-authored-by: 安妮的心动录 <74543653+anneheartrecord@users.noreply.github.com> Co-authored-by: Zekeriya Akgül <zkry.akgul@gmail.com> Co-authored-by: Thomas Menard <menardorama@gmail.com> Co-authored-by: Zang Peiyu <166481866+factnn@users.noreply.github.com> Co-authored-by: Emerson Gomes <emerson.gomes@thalesgroup.com> Co-authored-by: Mark Lopez <m@silvenga.com> Co-authored-by: Varshith <kvarshithgowda@gmail.com> Co-authored-by: Huynh Duc Tran <110240973+hdt12a1@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Samarth Maganahalli <samarth.maganahalli@rubrik.com> Co-authored-by: Dushyant Acharya <dushyantacharya873@gmail.com> Co-authored-by: Dushyant Acharya <dushyantacharya@Dushyants-MacBook-Pro.local> Co-authored-by: Thijmen Stavenuiter <thijmenstavenuiter@gmail.com> Co-authored-by: Vineeth Sai <vineethsai4444@gmail.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: rvishwas26 <rvishwas@athenahealth.com> Co-authored-by: Yassin Kortam <yassin@berri.ai> Co-authored-by: Joel Tony <github@jaytau.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: hcl <chenglunhu@gmail.com> Co-authored-by: ztko <96878659+koztkozt@users.noreply.github.com> Co-authored-by: Nahrin <nahrin@nahrinoda.com> Co-authored-by: Humphrey <a739376838@gmail.com> Co-authored-by: kursadlacin <kursadlacin@gmail.com> Co-authored-by: kursad <kursad.lacin@brado.net> Co-authored-by: Yuriy <yuriy.shuyskiy@gmail.com> Co-authored-by: Recep S <22618852+us@users.noreply.github.com> Co-authored-by: Moshe Malawach <moshe.malawach@protonmail.com> Co-authored-by: Moshe Malawach <moshemalawach@users.noreply.github.com> Co-authored-by: Rongkun Yan <2493404415@qq.com>
8328 lines
303 KiB
Python
8328 lines
303 KiB
Python
import asyncio
|
||
import importlib
|
||
import json
|
||
import os
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from unittest import mock
|
||
from unittest.mock import AsyncMock, MagicMock, mock_open, patch
|
||
|
||
import click
|
||
import httpx
|
||
import pytest
|
||
import yaml
|
||
from fastapi import FastAPI
|
||
from fastapi.staticfiles import StaticFiles
|
||
from fastapi.testclient import TestClient
|
||
|
||
sys.path.insert(
|
||
0, os.path.abspath("../../..")
|
||
) # Adds the parent directory to the system-path
|
||
|
||
import litellm
|
||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||
from litellm.proxy.proxy_server import app, initialize
|
||
from litellm.utils import _invalidate_model_cost_lowercase_map
|
||
|
||
example_embedding_result = {
|
||
"object": "list",
|
||
"data": [
|
||
{
|
||
"object": "embedding",
|
||
"index": 0,
|
||
"embedding": [
|
||
-0.006929283495992422,
|
||
-0.005336422007530928,
|
||
-4.547132266452536e-05,
|
||
-0.024047505110502243,
|
||
-0.006929283495992422,
|
||
-0.005336422007530928,
|
||
-4.547132266452536e-05,
|
||
-0.024047505110502243,
|
||
-0.006929283495992422,
|
||
-0.005336422007530928,
|
||
-4.547132266452536e-05,
|
||
-0.024047505110502243,
|
||
],
|
||
}
|
||
],
|
||
"model": "text-embedding-3-small",
|
||
"usage": {"prompt_tokens": 5, "total_tokens": 5},
|
||
}
|
||
|
||
|
||
def mock_patch_aembedding():
|
||
return mock.patch(
|
||
"litellm.proxy.proxy_server.llm_router.aembedding",
|
||
return_value=example_embedding_result,
|
||
)
|
||
|
||
|
||
@pytest.fixture(scope="function")
|
||
def client_no_auth():
|
||
# Assuming litellm.proxy.proxy_server is an object
|
||
from litellm.proxy.proxy_server import cleanup_router_config_variables
|
||
|
||
cleanup_router_config_variables()
|
||
filepath = os.path.dirname(os.path.abspath(__file__))
|
||
config_fp = f"{filepath}/test_configs/test_config_no_auth.yaml"
|
||
# initialize can get run in parallel, it sets specific variables for the fast api app, sinc eit gets run in parallel different tests use the wrong variables
|
||
asyncio.run(initialize(config=config_fp, debug=True))
|
||
return TestClient(app)
|
||
|
||
|
||
def test_login_v2_returns_redirect_url_and_sets_cookie(monkeypatch):
|
||
mock_login_result = {"user_id": "test-user"}
|
||
mock_prisma_client = MagicMock()
|
||
mock_authenticate_user = AsyncMock(return_value=mock_login_result)
|
||
mock_create_ui_token_object = MagicMock(return_value={"user_id": "test-user"})
|
||
mock_jwt_encode = MagicMock(return_value="signed-token")
|
||
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.auth.login_utils.authenticate_user",
|
||
mock_authenticate_user,
|
||
)
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.auth.login_utils.create_ui_token_object",
|
||
mock_create_ui_token_object,
|
||
)
|
||
monkeypatch.setattr("jwt.encode", mock_jwt_encode)
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "test-master-key")
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False)
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||
monkeypatch.setattr("litellm.proxy.utils.get_server_root_path", lambda: "")
|
||
monkeypatch.setattr("litellm.proxy.utils.get_proxy_base_url", lambda: None)
|
||
|
||
client = TestClient(app)
|
||
response = client.post(
|
||
"/v2/login",
|
||
json={"username": "alice", "password": "secret"},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
assert response.json() == {
|
||
"redirect_url": "http://testserver/ui/?login=success",
|
||
"token": "signed-token",
|
||
}
|
||
assert response.cookies.get("token") == "signed-token"
|
||
|
||
mock_authenticate_user.assert_awaited_once_with(
|
||
username="alice",
|
||
password="secret",
|
||
master_key="test-master-key",
|
||
prisma_client=mock_prisma_client,
|
||
)
|
||
mock_create_ui_token_object.assert_called_once_with(
|
||
login_result=mock_login_result,
|
||
general_settings={},
|
||
premium_user=False,
|
||
)
|
||
mock_jwt_encode.assert_called_once_with(
|
||
{"user_id": "test-user"},
|
||
"test-master-key",
|
||
algorithm="HS256",
|
||
)
|
||
|
||
|
||
def test_login_v2_returns_json_on_proxy_exception(monkeypatch):
|
||
"""Test that /v2/login returns JSON error when ProxyException is raised"""
|
||
from litellm.proxy._types import ProxyErrorTypes, ProxyException
|
||
|
||
mock_prisma_client = MagicMock()
|
||
mock_authenticate_user = AsyncMock(
|
||
side_effect=ProxyException(
|
||
message="Invalid credentials",
|
||
type=ProxyErrorTypes.auth_error,
|
||
param="password",
|
||
code=401,
|
||
)
|
||
)
|
||
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.auth.login_utils.authenticate_user",
|
||
mock_authenticate_user,
|
||
)
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "test-master-key")
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||
|
||
client = TestClient(app)
|
||
response = client.post(
|
||
"/v2/login",
|
||
json={"username": "alice", "password": "wrong"},
|
||
)
|
||
|
||
assert response.status_code == 401
|
||
assert response.headers["content-type"] == "application/json"
|
||
data = response.json()
|
||
assert "error" in data
|
||
assert data["error"]["message"] == "Invalid credentials"
|
||
assert data["error"]["type"] == "auth_error"
|
||
|
||
|
||
def test_login_v2_returns_json_on_http_exception(monkeypatch):
|
||
"""Test that /v2/login converts HTTPException to JSON error response"""
|
||
from fastapi import HTTPException
|
||
|
||
mock_prisma_client = MagicMock()
|
||
mock_authenticate_user = AsyncMock(
|
||
side_effect=HTTPException(status_code=401, detail="Unauthorized")
|
||
)
|
||
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.auth.login_utils.authenticate_user",
|
||
mock_authenticate_user,
|
||
)
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "test-master-key")
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||
|
||
client = TestClient(app)
|
||
response = client.post(
|
||
"/v2/login",
|
||
json={"username": "alice", "password": "secret"},
|
||
)
|
||
|
||
assert response.status_code == 401
|
||
assert response.headers["content-type"] == "application/json"
|
||
data = response.json()
|
||
assert "error" in data
|
||
assert isinstance(data["error"], dict)
|
||
|
||
|
||
def test_login_v2_returns_json_on_unexpected_exception(monkeypatch):
|
||
"""Test that /v2/login returns JSON error when unexpected exception occurs"""
|
||
mock_prisma_client = MagicMock()
|
||
mock_authenticate_user = AsyncMock(side_effect=ValueError("Unexpected error"))
|
||
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.auth.login_utils.authenticate_user",
|
||
mock_authenticate_user,
|
||
)
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "test-master-key")
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||
|
||
client = TestClient(app)
|
||
response = client.post(
|
||
"/v2/login",
|
||
json={"username": "alice", "password": "secret"},
|
||
)
|
||
|
||
assert response.status_code == 500
|
||
assert response.headers["content-type"] == "application/json"
|
||
data = response.json()
|
||
assert "error" in data
|
||
assert isinstance(data["error"], dict)
|
||
assert "Unexpected error" in data["error"]["message"]
|
||
|
||
|
||
def test_login_v2_returns_json_on_invalid_json_body(monkeypatch):
|
||
"""Test that /v2/login returns JSON error when request body is invalid JSON"""
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "test-master-key")
|
||
|
||
client = TestClient(app)
|
||
response = client.post(
|
||
"/v2/login",
|
||
content="invalid json",
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
|
||
assert response.status_code == 500
|
||
assert response.headers["content-type"] == "application/json"
|
||
data = response.json()
|
||
assert "error" in data
|
||
assert isinstance(data["error"], dict)
|
||
|
||
|
||
def test_login_v3_rejected_without_control_plane_url(monkeypatch):
|
||
"""v3/login returns 404 when control_plane_url is not configured."""
|
||
mock_prisma_client = MagicMock()
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "test-master-key")
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||
|
||
client = TestClient(app)
|
||
response = client.post(
|
||
"/v3/login",
|
||
json={"username": "alice", "password": "secret"},
|
||
)
|
||
|
||
assert response.status_code == 404
|
||
assert "control_plane_url" in response.json()["error"]["message"]
|
||
|
||
|
||
def test_login_v3_returns_code(monkeypatch):
|
||
"""v3/login returns an opaque code, not the JWT directly."""
|
||
mock_prisma_client = MagicMock()
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.auth.login_utils.authenticate_user",
|
||
AsyncMock(return_value={"user_id": "test-user"}),
|
||
)
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.auth.login_utils.create_ui_token_object",
|
||
MagicMock(return_value={"user_id": "test-user"}),
|
||
)
|
||
monkeypatch.setattr("jwt.encode", MagicMock(return_value="signed-token"))
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "test-master-key")
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.proxy_server.general_settings",
|
||
{"control_plane_url": "https://cp.example.com"},
|
||
)
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False)
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||
mock_config = MagicMock()
|
||
mock_config.worker_registry = []
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_config", mock_config)
|
||
monkeypatch.setattr("litellm.proxy.utils.get_server_root_path", lambda: "")
|
||
monkeypatch.setattr("litellm.proxy.utils.get_proxy_base_url", lambda: None)
|
||
|
||
client = TestClient(app)
|
||
response = client.post(
|
||
"/v3/login",
|
||
json={"username": "alice", "password": "secret"},
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert "code" in data
|
||
assert data["expires_in"] == 60
|
||
assert "token" not in data
|
||
|
||
|
||
def test_login_v3_exchange_happy_path(monkeypatch):
|
||
"""Full flow: v3/login returns code, v3/login/exchange redeems it for JWT."""
|
||
mock_prisma_client = MagicMock()
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.auth.login_utils.authenticate_user",
|
||
AsyncMock(return_value={"user_id": "test-user"}),
|
||
)
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.auth.login_utils.create_ui_token_object",
|
||
MagicMock(return_value={"user_id": "test-user"}),
|
||
)
|
||
monkeypatch.setattr("jwt.encode", MagicMock(return_value="signed-token"))
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "test-master-key")
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.proxy_server.general_settings",
|
||
{"control_plane_url": "https://cp.example.com"},
|
||
)
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False)
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||
mock_config = MagicMock()
|
||
mock_config.worker_registry = []
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_config", mock_config)
|
||
monkeypatch.setattr("litellm.proxy.utils.get_server_root_path", lambda: "")
|
||
monkeypatch.setattr("litellm.proxy.utils.get_proxy_base_url", lambda: None)
|
||
|
||
client = TestClient(app)
|
||
|
||
# Step 1: login — get code
|
||
login_response = client.post(
|
||
"/v3/login",
|
||
json={"username": "alice", "password": "secret"},
|
||
)
|
||
assert login_response.status_code == 200
|
||
code = login_response.json()["code"]
|
||
|
||
# Step 2: exchange — get JWT
|
||
exchange_response = client.post(
|
||
"/v3/login/exchange",
|
||
json={"code": code},
|
||
)
|
||
assert exchange_response.status_code == 200
|
||
exchange_data = exchange_response.json()
|
||
assert exchange_data["token"] == "signed-token"
|
||
assert "redirect_url" in exchange_data
|
||
assert exchange_response.cookies.get("token") == "signed-token"
|
||
|
||
|
||
def test_login_v3_exchange_single_use(monkeypatch):
|
||
"""Code can only be redeemed once."""
|
||
mock_prisma_client = MagicMock()
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.auth.login_utils.authenticate_user",
|
||
AsyncMock(return_value={"user_id": "test-user"}),
|
||
)
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.auth.login_utils.create_ui_token_object",
|
||
MagicMock(return_value={"user_id": "test-user"}),
|
||
)
|
||
monkeypatch.setattr("jwt.encode", MagicMock(return_value="signed-token"))
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "test-master-key")
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.proxy_server.general_settings",
|
||
{"control_plane_url": "https://cp.example.com"},
|
||
)
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False)
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||
mock_config = MagicMock()
|
||
mock_config.worker_registry = []
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.proxy_config", mock_config)
|
||
monkeypatch.setattr("litellm.proxy.utils.get_server_root_path", lambda: "")
|
||
monkeypatch.setattr("litellm.proxy.utils.get_proxy_base_url", lambda: None)
|
||
|
||
client = TestClient(app)
|
||
|
||
login_response = client.post(
|
||
"/v3/login",
|
||
json={"username": "alice", "password": "secret"},
|
||
)
|
||
code = login_response.json()["code"]
|
||
|
||
# First exchange succeeds
|
||
first = client.post("/v3/login/exchange", json={"code": code})
|
||
assert first.status_code == 200
|
||
|
||
# Second exchange fails
|
||
second = client.post("/v3/login/exchange", json={"code": code})
|
||
assert second.status_code == 401
|
||
|
||
|
||
def test_login_v3_exchange_invalid_code(monkeypatch):
|
||
"""Random code returns 401."""
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.proxy_server.general_settings",
|
||
{"control_plane_url": "https://cp.example.com"},
|
||
)
|
||
client = TestClient(app)
|
||
response = client.post(
|
||
"/v3/login/exchange",
|
||
json={"code": "nonexistent-code"},
|
||
)
|
||
assert response.status_code == 401
|
||
|
||
|
||
def test_login_v3_exchange_rejected_without_control_plane_url(monkeypatch):
|
||
"""v3/login/exchange returns 404 when control_plane_url is not configured."""
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
|
||
|
||
client = TestClient(app)
|
||
response = client.post(
|
||
"/v3/login/exchange",
|
||
json={"code": "some-code"},
|
||
)
|
||
|
||
assert response.status_code == 404
|
||
assert "control_plane_url" in response.json()["error"]["message"]
|
||
|
||
|
||
def test_login_v3_returns_json_on_proxy_exception(monkeypatch):
|
||
"""Test that /v3/login returns JSON error when ProxyException is raised"""
|
||
from litellm.proxy._types import ProxyErrorTypes, ProxyException
|
||
|
||
mock_prisma_client = MagicMock()
|
||
mock_authenticate_user = AsyncMock(
|
||
side_effect=ProxyException(
|
||
message="Invalid credentials",
|
||
type=ProxyErrorTypes.auth_error,
|
||
param="password",
|
||
code=401,
|
||
)
|
||
)
|
||
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.auth.login_utils.authenticate_user",
|
||
mock_authenticate_user,
|
||
)
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "test-master-key")
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.proxy_server.general_settings",
|
||
{"control_plane_url": "https://cp.example.com"},
|
||
)
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||
|
||
client = TestClient(app)
|
||
response = client.post(
|
||
"/v3/login",
|
||
json={"username": "alice", "password": "wrong"},
|
||
)
|
||
|
||
assert response.status_code == 401
|
||
assert response.headers["content-type"] == "application/json"
|
||
data = response.json()
|
||
assert "error" in data
|
||
assert data["error"]["message"] == "Invalid credentials"
|
||
assert data["error"]["type"] == "auth_error"
|
||
|
||
|
||
def test_fallback_login_has_no_deprecation_banner(client_no_auth):
|
||
response = client_no_auth.get("/fallback/login")
|
||
|
||
assert response.status_code == 200
|
||
html = response.text
|
||
assert '<div class="deprecation-banner">' not in html
|
||
assert "Deprecated:" not in html
|
||
assert "<form" in html
|
||
|
||
|
||
@pytest.mark.parametrize(
|
||
"ui_logo_path",
|
||
[
|
||
"/etc/litellm/secret-config.json",
|
||
"/var/secrets/admin.key",
|
||
"/proc/self/environ",
|
||
"relative/path/logo.png",
|
||
],
|
||
)
|
||
def test_get_logo_url_does_not_disclose_local_paths(
|
||
client_no_auth, monkeypatch, ui_logo_path
|
||
):
|
||
# ``/get_logo_url`` is unauthenticated. Returning a local filesystem
|
||
# path verbatim discloses admin-only config to any caller. Only
|
||
# browser-loadable HTTP(S) URLs should be returned; for local paths
|
||
# the dashboard falls back to ``/get_image``.
|
||
monkeypatch.setenv("UI_LOGO_PATH", ui_logo_path)
|
||
|
||
response = client_no_auth.get("/get_logo_url")
|
||
|
||
assert response.status_code == 200
|
||
assert response.json() == {"logo_url": ""}
|
||
|
||
|
||
def test_get_logo_url_returns_https_url(client_no_auth, monkeypatch):
|
||
monkeypatch.setenv("UI_LOGO_PATH", "https://cdn.public.example/logo.png")
|
||
|
||
response = client_no_auth.get("/get_logo_url")
|
||
|
||
assert response.status_code == 200
|
||
assert response.json() == {"logo_url": "https://cdn.public.example/logo.png"}
|
||
|
||
|
||
def test_get_logo_url_returns_http_url(client_no_auth, monkeypatch):
|
||
# HTTP URLs (typically internal CDN) are still returned — those are
|
||
# intended to be loaded directly by the browser.
|
||
monkeypatch.setenv("UI_LOGO_PATH", "http://internal-cdn.corp:8080/logo.png")
|
||
|
||
response = client_no_auth.get("/get_logo_url")
|
||
|
||
assert response.status_code == 200
|
||
assert response.json() == {"logo_url": "http://internal-cdn.corp:8080/logo.png"}
|
||
|
||
|
||
def test_get_logo_url_returns_empty_when_unset(client_no_auth, monkeypatch):
|
||
monkeypatch.delenv("UI_LOGO_PATH", raising=False)
|
||
|
||
response = client_no_auth.get("/get_logo_url")
|
||
|
||
assert response.status_code == 200
|
||
assert response.json() == {"logo_url": ""}
|
||
|
||
|
||
def test_sso_key_generate_shows_deprecation_banner(client_no_auth, monkeypatch):
|
||
# Ensure the route returns the HTML form instead of redirecting
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.management_endpoints.ui_sso.show_missing_vars_in_env",
|
||
lambda: None,
|
||
)
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.management_endpoints.ui_sso.SSOAuthenticationHandler.get_redirect_url_for_sso",
|
||
lambda *args, **kwargs: "http://test/redirect",
|
||
)
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.management_endpoints.ui_sso.SSOAuthenticationHandler._get_cli_state",
|
||
lambda *args, **kwargs: None,
|
||
)
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.management_endpoints.ui_sso.SSOAuthenticationHandler.should_use_sso_handler",
|
||
lambda *args, **kwargs: False,
|
||
)
|
||
# Mock premium_user to bypass enterprise check (prevents 403 Forbidden)
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.proxy_server.premium_user",
|
||
True,
|
||
)
|
||
monkeypatch.setenv("UI_USERNAME", "admin")
|
||
|
||
response = client_no_auth.get("/sso/key/generate")
|
||
|
||
assert response.status_code == 200
|
||
html = response.text
|
||
assert '<div class="deprecation-banner">' in html
|
||
assert "Deprecated:" in html
|
||
|
||
|
||
def test_restructure_ui_html_files_handles_nested_routes(tmp_path):
|
||
"""
|
||
Test that _restructure_ui_html_files correctly restructures HTML files.
|
||
Note: This function is always called now, both in development and non-root Docker environments.
|
||
"""
|
||
from litellm.proxy import proxy_server
|
||
|
||
ui_root = tmp_path / "ui"
|
||
ui_root.mkdir()
|
||
|
||
def write_file(path: Path, content: str) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(content)
|
||
|
||
write_file(ui_root / "home.html", "home")
|
||
write_file(ui_root / "mcp" / "oauth" / "callback.html", "callback")
|
||
write_file(ui_root / "existing" / "index.html", "keep")
|
||
write_file(ui_root / "_next" / "ignore.html", "asset")
|
||
write_file(ui_root / "litellm-asset-prefix" / "ignore.html", "asset")
|
||
|
||
proxy_server._restructure_ui_html_files(str(ui_root))
|
||
|
||
assert not (ui_root / "home.html").exists()
|
||
assert (ui_root / "home" / "index.html").read_text() == "home"
|
||
assert not (ui_root / "mcp" / "oauth" / "callback.html").exists()
|
||
assert (
|
||
ui_root / "mcp" / "oauth" / "callback" / "index.html"
|
||
).read_text() == "callback"
|
||
assert (ui_root / "existing" / "index.html").read_text() == "keep"
|
||
assert (ui_root / "_next" / "ignore.html").read_text() == "asset"
|
||
assert (ui_root / "litellm-asset-prefix" / "ignore.html").read_text() == "asset"
|
||
|
||
|
||
def test_ui_extensionless_route_requires_restructure(tmp_path):
|
||
"""
|
||
Regression for non-root fallback: /ui/login expects login/index.html.
|
||
Note: Restructuring always happens now, both in development and non-root Docker environments.
|
||
"""
|
||
|
||
from litellm.proxy import proxy_server
|
||
|
||
ui_root = tmp_path / "ui"
|
||
ui_root.mkdir()
|
||
(ui_root / "index.html").write_text("index")
|
||
(ui_root / "login.html").write_text("login")
|
||
|
||
fastapi_app = FastAPI()
|
||
fastapi_app.mount("/ui", StaticFiles(directory=str(ui_root), html=True), name="ui")
|
||
client = TestClient(fastapi_app)
|
||
|
||
assert client.get("/ui/login.html").status_code == 200
|
||
assert client.get("/ui/login").status_code == 404
|
||
|
||
proxy_server._restructure_ui_html_files(str(ui_root))
|
||
|
||
response = client.get("/ui/login")
|
||
assert response.status_code == 200
|
||
assert "login" in response.text
|
||
|
||
|
||
def test_admin_ui_export_serves_nested_extensionless_routes():
|
||
out_dir = Path(litellm.__file__).parent / "proxy" / "_experimental" / "out"
|
||
assert out_dir.is_dir(), f"missing UI export at {out_dir}"
|
||
|
||
nested_html_offenders = [
|
||
path.relative_to(out_dir).as_posix()
|
||
for path in out_dir.rglob("*.html")
|
||
if path.parent != out_dir
|
||
and path.name != "index.html"
|
||
and "_next" not in path.parts
|
||
and "litellm-asset-prefix" not in path.parts
|
||
]
|
||
assert not nested_html_offenders, (
|
||
"Nested routes must be named index.html. Offenders: " f"{nested_html_offenders}"
|
||
)
|
||
|
||
callback_index = out_dir / "mcp" / "oauth" / "callback" / "index.html"
|
||
assert callback_index.is_file(), (
|
||
f"MCP OAuth callback page must exist at {callback_index}; "
|
||
"without it /ui/mcp/oauth/callback 404s after Linear redirects back."
|
||
)
|
||
|
||
fastapi_app = FastAPI()
|
||
fastapi_app.mount("/ui", StaticFiles(directory=str(out_dir), html=True), name="ui")
|
||
client = TestClient(fastapi_app)
|
||
|
||
redirect = client.get(
|
||
"/ui/mcp/oauth/callback?code=abc&state=xyz",
|
||
follow_redirects=False,
|
||
)
|
||
assert redirect.status_code == 307
|
||
assert redirect.headers["location"].endswith(
|
||
"/ui/mcp/oauth/callback/?code=abc&state=xyz"
|
||
)
|
||
|
||
landed = client.get("/ui/mcp/oauth/callback?code=abc&state=xyz")
|
||
assert landed.status_code == 200
|
||
assert "<html" in landed.text.lower()
|
||
|
||
|
||
def test_restructure_always_happens(monkeypatch):
|
||
"""
|
||
Test that restructuring logic always executes regardless of LITELLM_NON_ROOT setting.
|
||
In development (is_non_root=False), restructuring happens directly in _experimental/out.
|
||
In non-root Docker (is_non_root=True), restructuring happens in /var/lib/litellm/ui.
|
||
"""
|
||
# Test Case 1: is_non_root is True - restructuring happens in /var/lib/litellm/ui
|
||
monkeypatch.setenv("LITELLM_NON_ROOT", "true")
|
||
|
||
runtime_ui_path = "/var/lib/litellm/ui"
|
||
packaged_ui_path = "/some/packaged/ui/path"
|
||
|
||
# Simulate the logic from proxy_server.py
|
||
is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true"
|
||
if is_non_root:
|
||
ui_path = runtime_ui_path
|
||
else:
|
||
ui_path = packaged_ui_path
|
||
|
||
# Restructuring always happens now, regardless of ui_path vs packaged_ui_path
|
||
should_restructure = True
|
||
|
||
assert is_non_root is True
|
||
assert should_restructure is True
|
||
assert ui_path == runtime_ui_path
|
||
|
||
# Test Case 2: is_non_root is False - restructuring happens directly in packaged_ui_path
|
||
monkeypatch.delenv("LITELLM_NON_ROOT", raising=False)
|
||
|
||
# Simulate the logic from proxy_server.py
|
||
is_non_root = os.getenv("LITELLM_NON_ROOT", "").lower() == "true"
|
||
if is_non_root:
|
||
ui_path = runtime_ui_path
|
||
else:
|
||
ui_path = packaged_ui_path
|
||
|
||
# Restructuring always happens now, even when ui_path == packaged_ui_path
|
||
should_restructure = True
|
||
|
||
assert is_non_root is False
|
||
assert should_restructure is True
|
||
assert ui_path == packaged_ui_path
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_initialize_scheduled_jobs_credentials(monkeypatch):
|
||
"""
|
||
Test that get_credentials is only called when store_model_in_db is True
|
||
"""
|
||
monkeypatch.delenv("DISABLE_PRISMA_SCHEMA_UPDATE", raising=False)
|
||
monkeypatch.delenv("STORE_MODEL_IN_DB", raising=False)
|
||
from litellm.proxy.proxy_server import ProxyStartupEvent
|
||
from litellm.proxy.utils import ProxyLogging
|
||
|
||
# Mock dependencies
|
||
mock_prisma_client = MagicMock()
|
||
mock_proxy_logging = MagicMock(spec=ProxyLogging)
|
||
mock_proxy_logging.slack_alerting_instance = MagicMock()
|
||
mock_proxy_config = AsyncMock()
|
||
|
||
with (
|
||
patch("litellm.proxy.proxy_server.proxy_config", mock_proxy_config),
|
||
patch("litellm.proxy.proxy_server.store_model_in_db", False),
|
||
): # set store_model_in_db to False
|
||
# Test when store_model_in_db is False
|
||
await ProxyStartupEvent.initialize_scheduled_background_jobs(
|
||
general_settings={},
|
||
prisma_client=mock_prisma_client,
|
||
proxy_budget_rescheduler_min_time=1,
|
||
proxy_budget_rescheduler_max_time=2,
|
||
proxy_batch_write_at=5,
|
||
proxy_logging_obj=mock_proxy_logging,
|
||
)
|
||
|
||
# Verify get_credentials was not called
|
||
mock_proxy_config.get_credentials.assert_not_called()
|
||
|
||
# Now test with store_model_in_db = True
|
||
with (
|
||
patch("litellm.proxy.proxy_server.proxy_config", mock_proxy_config),
|
||
patch("litellm.proxy.proxy_server.store_model_in_db", True),
|
||
patch("litellm.proxy.proxy_server.get_secret_bool", return_value=True),
|
||
):
|
||
await ProxyStartupEvent.initialize_scheduled_background_jobs(
|
||
general_settings={},
|
||
prisma_client=mock_prisma_client,
|
||
proxy_budget_rescheduler_min_time=1,
|
||
proxy_budget_rescheduler_max_time=2,
|
||
proxy_batch_write_at=5,
|
||
proxy_logging_obj=mock_proxy_logging,
|
||
)
|
||
|
||
# Verify get_credentials was called both directly and scheduled
|
||
assert mock_proxy_config.get_credentials.call_count == 1 # Direct call
|
||
|
||
# Verify a scheduled job was added for get_credentials
|
||
mock_scheduler_calls = [
|
||
call[0] for call in mock_proxy_config.get_credentials.mock_calls
|
||
]
|
||
assert len(mock_scheduler_calls) > 0
|
||
|
||
|
||
def test_update_config_fields_deep_merge_db_wins():
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
current_config = {
|
||
"router_settings": {
|
||
"routing_mode": "cost_optimized",
|
||
"model_group_alias": {
|
||
# Existing alias with older model + different hidden flag
|
||
"claude-sonnet-4": {
|
||
"model": "claude-sonnet-4-20240219",
|
||
"hidden": True,
|
||
},
|
||
# An extra alias that should remain untouched unless DB overrides it
|
||
"legacy-sonnet": {
|
||
"model": "claude-2.1",
|
||
"hidden": True,
|
||
},
|
||
},
|
||
}
|
||
}
|
||
|
||
db_param_value = {
|
||
"model_group_alias": {
|
||
# Conflict: DB should win (both 'model' and 'hidden')
|
||
"claude-sonnet-4": {
|
||
"model": "claude-sonnet-4-20250514",
|
||
"hidden": False,
|
||
},
|
||
# New alias to be added by the merge
|
||
"claude-sonnet-latest": {
|
||
"model": "claude-sonnet-4-20250514",
|
||
"hidden": True,
|
||
},
|
||
# Demonstrate that None values from DB are skipped (preserve existing)
|
||
"legacy-sonnet": {"hidden": None}, # should not clobber current True
|
||
}
|
||
}
|
||
|
||
updated = proxy_config._update_config_fields(
|
||
current_config=current_config,
|
||
param_name="router_settings",
|
||
db_param_value=db_param_value,
|
||
)
|
||
|
||
rs = updated["router_settings"]
|
||
aliases = rs["model_group_alias"]
|
||
|
||
# DB wins on conflicts (deep) for existing alias
|
||
assert aliases["claude-sonnet-4"]["model"] == "claude-sonnet-4-20250514"
|
||
assert aliases["claude-sonnet-4"]["hidden"] is False
|
||
|
||
# New alias introduced by DB is present with its values
|
||
assert "claude-sonnet-latest" in aliases
|
||
assert aliases["claude-sonnet-latest"]["model"] == "claude-sonnet-4-20250514"
|
||
assert aliases["claude-sonnet-latest"]["hidden"] is True
|
||
|
||
# None in DB does not overwrite existing values
|
||
assert aliases["legacy-sonnet"]["model"] == "claude-2.1"
|
||
assert aliases["legacy-sonnet"]["hidden"] is True
|
||
|
||
# Unrelated router_settings keys are preserved
|
||
assert rs["routing_mode"] == "cost_optimized"
|
||
|
||
|
||
def test_get_config_custom_callback_api_env_vars(monkeypatch):
|
||
"""
|
||
Ensure /get/config/callbacks returns custom callback env vars when both custom values are provided.
|
||
"""
|
||
from litellm.proxy.proxy_server import app, proxy_config, user_api_key_auth
|
||
|
||
# Mock config with custom_callback_api enabled and generic logger env vars present
|
||
config_data = {
|
||
"litellm_settings": {"success_callback": ["custom_callback_api"]},
|
||
"general_settings": {},
|
||
"environment_variables": {
|
||
"GENERIC_LOGGER_ENDPOINT": "https://callback.example.com",
|
||
"GENERIC_LOGGER_HEADERS": "Auth: token",
|
||
},
|
||
}
|
||
|
||
# Mock proxy_config.get_config and router settings
|
||
mock_router = MagicMock()
|
||
mock_router.get_settings.return_value = {}
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router)
|
||
monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=config_data))
|
||
|
||
# Bypass auth dependency
|
||
original_overrides = app.dependency_overrides.copy()
|
||
app.dependency_overrides[user_api_key_auth] = lambda: MagicMock()
|
||
|
||
client = TestClient(app)
|
||
try:
|
||
response = client.get("/get/config/callbacks")
|
||
finally:
|
||
app.dependency_overrides = original_overrides
|
||
|
||
assert response.status_code == 200
|
||
callbacks = response.json()["callbacks"]
|
||
custom_cb = next(
|
||
(cb for cb in callbacks if cb["name"] == "custom_callback_api"), None
|
||
)
|
||
|
||
assert custom_cb is not None
|
||
assert custom_cb["variables"] == {
|
||
"GENERIC_LOGGER_ENDPOINT": "https://callback.example.com",
|
||
"GENERIC_LOGGER_HEADERS": "Auth: token",
|
||
}
|
||
|
||
|
||
# Mock Prisma
|
||
class MockPrisma:
|
||
def __init__(self, database_url=None, proxy_logging_obj=None, http_client=None):
|
||
self.database_url = database_url
|
||
self.proxy_logging_obj = proxy_logging_obj
|
||
self.http_client = http_client
|
||
|
||
async def connect(self):
|
||
pass
|
||
|
||
async def disconnect(self):
|
||
pass
|
||
|
||
|
||
mock_prisma = MockPrisma()
|
||
|
||
|
||
@patch(
|
||
"litellm.proxy.proxy_server.ProxyStartupEvent._setup_prisma_client",
|
||
return_value=mock_prisma,
|
||
)
|
||
@pytest.mark.asyncio
|
||
async def test_aaaproxy_startup_master_key(mock_prisma, monkeypatch, tmp_path):
|
||
"""
|
||
Test that master_key is correctly loaded from either config.yaml or environment variables
|
||
"""
|
||
import yaml
|
||
from fastapi import FastAPI
|
||
|
||
# Import happens here - this is when the module probably reads the config path
|
||
from litellm.proxy.proxy_server import proxy_startup_event
|
||
|
||
# Mock the Prisma import
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.PrismaClient", MockPrisma)
|
||
|
||
# Create test app
|
||
app = FastAPI()
|
||
|
||
# Test Case 1: Master key from config.yaml
|
||
test_master_key = "sk-12345"
|
||
test_config = {"general_settings": {"master_key": test_master_key}}
|
||
|
||
# Create a temporary config file
|
||
config_path = tmp_path / "config.yaml"
|
||
with open(config_path, "w") as f:
|
||
yaml.dump(test_config, f)
|
||
|
||
print(f"SET ENV VARIABLE - CONFIG_FILE_PATH, str(config_path): {str(config_path)}")
|
||
# Second setting of CONFIG_FILE_PATH to a different value
|
||
monkeypatch.setenv("CONFIG_FILE_PATH", str(config_path))
|
||
print(f"config_path: {config_path}")
|
||
print(f"os.getenv('CONFIG_FILE_PATH'): {os.getenv('CONFIG_FILE_PATH')}")
|
||
async with proxy_startup_event(app):
|
||
from litellm.proxy.proxy_server import master_key
|
||
|
||
assert master_key == test_master_key
|
||
|
||
# Test Case 2: Master key from environment variable
|
||
test_env_master_key = "sk-test-67890"
|
||
|
||
# Create empty config
|
||
empty_config = {"general_settings": {}}
|
||
with open(config_path, "w") as f:
|
||
yaml.dump(empty_config, f)
|
||
|
||
monkeypatch.setenv("LITELLM_MASTER_KEY", test_env_master_key)
|
||
print("test_env_master_key: {}".format(test_env_master_key))
|
||
async with proxy_startup_event(app):
|
||
from litellm.proxy.proxy_server import master_key
|
||
|
||
assert master_key == test_env_master_key
|
||
|
||
# Test Case 3: Master key with os.environ prefix
|
||
test_resolved_key = "sk-resolved-key"
|
||
test_config_with_prefix = {
|
||
"general_settings": {"master_key": "os.environ/CUSTOM_MASTER_KEY"}
|
||
}
|
||
|
||
# Create config with os.environ prefix
|
||
with open(config_path, "w") as f:
|
||
yaml.dump(test_config_with_prefix, f)
|
||
|
||
monkeypatch.setenv("CUSTOM_MASTER_KEY", test_resolved_key)
|
||
async with proxy_startup_event(app):
|
||
from litellm.proxy.proxy_server import master_key
|
||
|
||
assert master_key == test_resolved_key
|
||
|
||
|
||
def test_team_info_masking():
|
||
"""
|
||
Test that sensitive team information is properly masked
|
||
|
||
Ref: https://huntr.com/bounties/661b388a-44d8-4ad5-862b-4dc5b80be30a
|
||
"""
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
# Test team object with sensitive data
|
||
team1_info = {
|
||
"success_callback": "['langfuse', 's3']",
|
||
"langfuse_secret": "secret-test-key",
|
||
"langfuse_public_key": "public-test-key",
|
||
}
|
||
|
||
with pytest.raises(Exception) as exc_info:
|
||
proxy_config._get_team_config(
|
||
team_id="test_dev",
|
||
all_teams_config=[team1_info],
|
||
)
|
||
|
||
print("Got exception: {}".format(exc_info.value))
|
||
assert "secret-test-key" not in str(exc_info.value)
|
||
assert "public-test-key" not in str(exc_info.value)
|
||
|
||
|
||
def test_embedding_input_array_of_tokens(client_no_auth):
|
||
"""
|
||
Test to bypass decoding input as array of tokens for selected providers
|
||
|
||
Ref: https://github.com/BerriAI/litellm/issues/10113
|
||
"""
|
||
from litellm.proxy import proxy_server
|
||
|
||
# The client_no_auth fixture should initialize the router
|
||
# Assert this to catch any router initialization regressions
|
||
assert proxy_server.llm_router is not None, (
|
||
"llm_router is None after client_no_auth fixture initialized. "
|
||
"This indicates a router initialization issue that should be investigated."
|
||
)
|
||
|
||
try:
|
||
with mock.patch.object(
|
||
proxy_server.llm_router,
|
||
"aembedding",
|
||
return_value=example_embedding_result,
|
||
) as mock_aembedding:
|
||
test_data = {
|
||
"model": "vllm_embed_model",
|
||
"input": [[2046, 13269, 158208]],
|
||
}
|
||
|
||
response = client_no_auth.post("/v1/embeddings", json=test_data)
|
||
|
||
# Assert that aembedding was called, and that input was not modified
|
||
mock_aembedding.assert_called_once()
|
||
call_args, call_kwargs = mock_aembedding.call_args
|
||
assert call_kwargs["model"] == "vllm_embed_model"
|
||
assert call_kwargs["input"] == [[2046, 13269, 158208]]
|
||
|
||
assert response.status_code == 200
|
||
result = response.json()
|
||
print(len(result["data"][0]["embedding"]))
|
||
assert (
|
||
len(result["data"][0]["embedding"]) > 10
|
||
) # this usually has len==1536 so
|
||
except Exception as e:
|
||
pytest.fail(f"LiteLLM Proxy test failed. Exception - {str(e)}")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_all_team_models():
|
||
"""
|
||
Test get_all_team_models function with both "*" and specific team IDs
|
||
"""
|
||
from unittest.mock import AsyncMock, MagicMock
|
||
|
||
from litellm.proxy._types import LiteLLM_TeamTable
|
||
from litellm.proxy.proxy_server import get_all_team_models
|
||
|
||
# Mock team data
|
||
mock_team1 = MagicMock()
|
||
mock_team1.model_dump.return_value = {
|
||
"team_id": "team1",
|
||
"models": ["gpt-4", "gpt-3.5-turbo"],
|
||
"team_alias": "Team 1",
|
||
}
|
||
|
||
mock_team2 = MagicMock()
|
||
mock_team2.model_dump.return_value = {
|
||
"team_id": "team2",
|
||
"models": ["claude-3", "gpt-4"],
|
||
"team_alias": "Team 2",
|
||
}
|
||
|
||
# Mock model data returned by router
|
||
mock_models_gpt4 = [
|
||
{"model_info": {"id": "gpt-4-model-1"}},
|
||
{"model_info": {"id": "gpt-4-model-2"}},
|
||
]
|
||
mock_models_gpt35 = [
|
||
{"model_info": {"id": "gpt-3.5-turbo-model-1"}},
|
||
]
|
||
mock_models_claude = [
|
||
{"model_info": {"id": "claude-3-model-1"}},
|
||
]
|
||
|
||
# Mock prisma client
|
||
mock_prisma_client = MagicMock()
|
||
mock_db = MagicMock()
|
||
mock_litellm_teamtable = MagicMock()
|
||
|
||
mock_prisma_client.db = mock_db
|
||
mock_db.litellm_teamtable = mock_litellm_teamtable
|
||
|
||
# Make find_many async
|
||
mock_litellm_teamtable.find_many = AsyncMock()
|
||
|
||
# Mock router
|
||
mock_router = MagicMock()
|
||
|
||
def mock_get_model_list(model_name, team_id=None):
|
||
if model_name == "gpt-4":
|
||
return mock_models_gpt4
|
||
elif model_name == "gpt-3.5-turbo":
|
||
return mock_models_gpt35
|
||
elif model_name == "claude-3":
|
||
return mock_models_claude
|
||
return None
|
||
|
||
mock_router.get_model_list.side_effect = mock_get_model_list
|
||
|
||
# Test Case 1: user_teams = "*" (all teams)
|
||
mock_litellm_teamtable.find_many.return_value = [mock_team1, mock_team2]
|
||
|
||
with patch("litellm.proxy.proxy_server.LiteLLM_TeamTable") as mock_team_table_class:
|
||
# Configure the mock class to return proper instances
|
||
def mock_team_table_constructor(**kwargs):
|
||
mock_instance = MagicMock()
|
||
mock_instance.team_id = kwargs["team_id"]
|
||
mock_instance.models = kwargs["models"]
|
||
mock_instance.access_group_ids = kwargs.get("access_group_ids")
|
||
return mock_instance
|
||
|
||
mock_team_table_class.side_effect = mock_team_table_constructor
|
||
|
||
result = await get_all_team_models(
|
||
user_teams="*",
|
||
prisma_client=mock_prisma_client,
|
||
llm_router=mock_router,
|
||
)
|
||
|
||
# Verify find_many was called without where clause for "*"
|
||
mock_litellm_teamtable.find_many.assert_called_with()
|
||
|
||
# Verify router.get_model_list was called for each model
|
||
expected_calls = [
|
||
mock.call(model_name="gpt-4", team_id="team1"),
|
||
mock.call(model_name="gpt-3.5-turbo", team_id="team1"),
|
||
mock.call(model_name="claude-3", team_id="team2"),
|
||
mock.call(model_name="gpt-4", team_id="team2"),
|
||
]
|
||
mock_router.get_model_list.assert_has_calls(expected_calls, any_order=True)
|
||
|
||
# Test Case 2: user_teams = specific list
|
||
mock_litellm_teamtable.reset_mock()
|
||
mock_router.reset_mock()
|
||
mock_router.get_model_list.side_effect = mock_get_model_list
|
||
|
||
# Only return team1 for specific team query
|
||
mock_litellm_teamtable.find_many.return_value = [mock_team1]
|
||
|
||
with patch("litellm.proxy.proxy_server.LiteLLM_TeamTable") as mock_team_table_class:
|
||
mock_team_table_class.side_effect = mock_team_table_constructor
|
||
|
||
result = await get_all_team_models(
|
||
user_teams=["team1"],
|
||
prisma_client=mock_prisma_client,
|
||
llm_router=mock_router,
|
||
)
|
||
|
||
# Verify find_many was called with where clause for specific teams
|
||
mock_litellm_teamtable.find_many.assert_called_with(
|
||
where={"team_id": {"in": ["team1"]}}
|
||
)
|
||
|
||
# Verify router.get_model_list was called only for team1 models
|
||
expected_calls = [
|
||
mock.call(model_name="gpt-4", team_id="team1"),
|
||
mock.call(model_name="gpt-3.5-turbo", team_id="team1"),
|
||
]
|
||
mock_router.get_model_list.assert_has_calls(expected_calls, any_order=True)
|
||
|
||
# Test Case 3: Empty teams list
|
||
mock_litellm_teamtable.reset_mock()
|
||
mock_router.reset_mock()
|
||
mock_litellm_teamtable.find_many.return_value = []
|
||
|
||
result = await get_all_team_models(
|
||
user_teams=[],
|
||
prisma_client=mock_prisma_client,
|
||
llm_router=mock_router,
|
||
)
|
||
|
||
# Verify find_many was called with empty list
|
||
mock_litellm_teamtable.find_many.assert_called_with(where={"team_id": {"in": []}})
|
||
|
||
# Should return empty list when no teams
|
||
assert result == {}
|
||
|
||
# Test Case 4: Router returns None for some models
|
||
mock_litellm_teamtable.reset_mock()
|
||
mock_router.reset_mock()
|
||
mock_litellm_teamtable.find_many.return_value = [mock_team1]
|
||
|
||
def mock_get_model_list_with_none(model_name, team_id=None):
|
||
if model_name == "gpt-4":
|
||
return mock_models_gpt4
|
||
# Return None for gpt-3.5-turbo to test None handling
|
||
return None
|
||
|
||
mock_router.get_model_list.side_effect = mock_get_model_list_with_none
|
||
|
||
with patch("litellm.proxy.proxy_server.LiteLLM_TeamTable") as mock_team_table_class:
|
||
mock_team_table_class.side_effect = mock_team_table_constructor
|
||
|
||
result = await get_all_team_models(
|
||
user_teams=["team1"],
|
||
prisma_client=mock_prisma_client,
|
||
llm_router=mock_router,
|
||
)
|
||
|
||
# Should handle None return gracefully
|
||
assert isinstance(result, dict)
|
||
print("result: ", result)
|
||
assert result == {"gpt-4-model-1": ["team1"], "gpt-4-model-2": ["team1"]}
|
||
|
||
|
||
def test_add_team_models_to_all_models():
|
||
"""
|
||
Test add_team_models_to_all_models function
|
||
"""
|
||
from litellm.proxy._types import LiteLLM_TeamTable
|
||
from litellm.proxy.proxy_server import _add_team_models_to_all_models
|
||
|
||
team_db_objects_typed = MagicMock(spec=LiteLLM_TeamTable)
|
||
team_db_objects_typed.team_id = "team1"
|
||
team_db_objects_typed.models = ["all-proxy-models"]
|
||
|
||
llm_router = MagicMock()
|
||
llm_router.get_model_list.return_value = [
|
||
{"model_info": {"id": "gpt-4-model-1", "team_id": "team2"}},
|
||
{"model_info": {"id": "gpt-4-model-2"}},
|
||
]
|
||
|
||
result = _add_team_models_to_all_models(
|
||
team_db_objects_typed=[team_db_objects_typed],
|
||
llm_router=llm_router,
|
||
)
|
||
assert result == {"gpt-4-model-2": {"team1"}}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_apply_search_filter_matches_team_public_model_name():
|
||
"""
|
||
Regression test: team BYOK models persist an internal model_name
|
||
(e.g. `model_name_{team_id}_{uuid}`) and surface the user-facing name
|
||
via `model_info.team_public_model_name`. The /v2/model/info search
|
||
filter must match that public name so BYOK rows appear in results.
|
||
"""
|
||
from litellm.proxy.proxy_server import _apply_search_filter_to_models
|
||
|
||
byok_model = {
|
||
"model_name": "model_name_team-abc-123_4a6b8",
|
||
"litellm_params": {"model": "claude-sonnet-4-5"},
|
||
"model_info": {
|
||
"id": "byok-id-1",
|
||
"team_id": "team-abc-123",
|
||
"team_public_model_name": "team-claude-sonnet",
|
||
"db_model": True,
|
||
},
|
||
}
|
||
unrelated_model = {
|
||
"model_name": "gpt-4",
|
||
"litellm_params": {"model": "gpt-4"},
|
||
"model_info": {"id": "normal-id-1", "db_model": False},
|
||
}
|
||
|
||
# Search matching only team_public_model_name should still include BYOK
|
||
filtered, _ = await _apply_search_filter_to_models(
|
||
all_models=[byok_model, unrelated_model],
|
||
search="claude",
|
||
prisma_client=None,
|
||
proxy_config=MagicMock(),
|
||
)
|
||
filtered_ids = {m["model_info"]["id"] for m in filtered}
|
||
assert "byok-id-1" in filtered_ids
|
||
assert "normal-id-1" not in filtered_ids
|
||
|
||
# Search by internal model_name still matches as before
|
||
filtered, _ = await _apply_search_filter_to_models(
|
||
all_models=[byok_model, unrelated_model],
|
||
search="model_name_team-abc-123",
|
||
prisma_client=None,
|
||
proxy_config=MagicMock(),
|
||
)
|
||
assert [m["model_info"]["id"] for m in filtered] == ["byok-id-1"]
|
||
|
||
# Non-matching search returns nothing
|
||
filtered, _ = await _apply_search_filter_to_models(
|
||
all_models=[byok_model, unrelated_model],
|
||
search="gemini",
|
||
prisma_client=None,
|
||
proxy_config=MagicMock(),
|
||
)
|
||
assert filtered == []
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_apply_search_filter_scopes_byok_to_caller_teams():
|
||
"""
|
||
Regression test: `/v2/model/info?search=...` must not leak BYOK rows
|
||
from teams the caller is not a member of. Even with a bounded
|
||
`model_name`-contains DB query, a non-admin caller could otherwise
|
||
see other teams' BYOK rows that happen to match by internal name.
|
||
The post-fetch team scope drops those.
|
||
"""
|
||
from litellm.proxy.proxy_server import _apply_search_filter_to_models
|
||
|
||
# In-router BYOK rows: one in the caller's team, one in someone else's.
|
||
caller_team_byok = {
|
||
"model_name": "model_name_team-mine_internal",
|
||
"litellm_params": {"model": "claude-sonnet"},
|
||
"model_info": {
|
||
"id": "byok-mine",
|
||
"team_id": "team-mine",
|
||
"team_public_model_name": "claude-sonnet-prod",
|
||
"db_model": True,
|
||
},
|
||
}
|
||
other_team_byok = {
|
||
"model_name": "model_name_team-other_internal",
|
||
"litellm_params": {"model": "claude-sonnet"},
|
||
"model_info": {
|
||
"id": "byok-other",
|
||
"team_id": "team-other",
|
||
"team_public_model_name": "claude-sonnet-staging",
|
||
"db_model": True,
|
||
},
|
||
}
|
||
# Non-team row stays in the router-side result regardless of teams.
|
||
public_model = {
|
||
"model_name": "claude-public",
|
||
"litellm_params": {"model": "claude-sonnet"},
|
||
"model_info": {"id": "public-id", "db_model": False},
|
||
}
|
||
|
||
# DB-only BYOK rows fetched by the over-broad JSON branch.
|
||
db_caller_row = MagicMock()
|
||
db_caller_row.model_id = "byok-db-mine"
|
||
db_caller_row.model_name = "model_name_team-mine_db"
|
||
db_caller_row.model_info = {
|
||
"id": "byok-db-mine",
|
||
"team_id": "team-mine",
|
||
"team_public_model_name": "Claude DB Mine",
|
||
"db_model": True,
|
||
}
|
||
db_other_row = MagicMock()
|
||
db_other_row.model_id = "byok-db-other"
|
||
db_other_row.model_name = "model_name_team-other_db"
|
||
db_other_row.model_info = {
|
||
"id": "byok-db-other",
|
||
"team_id": "team-other",
|
||
"team_public_model_name": "Claude DB Other",
|
||
"db_model": True,
|
||
}
|
||
|
||
prisma_client = MagicMock()
|
||
prisma_client.db.litellm_proxymodeltable.count = AsyncMock(return_value=2)
|
||
prisma_client.db.litellm_proxymodeltable.find_many = AsyncMock(
|
||
return_value=[db_caller_row, db_other_row]
|
||
)
|
||
caller_user_row = MagicMock()
|
||
caller_user_row.teams = ["team-mine"]
|
||
prisma_client.db.litellm_usertable.find_unique = AsyncMock(
|
||
return_value=caller_user_row
|
||
)
|
||
|
||
proxy_config = MagicMock()
|
||
proxy_config.decrypt_model_list_from_db = lambda rows: [
|
||
{
|
||
"model_name": r.model_name,
|
||
"model_info": r.model_info,
|
||
"litellm_params": {"model": "claude-sonnet"},
|
||
}
|
||
for r in rows
|
||
]
|
||
|
||
non_admin = MagicMock(spec=UserAPIKeyAuth)
|
||
non_admin.user_role = LitellmUserRoles.INTERNAL_USER
|
||
non_admin.user_id = "user-mine"
|
||
|
||
filtered, total_count = await _apply_search_filter_to_models(
|
||
all_models=[caller_team_byok, other_team_byok, public_model],
|
||
search="claude",
|
||
prisma_client=prisma_client,
|
||
proxy_config=proxy_config,
|
||
user_api_key_dict=non_admin,
|
||
)
|
||
|
||
filtered_ids = {m["model_info"]["id"] for m in filtered}
|
||
assert "byok-mine" in filtered_ids
|
||
assert "byok-db-mine" in filtered_ids
|
||
assert "public-id" in filtered_ids
|
||
assert "byok-other" not in filtered_ids, (
|
||
"router-side BYOK from another team must be dropped from search "
|
||
"when caller doesn't belong to that team"
|
||
)
|
||
assert "byok-db-other" not in filtered_ids, (
|
||
"DB-only BYOK from another team must be dropped from search when "
|
||
"caller doesn't belong to that team"
|
||
)
|
||
# total_count is router_models_count (2: caller_team_byok + public_model,
|
||
# other_team_byok dropped router-side) + DB count (2 from the mocked
|
||
# `count()`). The DB count is the *unscoped* match count; non-admin
|
||
# team scoping applies only to the returned page so the count can be
|
||
# over-reported, but it must never under-report (callers can paginate
|
||
# within the bound).
|
||
assert total_count == 4
|
||
|
||
# Admins keep the un-scoped view across teams.
|
||
admin = MagicMock(spec=UserAPIKeyAuth)
|
||
admin.user_role = LitellmUserRoles.PROXY_ADMIN
|
||
admin.user_id = "admin-1"
|
||
|
||
filtered_admin, _ = await _apply_search_filter_to_models(
|
||
all_models=[caller_team_byok, other_team_byok, public_model],
|
||
search="claude",
|
||
prisma_client=prisma_client,
|
||
proxy_config=proxy_config,
|
||
user_api_key_dict=admin,
|
||
)
|
||
admin_ids = {m["model_info"]["id"] for m in filtered_admin}
|
||
assert "byok-other" in admin_ids
|
||
assert "byok-db-other" in admin_ids
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_apply_search_filter_bounds_db_fetch_by_page_and_cap():
|
||
"""
|
||
Regression test: a broad search term must not force a full BYOK-table
|
||
read + decrypt on each request.
|
||
|
||
* Unsorted searches: `find_many(take=N)` where N is just enough to
|
||
fill the current page after counting router-side matches.
|
||
* Sorted searches: `find_many(take=cap)` falls back to
|
||
`_SORTED_SEARCH_DB_FETCH_CAP` so ordering still works across a
|
||
large match set without scanning the whole table.
|
||
"""
|
||
from litellm.proxy.proxy_server import (
|
||
_SORTED_SEARCH_DB_FETCH_CAP,
|
||
_apply_search_filter_to_models,
|
||
)
|
||
|
||
prisma_client = MagicMock()
|
||
prisma_client.db.litellm_proxymodeltable.count = AsyncMock(return_value=10_000)
|
||
prisma_client.db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[])
|
||
|
||
proxy_config = MagicMock()
|
||
proxy_config.decrypt_model_list_from_db = lambda rows: []
|
||
|
||
# Unsorted: page=1, size=50, no router-side matches -> take must be 50.
|
||
await _apply_search_filter_to_models(
|
||
all_models=[],
|
||
search="model",
|
||
prisma_client=prisma_client,
|
||
proxy_config=proxy_config,
|
||
page=1,
|
||
size=50,
|
||
sort_by=None,
|
||
)
|
||
take = prisma_client.db.litellm_proxymodeltable.find_many.call_args.kwargs["take"]
|
||
assert take == 50, "unsorted search must take just one page's worth of rows"
|
||
|
||
# Sorted: still bounded, but by the hard cap rather than the page.
|
||
prisma_client.db.litellm_proxymodeltable.find_many.reset_mock()
|
||
await _apply_search_filter_to_models(
|
||
all_models=[],
|
||
search="model",
|
||
prisma_client=prisma_client,
|
||
proxy_config=proxy_config,
|
||
page=1,
|
||
size=50,
|
||
sort_by="model_name",
|
||
)
|
||
take = prisma_client.db.litellm_proxymodeltable.find_many.call_args.kwargs["take"]
|
||
assert take == _SORTED_SEARCH_DB_FETCH_CAP
|
||
assert take < 10_000, "sorted search must cap below the full match set"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_filter_models_by_team_id_excludes_viewer_direct_access():
|
||
"""
|
||
Regression test: when the UI picks a specific team in the Current Team
|
||
selector, the model list must show only that team's BYOK rows + the
|
||
models assigned to the team. The admin viewer's `direct_access` flag
|
||
(set on every non-team model upstream) must NOT widen the team's
|
||
visible set, or selecting team-111 still shows every public model.
|
||
"""
|
||
from litellm.proxy.proxy_server import _filter_models_by_team_id
|
||
|
||
public_model = {
|
||
"model_name": "gpt-4",
|
||
"litellm_params": {"model": "gpt-4"},
|
||
"model_info": {
|
||
"id": "public-id",
|
||
# admin viewer has direct_access on this public model
|
||
"direct_access": True,
|
||
# team-111 is NOT in access_via_team_ids -> shouldn't show for team-111
|
||
"access_via_team_ids": ["team-222"],
|
||
},
|
||
}
|
||
team111_byok = {
|
||
"model_name": "model_name_team-111_uuid",
|
||
"litellm_params": {"model": "claude-sonnet"},
|
||
"model_info": {
|
||
"id": "byok-team-111",
|
||
"team_id": "team-111",
|
||
"team_public_model_name": "team-claude",
|
||
"access_via_team_ids": ["team-111"],
|
||
},
|
||
}
|
||
team222_byok = {
|
||
"model_name": "model_name_team-222_uuid",
|
||
"litellm_params": {"model": "claude-haiku"},
|
||
"model_info": {
|
||
"id": "byok-team-222",
|
||
"team_id": "team-222",
|
||
"team_public_model_name": "team-haiku",
|
||
"access_via_team_ids": ["team-222"],
|
||
},
|
||
}
|
||
|
||
prisma = MagicMock()
|
||
team_db = MagicMock()
|
||
team_db.model_dump.return_value = {
|
||
"team_id": "team-111",
|
||
"team_alias": "Team 111",
|
||
# specific models list that doesn't include the BYOK's internal name
|
||
"models": ["some-other-model"],
|
||
"access_group_ids": None,
|
||
}
|
||
prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_db)
|
||
prisma.db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[])
|
||
|
||
router = MagicMock()
|
||
router.get_model_access_groups = MagicMock(return_value={})
|
||
# team-111 only resolves "some-other-model", which has no deployments
|
||
router.get_model_list = MagicMock(return_value=[])
|
||
|
||
filtered = await _filter_models_by_team_id(
|
||
all_models=[public_model, team111_byok, team222_byok],
|
||
team_id="team-111",
|
||
prisma_client=prisma,
|
||
llm_router=router,
|
||
)
|
||
visible_ids = sorted(m["model_info"]["id"] for m in filtered)
|
||
|
||
assert "byok-team-111" in visible_ids, "team-111's own BYOK must always be visible"
|
||
assert "byok-team-222" not in visible_ids, "must not leak other teams' BYOK"
|
||
assert (
|
||
"public-id" not in visible_ids
|
||
), "viewer's direct_access must not widen the team's visible set"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_filter_models_by_team_id_rejects_non_member():
|
||
"""
|
||
Regression test: /v2/model/info?teamId=X includes BYOK rows solely on
|
||
`model_info.team_id == X`. Without an auth check, any authenticated user
|
||
could enumerate another team's BYOK metadata by guessing its id. Callers
|
||
that are neither proxy admins nor members of `team_id` must get 403.
|
||
"""
|
||
from fastapi import HTTPException
|
||
|
||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||
from litellm.proxy.proxy_server import _filter_models_by_team_id
|
||
|
||
byok = {
|
||
"model_name": "model_name_team-111_uuid",
|
||
"litellm_params": {"model": "claude"},
|
||
"model_info": {"id": "byok-team-111", "team_id": "team-111"},
|
||
}
|
||
|
||
prisma = MagicMock()
|
||
# Caller is in team-222 only
|
||
user_row = MagicMock()
|
||
user_row.teams = ["team-222"]
|
||
prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=user_row)
|
||
|
||
caller = UserAPIKeyAuth(
|
||
user_id="alice",
|
||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||
api_key="sk-test",
|
||
)
|
||
|
||
with pytest.raises(HTTPException) as excinfo:
|
||
await _filter_models_by_team_id(
|
||
all_models=[byok],
|
||
team_id="team-111",
|
||
prisma_client=prisma,
|
||
llm_router=MagicMock(),
|
||
user_api_key_dict=caller,
|
||
)
|
||
assert excinfo.value.status_code == 403
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_filter_models_by_team_id_allows_team_member():
|
||
"""
|
||
A caller who IS a member of `team_id` must be allowed to filter, and
|
||
should see that team's BYOK rows.
|
||
"""
|
||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||
from litellm.proxy.proxy_server import _filter_models_by_team_id
|
||
|
||
byok = {
|
||
"model_name": "model_name_team-111_uuid",
|
||
"litellm_params": {"model": "claude"},
|
||
"model_info": {"id": "byok-team-111", "team_id": "team-111"},
|
||
}
|
||
|
||
prisma = MagicMock()
|
||
user_row = MagicMock()
|
||
user_row.teams = ["team-111", "team-999"]
|
||
prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=user_row)
|
||
team_db = MagicMock()
|
||
team_db.model_dump.return_value = {
|
||
"team_id": "team-111",
|
||
"team_alias": "Team 111",
|
||
"models": [],
|
||
"access_group_ids": None,
|
||
}
|
||
prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_db)
|
||
prisma.db.litellm_proxymodeltable.find_many = AsyncMock(return_value=[])
|
||
|
||
router = MagicMock()
|
||
router.get_model_access_groups = MagicMock(return_value={})
|
||
router.get_model_list = MagicMock(return_value=[byok])
|
||
|
||
caller = UserAPIKeyAuth(
|
||
user_id="bob",
|
||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||
api_key="sk-test",
|
||
)
|
||
|
||
result = await _filter_models_by_team_id(
|
||
all_models=[byok],
|
||
team_id="team-111",
|
||
prisma_client=prisma,
|
||
llm_router=router,
|
||
user_api_key_dict=caller,
|
||
)
|
||
assert [m["model_info"]["id"] for m in result] == ["byok-team-111"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_caller_byok_team_scope_treats_view_only_admin_as_unscoped():
|
||
"""
|
||
Regression test: `PROXY_ADMIN_VIEW_ONLY` is an admin role
|
||
("can login, view all own keys, view all spend"). Search results for
|
||
this role must show BYOK rows across all teams, not be silently scoped
|
||
to the user-id's `teams` field — that path narrows results to whatever
|
||
teams the admin happens to be a member of, regressing pre-PR behavior.
|
||
"""
|
||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||
from litellm.proxy.proxy_server import _get_caller_byok_team_scope
|
||
|
||
caller = UserAPIKeyAuth(
|
||
user_id="view-admin",
|
||
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
|
||
api_key="sk-test",
|
||
)
|
||
scope = await _get_caller_byok_team_scope(
|
||
user_api_key_dict=caller,
|
||
prisma_client=MagicMock(),
|
||
)
|
||
assert scope is None, "PROXY_ADMIN_VIEW_ONLY must be unscoped, like PROXY_ADMIN"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_add_access_group_models_to_team_models():
|
||
"""
|
||
Test that models reachable via team access groups are included in team_models.
|
||
|
||
Scenario: A team has models=["gpt-4"] and access_group_ids=["premium"].
|
||
The "premium" access group contains ["claude-3", "gemini"].
|
||
After resolution, the team should see gpt-4 (direct) + claude-3/gemini (via access group).
|
||
"""
|
||
from litellm.proxy._types import LiteLLM_TeamTable
|
||
from litellm.proxy.proxy_server import _add_access_group_models_to_team_models
|
||
|
||
# Team with specific models AND access groups
|
||
team_with_access_groups = MagicMock(spec=LiteLLM_TeamTable)
|
||
team_with_access_groups.team_id = "team1"
|
||
team_with_access_groups.models = ["gpt-4"] # non-empty = specific models
|
||
team_with_access_groups.access_group_ids = ["premium"]
|
||
|
||
# Team with no access groups — should be skipped
|
||
team_without_access_groups = MagicMock(spec=LiteLLM_TeamTable)
|
||
team_without_access_groups.team_id = "team2"
|
||
team_without_access_groups.models = ["gpt-4"]
|
||
team_without_access_groups.access_group_ids = None
|
||
|
||
# Team with empty access_group_ids list — should be skipped
|
||
team_empty_access_groups = MagicMock(spec=LiteLLM_TeamTable)
|
||
team_empty_access_groups.team_id = "team2b"
|
||
team_empty_access_groups.models = ["gpt-4"]
|
||
team_empty_access_groups.access_group_ids = []
|
||
|
||
# Team with empty models (all access) — should be skipped
|
||
team_all_access = MagicMock(spec=LiteLLM_TeamTable)
|
||
team_all_access.team_id = "team3"
|
||
team_all_access.models = []
|
||
team_all_access.access_group_ids = ["premium"]
|
||
|
||
# Team with all-proxy-models sentinel (all access) — should be skipped
|
||
team_all_proxy = MagicMock(spec=LiteLLM_TeamTable)
|
||
team_all_proxy.team_id = "team4"
|
||
team_all_proxy.models = ["all-proxy-models"]
|
||
team_all_proxy.access_group_ids = ["premium"]
|
||
|
||
# Mock router
|
||
mock_router = MagicMock()
|
||
|
||
def mock_get_model_list(model_name, team_id=None):
|
||
if model_name == "claude-3":
|
||
return [{"model_info": {"id": "claude-3-id"}}]
|
||
elif model_name == "gemini":
|
||
return [{"model_info": {"id": "gemini-id"}}]
|
||
return None
|
||
|
||
mock_router.get_model_list.side_effect = mock_get_model_list
|
||
|
||
# Pre-existing team_models (e.g., from _add_team_models_to_all_models)
|
||
existing_team_models = {
|
||
"gpt-4-id": {"team1"},
|
||
}
|
||
|
||
# Mock prisma client with batch find_many returning access group rows
|
||
mock_ag_row = MagicMock()
|
||
mock_ag_row.access_group_id = "premium"
|
||
mock_ag_row.access_model_names = ["claude-3", "gemini"]
|
||
|
||
mock_prisma_client = MagicMock()
|
||
mock_prisma_client.db.litellm_accessgrouptable.find_many = AsyncMock(
|
||
return_value=[mock_ag_row]
|
||
)
|
||
|
||
result = await _add_access_group_models_to_team_models(
|
||
team_db_objects_typed=[
|
||
team_with_access_groups,
|
||
team_without_access_groups,
|
||
team_empty_access_groups,
|
||
team_all_access,
|
||
team_all_proxy,
|
||
],
|
||
llm_router=mock_router,
|
||
prisma_client=mock_prisma_client,
|
||
team_models=existing_team_models,
|
||
)
|
||
|
||
# Single batch query with only the eligible team's access group IDs
|
||
mock_prisma_client.db.litellm_accessgrouptable.find_many.assert_called_once()
|
||
call_args = mock_prisma_client.db.litellm_accessgrouptable.find_many.call_args
|
||
queried_ids = call_args[1]["where"]["access_group_id"]["in"]
|
||
assert set(queried_ids) == {"premium"}
|
||
|
||
# Original model still present
|
||
assert "gpt-4-id" in result
|
||
assert "team1" in result["gpt-4-id"]
|
||
|
||
# Access group models added for team1
|
||
assert "claude-3-id" in result
|
||
assert "team1" in result["claude-3-id"]
|
||
assert "gemini-id" in result
|
||
assert "team1" in result["gemini-id"]
|
||
|
||
# Skipped teams should NOT have added these models
|
||
for skipped_team in ["team2", "team2b", "team3", "team4"]:
|
||
assert skipped_team not in result.get("claude-3-id", set())
|
||
assert skipped_team not in result.get("gemini-id", set())
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_add_access_group_models_multiple_teams_shared_group():
|
||
"""
|
||
Test that multiple teams sharing the same access group each get the models,
|
||
and only one batch DB query is made.
|
||
"""
|
||
from litellm.proxy._types import LiteLLM_TeamTable
|
||
from litellm.proxy.proxy_server import _add_access_group_models_to_team_models
|
||
|
||
team_a = MagicMock(spec=LiteLLM_TeamTable)
|
||
team_a.team_id = "team-a"
|
||
team_a.models = ["gpt-4"]
|
||
team_a.access_group_ids = ["shared-group"]
|
||
|
||
team_b = MagicMock(spec=LiteLLM_TeamTable)
|
||
team_b.team_id = "team-b"
|
||
team_b.models = ["gpt-3.5"]
|
||
team_b.access_group_ids = ["shared-group", "extra-group"]
|
||
|
||
mock_router = MagicMock()
|
||
|
||
def mock_get_model_list(model_name, team_id=None):
|
||
if model_name == "claude-3":
|
||
return [{"model_info": {"id": "claude-3-id"}}]
|
||
elif model_name == "gemini":
|
||
return [{"model_info": {"id": "gemini-id"}}]
|
||
return None
|
||
|
||
mock_router.get_model_list.side_effect = mock_get_model_list
|
||
|
||
mock_shared_row = MagicMock()
|
||
mock_shared_row.access_group_id = "shared-group"
|
||
mock_shared_row.access_model_names = ["claude-3"]
|
||
|
||
mock_extra_row = MagicMock()
|
||
mock_extra_row.access_group_id = "extra-group"
|
||
mock_extra_row.access_model_names = ["gemini"]
|
||
|
||
mock_prisma_client = MagicMock()
|
||
mock_prisma_client.db.litellm_accessgrouptable.find_many = AsyncMock(
|
||
return_value=[mock_shared_row, mock_extra_row]
|
||
)
|
||
|
||
result = await _add_access_group_models_to_team_models(
|
||
team_db_objects_typed=[team_a, team_b],
|
||
llm_router=mock_router,
|
||
prisma_client=mock_prisma_client,
|
||
team_models={},
|
||
)
|
||
|
||
# Single batch query for both groups
|
||
mock_prisma_client.db.litellm_accessgrouptable.find_many.assert_called_once()
|
||
call_args = mock_prisma_client.db.litellm_accessgrouptable.find_many.call_args
|
||
queried_ids = set(call_args[1]["where"]["access_group_id"]["in"])
|
||
assert queried_ids == {"shared-group", "extra-group"}
|
||
|
||
# Both teams get claude-3 from the shared group
|
||
assert "claude-3-id" in result
|
||
assert "team-a" in result["claude-3-id"]
|
||
assert "team-b" in result["claude-3-id"]
|
||
|
||
# Only team-b gets gemini (from extra-group)
|
||
assert "gemini-id" in result
|
||
assert "team-b" in result["gemini-id"]
|
||
assert "team-a" not in result["gemini-id"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_add_access_group_models_no_eligible_teams():
|
||
"""
|
||
When no teams have access groups, find_many should not be called at all.
|
||
"""
|
||
from litellm.proxy._types import LiteLLM_TeamTable
|
||
from litellm.proxy.proxy_server import _add_access_group_models_to_team_models
|
||
|
||
team = MagicMock(spec=LiteLLM_TeamTable)
|
||
team.team_id = "team1"
|
||
team.models = ["gpt-4"]
|
||
team.access_group_ids = None
|
||
|
||
mock_prisma_client = MagicMock()
|
||
mock_prisma_client.db.litellm_accessgrouptable.find_many = AsyncMock()
|
||
|
||
result = await _add_access_group_models_to_team_models(
|
||
team_db_objects_typed=[team],
|
||
llm_router=MagicMock(),
|
||
prisma_client=mock_prisma_client,
|
||
team_models={"existing-id": {"team1"}},
|
||
)
|
||
|
||
# No DB call made
|
||
mock_prisma_client.db.litellm_accessgrouptable.find_many.assert_not_called()
|
||
|
||
# Original data unchanged
|
||
assert result == {"existing-id": {"team1"}}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_all_team_models_with_access_groups():
|
||
"""
|
||
End-to-end test: get_all_team_models includes models from access groups.
|
||
|
||
Scenario: User is on team1 which has models=["gpt-4"] and
|
||
access_group_ids=["premium"]. The "premium" group has ["claude-3"].
|
||
The result should include both gpt-4 and claude-3 deployments for team1.
|
||
"""
|
||
from litellm.proxy.proxy_server import get_all_team_models
|
||
|
||
mock_team1 = MagicMock()
|
||
mock_team1.model_dump.return_value = {
|
||
"team_id": "team1",
|
||
"models": ["gpt-4"],
|
||
"team_alias": "Team 1",
|
||
"access_group_ids": ["premium"],
|
||
}
|
||
|
||
# Mock access group row returned by batch find_many
|
||
mock_ag_row = MagicMock()
|
||
mock_ag_row.access_group_id = "premium"
|
||
mock_ag_row.access_model_names = ["claude-3"]
|
||
|
||
mock_prisma_client = MagicMock()
|
||
mock_db = MagicMock()
|
||
mock_litellm_teamtable = MagicMock()
|
||
mock_prisma_client.db = mock_db
|
||
mock_db.litellm_teamtable = mock_litellm_teamtable
|
||
mock_litellm_teamtable.find_many = AsyncMock(return_value=[mock_team1])
|
||
mock_db.litellm_accessgrouptable = MagicMock()
|
||
mock_db.litellm_accessgrouptable.find_many = AsyncMock(return_value=[mock_ag_row])
|
||
|
||
mock_router = MagicMock()
|
||
|
||
def mock_get_model_list(model_name, team_id=None):
|
||
if model_name == "gpt-4":
|
||
return [{"model_info": {"id": "gpt-4-deploy-1"}}]
|
||
elif model_name == "claude-3":
|
||
return [{"model_info": {"id": "claude-3-deploy-1"}}]
|
||
return None
|
||
|
||
mock_router.get_model_list.side_effect = mock_get_model_list
|
||
|
||
with patch("litellm.proxy.proxy_server.LiteLLM_TeamTable") as mock_tt_class:
|
||
|
||
def mock_team_table_constructor(**kwargs):
|
||
mock_instance = MagicMock()
|
||
mock_instance.team_id = kwargs["team_id"]
|
||
mock_instance.models = kwargs["models"]
|
||
mock_instance.access_group_ids = kwargs.get("access_group_ids")
|
||
return mock_instance
|
||
|
||
mock_tt_class.side_effect = mock_team_table_constructor
|
||
|
||
result = await get_all_team_models(
|
||
user_teams=["team1"],
|
||
prisma_client=mock_prisma_client,
|
||
llm_router=mock_router,
|
||
)
|
||
|
||
# gpt-4 from direct team.models
|
||
assert "gpt-4-deploy-1" in result
|
||
assert "team1" in result["gpt-4-deploy-1"]
|
||
|
||
# claude-3 from access group
|
||
assert "claude-3-deploy-1" in result
|
||
assert "team1" in result["claude-3-deploy-1"]
|
||
|
||
# Return type is Dict[str, List[str]]
|
||
assert isinstance(result["gpt-4-deploy-1"], list)
|
||
assert isinstance(result["claude-3-deploy-1"], list)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_delete_deployment_type_mismatch():
|
||
"""
|
||
Test that the _delete_deployment function handles type mismatches correctly.
|
||
Specifically test that models 12345678 and 12345679 are NOT deleted when
|
||
they exist in both combined_id_list (as integers) and router_model_ids (as strings).
|
||
|
||
This test reproduces the bug where type mismatch causes valid models to be deleted.
|
||
"""
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
# Create mock ProxyConfig instance
|
||
pc = ProxyConfig()
|
||
|
||
# Mock llm_router with string IDs (this is the source of the type mismatch)
|
||
mock_llm_router = MagicMock()
|
||
mock_llm_router.get_model_ids.return_value = [
|
||
"a96e12e76b36a57cfae57a41288eb41567629cac89b4828c6f7074afc3534695",
|
||
"a40186dd0fdb9b7282380277d7f57044d29de95bfbfcd7f4322b3493702d5cd3",
|
||
"12345678", # String ID
|
||
"12345679", # String ID
|
||
]
|
||
|
||
# Track which deployments were deleted
|
||
deleted_ids = []
|
||
|
||
def mock_delete_deployment(id):
|
||
deleted_ids.append(id)
|
||
return True # Simulate successful deletion
|
||
|
||
mock_llm_router.delete_deployment = MagicMock(side_effect=mock_delete_deployment)
|
||
|
||
async def mock_get_config(config_file_path):
|
||
return {
|
||
"model_list": [
|
||
{
|
||
"model_name": "openai-gpt-4o",
|
||
"litellm_params": {"model": "gpt-4o"},
|
||
"model_info": {"id": 12345678},
|
||
},
|
||
{
|
||
"model_name": "openai-gpt-4o",
|
||
"litellm_params": {"model": "gpt-4o"},
|
||
"model_info": {"id": 12345679},
|
||
},
|
||
]
|
||
}
|
||
|
||
pc.get_config = AsyncMock(side_effect=mock_get_config)
|
||
|
||
# Patch the global llm_router
|
||
with (
|
||
patch("litellm.proxy.proxy_server.llm_router", mock_llm_router),
|
||
patch("litellm.proxy.proxy_server.user_config_file_path", "test_config.yaml"),
|
||
):
|
||
# Call the function under test
|
||
deleted_count = await pc._delete_deployment(db_models=[])
|
||
|
||
# The two SHA-hash models have no corresponding entry in combined_id_list
|
||
# and must be evicted.
|
||
assert (
|
||
deleted_count == 2
|
||
), f"Expected 2 deletions (SHA-hash models), got {deleted_count}"
|
||
assert (
|
||
"a96e12e76b36a57cfae57a41288eb41567629cac89b4828c6f7074afc3534695"
|
||
in deleted_ids
|
||
)
|
||
assert (
|
||
"a40186dd0fdb9b7282380277d7f57044d29de95bfbfcd7f4322b3493702d5cd3"
|
||
in deleted_ids
|
||
)
|
||
|
||
# Models 12345678 and 12345679 exist in the config (as integers); str()
|
||
# conversion in _delete_deployment makes them match the router's string IDs,
|
||
# so they must NOT be evicted.
|
||
assert (
|
||
"12345678" not in deleted_ids
|
||
), f"Model 12345678 should NOT be deleted. Deleted IDs: {deleted_ids}"
|
||
assert (
|
||
"12345679" not in deleted_ids
|
||
), f"Model 12345679 should NOT be deleted. Deleted IDs: {deleted_ids}"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_config_from_file(tmp_path, monkeypatch):
|
||
"""
|
||
Test the _get_config_from_file method of ProxyConfig class.
|
||
Tests various scenarios: valid file, non-existent file, no file path, None config.
|
||
"""
|
||
import yaml
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
# Create a ProxyConfig instance
|
||
proxy_config = ProxyConfig()
|
||
|
||
# Test Case 1: Valid YAML config file exists
|
||
test_config = {
|
||
"model_list": [{"model_name": "gpt-4", "litellm_params": {"model": "gpt-4"}}],
|
||
"general_settings": {"master_key": "sk-test"},
|
||
"router_settings": {"enable_pre_call_checks": True},
|
||
"litellm_settings": {"drop_params": True},
|
||
}
|
||
|
||
config_file = tmp_path / "test_config.yaml"
|
||
with open(config_file, "w") as f:
|
||
yaml.dump(test_config, f)
|
||
|
||
# Clear global user_config_file_path for this test
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.user_config_file_path", None)
|
||
|
||
result = await proxy_config._get_config_from_file(str(config_file))
|
||
assert result == test_config
|
||
|
||
# Verify that user_config_file_path was set
|
||
from litellm.proxy.proxy_server import user_config_file_path
|
||
|
||
assert user_config_file_path == str(config_file)
|
||
|
||
# Test Case 2: File path provided but file doesn't exist
|
||
non_existent_file = tmp_path / "non_existent.yaml"
|
||
|
||
with pytest.raises(Exception, match=f"Config file not found: {non_existent_file}"):
|
||
await proxy_config._get_config_from_file(str(non_existent_file))
|
||
|
||
# Test Case 3: No file path provided (should return default config)
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.user_config_file_path", None)
|
||
|
||
expected_default = {
|
||
"model_list": [],
|
||
"general_settings": {},
|
||
"router_settings": {},
|
||
"litellm_settings": {},
|
||
}
|
||
|
||
result = await proxy_config._get_config_from_file(None)
|
||
assert result == expected_default
|
||
|
||
# Test Case 4: Empty YAML file (should raise exception for None config)
|
||
empty_file = tmp_path / "empty_config.yaml"
|
||
with open(empty_file, "w") as f:
|
||
f.write("") # Write empty content which will result in None when loaded
|
||
|
||
with pytest.raises(Exception, match="Config cannot be None or Empty."):
|
||
await proxy_config._get_config_from_file(str(empty_file))
|
||
|
||
# Test Case 5: Using global user_config_file_path when no config_file_path provided
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.proxy_server.user_config_file_path", str(config_file)
|
||
)
|
||
|
||
result = await proxy_config._get_config_from_file(None)
|
||
assert result == test_config
|
||
|
||
|
||
def test_normalize_datetime_for_sorting():
|
||
"""
|
||
Test the _normalize_datetime_for_sorting function.
|
||
Tests various scenarios: None values, ISO format strings, datetime objects (naive and aware).
|
||
"""
|
||
from litellm.proxy.proxy_server import _normalize_datetime_for_sorting
|
||
|
||
# Test Case 1: None value
|
||
assert _normalize_datetime_for_sorting(None) is None
|
||
|
||
# Test Case 2: ISO format string with 'Z' suffix
|
||
dt_str_z = "2024-01-15T10:30:00Z"
|
||
result = _normalize_datetime_for_sorting(dt_str_z)
|
||
assert result is not None
|
||
assert isinstance(result, datetime)
|
||
assert result.tzinfo == timezone.utc
|
||
assert result.year == 2024
|
||
assert result.month == 1
|
||
assert result.day == 15
|
||
assert result.hour == 10
|
||
assert result.minute == 30
|
||
|
||
# Test Case 3: ISO format string without 'Z' suffix (naive)
|
||
dt_str_naive = "2024-01-15T10:30:00"
|
||
result = _normalize_datetime_for_sorting(dt_str_naive)
|
||
assert result is not None
|
||
assert isinstance(result, datetime)
|
||
assert result.tzinfo == timezone.utc
|
||
|
||
# Test Case 4: ISO format string with timezone offset
|
||
dt_str_tz = "2024-01-15T10:30:00+05:00"
|
||
result = _normalize_datetime_for_sorting(dt_str_tz)
|
||
assert result is not None
|
||
assert isinstance(result, datetime)
|
||
assert result.tzinfo == timezone.utc
|
||
# Should convert from +05:00 to UTC (subtract 5 hours)
|
||
assert result.hour == 5 # 10:30 - 5 hours = 5:30 UTC
|
||
|
||
# Test Case 5: Naive datetime object
|
||
naive_dt = datetime(2024, 1, 15, 10, 30, 0)
|
||
result = _normalize_datetime_for_sorting(naive_dt)
|
||
assert result is not None
|
||
assert isinstance(result, datetime)
|
||
assert result.tzinfo == timezone.utc
|
||
assert result.year == 2024
|
||
assert result.month == 1
|
||
assert result.day == 15
|
||
|
||
# Test Case 6: Timezone-aware datetime object (non-UTC)
|
||
from datetime import timedelta
|
||
|
||
aware_dt = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone(timedelta(hours=5)))
|
||
result = _normalize_datetime_for_sorting(aware_dt)
|
||
assert result is not None
|
||
assert isinstance(result, datetime)
|
||
assert result.tzinfo == timezone.utc
|
||
# Should convert from +05:00 to UTC
|
||
assert result.hour == 5
|
||
|
||
# Test Case 7: UTC-aware datetime object
|
||
utc_dt = datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
|
||
result = _normalize_datetime_for_sorting(utc_dt)
|
||
assert result is not None
|
||
assert isinstance(result, datetime)
|
||
assert result.tzinfo == timezone.utc
|
||
assert result == utc_dt
|
||
|
||
# Test Case 8: Invalid string format
|
||
invalid_str = "not-a-date"
|
||
result = _normalize_datetime_for_sorting(invalid_str)
|
||
assert result is None
|
||
|
||
# Test Case 9: Invalid type (should return None)
|
||
result = _normalize_datetime_for_sorting(12345)
|
||
assert result is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_add_proxy_budget_to_db_only_creates_user_no_keys():
|
||
"""
|
||
Test that _add_proxy_budget_to_db only creates a user and no keys are added.
|
||
|
||
This validates that generate_key_helper_fn is called with table_name="user"
|
||
which should prevent key creation in LiteLLM_VerificationToken table.
|
||
"""
|
||
from unittest.mock import AsyncMock, patch
|
||
|
||
import litellm
|
||
from litellm.proxy.proxy_server import ProxyStartupEvent
|
||
|
||
# Set up required litellm settings
|
||
litellm.budget_duration = "30d"
|
||
litellm.max_budget = 100.0
|
||
|
||
litellm_proxy_budget_name = "litellm-proxy-budget"
|
||
|
||
# Mock generate_key_helper_fn to capture its call arguments
|
||
mock_generate_key_helper = AsyncMock(
|
||
return_value={
|
||
"user_id": litellm_proxy_budget_name,
|
||
"max_budget": 100.0,
|
||
"budget_duration": "30d",
|
||
"spend": 0,
|
||
"models": [],
|
||
}
|
||
)
|
||
|
||
# Patch generate_key_helper_fn in proxy_server where it's being called from
|
||
with patch(
|
||
"litellm.proxy.proxy_server.generate_key_helper_fn", mock_generate_key_helper
|
||
):
|
||
# Call the function under test
|
||
ProxyStartupEvent._add_proxy_budget_to_db(litellm_proxy_budget_name)
|
||
|
||
# Allow async task to complete
|
||
import asyncio
|
||
|
||
await asyncio.sleep(0.1)
|
||
|
||
# Verify that generate_key_helper_fn was called
|
||
mock_generate_key_helper.assert_called_once()
|
||
call_args = mock_generate_key_helper.call_args
|
||
|
||
# Verify critical parameters that prevent key creation
|
||
assert call_args.kwargs["request_type"] == "user"
|
||
assert call_args.kwargs["table_name"] == "user"
|
||
assert call_args.kwargs["user_id"] == litellm_proxy_budget_name
|
||
assert call_args.kwargs["max_budget"] == 100.0
|
||
assert call_args.kwargs["budget_duration"] == "30d"
|
||
assert call_args.kwargs["query_type"] == "update_data"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_add_proxy_budget_to_db_backfills_budget_reset_at():
|
||
"""
|
||
Test that _upsert_proxy_budget_with_reset_at_backfill issues a conditional
|
||
update_many with `WHERE budget_reset_at IS NULL` to backfill the column on
|
||
rows that pre-existed without a reset schedule. Without this, the proxy
|
||
admin row stays at NULL and reset_budget_for_litellm_users never matches
|
||
it (NULL < now() is unknown in SQL), so the global proxy budget never
|
||
resets.
|
||
"""
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
import litellm
|
||
from litellm.proxy.proxy_server import ProxyStartupEvent
|
||
|
||
litellm.budget_duration = "30d"
|
||
litellm.max_budget = 100.0
|
||
litellm_proxy_budget_name = "litellm-proxy-budget"
|
||
|
||
mock_prisma = MagicMock()
|
||
mock_prisma.db.litellm_usertable.update_many = AsyncMock(return_value={"count": 1})
|
||
|
||
mock_generate_key_helper = AsyncMock(
|
||
return_value={
|
||
"user_id": litellm_proxy_budget_name,
|
||
"max_budget": 100.0,
|
||
"budget_duration": "30d",
|
||
"spend": 0,
|
||
"models": [],
|
||
}
|
||
)
|
||
|
||
with (
|
||
patch(
|
||
"litellm.proxy.proxy_server.generate_key_helper_fn",
|
||
mock_generate_key_helper,
|
||
),
|
||
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma),
|
||
):
|
||
await ProxyStartupEvent._upsert_proxy_budget_with_reset_at_backfill(
|
||
litellm_proxy_budget_name
|
||
)
|
||
|
||
# Upsert ran with the configured budget
|
||
mock_generate_key_helper.assert_called_once()
|
||
|
||
# Backfill update_many ran with the conditional WHERE
|
||
mock_prisma.db.litellm_usertable.update_many.assert_called_once()
|
||
backfill_call = mock_prisma.db.litellm_usertable.update_many.call_args
|
||
assert backfill_call.kwargs["where"]["user_id"] == litellm_proxy_budget_name
|
||
assert backfill_call.kwargs["where"]["budget_reset_at"] is None
|
||
|
||
# The backfilled value must be a real future datetime — anything else and
|
||
# reset_budget_for_litellm_users would still skip the row.
|
||
from datetime import datetime, timezone
|
||
|
||
backfilled_reset_at = backfill_call.kwargs["data"]["budget_reset_at"]
|
||
assert isinstance(backfilled_reset_at, datetime)
|
||
assert backfilled_reset_at > datetime.now(timezone.utc)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_custom_ui_sso_sign_in_handler_config_loading():
|
||
"""
|
||
Test that custom_ui_sso_sign_in_handler from config gets properly loaded into the global variable
|
||
"""
|
||
import tempfile
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
import yaml
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
# Create a test config with custom_ui_sso_sign_in_handler
|
||
test_config = {
|
||
"general_settings": {
|
||
"custom_ui_sso_sign_in_handler": "custom_hooks.custom_ui_sso_hook.custom_ui_sso_sign_in_handler"
|
||
},
|
||
"model_list": [],
|
||
"router_settings": {},
|
||
"litellm_settings": {},
|
||
}
|
||
|
||
# Create temporary config file
|
||
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
|
||
yaml.dump(test_config, f)
|
||
config_file_path = f.name
|
||
|
||
# Mock the get_instance_fn to return a mock handler
|
||
mock_custom_handler = MagicMock()
|
||
|
||
try:
|
||
with patch(
|
||
"litellm.proxy.proxy_server.get_instance_fn",
|
||
return_value=mock_custom_handler,
|
||
) as mock_get_instance:
|
||
# Create ProxyConfig instance and load config
|
||
proxy_config = ProxyConfig()
|
||
# Create a mock router since load_config requires it
|
||
mock_router = MagicMock()
|
||
await proxy_config.load_config(
|
||
router=mock_router, config_file_path=config_file_path
|
||
)
|
||
|
||
# Verify get_instance_fn was called with correct parameters
|
||
mock_get_instance.assert_called_with(
|
||
value="custom_hooks.custom_ui_sso_hook.custom_ui_sso_sign_in_handler",
|
||
config_file_path=config_file_path,
|
||
)
|
||
|
||
# Verify the global variable was set
|
||
from litellm.proxy.proxy_server import user_custom_ui_sso_sign_in_handler
|
||
|
||
assert user_custom_ui_sso_sign_in_handler == mock_custom_handler
|
||
|
||
finally:
|
||
# Clean up temporary file
|
||
import os
|
||
|
||
os.unlink(config_file_path)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_load_config_max_budget_env_var_coerced_to_float(tmp_path, monkeypatch):
|
||
"""
|
||
max_budget configured as os.environ/MAX_BUDGET resolves to a string;
|
||
load_config must coerce it to float so the startup check
|
||
`litellm.max_budget > 0` doesn't raise TypeError.
|
||
"""
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
monkeypatch.setenv("MAX_BUDGET", "10")
|
||
test_config = {
|
||
"model_list": [],
|
||
"litellm_settings": {"max_budget": "os.environ/MAX_BUDGET"},
|
||
}
|
||
config_file = tmp_path / "config.yaml"
|
||
config_file.write_text(yaml.dump(test_config))
|
||
|
||
original_max_budget = litellm.max_budget
|
||
try:
|
||
proxy_config = ProxyConfig()
|
||
await proxy_config.load_config(
|
||
router=MagicMock(), config_file_path=str(config_file)
|
||
)
|
||
assert isinstance(litellm.max_budget, float)
|
||
assert litellm.max_budget == 10.0
|
||
assert litellm.max_budget > 0
|
||
finally:
|
||
litellm.max_budget = original_max_budget
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_load_environment_variables_direct_and_os_environ():
|
||
"""
|
||
Test _load_environment_variables method with direct values and os.environ/ prefixed values
|
||
"""
|
||
from unittest.mock import patch
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
# Test config with both direct values and os.environ/ prefixed values
|
||
test_config = {
|
||
"environment_variables": {
|
||
"DIRECT_VAR": "direct_value",
|
||
"NUMERIC_VAR": 12345,
|
||
"BOOL_VAR": True,
|
||
"SECRET_VAR": "os.environ/ACTUAL_SECRET_VAR",
|
||
}
|
||
}
|
||
|
||
# Mock get_secret_str to return a resolved value
|
||
mock_secret_value = "resolved_secret_value"
|
||
|
||
with patch(
|
||
"litellm.proxy.proxy_server.get_secret_str", return_value=mock_secret_value
|
||
) as mock_get_secret:
|
||
with patch.dict(
|
||
os.environ, {}, clear=False
|
||
): # Don't clear existing env vars, just track changes
|
||
# Call the method under test
|
||
proxy_config._load_environment_variables(test_config)
|
||
|
||
# Verify direct environment variables were set correctly
|
||
assert os.environ["DIRECT_VAR"] == "direct_value"
|
||
assert os.environ["NUMERIC_VAR"] == "12345" # Should be converted to string
|
||
assert os.environ["BOOL_VAR"] == "True" # Should be converted to string
|
||
|
||
# Verify os.environ/ prefixed variable was resolved and set
|
||
assert os.environ["SECRET_VAR"] == mock_secret_value
|
||
|
||
# Verify get_secret_str was called with the correct value
|
||
mock_get_secret.assert_called_once_with(
|
||
secret_name="os.environ/ACTUAL_SECRET_VAR"
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_load_environment_variables_litellm_license_and_edge_cases():
|
||
"""
|
||
Test _load_environment_variables method with LITELLM_LICENSE special handling and edge cases
|
||
"""
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
# Test Case 1: LITELLM_LICENSE in environment_variables
|
||
test_config_with_license = {
|
||
"environment_variables": {
|
||
"LITELLM_LICENSE": "test_license_key",
|
||
"OTHER_VAR": "other_value",
|
||
}
|
||
}
|
||
|
||
# Mock _license_check
|
||
mock_license_check = MagicMock()
|
||
mock_license_check.is_premium.return_value = True
|
||
|
||
with patch("litellm.proxy.proxy_server._license_check", mock_license_check):
|
||
with patch.dict(os.environ, {}, clear=False):
|
||
# Call the method under test
|
||
proxy_config._load_environment_variables(test_config_with_license)
|
||
|
||
# Verify LITELLM_LICENSE was set in environment
|
||
assert os.environ["LITELLM_LICENSE"] == "test_license_key"
|
||
|
||
# Verify license check was updated
|
||
assert mock_license_check.license_str == "test_license_key"
|
||
mock_license_check.is_premium.assert_called_once()
|
||
|
||
# Test Case 2: No environment_variables in config
|
||
test_config_no_env_vars = {}
|
||
|
||
# This should not raise any errors and should return without doing anything
|
||
result = proxy_config._load_environment_variables(test_config_no_env_vars)
|
||
assert result is None # Method returns None
|
||
|
||
# Test Case 3: environment_variables is None
|
||
test_config_none_env_vars = {"environment_variables": None}
|
||
|
||
# This should not raise any errors and should return without doing anything
|
||
result = proxy_config._load_environment_variables(test_config_none_env_vars)
|
||
assert result is None # Method returns None
|
||
|
||
# Test Case 4: os.environ/ prefix but get_secret_str returns None
|
||
test_config_secret_none = {
|
||
"environment_variables": {"FAILED_SECRET": "os.environ/NONEXISTENT_SECRET"}
|
||
}
|
||
|
||
with patch("litellm.proxy.proxy_server.get_secret_str", return_value=None):
|
||
with patch.dict(os.environ, {}, clear=False):
|
||
# Call the method under test
|
||
proxy_config._load_environment_variables(test_config_secret_none)
|
||
|
||
# Verify that the environment variable was not set when secret resolution fails
|
||
assert "FAILED_SECRET" not in os.environ
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_load_environment_variables_blocks_dangerous_keys():
|
||
"""
|
||
Test that _load_environment_variables rejects dangerous env var keys
|
||
like PATH, LD_PRELOAD, PYTHONPATH, etc.
|
||
"""
|
||
import logging
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
original_path = os.environ.get("PATH", "")
|
||
|
||
test_config = {
|
||
"environment_variables": {
|
||
"PATH": "/tmp/evil",
|
||
"LD_PRELOAD": "/tmp/evil.so",
|
||
"PYTHONPATH": "/tmp/evil",
|
||
"SAFE_CUSTOM_VAR": "safe_value",
|
||
}
|
||
}
|
||
|
||
with patch.dict(os.environ, {}, clear=False):
|
||
proxy_config._load_environment_variables(test_config)
|
||
|
||
# Blocked keys should not be set to the attacker value
|
||
assert os.environ.get("PATH") != "/tmp/evil"
|
||
assert (
|
||
"LD_PRELOAD" not in os.environ or os.environ["LD_PRELOAD"] != "/tmp/evil.so"
|
||
)
|
||
assert os.environ.get("PYTHONPATH") != "/tmp/evil"
|
||
|
||
# Safe keys should still be set
|
||
assert os.environ["SAFE_CUSTOM_VAR"] == "safe_value"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_load_environment_variables_allows_proxy_keys():
|
||
"""
|
||
Test that HTTP_PROXY/HTTPS_PROXY are allowed since they are commonly used
|
||
in corporate environments to route outbound API calls.
|
||
"""
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
test_config = {
|
||
"environment_variables": {
|
||
"HTTP_PROXY": "http://corp-proxy:8080",
|
||
"HTTPS_PROXY": "http://corp-proxy:8080",
|
||
}
|
||
}
|
||
|
||
with patch.dict(os.environ, {}, clear=False):
|
||
proxy_config._load_environment_variables(test_config)
|
||
|
||
assert os.environ["HTTP_PROXY"] == "http://corp-proxy:8080"
|
||
assert os.environ["HTTPS_PROXY"] == "http://corp-proxy:8080"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_load_environment_variables_blocks_no_proxy():
|
||
"""
|
||
Test that NO_PROXY/no_proxy are blocked to prevent bypassing proxy-based
|
||
network monitoring.
|
||
"""
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
test_config = {
|
||
"environment_variables": {
|
||
"NO_PROXY": "internal-service",
|
||
"no_proxy": "internal-service",
|
||
}
|
||
}
|
||
|
||
with patch.dict(os.environ, {}, clear=False):
|
||
proxy_config._load_environment_variables(test_config)
|
||
|
||
assert os.environ.get("NO_PROXY") != "internal-service"
|
||
assert os.environ.get("no_proxy") != "internal-service"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_write_config_to_file(monkeypatch):
|
||
"""
|
||
Do not write config to file if store_model_in_db is True
|
||
"""
|
||
from unittest.mock import AsyncMock, MagicMock, mock_open, patch
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
# Set store_model_in_db to True
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", True)
|
||
|
||
# Mock prisma_client to not be None (so DB path is taken)
|
||
mock_prisma_client = AsyncMock()
|
||
mock_prisma_client.insert_data = AsyncMock()
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
|
||
|
||
# Mock general_settings
|
||
mock_general_settings = {"store_model_in_db": True}
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.proxy_server.general_settings", mock_general_settings
|
||
)
|
||
|
||
# Mock user_config_file_path
|
||
test_config_path = "/tmp/test_config.yaml"
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.proxy_server.user_config_file_path", test_config_path
|
||
)
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
# Mock the open function to track if file writing is attempted
|
||
mock_file_open = mock_open()
|
||
|
||
with patch("builtins.open", mock_file_open), patch("yaml.dump") as mock_yaml_dump:
|
||
# Call save_config with test data
|
||
test_config = {"key": "value", "model_list": ["model1", "model2"]}
|
||
await proxy_config.save_config(new_config=test_config)
|
||
|
||
# Verify that file was NOT opened for writing (since store_model_in_db=True)
|
||
mock_file_open.assert_not_called()
|
||
mock_yaml_dump.assert_not_called()
|
||
|
||
# Verify that database insert was called instead
|
||
mock_prisma_client.insert_data.assert_called_once()
|
||
|
||
# Verify the config passed to DB has model_list removed
|
||
call_args = mock_prisma_client.insert_data.call_args
|
||
assert call_args.kwargs["data"] == {
|
||
"key": "value"
|
||
} # model_list should be popped
|
||
assert call_args.kwargs["table_name"] == "config"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_write_config_to_file_when_store_model_in_db_false(monkeypatch):
|
||
"""
|
||
Test that config IS written to file when store_model_in_db is False
|
||
"""
|
||
from unittest.mock import AsyncMock, MagicMock, mock_open, patch
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
# Set store_model_in_db to False
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False)
|
||
|
||
# Mock prisma_client to be None (so file path is taken)
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None)
|
||
|
||
# Mock general_settings
|
||
mock_general_settings = {"store_model_in_db": False}
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.proxy_server.general_settings", mock_general_settings
|
||
)
|
||
|
||
# Mock user_config_file_path
|
||
test_config_path = "/tmp/test_config.yaml"
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.proxy_server.user_config_file_path", test_config_path
|
||
)
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
# Mock the open function and yaml.dump
|
||
mock_file_open = mock_open()
|
||
|
||
with patch("builtins.open", mock_file_open), patch("yaml.dump") as mock_yaml_dump:
|
||
# Call save_config with test data
|
||
test_config = {"key": "value", "other_key": "other_value"}
|
||
await proxy_config.save_config(new_config=test_config)
|
||
|
||
# Verify that file WAS opened for writing (since store_model_in_db=False)
|
||
mock_file_open.assert_called_once_with(f"{test_config_path}", "w")
|
||
|
||
# Verify yaml.dump was called with the config
|
||
mock_yaml_dump.assert_called_once_with(
|
||
test_config,
|
||
mock_file_open.return_value.__enter__.return_value,
|
||
default_flow_style=False,
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_async_data_generator_midstream_error():
|
||
"""
|
||
Test async_data_generator handles midstream error from async_post_call_streaming_hook
|
||
Specifically testing the case where Azure Content Safety Guardrail returns an error
|
||
"""
|
||
from litellm.proxy._types import UserAPIKeyAuth
|
||
from litellm.proxy.proxy_server import async_data_generator
|
||
from litellm.proxy.utils import ProxyLogging
|
||
|
||
# Create mock objects
|
||
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
||
mock_request_data = {
|
||
"model": "gpt-3.5-turbo",
|
||
"messages": [{"role": "user", "content": "test"}],
|
||
}
|
||
|
||
# Mock response chunks - simulating normal streaming that gets interrupted
|
||
mock_chunks = [
|
||
{"choices": [{"delta": {"content": "Hello"}}]},
|
||
{"choices": [{"delta": {"content": " world"}}]},
|
||
{"choices": [{"delta": {"content": " this"}}]},
|
||
]
|
||
|
||
# Mock the proxy_logging_obj
|
||
mock_proxy_logging_obj = MagicMock(spec=ProxyLogging)
|
||
|
||
# Mock async_post_call_streaming_iterator_hook to yield chunks
|
||
async def mock_streaming_iterator(*args, **kwargs):
|
||
for chunk in mock_chunks:
|
||
yield chunk
|
||
|
||
mock_proxy_logging_obj.async_post_call_streaming_iterator_hook = (
|
||
mock_streaming_iterator
|
||
)
|
||
|
||
# Mock async_post_call_streaming_hook to return error on third chunk
|
||
def mock_streaming_hook(*args, **kwargs):
|
||
chunk = kwargs.get("response")
|
||
# Return error message for the third chunk (simulating guardrail trigger)
|
||
if chunk == mock_chunks[2]:
|
||
return 'data: {"error": {"error": "Azure Content Safety Guardrail: Hate crossed severity 2, Got severity: 2"}}'
|
||
# Return normal chunks for first two
|
||
return chunk
|
||
|
||
mock_proxy_logging_obj.async_post_call_streaming_hook = AsyncMock(
|
||
side_effect=mock_streaming_hook
|
||
)
|
||
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock()
|
||
|
||
# Mock the global proxy_logging_obj
|
||
with patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj):
|
||
# Create a mock response object
|
||
mock_response = MagicMock()
|
||
|
||
# Collect all yielded data from the generator
|
||
yielded_data = []
|
||
try:
|
||
async for data in async_data_generator(
|
||
mock_response, mock_user_api_key_dict, mock_request_data
|
||
):
|
||
yielded_data.append(data)
|
||
except Exception as e:
|
||
# If there's an exception, that's also part of what we want to test
|
||
pass
|
||
|
||
# Verify the results
|
||
assert (
|
||
len(yielded_data) >= 3
|
||
), f"Expected at least 3 chunks, got {len(yielded_data)}: {yielded_data}"
|
||
|
||
# First two chunks should be normal data
|
||
assert yielded_data[0].startswith(
|
||
"data: "
|
||
), f"First chunk should start with 'data: ', got: {yielded_data[0]}"
|
||
assert yielded_data[1].startswith(
|
||
"data: "
|
||
), f"Second chunk should start with 'data: ', got: {yielded_data[1]}"
|
||
|
||
# The error message should be yielded
|
||
error_found = False
|
||
done_found = False
|
||
|
||
for data in yielded_data:
|
||
if "Azure Content Safety Guardrail: Hate crossed severity 2" in data:
|
||
error_found = True
|
||
if "data: [DONE]" in data:
|
||
done_found = True
|
||
|
||
assert (
|
||
error_found
|
||
), f"Error message should be found in yielded data. Got: {yielded_data}"
|
||
assert done_found, f"[DONE] message should be found at the end. Got: {yielded_data}"
|
||
|
||
# Verify that the streaming hook was called for each chunk
|
||
assert mock_proxy_logging_obj.async_post_call_streaming_hook.call_count == len(
|
||
mock_chunks
|
||
)
|
||
|
||
# Verify that post_call_failure_hook was NOT called (since this is not an exception case)
|
||
mock_proxy_logging_obj.post_call_failure_hook.assert_not_called()
|
||
|
||
|
||
def _has_nested_none_values(obj, path="root"):
|
||
"""
|
||
Recursively check if an object contains nested None values.
|
||
|
||
Args:
|
||
obj: The object to check
|
||
path: Current path in the object tree (for debugging)
|
||
|
||
Returns:
|
||
List of paths where None values were found
|
||
"""
|
||
none_paths = []
|
||
|
||
if obj is None:
|
||
none_paths.append(path)
|
||
elif isinstance(obj, dict):
|
||
for key, value in obj.items():
|
||
none_paths.extend(_has_nested_none_values(value, f"{path}.{key}"))
|
||
elif isinstance(obj, (list, tuple)):
|
||
for i, item in enumerate(obj):
|
||
none_paths.extend(_has_nested_none_values(item, f"{path}[{i}]"))
|
||
elif hasattr(obj, "__dict__"):
|
||
# Handle object attributes
|
||
for key, value in obj.__dict__.items():
|
||
if not key.startswith("_"): # Skip private attributes
|
||
none_paths.extend(_has_nested_none_values(value, f"{path}.{key}"))
|
||
|
||
return none_paths
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_chat_completion_result_no_nested_none_values():
|
||
"""
|
||
Test that chat_completion result doesn't have nested None values when using exclude_none=True
|
||
"""
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
from fastapi import Request, Response
|
||
from pydantic import BaseModel
|
||
|
||
import litellm
|
||
from litellm.proxy._types import UserAPIKeyAuth
|
||
from litellm.proxy.proxy_server import chat_completion
|
||
|
||
# Create a mock ModelResponse with nested None values
|
||
mock_model_response = litellm.ModelResponse()
|
||
mock_model_response.id = "test-id"
|
||
mock_model_response.model = "gpt-3.5-turbo"
|
||
mock_model_response.object = "chat.completion"
|
||
mock_model_response.created = 1234567890
|
||
|
||
# Create message with None values that should be excluded
|
||
mock_message = litellm.Message(
|
||
content="Hello, world!",
|
||
role="assistant",
|
||
function_call=None, # This should be excluded
|
||
tool_calls=None, # This should be excluded
|
||
audio=None, # This should be excluded
|
||
reasoning_content=None, # This should be excluded
|
||
thinking_blocks=None, # This should be excluded
|
||
annotations=None, # This should be excluded
|
||
)
|
||
|
||
# Create choice with potential None values
|
||
mock_choice = litellm.Choices(
|
||
finish_reason="stop",
|
||
index=0,
|
||
message=mock_message,
|
||
logprobs=None, # This should be excluded when exclude_none=True
|
||
)
|
||
|
||
mock_model_response.choices = [mock_choice]
|
||
setattr(
|
||
mock_model_response,
|
||
"usage",
|
||
litellm.Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15),
|
||
)
|
||
|
||
# Verify the mock has None values before serialization
|
||
raw_dict = mock_model_response.model_dump()
|
||
none_paths_before = _has_nested_none_values(raw_dict)
|
||
assert (
|
||
len(none_paths_before) > 0
|
||
), "Mock should have None values before exclude_none=True"
|
||
|
||
# Mock the request processing to return our mock response
|
||
mock_base_processor = MagicMock()
|
||
mock_base_processor.base_process_llm_request = AsyncMock(
|
||
return_value=mock_model_response
|
||
)
|
||
|
||
# Mock other dependencies
|
||
mock_request = MagicMock(spec=Request)
|
||
mock_response = MagicMock(spec=Response)
|
||
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
||
|
||
with (
|
||
patch(
|
||
"litellm.proxy.proxy_server._read_request_body",
|
||
return_value={"model": "gpt-3.5-turbo", "messages": []},
|
||
),
|
||
patch(
|
||
"litellm.proxy.proxy_server.ProxyBaseLLMRequestProcessing",
|
||
return_value=mock_base_processor,
|
||
),
|
||
):
|
||
# Call the chat_completion function
|
||
result = await chat_completion(
|
||
request=mock_request,
|
||
fastapi_response=mock_response,
|
||
user_api_key_dict=mock_user_api_key_dict,
|
||
)
|
||
|
||
# Verify the result is a dict (since isinstance(result, BaseModel) was True)
|
||
assert isinstance(result, dict), f"Expected dict result, got {type(result)}"
|
||
|
||
# Check that there are no nested None values in the result
|
||
none_paths_after = _has_nested_none_values(result)
|
||
assert (
|
||
len(none_paths_after) == 0
|
||
), f"Result should not contain nested None values. Found None at: {none_paths_after}"
|
||
|
||
# Verify essential fields are present
|
||
assert "id" in result
|
||
assert "model" in result
|
||
assert "object" in result
|
||
assert "created" in result
|
||
assert "choices" in result
|
||
assert "usage" in result
|
||
|
||
# Verify that the choices contain the expected message content
|
||
assert len(result["choices"]) == 1
|
||
assert result["choices"][0]["message"]["content"] == "Hello, world!"
|
||
assert result["choices"][0]["message"]["role"] == "assistant"
|
||
|
||
# Verify that None fields were excluded (should not be present in the dict)
|
||
message = result["choices"][0]["message"]
|
||
excluded_fields = [
|
||
"function_call",
|
||
"tool_calls",
|
||
"audio",
|
||
"reasoning_content",
|
||
"thinking_blocks",
|
||
"annotations",
|
||
]
|
||
for field in excluded_fields:
|
||
assert (
|
||
field not in message
|
||
), f"Field '{field}' should be excluded when it's None"
|
||
|
||
|
||
# ============================================================================
|
||
# Price Data Reload Tests
|
||
# ============================================================================
|
||
|
||
|
||
class TestPriceDataReloadAPI:
|
||
"""Test cases for price data reload API endpoints"""
|
||
|
||
@pytest.fixture
|
||
def client_with_auth(self):
|
||
"""Create a test client with authentication"""
|
||
from litellm.proxy._types import LitellmUserRoles
|
||
from litellm.proxy.proxy_server import cleanup_router_config_variables
|
||
|
||
cleanup_router_config_variables()
|
||
filepath = os.path.dirname(os.path.abspath(__file__))
|
||
config_fp = f"{filepath}/test_configs/test_config_no_auth.yaml"
|
||
asyncio.run(initialize(config=config_fp, debug=True))
|
||
|
||
# Mock admin user authentication
|
||
mock_auth = MagicMock()
|
||
mock_auth.user_role = LitellmUserRoles.PROXY_ADMIN
|
||
app.dependency_overrides[user_api_key_auth] = lambda: mock_auth
|
||
|
||
return TestClient(app)
|
||
|
||
def test_reload_model_cost_map_admin_access(self, client_with_auth):
|
||
"""Test that admin users can access the reload endpoint"""
|
||
# Save the original model_cost so the endpoint's direct assignment
|
||
# (litellm.model_cost = new_model_cost_map) does not contaminate
|
||
# subsequent tests running in the same worker process.
|
||
original_model_cost = litellm.model_cost.copy()
|
||
try:
|
||
with patch(
|
||
"litellm.litellm_core_utils.get_model_cost_map.get_model_cost_map"
|
||
) as mock_get_map:
|
||
mock_get_map.return_value = {
|
||
"gpt-3.5-turbo": {"input_cost_per_token": 0.001}
|
||
}
|
||
# Mock the database connection
|
||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
|
||
mock_prisma.db.litellm_config.find_unique = AsyncMock(
|
||
return_value=None
|
||
)
|
||
mock_prisma.db.litellm_config.upsert = AsyncMock(return_value=None)
|
||
|
||
response = client_with_auth.post("/reload/model_cost_map")
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert data["status"] == "success"
|
||
assert "message" in data
|
||
assert "timestamp" in data
|
||
assert "models_count" in data
|
||
# The new implementation immediately reloads and returns the count
|
||
assert (
|
||
"Price data reloaded successfully! 1 models updated."
|
||
in data["message"]
|
||
)
|
||
assert data["models_count"] == 1
|
||
finally:
|
||
# Restore the full model cost map so subsequent tests are not affected
|
||
litellm.model_cost = original_model_cost
|
||
_invalidate_model_cost_lowercase_map()
|
||
|
||
def test_reload_model_cost_map_non_admin_access(self, client_with_auth):
|
||
"""Test that non-admin users cannot access the reload endpoint"""
|
||
# Mock non-admin user
|
||
mock_auth = MagicMock()
|
||
mock_auth.user_role = "user" # Non-admin role
|
||
app.dependency_overrides[user_api_key_auth] = lambda: mock_auth
|
||
|
||
response = client_with_auth.post("/reload/model_cost_map")
|
||
|
||
assert response.status_code == 403
|
||
data = response.json()
|
||
assert "Access denied" in data["detail"]
|
||
assert "Admin role required" in data["detail"]
|
||
|
||
def test_get_model_cost_map_public_access(self, client_no_auth):
|
||
"""Test that the model cost map endpoint is publicly accessible"""
|
||
with patch(
|
||
"litellm.model_cost", {"gpt-3.5-turbo": {"input_cost_per_token": 0.001}}
|
||
):
|
||
response = client_no_auth.get("/public/litellm_model_cost_map")
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert "gpt-3.5-turbo" in data
|
||
|
||
def test_reload_model_cost_map_error_handling(self, client_with_auth):
|
||
"""Test error handling in the reload endpoint"""
|
||
with patch(
|
||
"litellm.litellm_core_utils.get_model_cost_map.get_model_cost_map"
|
||
) as mock_get_map:
|
||
mock_get_map.side_effect = Exception("Network error")
|
||
|
||
# Mock the database connection
|
||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
|
||
mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=None)
|
||
mock_prisma.db.litellm_config.upsert = AsyncMock(return_value=None)
|
||
|
||
response = client_with_auth.post("/reload/model_cost_map")
|
||
|
||
assert (
|
||
response.status_code == 500
|
||
) # The new implementation immediately reloads and fails on error
|
||
data = response.json()
|
||
assert "Failed to reload model cost map" in data["detail"]
|
||
|
||
def test_schedule_model_cost_map_reload_admin_access(self, client_with_auth):
|
||
"""Test that admin users can schedule periodic reload"""
|
||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
|
||
# Mock database upsert
|
||
mock_prisma.db.litellm_config.upsert = AsyncMock(return_value=None)
|
||
|
||
response = client_with_auth.post("/schedule/model_cost_map_reload?hours=6")
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert data["status"] == "success"
|
||
assert data["interval_hours"] == 6
|
||
assert "message" in data
|
||
assert "timestamp" in data
|
||
|
||
def test_schedule_model_cost_map_reload_non_admin_access(self, client_with_auth):
|
||
"""Test that non-admin users cannot schedule periodic reload"""
|
||
# Mock non-admin user
|
||
mock_auth = MagicMock()
|
||
mock_auth.user_role = "user" # Non-admin role
|
||
app.dependency_overrides[user_api_key_auth] = lambda: mock_auth
|
||
|
||
response = client_with_auth.post("/schedule/model_cost_map_reload?hours=6")
|
||
|
||
assert response.status_code == 403
|
||
data = response.json()
|
||
assert "Access denied" in data["detail"]
|
||
assert "Admin role required" in data["detail"]
|
||
|
||
def test_schedule_model_cost_map_reload_invalid_hours(self, client_with_auth):
|
||
"""Test that invalid hours parameter is rejected"""
|
||
response = client_with_auth.post("/schedule/model_cost_map_reload?hours=0")
|
||
|
||
assert response.status_code == 400
|
||
data = response.json()
|
||
assert "Hours must be greater than 0" in data["detail"]
|
||
|
||
def test_cancel_model_cost_map_reload_admin_access(self, client_with_auth):
|
||
"""Test that admin users can cancel periodic reload"""
|
||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
|
||
# Mock database delete
|
||
mock_prisma.db.litellm_config.delete = AsyncMock(return_value=None)
|
||
|
||
response = client_with_auth.delete("/schedule/model_cost_map_reload")
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert data["status"] == "success"
|
||
assert "message" in data
|
||
assert "timestamp" in data
|
||
|
||
def test_cancel_model_cost_map_reload_non_admin_access(self, client_with_auth):
|
||
"""Test that non-admin users cannot cancel periodic reload"""
|
||
# Mock non-admin user
|
||
mock_auth = MagicMock()
|
||
mock_auth.user_role = "user" # Non-admin role
|
||
app.dependency_overrides[user_api_key_auth] = lambda: mock_auth
|
||
|
||
response = client_with_auth.delete("/schedule/model_cost_map_reload")
|
||
|
||
assert response.status_code == 403
|
||
data = response.json()
|
||
assert "Access denied" in data["detail"]
|
||
assert "Admin role required" in data["detail"]
|
||
|
||
def test_get_model_cost_map_reload_status_admin_access(self, client_with_auth):
|
||
"""Test that admin users can get reload status"""
|
||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
|
||
# Mock database config record
|
||
mock_config = MagicMock()
|
||
mock_config.param_value = {"interval_hours": 6, "force_reload": False}
|
||
mock_prisma.db.litellm_config.find_unique = AsyncMock(
|
||
return_value=mock_config
|
||
)
|
||
|
||
# Mock the last reload time and current time
|
||
with patch(
|
||
"litellm.proxy.proxy_server.last_model_cost_map_reload",
|
||
"2024-01-01T06:00:00",
|
||
):
|
||
with patch("litellm.proxy.proxy_server.datetime") as mock_datetime:
|
||
# Mock current time to be 1 hour after last reload
|
||
mock_datetime.utcnow.return_value = datetime(2024, 1, 1, 7, 0, 0)
|
||
mock_datetime.fromisoformat = datetime.fromisoformat
|
||
|
||
response = client_with_auth.get(
|
||
"/schedule/model_cost_map_reload/status"
|
||
)
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert data["scheduled"] == True
|
||
assert data["interval_hours"] == 6
|
||
assert data["last_run"] == "2024-01-01T06:00:00"
|
||
assert data["next_run"] == "2024-01-01T12:00:00"
|
||
|
||
def test_get_model_cost_map_reload_status_non_admin_access(self, client_with_auth):
|
||
"""Test that non-admin users cannot get reload status"""
|
||
# Mock non-admin user
|
||
mock_auth = MagicMock()
|
||
mock_auth.user_role = "user" # Non-admin role
|
||
app.dependency_overrides[user_api_key_auth] = lambda: mock_auth
|
||
|
||
response = client_with_auth.get("/schedule/model_cost_map_reload/status")
|
||
|
||
assert response.status_code == 403
|
||
data = response.json()
|
||
assert "Access denied" in data["detail"]
|
||
assert "Admin role required" in data["detail"]
|
||
|
||
def test_get_model_cost_map_reload_status_no_config(self, client_with_auth):
|
||
"""Test that status returns not scheduled when no config exists"""
|
||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
|
||
mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=None)
|
||
|
||
response = client_with_auth.get("/schedule/model_cost_map_reload/status")
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert data["scheduled"] == False
|
||
assert data["interval_hours"] == None
|
||
assert data["last_run"] == None
|
||
assert data["next_run"] == None
|
||
|
||
def test_get_model_cost_map_reload_status_no_interval(self, client_with_auth):
|
||
"""Test that status returns not scheduled when no interval is configured"""
|
||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
|
||
# Mock config with no interval
|
||
mock_config = MagicMock()
|
||
mock_config.param_value = {"interval_hours": None, "force_reload": False}
|
||
mock_prisma.db.litellm_config.find_unique = AsyncMock(
|
||
return_value=mock_config
|
||
)
|
||
|
||
response = client_with_auth.get("/schedule/model_cost_map_reload/status")
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert data["scheduled"] == False
|
||
assert data["interval_hours"] == None
|
||
assert data["last_run"] == None
|
||
assert data["next_run"] == None
|
||
|
||
|
||
class TestPriceDataReloadIntegration:
|
||
"""Integration tests for the complete price data reload feature"""
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _flush_litellm_config_cache(self):
|
||
from litellm.proxy.utils import litellm_config_cache
|
||
|
||
litellm_config_cache.flush_cache()
|
||
yield
|
||
litellm_config_cache.flush_cache()
|
||
|
||
@pytest.fixture
|
||
def client_with_auth(self):
|
||
"""Create a test client with authentication"""
|
||
from litellm.proxy._types import LitellmUserRoles
|
||
from litellm.proxy.proxy_server import cleanup_router_config_variables
|
||
|
||
cleanup_router_config_variables()
|
||
filepath = os.path.dirname(os.path.abspath(__file__))
|
||
config_fp = f"{filepath}/test_configs/test_config_no_auth.yaml"
|
||
asyncio.run(initialize(config=config_fp, debug=True))
|
||
|
||
# Mock admin user authentication
|
||
mock_auth = MagicMock()
|
||
mock_auth.user_role = LitellmUserRoles.PROXY_ADMIN
|
||
app.dependency_overrides[user_api_key_auth] = lambda: mock_auth
|
||
|
||
return TestClient(app)
|
||
|
||
def test_complete_reload_flow(self, client_with_auth):
|
||
"""Test the complete reload flow from API to model cost update"""
|
||
# Mock the model cost map
|
||
mock_cost_map = {
|
||
"gpt-3.5-turbo": {
|
||
"input_cost_per_token": 0.001,
|
||
"output_cost_per_token": 0.002,
|
||
},
|
||
"gpt-4": {"input_cost_per_token": 0.03, "output_cost_per_token": 0.06},
|
||
}
|
||
|
||
original_model_cost = litellm.model_cost.copy()
|
||
try:
|
||
with patch(
|
||
"litellm.litellm_core_utils.get_model_cost_map.get_model_cost_map"
|
||
) as mock_get_map:
|
||
mock_get_map.return_value = mock_cost_map
|
||
|
||
# Mock the database connection
|
||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
|
||
mock_prisma.db.litellm_config.find_unique = AsyncMock(
|
||
return_value=None
|
||
)
|
||
mock_prisma.db.litellm_config.upsert = AsyncMock(return_value=None)
|
||
|
||
# Test reload endpoint
|
||
response = client_with_auth.post("/reload/model_cost_map")
|
||
assert response.status_code == 200
|
||
|
||
# Test get endpoint
|
||
response = client_with_auth.get("/public/litellm_model_cost_map")
|
||
assert response.status_code == 200
|
||
finally:
|
||
litellm.model_cost = original_model_cost
|
||
_invalidate_model_cost_lowercase_map()
|
||
|
||
def test_distributed_reload_check_function(self):
|
||
"""Test the _check_and_reload_model_cost_map function"""
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
from litellm.proxy.utils import litellm_config_cache
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
# Mock prisma client
|
||
mock_prisma = MagicMock()
|
||
|
||
# Test case 1: No config in database
|
||
mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=None)
|
||
# _check_and_reload_model_cost_map routes through get_config_param,
|
||
# which calls prisma.get_generic_data on a cache miss.
|
||
mock_prisma.get_generic_data = AsyncMock(return_value=None)
|
||
|
||
# Should return early without reloading
|
||
asyncio.run(proxy_config._check_and_reload_model_cost_map(mock_prisma))
|
||
|
||
# Test case 2: Config with interval but not time to reload
|
||
litellm_config_cache.flush_cache()
|
||
mock_config = MagicMock()
|
||
mock_config.param_value = {"interval_hours": 6, "force_reload": False}
|
||
mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=mock_config)
|
||
mock_prisma.get_generic_data = AsyncMock(return_value=mock_config)
|
||
|
||
# Mock current time and last reload time
|
||
with patch(
|
||
"litellm.proxy.proxy_server.last_model_cost_map_reload",
|
||
"2024-01-01T06:00:00",
|
||
):
|
||
with patch("litellm.proxy.proxy_server.datetime") as mock_datetime:
|
||
mock_datetime.utcnow.return_value = datetime(
|
||
2024, 1, 1, 7, 0, 0
|
||
) # 1 hour later
|
||
|
||
# Should not reload (only 1 hour passed, need 6)
|
||
asyncio.run(proxy_config._check_and_reload_model_cost_map(mock_prisma))
|
||
|
||
# Test case 3: Config with force reload
|
||
litellm_config_cache.flush_cache()
|
||
mock_config.param_value = {"interval_hours": 6, "force_reload": True}
|
||
mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=mock_config)
|
||
mock_prisma.get_generic_data = AsyncMock(return_value=mock_config)
|
||
mock_prisma.db.litellm_config.upsert = AsyncMock(return_value=None)
|
||
|
||
original_model_cost = litellm.model_cost.copy()
|
||
try:
|
||
with patch(
|
||
"litellm.litellm_core_utils.get_model_cost_map.get_model_cost_map"
|
||
) as mock_get_map:
|
||
mock_get_map.return_value = {
|
||
"gpt-3.5-turbo": {"input_cost_per_token": 0.001}
|
||
}
|
||
|
||
# Should reload due to force flag
|
||
asyncio.run(proxy_config._check_and_reload_model_cost_map(mock_prisma))
|
||
|
||
# Verify force_reload was reset to False
|
||
mock_prisma.db.litellm_config.upsert.assert_called()
|
||
call_args = mock_prisma.db.litellm_config.upsert.call_args
|
||
# The param_value is now a JSON string, so we need to parse it
|
||
param_value_json = call_args[1]["data"]["update"]["param_value"]
|
||
param_value_dict = json.loads(param_value_json)
|
||
assert param_value_dict["force_reload"] == False
|
||
assert param_value_dict.get("interval_hours") == 6
|
||
finally:
|
||
litellm.model_cost = original_model_cost
|
||
_invalidate_model_cost_lowercase_map()
|
||
|
||
def test_distributed_reload_preserves_interval_hours(self):
|
||
"""Test that _check_and_reload_model_cost_map preserves interval_hours after reload.
|
||
|
||
Regression test: the update branch of the upsert was previously dropping
|
||
interval_hours, causing scheduled reloads to self-destruct after first execution.
|
||
"""
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
mock_prisma = MagicMock()
|
||
|
||
# Set up config with interval_hours=24 and force_reload=True to trigger reload
|
||
mock_config = MagicMock()
|
||
mock_config.param_value = {"interval_hours": 24, "force_reload": True}
|
||
mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=mock_config)
|
||
# _check_and_reload_model_cost_map now reads through get_generic_data.
|
||
mock_prisma.get_generic_data = AsyncMock(return_value=mock_config)
|
||
mock_prisma.db.litellm_config.upsert = AsyncMock(return_value=None)
|
||
|
||
original_model_cost = litellm.model_cost.copy()
|
||
try:
|
||
with patch(
|
||
"litellm.litellm_core_utils.get_model_cost_map.get_model_cost_map"
|
||
) as mock_get_map:
|
||
mock_get_map.return_value = {"gpt-4": {"input_cost_per_token": 0.001}}
|
||
|
||
asyncio.run(proxy_config._check_and_reload_model_cost_map(mock_prisma))
|
||
|
||
# Verify the upsert update branch preserves interval_hours
|
||
mock_prisma.db.litellm_config.upsert.assert_called()
|
||
call_args = mock_prisma.db.litellm_config.upsert.call_args
|
||
param_value_json = call_args[1]["data"]["update"]["param_value"]
|
||
param_value_dict = json.loads(param_value_json)
|
||
assert param_value_dict["force_reload"] == False
|
||
assert param_value_dict["interval_hours"] == 24, (
|
||
"interval_hours must be preserved in the update branch; "
|
||
"dropping it causes the schedule to self-destruct"
|
||
)
|
||
finally:
|
||
litellm.model_cost = original_model_cost
|
||
_invalidate_model_cost_lowercase_map()
|
||
|
||
def test_manual_reload_preserves_interval_hours(self):
|
||
"""Test that manual reload via /reload/model_cost_map preserves existing interval_hours.
|
||
|
||
Regression test: the manual reload endpoint was overwriting param_value with
|
||
only force_reload=True, dropping any existing interval_hours schedule.
|
||
"""
|
||
from litellm.proxy._types import LitellmUserRoles
|
||
from litellm.proxy.proxy_server import cleanup_router_config_variables
|
||
|
||
cleanup_router_config_variables()
|
||
filepath = os.path.dirname(os.path.abspath(__file__))
|
||
config_fp = f"{filepath}/test_configs/test_config_no_auth.yaml"
|
||
asyncio.run(initialize(config=config_fp, debug=True))
|
||
|
||
mock_auth = MagicMock()
|
||
mock_auth.user_role = LitellmUserRoles.PROXY_ADMIN
|
||
app.dependency_overrides[user_api_key_auth] = lambda: mock_auth
|
||
client = TestClient(app)
|
||
|
||
original_model_cost = litellm.model_cost.copy()
|
||
try:
|
||
with patch(
|
||
"litellm.litellm_core_utils.get_model_cost_map.get_model_cost_map"
|
||
) as mock_get_map:
|
||
mock_get_map.return_value = {"gpt-4": {"input_cost_per_token": 0.001}}
|
||
|
||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
|
||
# Simulate existing config with a schedule
|
||
mock_existing = MagicMock()
|
||
mock_existing.param_value = {
|
||
"interval_hours": 12,
|
||
"force_reload": False,
|
||
}
|
||
mock_prisma.db.litellm_config.find_unique = AsyncMock(
|
||
return_value=mock_existing
|
||
)
|
||
mock_prisma.db.litellm_config.upsert = AsyncMock(return_value=None)
|
||
|
||
response = client.post("/reload/model_cost_map")
|
||
assert response.status_code == 200
|
||
|
||
# Verify interval_hours was preserved in the upsert
|
||
mock_prisma.db.litellm_config.upsert.assert_called()
|
||
call_args = mock_prisma.db.litellm_config.upsert.call_args
|
||
param_value_json = call_args[1]["data"]["update"]["param_value"]
|
||
param_value_dict = json.loads(param_value_json)
|
||
assert param_value_dict["force_reload"] == True
|
||
assert param_value_dict["interval_hours"] == 12, (
|
||
"interval_hours must be preserved when manual reload sets force_reload; "
|
||
"dropping it destroys any existing schedule"
|
||
)
|
||
finally:
|
||
litellm.model_cost = original_model_cost
|
||
_invalidate_model_cost_lowercase_map()
|
||
|
||
def test_anthropic_beta_headers_reload_preserves_interval_hours(self):
|
||
"""Test that _check_and_reload_anthropic_beta_headers preserves interval_hours after reload.
|
||
|
||
Regression test: the update branch of the upsert was dropping interval_hours,
|
||
identical to the model cost map bug.
|
||
"""
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
mock_prisma = MagicMock()
|
||
|
||
# Set up config with interval_hours=12 and force_reload=True to trigger reload
|
||
mock_config = MagicMock()
|
||
mock_config.param_value = {"interval_hours": 12, "force_reload": True}
|
||
mock_prisma.db.litellm_config.find_unique = AsyncMock(return_value=mock_config)
|
||
# _check_and_reload_anthropic_beta_headers now reads through get_generic_data.
|
||
mock_prisma.get_generic_data = AsyncMock(return_value=mock_config)
|
||
mock_prisma.db.litellm_config.upsert = AsyncMock(return_value=None)
|
||
|
||
with patch(
|
||
"litellm.anthropic_beta_headers_manager.reload_beta_headers_config"
|
||
) as mock_reload:
|
||
mock_reload.return_value = {"anthropic": {"beta_header": "test-value"}}
|
||
|
||
asyncio.run(
|
||
proxy_config._check_and_reload_anthropic_beta_headers(mock_prisma)
|
||
)
|
||
|
||
# Verify the upsert update branch preserves interval_hours
|
||
mock_prisma.db.litellm_config.upsert.assert_called()
|
||
call_args = mock_prisma.db.litellm_config.upsert.call_args
|
||
param_value_json = call_args[1]["data"]["update"]["param_value"]
|
||
param_value_dict = json.loads(param_value_json)
|
||
assert param_value_dict["force_reload"] == False
|
||
assert param_value_dict["interval_hours"] == 12, (
|
||
"interval_hours must be preserved in the update branch; "
|
||
"dropping it causes the schedule to self-destruct"
|
||
)
|
||
|
||
def test_anthropic_beta_headers_manual_reload_preserves_interval_hours(self):
|
||
"""Test that manual reload via /reload/anthropic_beta_headers preserves existing interval_hours.
|
||
|
||
Regression test: the manual reload endpoint was overwriting param_value with
|
||
only force_reload=True, dropping any existing interval_hours schedule.
|
||
"""
|
||
from litellm.proxy._types import LitellmUserRoles
|
||
from litellm.proxy.proxy_server import cleanup_router_config_variables
|
||
|
||
cleanup_router_config_variables()
|
||
filepath = os.path.dirname(os.path.abspath(__file__))
|
||
config_fp = f"{filepath}/test_configs/test_config_no_auth.yaml"
|
||
asyncio.run(initialize(config=config_fp, debug=True))
|
||
|
||
mock_auth = MagicMock()
|
||
mock_auth.user_role = LitellmUserRoles.PROXY_ADMIN
|
||
app.dependency_overrides[user_api_key_auth] = lambda: mock_auth
|
||
client = TestClient(app)
|
||
|
||
with patch(
|
||
"litellm.anthropic_beta_headers_manager.reload_beta_headers_config"
|
||
) as mock_reload:
|
||
mock_reload.return_value = {"anthropic": {"beta_header": "test-value"}}
|
||
|
||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
|
||
# Simulate existing config with a schedule
|
||
mock_existing = MagicMock()
|
||
mock_existing.param_value = {"interval_hours": 8, "force_reload": False}
|
||
mock_prisma.db.litellm_config.find_unique = AsyncMock(
|
||
return_value=mock_existing
|
||
)
|
||
mock_prisma.db.litellm_config.upsert = AsyncMock(return_value=None)
|
||
|
||
response = client.post("/reload/anthropic_beta_headers")
|
||
assert response.status_code == 200
|
||
|
||
# Verify interval_hours was preserved in the upsert
|
||
mock_prisma.db.litellm_config.upsert.assert_called()
|
||
call_args = mock_prisma.db.litellm_config.upsert.call_args
|
||
param_value_json = call_args[1]["data"]["update"]["param_value"]
|
||
param_value_dict = json.loads(param_value_json)
|
||
assert param_value_dict["force_reload"] == True
|
||
assert param_value_dict["interval_hours"] == 8, (
|
||
"interval_hours must be preserved when manual reload sets force_reload; "
|
||
"dropping it destroys any existing schedule"
|
||
)
|
||
|
||
def test_config_file_parsing(self):
|
||
"""Test parsing of config file with reload settings"""
|
||
config_content = """
|
||
general_settings:
|
||
master_key: sk-1234
|
||
model_cost_map_reload_interval: 21600
|
||
|
||
model_list:
|
||
- model_name: gpt-3.5-turbo
|
||
litellm_params:
|
||
model: gpt-3.5-turbo
|
||
- model_name: gpt-4
|
||
litellm_params:
|
||
model: gpt-4
|
||
"""
|
||
|
||
# Parse the config
|
||
config = yaml.safe_load(config_content)
|
||
|
||
# Verify the reload setting is present
|
||
assert "general_settings" in config
|
||
assert "model_cost_map_reload_interval" in config["general_settings"]
|
||
assert config["general_settings"]["model_cost_map_reload_interval"] == 21600
|
||
|
||
# Verify models are present
|
||
assert "model_list" in config
|
||
assert len(config["model_list"]) == 2
|
||
|
||
def test_database_config_storage(self):
|
||
"""Test that configuration is properly stored in database"""
|
||
# Mock prisma client
|
||
mock_prisma = MagicMock()
|
||
|
||
# Test the database upsert call that would be made by the schedule endpoint
|
||
mock_prisma.db.litellm_config.upsert = AsyncMock(return_value=None)
|
||
|
||
# Simulate the database call that the schedule endpoint would make
|
||
asyncio.run(
|
||
mock_prisma.db.litellm_config.upsert(
|
||
where={"param_name": "model_cost_map_reload_config"},
|
||
data={
|
||
"create": {
|
||
"param_name": "model_cost_map_reload_config",
|
||
"param_value": {"interval_hours": 6, "force_reload": False},
|
||
},
|
||
"update": {
|
||
"param_value": {"interval_hours": 6, "force_reload": False}
|
||
},
|
||
},
|
||
)
|
||
)
|
||
|
||
# Verify database upsert was called with correct data
|
||
mock_prisma.db.litellm_config.upsert.assert_called_once()
|
||
call_args = mock_prisma.db.litellm_config.upsert.call_args
|
||
assert call_args[1]["where"]["param_name"] == "model_cost_map_reload_config"
|
||
assert call_args[1]["data"]["create"]["param_value"]["interval_hours"] == 6
|
||
assert call_args[1]["data"]["create"]["param_value"]["force_reload"] == False
|
||
|
||
def test_manual_reload_force_flag(self):
|
||
"""Test that manual reload sets force flag correctly"""
|
||
# Mock prisma client
|
||
mock_prisma = MagicMock()
|
||
|
||
# Test the database upsert call that would be made by the manual reload endpoint
|
||
mock_prisma.db.litellm_config.upsert = AsyncMock(return_value=None)
|
||
|
||
# Simulate the database call that the manual reload endpoint would make
|
||
asyncio.run(
|
||
mock_prisma.db.litellm_config.upsert(
|
||
where={"param_name": "model_cost_map_reload_config"},
|
||
data={
|
||
"create": {
|
||
"param_name": "model_cost_map_reload_config",
|
||
"param_value": {"interval_hours": None, "force_reload": True},
|
||
},
|
||
"update": {"param_value": {"force_reload": True}},
|
||
},
|
||
)
|
||
)
|
||
|
||
# Verify force_reload flag was set
|
||
mock_prisma.db.litellm_config.upsert.assert_called_once()
|
||
call_args = mock_prisma.db.litellm_config.upsert.call_args
|
||
assert call_args[1]["data"]["update"]["param_value"]["force_reload"] == True
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_add_router_settings_from_db_config_merge_logic():
|
||
"""
|
||
Test the _add_router_settings_from_db_config method's merge logic.
|
||
|
||
This tests how router settings from config file and database are combined,
|
||
including scenarios where nested dictionaries should be properly merged.
|
||
"""
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
# Create ProxyConfig instance
|
||
proxy_config = ProxyConfig()
|
||
|
||
# Mock router
|
||
mock_router = MagicMock()
|
||
mock_router.update_settings = MagicMock()
|
||
|
||
# Test Case 1: Both config and DB settings exist - should merge them
|
||
config_data = {
|
||
"router_settings": {
|
||
"routing_strategy": "usage-based-routing",
|
||
"model_group_alias": {"gpt-4": "openai-gpt-4"},
|
||
"enable_pre_call_checks": True,
|
||
"timeout": 30,
|
||
"nested_config": {"setting1": "config_value1", "setting2": "config_value2"},
|
||
}
|
||
}
|
||
|
||
# Mock database config record
|
||
mock_db_config = MagicMock()
|
||
mock_db_config.param_value = {
|
||
"routing_strategy": "least-busy", # This should override config value
|
||
"retry_delay": 2, # This is new, should be added
|
||
"nested_config": {
|
||
"setting2": "db_value2", # This should override config value
|
||
"setting3": "db_value3", # This is new, should be added
|
||
},
|
||
}
|
||
|
||
# Mock prisma client
|
||
mock_prisma_client = MagicMock()
|
||
mock_prisma_client.db.litellm_config.find_first = AsyncMock(
|
||
return_value=mock_db_config
|
||
)
|
||
|
||
# Call the method under test
|
||
await proxy_config._add_router_settings_from_db_config(
|
||
config_data=config_data,
|
||
llm_router=mock_router,
|
||
prisma_client=mock_prisma_client,
|
||
)
|
||
|
||
# Verify find_first was called with correct parameters
|
||
mock_prisma_client.db.litellm_config.find_first.assert_called_once_with(
|
||
where={"param_name": "router_settings"}
|
||
)
|
||
|
||
# Verify update_settings was called
|
||
mock_router.update_settings.assert_called_once()
|
||
|
||
# Get the actual settings passed to update_settings
|
||
call_args = mock_router.update_settings.call_args
|
||
combined_settings = call_args[1] # kwargs
|
||
|
||
# Verify the merge results
|
||
# DB values should override config values
|
||
assert combined_settings["routing_strategy"] == "least-busy"
|
||
|
||
# Config-only values should be preserved
|
||
assert combined_settings["model_group_alias"] == {"gpt-4": "openai-gpt-4"}
|
||
assert combined_settings["enable_pre_call_checks"] == True
|
||
assert combined_settings["timeout"] == 30
|
||
|
||
# DB-only values should be added
|
||
assert combined_settings["retry_delay"] == 2
|
||
|
||
# Nested dictionaries should be merged (but this is shallow merge)
|
||
expected_nested = {
|
||
"setting1": "config_value1",
|
||
"setting2": "db_value2",
|
||
"setting3": "db_value3",
|
||
}
|
||
assert combined_settings["nested_config"] == expected_nested
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_add_router_settings_from_db_config_edge_cases():
|
||
"""
|
||
Test edge cases for _add_router_settings_from_db_config method.
|
||
"""
|
||
from unittest.mock import AsyncMock, MagicMock
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
mock_router = MagicMock()
|
||
mock_router.update_settings = MagicMock()
|
||
|
||
# Test Case 1: No router provided
|
||
await proxy_config._add_router_settings_from_db_config(
|
||
config_data={"router_settings": {"test": "value"}},
|
||
llm_router=None,
|
||
prisma_client=MagicMock(),
|
||
)
|
||
# Should not call anything when router is None
|
||
mock_router.update_settings.assert_not_called()
|
||
|
||
# Test Case 2: No prisma client provided
|
||
await proxy_config._add_router_settings_from_db_config(
|
||
config_data={"router_settings": {"test": "value"}},
|
||
llm_router=mock_router,
|
||
prisma_client=None,
|
||
)
|
||
# Should not call anything when prisma_client is None
|
||
mock_router.update_settings.assert_not_called()
|
||
|
||
# Test Case 3: DB returns None (no router_settings in DB)
|
||
mock_prisma_client = MagicMock()
|
||
mock_prisma_client.db.litellm_config.find_first = AsyncMock(return_value=None)
|
||
|
||
config_data = {"router_settings": {"routing_strategy": "usage-based"}}
|
||
|
||
await proxy_config._add_router_settings_from_db_config(
|
||
config_data=config_data,
|
||
llm_router=mock_router,
|
||
prisma_client=mock_prisma_client,
|
||
)
|
||
|
||
# Should use only config settings
|
||
mock_router.update_settings.assert_called_once_with(routing_strategy="usage-based")
|
||
mock_router.reset_mock()
|
||
|
||
# Test Case 4: Config has no router_settings
|
||
mock_db_config = MagicMock()
|
||
mock_db_config.param_value = {"db_setting": "db_value"}
|
||
mock_prisma_client.db.litellm_config.find_first = AsyncMock(
|
||
return_value=mock_db_config
|
||
)
|
||
|
||
await proxy_config._add_router_settings_from_db_config(
|
||
config_data={}, # No router_settings in config
|
||
llm_router=mock_router,
|
||
prisma_client=mock_prisma_client,
|
||
)
|
||
|
||
# Should use only DB settings
|
||
mock_router.update_settings.assert_called_once_with(db_setting="db_value")
|
||
mock_router.reset_mock()
|
||
|
||
# Test Case 5: Both config and DB router_settings are None/empty
|
||
mock_prisma_client.db.litellm_config.find_first = AsyncMock(return_value=None)
|
||
|
||
await proxy_config._add_router_settings_from_db_config(
|
||
config_data={}, llm_router=mock_router, prisma_client=mock_prisma_client
|
||
)
|
||
|
||
# Should not call update_settings when no settings exist
|
||
mock_router.update_settings.assert_not_called()
|
||
|
||
# Test Case 6: DB config exists but param_value is not a dict
|
||
mock_db_config_invalid = MagicMock()
|
||
mock_db_config_invalid.param_value = "not_a_dict"
|
||
mock_prisma_client.db.litellm_config.find_first = AsyncMock(
|
||
return_value=mock_db_config_invalid
|
||
)
|
||
|
||
config_data = {"router_settings": {"config_setting": "config_value"}}
|
||
|
||
await proxy_config._add_router_settings_from_db_config(
|
||
config_data=config_data,
|
||
llm_router=mock_router,
|
||
prisma_client=mock_prisma_client,
|
||
)
|
||
|
||
# Should use only config settings when DB param_value is invalid
|
||
mock_router.update_settings.assert_called_once_with(config_setting="config_value")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_add_router_settings_shallow_merge_behavior():
|
||
"""
|
||
Test that the merge behavior is shallow (nested dicts get replaced, not merged).
|
||
This documents the current behavior using _update_dictionary.
|
||
"""
|
||
from unittest.mock import AsyncMock, MagicMock
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
mock_router = MagicMock()
|
||
mock_router.update_settings = MagicMock()
|
||
|
||
# Config with nested dictionary
|
||
config_data = {
|
||
"router_settings": {
|
||
"nested_setting": {
|
||
"key1": "config_value1",
|
||
"key2": "config_value2",
|
||
"key3": "config_value3",
|
||
},
|
||
"top_level": "config_top",
|
||
}
|
||
}
|
||
|
||
# DB config that partially overlaps the nested dictionary
|
||
mock_db_config = MagicMock()
|
||
mock_db_config.param_value = {
|
||
"nested_setting": {
|
||
"key2": "db_value2", # Override existing key
|
||
"key4": "db_value4", # Add new key
|
||
# Note: key1 and key3 from config will be lost due to shallow merge
|
||
},
|
||
"top_level": "db_top", # Override top level
|
||
}
|
||
|
||
mock_prisma_client = MagicMock()
|
||
mock_prisma_client.db.litellm_config.find_first = AsyncMock(
|
||
return_value=mock_db_config
|
||
)
|
||
|
||
await proxy_config._add_router_settings_from_db_config(
|
||
config_data=config_data,
|
||
llm_router=mock_router,
|
||
prisma_client=mock_prisma_client,
|
||
)
|
||
|
||
# Get the merged settings
|
||
call_args = mock_router.update_settings.call_args
|
||
merged_settings = call_args[1]
|
||
|
||
# Verify shallow merge behavior:
|
||
# The entire nested_setting dict from config is replaced by the DB version
|
||
expected_nested = {
|
||
"key1": "config_value1",
|
||
"key3": "config_value3",
|
||
"key2": "db_value2",
|
||
"key4": "db_value4",
|
||
}
|
||
|
||
assert merged_settings["nested_setting"] == expected_nested
|
||
assert merged_settings["top_level"] == "db_top"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_model_info_v1_oci_secrets_not_leaked():
|
||
"""
|
||
Test that model_info_v1 endpoint properly masks OCI sensitive parameters and does not leak secrets.
|
||
"""
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
from litellm.proxy._types import UserAPIKeyAuth
|
||
from litellm.proxy.proxy_server import model_info_v1
|
||
|
||
# Mock user authentication
|
||
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
||
mock_user_api_key_dict.user_id = "test-user"
|
||
mock_user_api_key_dict.api_key = "test-key"
|
||
mock_user_api_key_dict.team_models = []
|
||
mock_user_api_key_dict.models = ["oci-grok-test"]
|
||
|
||
# Mock model data with OCI sensitive information
|
||
mock_model_data = {
|
||
"model_name": "oci-grok-test",
|
||
"litellm_params": {
|
||
"model": "oci/xai.grok-4",
|
||
"oci_key": "ocid1.api_key.oc1..aaaaaaaa7kbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbk",
|
||
"oci_region": "us-phoenix-1",
|
||
"oci_user": "ocid1.user.oc1..aaaaaaaa7kbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbk",
|
||
"oci_fingerprint": "aa:bb:cc:dd:ee:ff:11:22:33:44:55:66:77:88:99:00",
|
||
"oci_tenancy": "ocid1.tenancy.oc1..aaaaaaaa7kbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbk",
|
||
"oci_key_file": "/path/to/oci_api_key.pem",
|
||
"oci_compartment_id": "ocid1.compartment.oc1..aaaaaaaa7kbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbk",
|
||
"drop_params": True,
|
||
},
|
||
"model_info": {"mode": "completion", "id": "test-model-id"},
|
||
}
|
||
|
||
# Mock the llm_router to return our test data
|
||
mock_router = MagicMock()
|
||
mock_router.model_list = [mock_model_data]
|
||
mock_router.get_model_names.return_value = ["oci-grok-test"]
|
||
mock_router.get_model_access_groups.return_value = {}
|
||
|
||
# Mock global variables
|
||
with (
|
||
patch("litellm.proxy.proxy_server.llm_router", mock_router),
|
||
patch("litellm.proxy.proxy_server.llm_model_list", [mock_model_data]),
|
||
patch("litellm.proxy.proxy_server.prisma_client", None),
|
||
patch(
|
||
"litellm.proxy.proxy_server.general_settings",
|
||
{"infer_model_from_keys": False},
|
||
),
|
||
patch("litellm.proxy.proxy_server.user_model", None),
|
||
):
|
||
# Call the model_info_v1 endpoint
|
||
result = await model_info_v1(
|
||
user_api_key_dict=mock_user_api_key_dict, litellm_model_id=None
|
||
)
|
||
|
||
# Verify the result structure
|
||
assert "data" in result
|
||
assert len(result["data"]) == 1
|
||
|
||
model_info = result["data"][0]
|
||
litellm_params = model_info["litellm_params"]
|
||
|
||
# Verify that sensitive OCI fields are masked
|
||
assert "****" in litellm_params["oci_key"], "oci_key should be masked"
|
||
assert (
|
||
"****" in litellm_params["oci_fingerprint"]
|
||
), "oci_fingerprint should be masked"
|
||
assert "****" in litellm_params["oci_tenancy"], "oci_tenancy should be masked"
|
||
assert "****" in litellm_params["oci_key_file"], "oci_key_file should be masked"
|
||
|
||
# Verify that non-sensitive fields are NOT masked
|
||
assert (
|
||
litellm_params["model"] == "oci/xai.grok-4"
|
||
), "model field should not be masked"
|
||
assert (
|
||
litellm_params["oci_region"] == "us-phoenix-1"
|
||
), "oci_region should not be masked"
|
||
assert litellm_params["drop_params"] is True, "drop_params should not be masked"
|
||
|
||
# Verify the model field specifically is not masked (this was the original issue)
|
||
assert (
|
||
"****" not in litellm_params["model"]
|
||
), "model field should never be masked"
|
||
assert litellm_params["model"].startswith(
|
||
"oci/"
|
||
), "model should retain its full value"
|
||
|
||
# Verify that actual secret values are not present in the response
|
||
result_str = str(result)
|
||
assert (
|
||
"ocid1.api_key.oc1..aaaaaaaa7kbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbk"
|
||
not in result_str
|
||
)
|
||
assert "aa:bb:cc:dd:ee:ff:11:22:33:44:55:66:77:88:99:00" not in result_str
|
||
assert (
|
||
"ocid1.tenancy.oc1..aaaaaaaa7kbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbkbk"
|
||
not in result_str
|
||
)
|
||
assert "/path/to/oci_api_key.pem" not in result_str
|
||
|
||
|
||
def test_add_callback_from_db_to_in_memory_litellm_callbacks():
|
||
"""
|
||
Test that _add_callback_from_db_to_in_memory_litellm_callbacks correctly adds callbacks
|
||
for success, failure, and combined event types.
|
||
"""
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
# Mock the callback manager
|
||
mock_callback_manager = MagicMock()
|
||
|
||
with patch("litellm.proxy.proxy_server.litellm") as mock_litellm:
|
||
# Set up mock litellm attributes
|
||
mock_litellm._known_custom_logger_compatible_callbacks = []
|
||
mock_litellm.logging_callback_manager = mock_callback_manager
|
||
|
||
# Test Case 1: Add success callback
|
||
mock_success_callbacks = []
|
||
proxy_config._add_callback_from_db_to_in_memory_litellm_callbacks(
|
||
callback="prometheus",
|
||
event_types=["success"],
|
||
existing_callbacks=mock_success_callbacks,
|
||
)
|
||
mock_callback_manager.add_litellm_success_callback.assert_called_once_with(
|
||
"prometheus"
|
||
)
|
||
mock_callback_manager.reset_mock()
|
||
|
||
# Test Case 2: Add failure callback
|
||
mock_failure_callbacks = []
|
||
proxy_config._add_callback_from_db_to_in_memory_litellm_callbacks(
|
||
callback="langfuse",
|
||
event_types=["failure"],
|
||
existing_callbacks=mock_failure_callbacks,
|
||
)
|
||
mock_callback_manager.add_litellm_failure_callback.assert_called_once_with(
|
||
"langfuse"
|
||
)
|
||
mock_callback_manager.reset_mock()
|
||
|
||
# Test Case 3: Add callback for both success and failure
|
||
mock_callbacks = []
|
||
proxy_config._add_callback_from_db_to_in_memory_litellm_callbacks(
|
||
callback="s3",
|
||
event_types=["success", "failure"],
|
||
existing_callbacks=mock_callbacks,
|
||
)
|
||
mock_callback_manager.add_litellm_callback.assert_called_once_with("s3")
|
||
mock_callback_manager.reset_mock()
|
||
|
||
# Test Case 4: Don't add callback if it already exists
|
||
existing_callbacks_with_item = ["prometheus"]
|
||
proxy_config._add_callback_from_db_to_in_memory_litellm_callbacks(
|
||
callback="prometheus",
|
||
event_types=["success"],
|
||
existing_callbacks=existing_callbacks_with_item,
|
||
)
|
||
mock_callback_manager.add_litellm_success_callback.assert_not_called()
|
||
|
||
|
||
def test_should_load_db_object_with_supported_db_objects():
|
||
"""
|
||
Test _should_load_db_object method with supported_db_objects configuration.
|
||
|
||
Verifies that when supported_db_objects is set, only specified object types
|
||
are loaded from the database.
|
||
"""
|
||
from unittest.mock import patch
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
# Test Case 1: supported_db_objects not set - all objects should be loaded
|
||
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
||
assert proxy_config._should_load_db_object(object_type="models") is True
|
||
assert proxy_config._should_load_db_object(object_type="mcp") is True
|
||
assert proxy_config._should_load_db_object(object_type="guardrails") is True
|
||
assert proxy_config._should_load_db_object(object_type="vector_stores") is True
|
||
|
||
# Test Case 2: supported_db_objects set to only load MCP
|
||
with patch(
|
||
"litellm.proxy.proxy_server.general_settings",
|
||
{"supported_db_objects": ["mcp"]},
|
||
):
|
||
assert proxy_config._should_load_db_object(object_type="models") is False
|
||
assert proxy_config._should_load_db_object(object_type="mcp") is True
|
||
assert proxy_config._should_load_db_object(object_type="guardrails") is False
|
||
assert proxy_config._should_load_db_object(object_type="vector_stores") is False
|
||
assert proxy_config._should_load_db_object(object_type="prompts") is False
|
||
|
||
# Test Case 3: supported_db_objects set to load multiple types
|
||
with patch(
|
||
"litellm.proxy.proxy_server.general_settings",
|
||
{"supported_db_objects": ["mcp", "guardrails", "vector_stores"]},
|
||
):
|
||
assert proxy_config._should_load_db_object(object_type="models") is False
|
||
assert proxy_config._should_load_db_object(object_type="mcp") is True
|
||
assert proxy_config._should_load_db_object(object_type="guardrails") is True
|
||
assert proxy_config._should_load_db_object(object_type="vector_stores") is True
|
||
assert proxy_config._should_load_db_object(object_type="prompts") is False
|
||
|
||
# Test Case 4: supported_db_objects is not a list (should default to loading all)
|
||
with patch(
|
||
"litellm.proxy.proxy_server.general_settings",
|
||
{"supported_db_objects": "invalid_type"},
|
||
):
|
||
assert proxy_config._should_load_db_object(object_type="models") is True
|
||
assert proxy_config._should_load_db_object(object_type="mcp") is True
|
||
|
||
# Test Case 5: supported_db_objects is an empty list (nothing should be loaded)
|
||
with patch(
|
||
"litellm.proxy.proxy_server.general_settings",
|
||
{"supported_db_objects": []},
|
||
):
|
||
assert proxy_config._should_load_db_object(object_type="models") is False
|
||
assert proxy_config._should_load_db_object(object_type="mcp") is False
|
||
assert proxy_config._should_load_db_object(object_type="guardrails") is False
|
||
|
||
# Test Case 6: Test all available object types
|
||
with patch(
|
||
"litellm.proxy.proxy_server.general_settings",
|
||
{
|
||
"supported_db_objects": [
|
||
"models",
|
||
"mcp",
|
||
"guardrails",
|
||
"vector_stores",
|
||
"pass_through_endpoints",
|
||
"prompts",
|
||
"model_cost_map",
|
||
]
|
||
},
|
||
):
|
||
assert proxy_config._should_load_db_object(object_type="models") is True
|
||
assert proxy_config._should_load_db_object(object_type="mcp") is True
|
||
assert proxy_config._should_load_db_object(object_type="guardrails") is True
|
||
assert proxy_config._should_load_db_object(object_type="vector_stores") is True
|
||
assert (
|
||
proxy_config._should_load_db_object(object_type="pass_through_endpoints")
|
||
is True
|
||
)
|
||
assert proxy_config._should_load_db_object(object_type="prompts") is True
|
||
assert proxy_config._should_load_db_object(object_type="model_cost_map") is True
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_tag_cache_update_called():
|
||
"""
|
||
Test that update_cache updates tag cache when tags are provided.
|
||
"""
|
||
from litellm.caching.caching import DualCache
|
||
from litellm.proxy.proxy_server import user_api_key_cache
|
||
|
||
cache = DualCache()
|
||
|
||
setattr(
|
||
litellm.proxy.proxy_server,
|
||
"user_api_key_cache",
|
||
cache,
|
||
)
|
||
|
||
mock_tag_obj = {
|
||
"tag_name": "test-tag",
|
||
"spend": 10.0,
|
||
}
|
||
|
||
with patch.object(
|
||
cache, "async_get_cache", new=AsyncMock(return_value=mock_tag_obj)
|
||
) as mock_get_cache:
|
||
with patch.object(
|
||
cache, "async_set_cache_pipeline", new=AsyncMock()
|
||
) as mock_set_cache:
|
||
await litellm.proxy.proxy_server.update_cache(
|
||
token=None,
|
||
user_id=None,
|
||
end_user_id=None,
|
||
team_id=None,
|
||
response_cost=5.0,
|
||
parent_otel_span=None,
|
||
tags=["test-tag"],
|
||
)
|
||
|
||
await asyncio.sleep(0.1)
|
||
|
||
mock_get_cache.assert_awaited_once_with(key="tag:test-tag")
|
||
mock_set_cache.assert_awaited_once()
|
||
|
||
call_args = mock_set_cache.call_args
|
||
cache_list = call_args.kwargs["cache_list"]
|
||
|
||
assert len(cache_list) == 1
|
||
cache_key, cache_value = cache_list[0]
|
||
assert cache_key == "tag:test-tag"
|
||
assert cache_value["spend"] == 15.0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_tag_cache_update_multiple_tags():
|
||
"""
|
||
Test that multiple tags are updated in cache.
|
||
"""
|
||
from litellm.caching.caching import DualCache
|
||
from litellm.proxy.proxy_server import user_api_key_cache
|
||
|
||
cache = DualCache()
|
||
|
||
setattr(
|
||
litellm.proxy.proxy_server,
|
||
"user_api_key_cache",
|
||
cache,
|
||
)
|
||
|
||
mock_tag1_obj = {"tag_name": "tag1", "spend": 10.0}
|
||
mock_tag2_obj = {"tag_name": "tag2", "spend": 20.0}
|
||
|
||
async def mock_get_cache_side_effect(key):
|
||
if key == "tag:tag1":
|
||
return mock_tag1_obj
|
||
elif key == "tag:tag2":
|
||
return mock_tag2_obj
|
||
return None
|
||
|
||
with patch.object(
|
||
cache, "async_get_cache", new=AsyncMock(side_effect=mock_get_cache_side_effect)
|
||
) as mock_get_cache:
|
||
with patch.object(
|
||
cache, "async_set_cache_pipeline", new=AsyncMock()
|
||
) as mock_set_cache:
|
||
await litellm.proxy.proxy_server.update_cache(
|
||
token=None,
|
||
user_id=None,
|
||
end_user_id=None,
|
||
team_id=None,
|
||
response_cost=5.0,
|
||
parent_otel_span=None,
|
||
tags=["tag1", "tag2"],
|
||
)
|
||
|
||
await asyncio.sleep(0.1)
|
||
|
||
assert mock_get_cache.call_count == 2
|
||
mock_set_cache.assert_awaited_once()
|
||
|
||
call_args = mock_set_cache.call_args
|
||
cache_list = call_args.kwargs["cache_list"]
|
||
|
||
assert len(cache_list) == 2
|
||
|
||
tag_updates = {
|
||
cache_key: cache_value for cache_key, cache_value in cache_list
|
||
}
|
||
assert "tag:tag1" in tag_updates
|
||
assert "tag:tag2" in tag_updates
|
||
assert tag_updates["tag:tag1"]["spend"] == 15.0
|
||
assert tag_updates["tag:tag2"]["spend"] == 25.0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_init_sso_settings_in_db():
|
||
"""
|
||
Test that _init_sso_settings_in_db properly loads SSO settings from database,
|
||
uppercases keys, and calls _decrypt_and_set_db_env_variables.
|
||
"""
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
# Test Case 1: SSO settings exist in database
|
||
mock_sso_config = MagicMock()
|
||
mock_sso_config.sso_settings = {
|
||
"google_client_id": "test-client-id",
|
||
"google_client_secret": "test-client-secret",
|
||
"microsoft_client_id": "ms-client-id",
|
||
"microsoft_client_secret": "ms-client-secret",
|
||
}
|
||
|
||
mock_prisma_client = MagicMock()
|
||
mock_prisma_client.db.litellm_ssoconfig.find_unique = AsyncMock(
|
||
return_value=mock_sso_config
|
||
)
|
||
|
||
# Mock _decrypt_and_set_db_env_variables
|
||
with patch.object(
|
||
proxy_config, "_decrypt_and_set_db_env_variables"
|
||
) as mock_decrypt_and_set:
|
||
await proxy_config._init_sso_settings_in_db(prisma_client=mock_prisma_client)
|
||
|
||
# Verify find_unique was called with correct parameters
|
||
mock_prisma_client.db.litellm_ssoconfig.find_unique.assert_awaited_once_with(
|
||
where={"id": "sso_config"}
|
||
)
|
||
|
||
# Verify _decrypt_and_set_db_env_variables was called with uppercased keys
|
||
mock_decrypt_and_set.assert_called_once()
|
||
call_args = mock_decrypt_and_set.call_args
|
||
uppercased_settings = call_args.kwargs["environment_variables"]
|
||
|
||
# Verify all keys are uppercased
|
||
assert "GOOGLE_CLIENT_ID" in uppercased_settings
|
||
assert "GOOGLE_CLIENT_SECRET" in uppercased_settings
|
||
assert "MICROSOFT_CLIENT_ID" in uppercased_settings
|
||
assert "MICROSOFT_CLIENT_SECRET" in uppercased_settings
|
||
|
||
# Verify values are preserved
|
||
assert uppercased_settings["GOOGLE_CLIENT_ID"] == "test-client-id"
|
||
assert uppercased_settings["GOOGLE_CLIENT_SECRET"] == "test-client-secret"
|
||
assert uppercased_settings["MICROSOFT_CLIENT_ID"] == "ms-client-id"
|
||
assert uppercased_settings["MICROSOFT_CLIENT_SECRET"] == "ms-client-secret"
|
||
|
||
# Verify original lowercase keys are not present
|
||
assert "google_client_id" not in uppercased_settings
|
||
assert "microsoft_client_id" not in uppercased_settings
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_init_sso_settings_in_db_no_settings():
|
||
"""
|
||
Test that _init_sso_settings_in_db handles the case when no SSO settings exist in database.
|
||
"""
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
# Mock prisma client to return None (no SSO settings)
|
||
mock_prisma_client = MagicMock()
|
||
mock_prisma_client.db.litellm_ssoconfig.find_unique = AsyncMock(return_value=None)
|
||
|
||
# Mock _decrypt_and_set_db_env_variables
|
||
with patch.object(
|
||
proxy_config, "_decrypt_and_set_db_env_variables"
|
||
) as mock_decrypt_and_set:
|
||
await proxy_config._init_sso_settings_in_db(prisma_client=mock_prisma_client)
|
||
|
||
# Verify find_unique was called
|
||
mock_prisma_client.db.litellm_ssoconfig.find_unique.assert_awaited_once_with(
|
||
where={"id": "sso_config"}
|
||
)
|
||
|
||
# Verify _decrypt_and_set_db_env_variables was NOT called when no settings exist
|
||
mock_decrypt_and_set.assert_not_called()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_init_sso_settings_in_db_error_handling():
|
||
"""
|
||
Test that _init_sso_settings_in_db handles database errors gracefully.
|
||
"""
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
# Mock prisma client to raise an exception
|
||
mock_prisma_client = MagicMock()
|
||
mock_prisma_client.db.litellm_ssoconfig.find_unique = AsyncMock(
|
||
side_effect=Exception("Database connection error")
|
||
)
|
||
|
||
# The method should not raise an exception, it should log it instead
|
||
try:
|
||
await proxy_config._init_sso_settings_in_db(prisma_client=mock_prisma_client)
|
||
# If we get here, the exception was handled properly
|
||
assert True
|
||
except Exception as e:
|
||
# The exception should be caught and logged, not propagated
|
||
pytest.fail(
|
||
f"Exception should have been caught and logged, but was raised: {e}"
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_init_sso_settings_in_db_empty_settings():
|
||
"""
|
||
Test that _init_sso_settings_in_db handles empty SSO settings dictionary.
|
||
"""
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
# Mock SSO config with empty settings dictionary
|
||
mock_sso_config = MagicMock()
|
||
mock_sso_config.sso_settings = {}
|
||
|
||
mock_prisma_client = MagicMock()
|
||
mock_prisma_client.db.litellm_ssoconfig.find_unique = AsyncMock(
|
||
return_value=mock_sso_config
|
||
)
|
||
|
||
# Mock _decrypt_and_set_db_env_variables
|
||
with patch.object(
|
||
proxy_config, "_decrypt_and_set_db_env_variables"
|
||
) as mock_decrypt_and_set:
|
||
await proxy_config._init_sso_settings_in_db(prisma_client=mock_prisma_client)
|
||
|
||
# Verify find_unique was called
|
||
mock_prisma_client.db.litellm_ssoconfig.find_unique.assert_awaited_once_with(
|
||
where={"id": "sso_config"}
|
||
)
|
||
|
||
# Verify _decrypt_and_set_db_env_variables was called with empty dict
|
||
mock_decrypt_and_set.assert_called_once()
|
||
call_args = mock_decrypt_and_set.call_args
|
||
uppercased_settings = call_args.kwargs["environment_variables"]
|
||
|
||
# Verify empty dictionary
|
||
assert uppercased_settings == {}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_init_sso_settings_in_db_retries_on_transport_error():
|
||
"""`_init_sso_settings_in_db` self-heals across one ClientNotConnectedError
|
||
via call_with_db_reconnect_retry — mirrors the auth-path behavior so
|
||
startup/reload bursts don't spam the log."""
|
||
import prisma
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
mock_sso_config = MagicMock()
|
||
mock_sso_config.sso_settings = {"GOOGLE_CLIENT_ID": "xxx"}
|
||
|
||
invocations: list = []
|
||
|
||
async def _flaky_find_unique(**kwargs):
|
||
invocations.append(None)
|
||
if len(invocations) == 1:
|
||
raise prisma.errors.ClientNotConnectedError()
|
||
return mock_sso_config
|
||
|
||
mock_prisma_client = MagicMock()
|
||
mock_prisma_client.db.litellm_ssoconfig.find_unique = AsyncMock(
|
||
side_effect=_flaky_find_unique
|
||
)
|
||
mock_prisma_client.attempt_db_reconnect = AsyncMock(return_value=True)
|
||
mock_prisma_client._db_auth_reconnect_timeout_seconds = 2.0
|
||
mock_prisma_client._db_auth_reconnect_lock_timeout_seconds = 0.1
|
||
|
||
with patch.object(
|
||
proxy_config, "_decrypt_and_set_db_env_variables"
|
||
) as mock_decrypt:
|
||
await proxy_config._init_sso_settings_in_db(prisma_client=mock_prisma_client)
|
||
|
||
assert len(invocations) == 2
|
||
mock_prisma_client.attempt_db_reconnect.assert_awaited_once()
|
||
reconnect_kwargs = mock_prisma_client.attempt_db_reconnect.await_args.kwargs
|
||
assert reconnect_kwargs["reason"] == "init_sso_settings_in_db_lookup_failure"
|
||
mock_decrypt.assert_called_once()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_init_sso_settings_in_db_propagates_when_reconnect_fails():
|
||
"""When reconnect returns False (cooldown / lock contention), the original
|
||
ClientNotConnectedError is caught by the function's `except Exception` and
|
||
logged — no retry storm, no crash."""
|
||
import prisma
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
mock_prisma_client = MagicMock()
|
||
mock_prisma_client.db.litellm_ssoconfig.find_unique = AsyncMock(
|
||
side_effect=prisma.errors.ClientNotConnectedError()
|
||
)
|
||
mock_prisma_client.attempt_db_reconnect = AsyncMock(return_value=False)
|
||
mock_prisma_client._db_auth_reconnect_timeout_seconds = 2.0
|
||
mock_prisma_client._db_auth_reconnect_lock_timeout_seconds = 0.1
|
||
|
||
# Should NOT raise — the function's own try/except swallows the propagated error.
|
||
await proxy_config._init_sso_settings_in_db(prisma_client=mock_prisma_client)
|
||
|
||
mock_prisma_client.attempt_db_reconnect.assert_awaited_once()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_init_hashicorp_vault_config_override_retries_on_transport_error():
|
||
"""`_init_hashicorp_vault_config_override` self-heals across one
|
||
ClientNotConnectedError via call_with_db_reconnect_retry."""
|
||
import prisma
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
proxy_config._last_hashicorp_vault_config = None
|
||
|
||
invocations: list = []
|
||
|
||
async def _flaky_find_unique(**kwargs):
|
||
invocations.append(None)
|
||
if len(invocations) == 1:
|
||
raise prisma.errors.ClientNotConnectedError()
|
||
return None # No config in DB → function returns early after retry.
|
||
|
||
mock_prisma_client = MagicMock()
|
||
mock_prisma_client.db.litellm_configoverrides.find_unique = AsyncMock(
|
||
side_effect=_flaky_find_unique
|
||
)
|
||
mock_prisma_client.attempt_db_reconnect = AsyncMock(return_value=True)
|
||
mock_prisma_client._db_auth_reconnect_timeout_seconds = 2.0
|
||
mock_prisma_client._db_auth_reconnect_lock_timeout_seconds = 0.1
|
||
|
||
await proxy_config._init_hashicorp_vault_config_override(
|
||
prisma_client=mock_prisma_client
|
||
)
|
||
|
||
assert len(invocations) == 2
|
||
mock_prisma_client.attempt_db_reconnect.assert_awaited_once()
|
||
reconnect_kwargs = mock_prisma_client.attempt_db_reconnect.await_args.kwargs
|
||
assert (
|
||
reconnect_kwargs["reason"]
|
||
== "init_hashicorp_vault_config_override_lookup_failure"
|
||
)
|
||
|
||
|
||
def test_update_config_fields_uppercases_env_vars(monkeypatch):
|
||
"""
|
||
Ensure environment variables pulled from DB are uppercased when applied so
|
||
integrations like Datadog that expect uppercase env keys can read them.
|
||
"""
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
for key in ["DD_API_KEY", "DD_SITE", "dd_api_key", "dd_site"]:
|
||
monkeypatch.delenv(key, raising=False)
|
||
|
||
proxy_config = ProxyConfig()
|
||
updated_config = proxy_config._update_config_fields(
|
||
current_config={},
|
||
param_name="environment_variables",
|
||
db_param_value={"dd_api_key": "test-api-key", "dd_site": "us5.datadoghq.com"},
|
||
)
|
||
|
||
env_vars = updated_config.get("environment_variables", {})
|
||
assert env_vars["DD_API_KEY"] == "test-api-key"
|
||
assert env_vars["DD_SITE"] == "us5.datadoghq.com"
|
||
assert os.environ.get("DD_API_KEY") == "test-api-key"
|
||
assert os.environ.get("DD_SITE") == "us5.datadoghq.com"
|
||
|
||
|
||
def test_encrypt_env_variables_for_db_is_idempotent(monkeypatch):
|
||
"""
|
||
Regression: /config/update and save_config must not stack a second
|
||
encryption layer when a caller re-submits a value that is already
|
||
ciphertext (the Admin UI reads config back from /get/config/callbacks —
|
||
which returns the stored, still-encrypted value — and re-POSTs it on the
|
||
next save). _encrypt_env_variables_for_db must yield a value that decrypts
|
||
to the original plaintext in exactly ONE layer, no matter how many times
|
||
its own output is fed back in. It must also not mutate os.environ (write
|
||
path — loading into the process env is the read path's job).
|
||
"""
|
||
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
|
||
decrypt_value_helper,
|
||
)
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-test-salt-key")
|
||
monkeypatch.delenv("LANGFUSE_PUBLIC_KEY", raising=False)
|
||
|
||
proxy_config = ProxyConfig()
|
||
plaintext = "pk-langfuse-secret-value"
|
||
|
||
# First write: plaintext in -> single-encrypted out.
|
||
enc1 = proxy_config._encrypt_env_variables_for_db(
|
||
{"LANGFUSE_PUBLIC_KEY": plaintext}
|
||
)
|
||
assert enc1["LANGFUSE_PUBLIC_KEY"] != plaintext
|
||
assert (
|
||
decrypt_value_helper(
|
||
value=enc1["LANGFUSE_PUBLIC_KEY"], key="LANGFUSE_PUBLIC_KEY"
|
||
)
|
||
== plaintext
|
||
)
|
||
|
||
# UI round-trip: feed the ciphertext back in. Must NOT double-encrypt.
|
||
enc2 = proxy_config._encrypt_env_variables_for_db(enc1)
|
||
assert (
|
||
decrypt_value_helper(
|
||
value=enc2["LANGFUSE_PUBLIC_KEY"], key="LANGFUSE_PUBLIC_KEY"
|
||
)
|
||
== plaintext
|
||
)
|
||
|
||
# And again, ×3 total ciphertext re-feeds — still exactly one layer,
|
||
# never stacked, no matter how many times the UI re-saves.
|
||
enc3 = proxy_config._encrypt_env_variables_for_db(enc2)
|
||
enc4 = proxy_config._encrypt_env_variables_for_db(enc3)
|
||
for stacked in (enc3, enc4):
|
||
assert (
|
||
decrypt_value_helper(
|
||
value=stacked["LANGFUSE_PUBLIC_KEY"], key="LANGFUSE_PUBLIC_KEY"
|
||
)
|
||
== plaintext
|
||
)
|
||
|
||
# Write path must not leak the value into the process environment.
|
||
assert os.environ.get("LANGFUSE_PUBLIC_KEY") is None
|
||
|
||
|
||
def test_get_prompt_spec_for_db_prompt_with_versions():
|
||
"""
|
||
Test that _get_prompt_spec_for_db_prompt correctly converts database prompts
|
||
to PromptSpec with versioned naming convention.
|
||
"""
|
||
from unittest.mock import MagicMock
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
# Mock database prompt version 1
|
||
mock_prompt_v1 = MagicMock()
|
||
mock_prompt_v1.model_dump.return_value = {
|
||
"id": "uuid-1",
|
||
"prompt_id": "chat_prompt",
|
||
"version": 1,
|
||
"litellm_params": '{"prompt_id": "chat_prompt", "prompt_integration": "dotprompt", "model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "v1 content"}]}',
|
||
"prompt_info": '{"prompt_type": "db"}',
|
||
"created_at": "2024-01-01T00:00:00",
|
||
"updated_at": "2024-01-01T00:00:00",
|
||
}
|
||
|
||
# Mock database prompt version 2
|
||
mock_prompt_v2 = MagicMock()
|
||
mock_prompt_v2.model_dump.return_value = {
|
||
"id": "uuid-2",
|
||
"prompt_id": "chat_prompt",
|
||
"version": 2,
|
||
"litellm_params": '{"prompt_id": "chat_prompt", "prompt_integration": "dotprompt", "model": "gpt-4", "messages": [{"role": "user", "content": "v2 content"}]}',
|
||
"prompt_info": '{"prompt_type": "db"}',
|
||
"created_at": "2024-01-02T00:00:00",
|
||
"updated_at": "2024-01-02T00:00:00",
|
||
}
|
||
|
||
# Test version 1
|
||
prompt_spec_v1 = proxy_config._get_prompt_spec_for_db_prompt(
|
||
db_prompt=mock_prompt_v1
|
||
)
|
||
assert prompt_spec_v1.prompt_id == "chat_prompt.v1"
|
||
|
||
# Test version 2
|
||
prompt_spec_v2 = proxy_config._get_prompt_spec_for_db_prompt(
|
||
db_prompt=mock_prompt_v2
|
||
)
|
||
assert prompt_spec_v2.prompt_id == "chat_prompt.v2"
|
||
|
||
|
||
def test_root_redirect_when_docs_url_not_root_and_redirect_url_set(monkeypatch):
|
||
from fastapi.responses import RedirectResponse
|
||
|
||
from litellm.proxy.proxy_server import cleanup_router_config_variables
|
||
from litellm.proxy.utils import _get_docs_url
|
||
|
||
cleanup_router_config_variables()
|
||
filepath = os.path.dirname(os.path.abspath(__file__))
|
||
config_fp = f"{filepath}/test_configs/test_config_no_auth.yaml"
|
||
# Ensure docs are mounted on a non-root path to trigger redirect logic
|
||
monkeypatch.setenv("DOCS_URL", "/docs")
|
||
|
||
test_redirect_url = "/ui"
|
||
monkeypatch.setenv("ROOT_REDIRECT_URL", test_redirect_url)
|
||
|
||
asyncio.run(initialize(config=config_fp, debug=True))
|
||
|
||
docs_url = _get_docs_url()
|
||
root_redirect_url = os.getenv("ROOT_REDIRECT_URL")
|
||
|
||
# Remove any existing "/" route that might interfere
|
||
routes_to_remove = []
|
||
for route in app.routes:
|
||
if hasattr(route, "path") and route.path == "/":
|
||
if hasattr(route, "methods") and "GET" in route.methods:
|
||
routes_to_remove.append(route)
|
||
elif not hasattr(route, "methods"): # Catch-all routes
|
||
routes_to_remove.append(route)
|
||
|
||
for route in routes_to_remove:
|
||
app.routes.remove(route)
|
||
|
||
# Add the redirect route if conditions are met (matching the actual implementation)
|
||
if docs_url != "/" and root_redirect_url:
|
||
|
||
@app.get("/", include_in_schema=False)
|
||
async def root_redirect():
|
||
return RedirectResponse(url=root_redirect_url)
|
||
|
||
client = TestClient(app)
|
||
response = client.get("/", follow_redirects=False)
|
||
assert response.status_code == 307
|
||
assert response.headers["location"] == test_redirect_url
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_image_non_root_uses_var_lib_assets_dir(monkeypatch):
|
||
"""
|
||
Test that get_image uses /var/lib/litellm/assets when LITELLM_NON_ROOT is true.
|
||
"""
|
||
from unittest.mock import patch
|
||
|
||
from litellm.proxy.proxy_server import get_image
|
||
|
||
# Set LITELLM_NON_ROOT to true
|
||
monkeypatch.setenv("LITELLM_NON_ROOT", "true")
|
||
monkeypatch.delenv("UI_LOGO_PATH", raising=False)
|
||
|
||
# Mock os.path operations - exists=False for assets_dir so makedirs gets called
|
||
def exists_side_effect(path):
|
||
return False if path == "/var/lib/litellm/assets" else True
|
||
|
||
with (
|
||
patch("litellm.proxy.proxy_server.os.makedirs") as mock_makedirs,
|
||
patch(
|
||
"litellm.proxy.proxy_server.os.path.exists", side_effect=exists_side_effect
|
||
),
|
||
patch("litellm.proxy.proxy_server.os.access", return_value=True),
|
||
patch("litellm.proxy.proxy_server.os.getenv") as mock_getenv,
|
||
patch("litellm.proxy.proxy_server.FileResponse") as mock_file_response,
|
||
):
|
||
# Setup mock_getenv to return empty string for UI_LOGO_PATH
|
||
def getenv_side_effect(key, default=""):
|
||
if key == "UI_LOGO_PATH":
|
||
return ""
|
||
elif key == "LITELLM_NON_ROOT":
|
||
return "true"
|
||
return default
|
||
|
||
mock_getenv.side_effect = getenv_side_effect
|
||
|
||
# Call the function
|
||
await get_image()
|
||
|
||
# Verify makedirs was called with /var/lib/litellm/assets
|
||
mock_makedirs.assert_called_once_with("/var/lib/litellm/assets", exist_ok=True)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_image_non_root_fallback_to_default_logo(monkeypatch):
|
||
"""
|
||
Test that get_image falls back to default_site_logo when logo doesn't exist
|
||
in /var/lib/litellm/assets for non-root case.
|
||
"""
|
||
from unittest.mock import patch
|
||
|
||
from litellm.proxy.proxy_server import get_image
|
||
|
||
# Set LITELLM_NON_ROOT to true
|
||
monkeypatch.setenv("LITELLM_NON_ROOT", "true")
|
||
monkeypatch.delenv("UI_LOGO_PATH", raising=False)
|
||
|
||
# Track path.exists calls to verify it checks /var/lib/litellm/assets/logo.jpg
|
||
exists_calls = []
|
||
|
||
def exists_side_effect(path):
|
||
exists_calls.append(path)
|
||
# Return False for /var/lib/litellm/assets* so: makedirs is called, logo fallback
|
||
# triggers, and we don't return early with cached file
|
||
if "/var/lib/litellm/assets" in path:
|
||
return False
|
||
return True
|
||
|
||
# Mock os.path operations
|
||
with (
|
||
patch("litellm.proxy.proxy_server.os.makedirs") as mock_makedirs,
|
||
patch(
|
||
"litellm.proxy.proxy_server.os.path.exists", side_effect=exists_side_effect
|
||
),
|
||
patch("litellm.proxy.proxy_server.os.access", return_value=True),
|
||
patch("litellm.proxy.proxy_server.os.getenv") as mock_getenv,
|
||
patch("litellm.proxy.proxy_server.FileResponse") as mock_file_response,
|
||
):
|
||
# Setup mock_getenv
|
||
def getenv_side_effect(key, default=""):
|
||
if key == "UI_LOGO_PATH":
|
||
return ""
|
||
elif key == "LITELLM_NON_ROOT":
|
||
return "true"
|
||
return default
|
||
|
||
mock_getenv.side_effect = getenv_side_effect
|
||
|
||
# Call the function
|
||
await get_image()
|
||
|
||
# Verify makedirs was called with /var/lib/litellm/assets
|
||
mock_makedirs.assert_called_once_with("/var/lib/litellm/assets", exist_ok=True)
|
||
|
||
# Verify that exists was called to check /var/lib/litellm/assets/logo.jpg
|
||
assets_logo_path = "/var/lib/litellm/assets/logo.jpg"
|
||
assert any(
|
||
assets_logo_path in str(call) for call in exists_calls
|
||
), f"Should check if {assets_logo_path} exists"
|
||
|
||
# Verify FileResponse was called (with fallback logo)
|
||
assert mock_file_response.called, "FileResponse should be called"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_image_root_case_uses_current_dir(monkeypatch):
|
||
"""
|
||
Test that get_image uses current_dir when LITELLM_NON_ROOT is not true.
|
||
"""
|
||
from unittest.mock import patch
|
||
|
||
from litellm.proxy.proxy_server import get_image
|
||
|
||
# Don't set LITELLM_NON_ROOT (or set it to false)
|
||
monkeypatch.delenv("LITELLM_NON_ROOT", raising=False)
|
||
monkeypatch.delenv("UI_LOGO_PATH", raising=False)
|
||
|
||
# Mock os.path operations
|
||
with (
|
||
patch("litellm.proxy.proxy_server.os.makedirs") as mock_makedirs,
|
||
patch("litellm.proxy.proxy_server.os.path.exists", return_value=True),
|
||
patch("litellm.proxy.proxy_server.os.getenv") as mock_getenv,
|
||
patch("litellm.proxy.proxy_server.FileResponse") as mock_file_response,
|
||
):
|
||
# Setup mock_getenv
|
||
def getenv_side_effect(key, default=""):
|
||
if key == "UI_LOGO_PATH":
|
||
return ""
|
||
elif key == "LITELLM_NON_ROOT":
|
||
return "" # Not set or empty
|
||
return default
|
||
|
||
mock_getenv.side_effect = getenv_side_effect
|
||
|
||
# Call the function
|
||
await get_image()
|
||
|
||
# Verify makedirs was NOT called with /var/lib/litellm/assets (should not create it for root case)
|
||
var_lib_assets_calls = [
|
||
call
|
||
for call in mock_makedirs.call_args_list
|
||
if "/var/lib/litellm/assets" in str(call)
|
||
]
|
||
assert (
|
||
len(var_lib_assets_calls) == 0
|
||
), "Should not create /var/lib/litellm/assets for root case"
|
||
|
||
# Verify FileResponse was called
|
||
assert mock_file_response.called, "FileResponse should be called"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_image_custom_local_logo_bypasses_cache(monkeypatch, tmp_path):
|
||
"""
|
||
Test that when UI_LOGO_PATH is set to a local file, get_image serves it
|
||
directly and does not return a stale cached_logo.jpg.
|
||
|
||
Regression test: previously the cache check ran before reading UI_LOGO_PATH,
|
||
so a pre-existing cached_logo.jpg (e.g. from the base Docker image) would
|
||
always be returned, ignoring the user's custom logo.
|
||
"""
|
||
from litellm.proxy.proxy_server import get_image
|
||
|
||
custom_logo = tmp_path / "custom_logo.jpg"
|
||
custom_logo.write_bytes(b"\xff\xd8\xff custom logo")
|
||
monkeypatch.setenv("UI_LOGO_PATH", str(custom_logo))
|
||
monkeypatch.delenv("LITELLM_NON_ROOT", raising=False)
|
||
monkeypatch.delenv("LITELLM_ASSETS_PATH", raising=False)
|
||
|
||
calls_to_file_response = []
|
||
|
||
def fake_file_response(path, **kwargs):
|
||
calls_to_file_response.append(path)
|
||
return MagicMock()
|
||
|
||
with (
|
||
patch(
|
||
"litellm.proxy.proxy_server.FileResponse", side_effect=fake_file_response
|
||
),
|
||
):
|
||
await get_image()
|
||
|
||
assert (
|
||
len(calls_to_file_response) == 1
|
||
), "FileResponse should be called exactly once"
|
||
assert calls_to_file_response[0] == str(custom_logo.resolve()), (
|
||
f"Expected custom logo path, got {calls_to_file_response[0]}. "
|
||
"A stale cached_logo.jpg may have been returned instead."
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_image_default_logo_ignores_stale_cache(monkeypatch, tmp_path):
|
||
"""
|
||
Test that when UI_LOGO_PATH is NOT set, stale pre-fix cached_logo.jpg
|
||
files are ignored and the default logo is served.
|
||
"""
|
||
from unittest.mock import patch
|
||
|
||
from litellm.proxy.proxy_server import get_image
|
||
|
||
cache_path = tmp_path / "cached_logo.jpg"
|
||
cache_path.write_bytes(b"\xff\xd8\xff cached logo")
|
||
monkeypatch.delenv("UI_LOGO_PATH", raising=False)
|
||
monkeypatch.delenv("LITELLM_NON_ROOT", raising=False)
|
||
monkeypatch.setenv("LITELLM_ASSETS_PATH", str(tmp_path))
|
||
|
||
calls_to_file_response = []
|
||
|
||
def fake_file_response(path, **kwargs):
|
||
calls_to_file_response.append(path)
|
||
return MagicMock()
|
||
|
||
with (
|
||
patch(
|
||
"litellm.proxy.proxy_server.FileResponse", side_effect=fake_file_response
|
||
),
|
||
):
|
||
await get_image()
|
||
|
||
assert (
|
||
len(calls_to_file_response) == 1
|
||
), "FileResponse should be called exactly once"
|
||
served_path = calls_to_file_response[0]
|
||
assert served_path != str(cache_path.resolve())
|
||
assert served_path.endswith("logo.jpg")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_image_custom_logo_missing_falls_through_to_default(
|
||
monkeypatch, tmp_path
|
||
):
|
||
"""
|
||
Test that when UI_LOGO_PATH points to a non-existent local file,
|
||
get_image falls through to the default logo instead of failing.
|
||
"""
|
||
from unittest.mock import patch
|
||
|
||
from litellm.proxy.proxy_server import get_image
|
||
|
||
custom_logo_path = tmp_path / "nonexistent_logo.jpg"
|
||
monkeypatch.setenv("UI_LOGO_PATH", str(custom_logo_path))
|
||
monkeypatch.delenv("LITELLM_NON_ROOT", raising=False)
|
||
monkeypatch.setenv("LITELLM_ASSETS_PATH", str(tmp_path))
|
||
|
||
calls_to_file_response = []
|
||
|
||
def fake_file_response(path, **kwargs):
|
||
calls_to_file_response.append(path)
|
||
return MagicMock()
|
||
|
||
with (
|
||
patch(
|
||
"litellm.proxy.proxy_server.FileResponse", side_effect=fake_file_response
|
||
),
|
||
):
|
||
await get_image()
|
||
|
||
assert (
|
||
len(calls_to_file_response) == 1
|
||
), "FileResponse should be called exactly once"
|
||
served_path = calls_to_file_response[0]
|
||
assert served_path != str(
|
||
custom_logo_path
|
||
), "Should not attempt to serve a non-existent custom logo"
|
||
assert served_path.endswith("logo.jpg")
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_image_custom_logo_missing_no_cache_serves_default(
|
||
monkeypatch, tmp_path
|
||
):
|
||
"""
|
||
Test that when UI_LOGO_PATH points to a non-existent file AND there is no
|
||
cached_logo.jpg, get_image serves the default logo instead of the non-existent
|
||
custom path.
|
||
"""
|
||
from unittest.mock import patch
|
||
|
||
from litellm.proxy.proxy_server import get_image
|
||
|
||
custom_logo_path = tmp_path / "nonexistent_logo.jpg"
|
||
monkeypatch.setenv("UI_LOGO_PATH", str(custom_logo_path))
|
||
monkeypatch.delenv("LITELLM_NON_ROOT", raising=False)
|
||
monkeypatch.setenv("LITELLM_ASSETS_PATH", str(tmp_path))
|
||
|
||
calls_to_file_response = []
|
||
|
||
def fake_file_response(path, **kwargs):
|
||
calls_to_file_response.append(path)
|
||
return MagicMock()
|
||
|
||
with (
|
||
patch(
|
||
"litellm.proxy.proxy_server.FileResponse", side_effect=fake_file_response
|
||
),
|
||
):
|
||
await get_image()
|
||
|
||
assert (
|
||
len(calls_to_file_response) == 1
|
||
), "FileResponse should be called exactly once"
|
||
served_path = calls_to_file_response[0]
|
||
assert served_path != str(
|
||
custom_logo_path
|
||
), "Should not attempt to serve a non-existent custom logo"
|
||
assert served_path.endswith(
|
||
"logo.jpg"
|
||
), f"Expected fallback to default logo.jpg, got {served_path}"
|
||
|
||
|
||
def test_get_config_normalizes_string_callbacks(monkeypatch):
|
||
"""
|
||
Test that /get/config/callbacks normalizes string callbacks to lists.
|
||
"""
|
||
from litellm.proxy.proxy_server import app, proxy_config, user_api_key_auth
|
||
|
||
config_data = {
|
||
"litellm_settings": {
|
||
"success_callback": "langfuse",
|
||
"failure_callback": None,
|
||
"callbacks": ["prometheus", "datadog"],
|
||
},
|
||
"general_settings": {},
|
||
"environment_variables": {},
|
||
}
|
||
|
||
mock_router = MagicMock()
|
||
mock_router.get_settings.return_value = {}
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.llm_router", mock_router)
|
||
monkeypatch.setattr(proxy_config, "get_config", AsyncMock(return_value=config_data))
|
||
|
||
original_overrides = app.dependency_overrides.copy()
|
||
app.dependency_overrides[user_api_key_auth] = lambda: MagicMock()
|
||
|
||
client = TestClient(app)
|
||
try:
|
||
response = client.get("/get/config/callbacks")
|
||
finally:
|
||
app.dependency_overrides = original_overrides
|
||
|
||
assert response.status_code == 200
|
||
callbacks = response.json()["callbacks"]
|
||
|
||
success_callbacks = [cb["name"] for cb in callbacks if cb.get("type") == "success"]
|
||
failure_callbacks = [cb["name"] for cb in callbacks if cb.get("type") == "failure"]
|
||
success_and_failure_callbacks = [
|
||
cb["name"] for cb in callbacks if cb.get("type") == "success_and_failure"
|
||
]
|
||
|
||
assert "langfuse" in success_callbacks
|
||
assert len(failure_callbacks) == 0
|
||
assert "prometheus" in success_and_failure_callbacks
|
||
assert "datadog" in success_and_failure_callbacks
|
||
|
||
|
||
def test_deep_merge_dicts_skips_none_and_empty_lists(monkeypatch):
|
||
"""
|
||
Test that _update_config_fields deep merge skips None values and empty lists.
|
||
"""
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
current_config = {
|
||
"general_settings": {
|
||
"max_parallel_requests": 10,
|
||
"allowed_models": ["gpt-3.5-turbo", "gpt-4"],
|
||
"nested": {
|
||
"key1": "value1",
|
||
"key2": "value2",
|
||
},
|
||
}
|
||
}
|
||
|
||
db_param_value = {
|
||
"max_parallel_requests": None,
|
||
"allowed_models": [],
|
||
"new_key": "new_value",
|
||
"nested": {
|
||
"key1": "updated_value1",
|
||
"key3": "value3",
|
||
},
|
||
}
|
||
|
||
result = proxy_config._update_config_fields(
|
||
current_config, "general_settings", db_param_value
|
||
)
|
||
|
||
assert result["general_settings"]["max_parallel_requests"] == 10
|
||
assert result["general_settings"]["allowed_models"] == ["gpt-3.5-turbo", "gpt-4"]
|
||
assert result["general_settings"]["new_key"] == "new_value"
|
||
assert result["general_settings"]["nested"]["key1"] == "updated_value1"
|
||
assert result["general_settings"]["nested"]["key2"] == "value2"
|
||
assert result["general_settings"]["nested"]["key3"] == "value3"
|
||
|
||
|
||
class TestInvitationEndpoints:
|
||
"""Tests for /invitation/new and /invitation/delete endpoints."""
|
||
|
||
@pytest.fixture
|
||
def client_with_auth(self):
|
||
"""Create a test client with admin authentication."""
|
||
from litellm.proxy._types import LitellmUserRoles
|
||
from litellm.proxy.proxy_server import cleanup_router_config_variables
|
||
|
||
cleanup_router_config_variables()
|
||
filepath = os.path.dirname(os.path.abspath(__file__))
|
||
config_fp = f"{filepath}/test_configs/test_config_no_auth.yaml"
|
||
asyncio.run(initialize(config=config_fp, debug=True))
|
||
|
||
mock_auth = MagicMock()
|
||
mock_auth.user_id = "admin-user-id"
|
||
mock_auth.user_role = LitellmUserRoles.PROXY_ADMIN
|
||
mock_auth.api_key = "sk-test"
|
||
app.dependency_overrides[user_api_key_auth] = lambda: mock_auth
|
||
|
||
return TestClient(app)
|
||
|
||
@pytest.mark.parametrize(
|
||
"endpoint,payload,mock_return",
|
||
[
|
||
(
|
||
"/invitation/new",
|
||
{"user_id": "target-user-123"},
|
||
{
|
||
"id": "inv-123",
|
||
"user_id": "target-user-123",
|
||
"is_accepted": False,
|
||
"accepted_at": None,
|
||
"expires_at": "2025-02-18T00:00:00",
|
||
"created_at": "2025-02-11T00:00:00",
|
||
"created_by": "admin-user-id",
|
||
"updated_at": "2025-02-11T00:00:00",
|
||
"updated_by": "admin-user-id",
|
||
},
|
||
),
|
||
(
|
||
"/invitation/delete",
|
||
{"invitation_id": "inv-456"},
|
||
{
|
||
"id": "inv-456",
|
||
"user_id": "target-user-123",
|
||
"is_accepted": False,
|
||
"accepted_at": None,
|
||
"expires_at": "2025-02-18T00:00:00",
|
||
"created_at": "2025-02-11T00:00:00",
|
||
"created_by": "admin-user-id",
|
||
"updated_at": "2025-02-11T00:00:00",
|
||
"updated_by": "admin-user-id",
|
||
},
|
||
),
|
||
],
|
||
)
|
||
def test_invitation_endpoints_proxy_admin_success(
|
||
self, client_with_auth, endpoint, payload, mock_return
|
||
):
|
||
"""Proxy admin can successfully create and delete invitations."""
|
||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
|
||
mock_prisma.db.litellm_invitationlink = MagicMock()
|
||
if endpoint == "/invitation/new":
|
||
mock_create = AsyncMock(return_value=mock_return)
|
||
with patch(
|
||
"litellm.proxy.management_helpers.user_invitation.create_invitation_for_user",
|
||
mock_create,
|
||
):
|
||
response = client_with_auth.post(endpoint, json=payload)
|
||
else:
|
||
mock_prisma.db.litellm_invitationlink.find_unique = AsyncMock(
|
||
return_value={**mock_return, "created_by": "admin-user-id"}
|
||
)
|
||
mock_prisma.db.litellm_invitationlink.delete = AsyncMock(
|
||
return_value=mock_return
|
||
)
|
||
response = client_with_auth.post(endpoint, json=payload)
|
||
|
||
assert response.status_code == 200
|
||
data = response.json()
|
||
assert data["id"] == mock_return["id"]
|
||
assert data["user_id"] == mock_return["user_id"]
|
||
|
||
@pytest.mark.parametrize(
|
||
"endpoint,payload",
|
||
[
|
||
("/invitation/new", {"user_id": "target-user-123"}),
|
||
("/invitation/delete", {"invitation_id": "inv-456"}),
|
||
],
|
||
)
|
||
def test_invitation_endpoints_non_admin_denied(
|
||
self, client_with_auth, endpoint, payload
|
||
):
|
||
"""Non-admin users cannot access invitation endpoints."""
|
||
from litellm.proxy._types import LitellmUserRoles
|
||
|
||
mock_auth = MagicMock()
|
||
mock_auth.user_id = "regular-user"
|
||
mock_auth.user_role = LitellmUserRoles.INTERNAL_USER
|
||
mock_auth.api_key = "sk-regular"
|
||
app.dependency_overrides[user_api_key_auth] = lambda: mock_auth
|
||
|
||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
|
||
mock_prisma.db.litellm_invitationlink = MagicMock()
|
||
# Avoid triggering async DB calls in _user_has_admin_privileges
|
||
with patch(
|
||
"litellm.proxy.proxy_server._user_has_admin_privileges",
|
||
new_callable=AsyncMock,
|
||
return_value=False,
|
||
):
|
||
response = client_with_auth.post(endpoint, json=payload)
|
||
|
||
assert response.status_code == 400
|
||
body = response.json()
|
||
# ProxyException handler returns {"error": {...}}, HTTPException returns {"detail": {...}}
|
||
error_content = body.get("error", body.get("detail", body))
|
||
assert "not allowed" in str(error_content).lower()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_async_data_generator_cleanup_on_early_exit():
|
||
"""
|
||
Test that async_data_generator calls response.aclose() in the finally block
|
||
when the generator is abandoned mid-stream (client disconnect).
|
||
"""
|
||
from litellm.proxy._types import UserAPIKeyAuth
|
||
from litellm.proxy.proxy_server import async_data_generator
|
||
from litellm.proxy.utils import ProxyLogging
|
||
|
||
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
||
mock_request_data = {
|
||
"model": "gpt-3.5-turbo",
|
||
"messages": [{"role": "user", "content": "test"}],
|
||
}
|
||
|
||
mock_chunks = [
|
||
{"choices": [{"delta": {"content": "Hello"}}]},
|
||
{"choices": [{"delta": {"content": " world"}}]},
|
||
{"choices": [{"delta": {"content": " more"}}]},
|
||
]
|
||
|
||
mock_proxy_logging_obj = MagicMock(spec=ProxyLogging)
|
||
|
||
async def mock_streaming_iterator(*args, **kwargs):
|
||
for chunk in mock_chunks:
|
||
yield chunk
|
||
|
||
mock_proxy_logging_obj.async_post_call_streaming_iterator_hook = (
|
||
mock_streaming_iterator
|
||
)
|
||
mock_proxy_logging_obj.async_post_call_streaming_hook = AsyncMock(
|
||
side_effect=lambda **kwargs: kwargs.get("response")
|
||
)
|
||
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock()
|
||
|
||
# Create a mock response with aclose
|
||
mock_response = MagicMock()
|
||
mock_response.aclose = AsyncMock()
|
||
|
||
with patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj):
|
||
# Consume only the first chunk then abandon the generator (simulates client disconnect)
|
||
gen = async_data_generator(
|
||
mock_response, mock_user_api_key_dict, mock_request_data
|
||
)
|
||
first_chunk = await gen.__anext__()
|
||
assert first_chunk.startswith("data: ")
|
||
|
||
# Close the generator early (simulates what ASGI does on client disconnect)
|
||
await gen.aclose()
|
||
|
||
# Verify aclose was called on the response to release the HTTP connection
|
||
mock_response.aclose.assert_awaited_once()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_async_data_generator_uses_direct_stream_fast_path_without_callbacks():
|
||
"""
|
||
When there are no streaming callbacks, async_data_generator should avoid
|
||
per-chunk hook machinery and iterate the provider stream directly.
|
||
"""
|
||
from litellm.proxy._types import UserAPIKeyAuth
|
||
from litellm.proxy.proxy_server import async_data_generator
|
||
from litellm.proxy.utils import ProxyLogging
|
||
|
||
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
||
mock_request_data = {
|
||
"model": "gpt-3.5-turbo",
|
||
"messages": [{"role": "user", "content": "test"}],
|
||
}
|
||
mock_chunks = [
|
||
{"choices": [{"delta": {"content": "Hello"}}]},
|
||
{"choices": [{"delta": {"content": " world"}}]},
|
||
]
|
||
|
||
class MockStream:
|
||
def __aiter__(self):
|
||
return self._stream()
|
||
|
||
async def _stream(self):
|
||
for chunk in mock_chunks:
|
||
yield chunk
|
||
|
||
async def aclose(self):
|
||
pass
|
||
|
||
mock_response = MockStream()
|
||
mock_response.aclose = AsyncMock()
|
||
mock_proxy_logging_obj = MagicMock(spec=ProxyLogging)
|
||
mock_proxy_logging_obj.has_streaming_callbacks.return_value = False
|
||
mock_proxy_logging_obj.needs_iterator_wrap.return_value = False
|
||
mock_proxy_logging_obj.needs_per_chunk_streaming_hook.return_value = False
|
||
mock_proxy_logging_obj.async_post_call_streaming_iterator_hook = MagicMock()
|
||
mock_proxy_logging_obj.async_post_call_streaming_hook = AsyncMock()
|
||
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock()
|
||
|
||
with patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj):
|
||
with patch.object(
|
||
ProxyLogging, "_fire_deferred_stream_logging"
|
||
) as mock_deferred_logging:
|
||
yielded_data = []
|
||
async for data in async_data_generator(
|
||
mock_response, mock_user_api_key_dict, mock_request_data
|
||
):
|
||
yielded_data.append(data)
|
||
|
||
yielded_text = [
|
||
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
|
||
for chunk in yielded_data
|
||
]
|
||
assert len([chunk for chunk in yielded_text if chunk.startswith("data: {")]) == 2
|
||
assert yielded_text[-1] == "data: [DONE]\n\n"
|
||
mock_proxy_logging_obj.async_post_call_streaming_iterator_hook.assert_not_called()
|
||
mock_proxy_logging_obj.async_post_call_streaming_hook.assert_not_awaited()
|
||
mock_deferred_logging.assert_called_once_with(mock_request_data)
|
||
mock_response.aclose.assert_awaited_once()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_async_data_generator_preserves_non_raw_sse_like_bytes():
|
||
"""
|
||
Already formatted SSE bytes from non-raw streams keep the legacy passthrough
|
||
behavior, including appending a missing event terminator.
|
||
"""
|
||
from litellm.proxy._types import UserAPIKeyAuth
|
||
from litellm.proxy.proxy_server import async_data_generator
|
||
from litellm.proxy.utils import ProxyLogging
|
||
|
||
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
||
mock_request_data = {
|
||
"model": "gemini-2.0-flash",
|
||
"messages": [{"role": "user", "content": "test"}],
|
||
}
|
||
gemini_event = b'data: {"candidates": [{"content": "hi"}]}\n\n'
|
||
gemini_event_without_terminator = b'data: {"candidates": [{"content": "there"}]}'
|
||
raw_payload = b'{"partial": true}'
|
||
|
||
class MockStream:
|
||
def __aiter__(self):
|
||
return self._stream()
|
||
|
||
async def _stream(self):
|
||
yield gemini_event
|
||
yield gemini_event_without_terminator
|
||
yield raw_payload
|
||
|
||
async def aclose(self):
|
||
pass
|
||
|
||
mock_response = MockStream()
|
||
mock_response.aclose = AsyncMock()
|
||
mock_proxy_logging_obj = MagicMock(spec=ProxyLogging)
|
||
mock_proxy_logging_obj.has_streaming_callbacks.return_value = False
|
||
mock_proxy_logging_obj.needs_iterator_wrap.return_value = False
|
||
mock_proxy_logging_obj.needs_per_chunk_streaming_hook.return_value = False
|
||
mock_proxy_logging_obj.async_post_call_streaming_iterator_hook = MagicMock()
|
||
mock_proxy_logging_obj.async_post_call_streaming_hook = AsyncMock()
|
||
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock()
|
||
|
||
with patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj):
|
||
with patch.object(ProxyLogging, "_fire_deferred_stream_logging"):
|
||
yielded_data = []
|
||
async for data in async_data_generator(
|
||
mock_response, mock_user_api_key_dict, mock_request_data
|
||
):
|
||
yielded_data.append(data)
|
||
|
||
yielded_text = [
|
||
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
|
||
for chunk in yielded_data
|
||
]
|
||
assert yielded_text[0] == gemini_event.decode("utf-8")
|
||
assert yielded_text[1] == gemini_event_without_terminator.decode("utf-8") + "\n\n"
|
||
assert yielded_text[2] == f'data: {raw_payload.decode("utf-8")}\n\n'
|
||
assert "b'data:" not in "".join(yielded_text)
|
||
assert yielded_text[-1] == "data: [DONE]\n\n"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_async_data_generator_buffers_split_google_native_sse_json_frame():
|
||
from litellm.proxy._types import UserAPIKeyAuth
|
||
from litellm.proxy.proxy_server import async_data_generator
|
||
from litellm.proxy.utils import ProxyLogging
|
||
|
||
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
||
mock_request_data = {
|
||
"model": "gemini-3.5-flash",
|
||
"_litellm_skip_openai_stream_done": True,
|
||
"_litellm_raw_sse_stream": True,
|
||
}
|
||
payload = (
|
||
'data: {"candidates": [{"content": {"role": "model", "parts": '
|
||
'[{"text": "", "thoughtSignature": "abc123def456"}]}}]}\n\n'
|
||
)
|
||
raw_chunks = [
|
||
payload[:2].encode("utf-8"),
|
||
payload[
|
||
2 : payload.index("thoughtSignature") + len('thoughtSignature": "abc')
|
||
].encode("utf-8"),
|
||
payload[
|
||
payload.index("thoughtSignature") + len('thoughtSignature": "abc') :
|
||
].encode("utf-8"),
|
||
]
|
||
|
||
class MockStream:
|
||
def __aiter__(self):
|
||
return self._stream()
|
||
|
||
async def _stream(self):
|
||
for chunk in raw_chunks:
|
||
yield chunk
|
||
|
||
async def aclose(self):
|
||
pass
|
||
|
||
mock_response = MockStream()
|
||
mock_response.aclose = AsyncMock()
|
||
mock_proxy_logging_obj = MagicMock(spec=ProxyLogging)
|
||
mock_proxy_logging_obj.has_streaming_callbacks.return_value = False
|
||
mock_proxy_logging_obj.needs_iterator_wrap.return_value = False
|
||
mock_proxy_logging_obj.needs_per_chunk_streaming_hook.return_value = False
|
||
mock_proxy_logging_obj.async_post_call_streaming_iterator_hook = MagicMock()
|
||
mock_proxy_logging_obj.async_post_call_streaming_hook = AsyncMock()
|
||
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock()
|
||
|
||
with patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj):
|
||
with patch.object(ProxyLogging, "_fire_deferred_stream_logging"):
|
||
yielded_data = []
|
||
async for data in async_data_generator(
|
||
mock_response, mock_user_api_key_dict, mock_request_data
|
||
):
|
||
yielded_data.append(data)
|
||
|
||
yielded_text = [
|
||
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
|
||
for chunk in yielded_data
|
||
]
|
||
|
||
assert yielded_text == [payload]
|
||
for chunk in yielded_text:
|
||
assert chunk.endswith("\n\n")
|
||
assert json.loads(chunk.removeprefix("data: ").strip())
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_async_data_generator_flushes_raw_sse_stream_without_trailing_delimiter():
|
||
from litellm.proxy._types import UserAPIKeyAuth
|
||
from litellm.proxy.proxy_server import async_data_generator
|
||
from litellm.proxy.utils import ProxyLogging
|
||
|
||
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
||
mock_request_data = {
|
||
"model": "gemini-3.5-flash",
|
||
"_litellm_skip_openai_stream_done": True,
|
||
"_litellm_raw_sse_stream": True,
|
||
}
|
||
|
||
class MockStream:
|
||
def __aiter__(self):
|
||
return self._stream()
|
||
|
||
async def _stream(self):
|
||
yield b'data: {"candidates": [{"content": "unterminated"}]'
|
||
|
||
async def aclose(self):
|
||
pass
|
||
|
||
mock_response = MockStream()
|
||
mock_response.aclose = AsyncMock()
|
||
mock_proxy_logging_obj = MagicMock(spec=ProxyLogging)
|
||
mock_proxy_logging_obj.has_streaming_callbacks.return_value = False
|
||
mock_proxy_logging_obj.needs_iterator_wrap.return_value = False
|
||
mock_proxy_logging_obj.needs_per_chunk_streaming_hook.return_value = False
|
||
mock_proxy_logging_obj.async_post_call_streaming_iterator_hook = MagicMock()
|
||
mock_proxy_logging_obj.async_post_call_streaming_hook = AsyncMock()
|
||
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock()
|
||
|
||
with (
|
||
patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj),
|
||
patch.object(ProxyLogging, "_fire_deferred_stream_logging"),
|
||
):
|
||
yielded_data = []
|
||
async for data in async_data_generator(
|
||
mock_response, mock_user_api_key_dict, mock_request_data
|
||
):
|
||
yielded_data.append(data)
|
||
|
||
yielded_text = [
|
||
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
|
||
for chunk in yielded_data
|
||
]
|
||
assert len(yielded_text) == 1
|
||
assert yielded_text[0] == 'data: {"candidates": [{"content": "unterminated"}]\n\n'
|
||
assert "[DONE]" not in yielded_text[0]
|
||
mock_proxy_logging_obj.post_call_failure_hook.assert_not_awaited()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_async_data_generator_errors_when_raw_sse_frame_exceeds_buffer_limit():
|
||
from litellm.proxy._types import UserAPIKeyAuth
|
||
from litellm.proxy.proxy_server import async_data_generator
|
||
from litellm.proxy.utils import ProxyLogging
|
||
|
||
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
||
mock_request_data = {
|
||
"model": "gemini-3.5-flash",
|
||
"_litellm_skip_openai_stream_done": True,
|
||
"_litellm_raw_sse_stream": True,
|
||
}
|
||
|
||
class MockStream:
|
||
def __aiter__(self):
|
||
return self._stream()
|
||
|
||
async def _stream(self):
|
||
yield b"data: "
|
||
yield b'{"candidates": [{"content": "unterminated"}]'
|
||
|
||
async def aclose(self):
|
||
pass
|
||
|
||
mock_response = MockStream()
|
||
mock_response.aclose = AsyncMock()
|
||
mock_proxy_logging_obj = MagicMock(spec=ProxyLogging)
|
||
mock_proxy_logging_obj.has_streaming_callbacks.return_value = False
|
||
mock_proxy_logging_obj.needs_iterator_wrap.return_value = False
|
||
mock_proxy_logging_obj.needs_per_chunk_streaming_hook.return_value = False
|
||
mock_proxy_logging_obj.async_post_call_streaming_iterator_hook = MagicMock()
|
||
mock_proxy_logging_obj.async_post_call_streaming_hook = AsyncMock()
|
||
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock()
|
||
|
||
with (
|
||
patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj),
|
||
patch("litellm.proxy.proxy_server._MAX_RAW_SSE_BUFFER_CHARS", 8),
|
||
patch.object(ProxyLogging, "_fire_deferred_stream_logging"),
|
||
):
|
||
yielded_data = []
|
||
async for data in async_data_generator(
|
||
mock_response, mock_user_api_key_dict, mock_request_data
|
||
):
|
||
yielded_data.append(data)
|
||
|
||
yielded_text = [
|
||
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
|
||
for chunk in yielded_data
|
||
]
|
||
assert len(yielded_text) == 1
|
||
assert "maximum buffered size" in yielded_text[0]
|
||
assert "[DONE]" not in yielded_text[0]
|
||
mock_proxy_logging_obj.post_call_failure_hook.assert_awaited_once()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize("as_bytes", [True, False])
|
||
async def test_async_data_generator_checks_raw_sse_buffer_limit_after_complete_frames(
|
||
as_bytes,
|
||
):
|
||
from litellm.proxy._types import UserAPIKeyAuth
|
||
from litellm.proxy.proxy_server import async_data_generator
|
||
from litellm.proxy.utils import ProxyLogging
|
||
|
||
complete_frame = 'data: {"candidates": [{"content": "ok"}]}\n\n'
|
||
partial_frame = "data: "
|
||
raw_chunk = complete_frame + partial_frame
|
||
|
||
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
||
mock_request_data = {
|
||
"model": "gemini-3.5-flash",
|
||
"_litellm_skip_openai_stream_done": True,
|
||
"_litellm_raw_sse_stream": True,
|
||
}
|
||
|
||
class MockStream:
|
||
def __aiter__(self):
|
||
return self._stream()
|
||
|
||
async def _stream(self):
|
||
yield raw_chunk.encode("utf-8") if as_bytes else raw_chunk
|
||
|
||
async def aclose(self):
|
||
pass
|
||
|
||
mock_response = MockStream()
|
||
mock_response.aclose = AsyncMock()
|
||
mock_proxy_logging_obj = MagicMock(spec=ProxyLogging)
|
||
mock_proxy_logging_obj.has_streaming_callbacks.return_value = False
|
||
mock_proxy_logging_obj.needs_iterator_wrap.return_value = False
|
||
mock_proxy_logging_obj.needs_per_chunk_streaming_hook.return_value = False
|
||
mock_proxy_logging_obj.async_post_call_streaming_iterator_hook = MagicMock()
|
||
mock_proxy_logging_obj.async_post_call_streaming_hook = AsyncMock()
|
||
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock()
|
||
|
||
with (
|
||
patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj),
|
||
patch("litellm.proxy.proxy_server._MAX_RAW_SSE_BUFFER_CHARS", 8),
|
||
patch.object(ProxyLogging, "_fire_deferred_stream_logging"),
|
||
):
|
||
yielded_data = []
|
||
async for data in async_data_generator(
|
||
mock_response, mock_user_api_key_dict, mock_request_data
|
||
):
|
||
yielded_data.append(data)
|
||
|
||
yielded_text = [
|
||
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
|
||
for chunk in yielded_data
|
||
]
|
||
assert yielded_text[0] == complete_frame
|
||
assert yielded_text[1] == partial_frame + "\n\n"
|
||
assert "[DONE]" not in "".join(yielded_text)
|
||
mock_proxy_logging_obj.post_call_failure_hook.assert_not_awaited()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_async_data_generator_google_genai_stream_omits_openai_done():
|
||
"""
|
||
google-genai SDK streamGenerateContent?alt=sse must not receive data: [DONE].
|
||
"""
|
||
from litellm.proxy._types import UserAPIKeyAuth
|
||
from litellm.proxy.proxy_server import async_data_generator
|
||
from litellm.proxy.utils import ProxyLogging
|
||
|
||
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
||
mock_request_data = {
|
||
"model": "gemini-2.0-flash",
|
||
"_litellm_skip_openai_stream_done": True,
|
||
}
|
||
gemini_event = (
|
||
b'data: {"candidates": [{"content": {"parts": [{"text": "Hi"}]}}]}\n\n'
|
||
)
|
||
|
||
class MockStream:
|
||
def __aiter__(self):
|
||
return self._stream()
|
||
|
||
async def _stream(self):
|
||
yield gemini_event
|
||
|
||
async def aclose(self):
|
||
pass
|
||
|
||
mock_response = MockStream()
|
||
mock_response.aclose = AsyncMock()
|
||
mock_proxy_logging_obj = MagicMock(spec=ProxyLogging)
|
||
mock_proxy_logging_obj.has_streaming_callbacks.return_value = False
|
||
mock_proxy_logging_obj.needs_iterator_wrap.return_value = False
|
||
mock_proxy_logging_obj.needs_per_chunk_streaming_hook.return_value = False
|
||
mock_proxy_logging_obj.async_post_call_streaming_iterator_hook = MagicMock()
|
||
mock_proxy_logging_obj.async_post_call_streaming_hook = AsyncMock()
|
||
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock()
|
||
|
||
with patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj):
|
||
with patch.object(ProxyLogging, "_fire_deferred_stream_logging"):
|
||
yielded_data = []
|
||
async for data in async_data_generator(
|
||
mock_response, mock_user_api_key_dict, mock_request_data
|
||
):
|
||
yielded_data.append(data)
|
||
|
||
yielded_text = [
|
||
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
|
||
for chunk in yielded_data
|
||
]
|
||
assert yielded_text == [gemini_event.decode("utf-8")]
|
||
assert "[DONE]" not in "".join(yielded_text)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_async_data_generator_does_not_mark_completed_stream_as_disconnect():
|
||
from litellm.proxy._types import UserAPIKeyAuth
|
||
from litellm.proxy.proxy_server import async_data_generator
|
||
from litellm.proxy.utils import ProxyLogging
|
||
|
||
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
||
mock_request_data = {"model": "gpt-4o", "metadata": {}}
|
||
|
||
class MockStream:
|
||
def __aiter__(self):
|
||
return self._stream()
|
||
|
||
async def _stream(self):
|
||
yield {"choices": [{"delta": {"content": "done"}}]}
|
||
|
||
async def aclose(self):
|
||
pass
|
||
|
||
mock_request = MagicMock()
|
||
mock_request.is_disconnected = AsyncMock(return_value=True)
|
||
mock_response = MockStream()
|
||
mock_response.aclose = AsyncMock()
|
||
mock_proxy_logging_obj = MagicMock(spec=ProxyLogging)
|
||
mock_proxy_logging_obj.has_streaming_callbacks.return_value = False
|
||
mock_proxy_logging_obj.needs_iterator_wrap.return_value = False
|
||
mock_proxy_logging_obj.needs_per_chunk_streaming_hook.return_value = False
|
||
mock_proxy_logging_obj.async_post_call_streaming_iterator_hook = MagicMock()
|
||
mock_proxy_logging_obj.async_post_call_streaming_hook = AsyncMock()
|
||
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock()
|
||
|
||
with patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj):
|
||
with patch.object(ProxyLogging, "_fire_deferred_stream_logging"):
|
||
yielded_data = []
|
||
async for data in async_data_generator(
|
||
mock_response,
|
||
mock_user_api_key_dict,
|
||
mock_request_data,
|
||
request=mock_request,
|
||
):
|
||
yielded_data.append(data)
|
||
|
||
assert yielded_data[-1] == "data: [DONE]\n\n"
|
||
mock_request.is_disconnected.assert_not_awaited()
|
||
assert "client_disconnected" not in mock_request_data["metadata"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_async_data_generator_google_genai_stream_forwards_error_without_done():
|
||
"""Stream errors must still reach the client when OpenAI [DONE] is skipped."""
|
||
from litellm.proxy._types import UserAPIKeyAuth
|
||
from litellm.proxy.proxy_server import async_data_generator
|
||
from litellm.proxy.utils import ProxyLogging
|
||
|
||
error_sse = 'data: {"error": {"message": "stream failed"}}\n\n'
|
||
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
||
mock_request_data = {
|
||
"model": "gemini-2.0-flash",
|
||
"_litellm_skip_openai_stream_done": True,
|
||
}
|
||
|
||
class MockStream:
|
||
def __aiter__(self):
|
||
return self._stream()
|
||
|
||
async def _stream(self):
|
||
yield error_sse
|
||
|
||
async def aclose(self):
|
||
pass
|
||
|
||
mock_response = MockStream()
|
||
mock_response.aclose = AsyncMock()
|
||
mock_proxy_logging_obj = MagicMock(spec=ProxyLogging)
|
||
mock_proxy_logging_obj.has_streaming_callbacks.return_value = False
|
||
mock_proxy_logging_obj.needs_iterator_wrap.return_value = False
|
||
mock_proxy_logging_obj.needs_per_chunk_streaming_hook.return_value = False
|
||
mock_proxy_logging_obj.async_post_call_streaming_iterator_hook = MagicMock()
|
||
mock_proxy_logging_obj.async_post_call_streaming_hook = AsyncMock()
|
||
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock()
|
||
|
||
with patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj):
|
||
with patch.object(ProxyLogging, "_fire_deferred_stream_logging"):
|
||
yielded_data = []
|
||
async for data in async_data_generator(
|
||
mock_response, mock_user_api_key_dict, mock_request_data
|
||
):
|
||
yielded_data.append(data)
|
||
|
||
yielded_text = [
|
||
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
|
||
for chunk in yielded_data
|
||
]
|
||
assert yielded_text == [error_sse]
|
||
assert "[DONE]" not in "".join(yielded_text)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_async_data_generator_cleanup_on_normal_completion():
|
||
"""
|
||
Test that async_data_generator calls response.aclose() even on normal completion.
|
||
"""
|
||
from litellm.proxy._types import UserAPIKeyAuth
|
||
from litellm.proxy.proxy_server import async_data_generator
|
||
from litellm.proxy.utils import ProxyLogging
|
||
|
||
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
||
mock_request_data = {
|
||
"model": "gpt-3.5-turbo",
|
||
"messages": [{"role": "user", "content": "test"}],
|
||
}
|
||
|
||
mock_chunks = [
|
||
{"choices": [{"delta": {"content": "Hello"}}]},
|
||
]
|
||
|
||
mock_proxy_logging_obj = MagicMock(spec=ProxyLogging)
|
||
|
||
async def mock_streaming_iterator(*args, **kwargs):
|
||
for chunk in mock_chunks:
|
||
yield chunk
|
||
|
||
mock_proxy_logging_obj.async_post_call_streaming_iterator_hook = (
|
||
mock_streaming_iterator
|
||
)
|
||
mock_proxy_logging_obj.async_post_call_streaming_hook = AsyncMock(
|
||
side_effect=lambda **kwargs: kwargs.get("response")
|
||
)
|
||
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock()
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.aclose = AsyncMock()
|
||
|
||
with patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj):
|
||
yielded_data = []
|
||
async for data in async_data_generator(
|
||
mock_response, mock_user_api_key_dict, mock_request_data
|
||
):
|
||
yielded_data.append(data)
|
||
|
||
# Should have completed normally with [DONE]
|
||
assert any("[DONE]" in d for d in yielded_data)
|
||
# aclose should still be called via finally block
|
||
mock_response.aclose.assert_awaited_once()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_async_data_generator_cleanup_on_midstream_error():
|
||
"""
|
||
Test that async_data_generator calls response.aclose() via finally block
|
||
even when an exception occurs mid-stream.
|
||
"""
|
||
from litellm.proxy._types import UserAPIKeyAuth
|
||
from litellm.proxy.proxy_server import async_data_generator
|
||
from litellm.proxy.utils import ProxyLogging
|
||
|
||
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
||
mock_request_data = {
|
||
"model": "gpt-3.5-turbo",
|
||
"messages": [{"role": "user", "content": "test"}],
|
||
}
|
||
|
||
mock_proxy_logging_obj = MagicMock(spec=ProxyLogging)
|
||
|
||
async def mock_streaming_iterator_with_error(*args, **kwargs):
|
||
yield {"choices": [{"delta": {"content": "Hello"}}]}
|
||
raise RuntimeError("upstream connection reset")
|
||
|
||
mock_proxy_logging_obj.async_post_call_streaming_iterator_hook = (
|
||
mock_streaming_iterator_with_error
|
||
)
|
||
mock_proxy_logging_obj.async_post_call_streaming_hook = AsyncMock(
|
||
side_effect=lambda **kwargs: kwargs.get("response")
|
||
)
|
||
mock_proxy_logging_obj.post_call_failure_hook = AsyncMock()
|
||
|
||
mock_response = MagicMock()
|
||
mock_response.aclose = AsyncMock()
|
||
|
||
with patch("litellm.proxy.proxy_server.proxy_logging_obj", mock_proxy_logging_obj):
|
||
yielded_data = []
|
||
async for data in async_data_generator(
|
||
mock_response, mock_user_api_key_dict, mock_request_data
|
||
):
|
||
yielded_data.append(data)
|
||
|
||
# Should have yielded data chunk and then an error chunk
|
||
assert len(yielded_data) >= 2
|
||
assert any("error" in d for d in yielded_data)
|
||
# aclose must still be called via finally block despite the error
|
||
mock_response.aclose.assert_awaited_once()
|
||
|
||
|
||
# ============================================================================
|
||
# store_model_in_db DB Config Override Tests
|
||
# ============================================================================
|
||
|
||
|
||
def test_store_model_in_db_in_config_general_settings():
|
||
"""
|
||
Verify store_model_in_db is a valid field in ConfigGeneralSettings
|
||
and validates correctly for True/False values.
|
||
"""
|
||
from litellm.proxy._types import ConfigGeneralSettings
|
||
|
||
assert "store_model_in_db" in ConfigGeneralSettings.model_fields
|
||
|
||
# Should validate with True
|
||
config = ConfigGeneralSettings(store_model_in_db=True)
|
||
assert config.store_model_in_db is True
|
||
|
||
# Should validate with False
|
||
config = ConfigGeneralSettings(store_model_in_db=False)
|
||
assert config.store_model_in_db is False
|
||
|
||
# Should validate with None (default)
|
||
config = ConfigGeneralSettings(store_model_in_db=None)
|
||
assert config.store_model_in_db is None
|
||
|
||
# Should validate with no value
|
||
config = ConfigGeneralSettings()
|
||
assert config.store_model_in_db is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_update_general_settings_store_model_in_db_true():
|
||
"""
|
||
Verify _update_general_settings sets global store_model_in_db to True
|
||
when DB general_settings has store_model_in_db=True.
|
||
"""
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
with (
|
||
patch("litellm.proxy.proxy_server.store_model_in_db", False) as mock_store,
|
||
patch("litellm.proxy.proxy_server.general_settings", {}) as mock_gs,
|
||
):
|
||
await proxy_config._update_general_settings(
|
||
db_general_settings={"store_model_in_db": True}
|
||
)
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
assert ps.store_model_in_db is True
|
||
assert ps.general_settings["store_model_in_db"] is True
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_update_general_settings_store_model_in_db_false():
|
||
"""
|
||
Verify _update_general_settings sets global store_model_in_db to False
|
||
when DB general_settings has store_model_in_db=False.
|
||
"""
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
with (
|
||
patch("litellm.proxy.proxy_server.store_model_in_db", True),
|
||
patch("litellm.proxy.proxy_server.general_settings", {}),
|
||
):
|
||
await proxy_config._update_general_settings(
|
||
db_general_settings={"store_model_in_db": False}
|
||
)
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
assert ps.store_model_in_db is False
|
||
assert ps.general_settings["store_model_in_db"] is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_update_general_settings_store_model_in_db_string_normalization():
|
||
"""
|
||
Verify _update_general_settings normalizes string values for store_model_in_db.
|
||
"""
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
# Test "true" string
|
||
with (
|
||
patch("litellm.proxy.proxy_server.store_model_in_db", False),
|
||
patch("litellm.proxy.proxy_server.general_settings", {}),
|
||
):
|
||
await proxy_config._update_general_settings(
|
||
db_general_settings={"store_model_in_db": "true"}
|
||
)
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
assert ps.store_model_in_db is True
|
||
|
||
# Test "True" string
|
||
with (
|
||
patch("litellm.proxy.proxy_server.store_model_in_db", False),
|
||
patch("litellm.proxy.proxy_server.general_settings", {}),
|
||
):
|
||
await proxy_config._update_general_settings(
|
||
db_general_settings={"store_model_in_db": "True"}
|
||
)
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
assert ps.store_model_in_db is True
|
||
|
||
# Test "false" string
|
||
with (
|
||
patch("litellm.proxy.proxy_server.store_model_in_db", True),
|
||
patch("litellm.proxy.proxy_server.general_settings", {}),
|
||
):
|
||
await proxy_config._update_general_settings(
|
||
db_general_settings={"store_model_in_db": "false"}
|
||
)
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
assert ps.store_model_in_db is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_update_general_settings_store_model_in_db_none_keeps_current():
|
||
"""
|
||
Verify _update_general_settings does not change store_model_in_db
|
||
when DB value is None.
|
||
"""
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
|
||
# When current is True and DB sends None, should stay True
|
||
with (
|
||
patch("litellm.proxy.proxy_server.store_model_in_db", True),
|
||
patch("litellm.proxy.proxy_server.general_settings", {}),
|
||
):
|
||
await proxy_config._update_general_settings(
|
||
db_general_settings={"store_model_in_db": None}
|
||
)
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
assert ps.store_model_in_db is True
|
||
|
||
# When current is False and DB sends None, should stay False
|
||
with (
|
||
patch("litellm.proxy.proxy_server.store_model_in_db", False),
|
||
patch("litellm.proxy.proxy_server.general_settings", {}),
|
||
):
|
||
await proxy_config._update_general_settings(
|
||
db_general_settings={"store_model_in_db": None}
|
||
)
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
assert ps.store_model_in_db is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_store_model_in_db_db_override_when_config_false():
|
||
"""
|
||
Verify the early DB check in initialize_scheduled_background_jobs
|
||
overrides store_model_in_db=False when DB has True.
|
||
"""
|
||
from litellm.proxy.proxy_server import ProxyStartupEvent
|
||
from litellm.proxy.utils import ProxyLogging
|
||
|
||
mock_prisma_client = MagicMock()
|
||
|
||
# Mock DB returning store_model_in_db=True in general_settings
|
||
mock_db_record = MagicMock()
|
||
mock_db_record.param_value = {"store_model_in_db": True}
|
||
mock_prisma_client.db.litellm_config.find_first = AsyncMock(
|
||
return_value=mock_db_record
|
||
)
|
||
|
||
mock_proxy_logging = MagicMock(spec=ProxyLogging)
|
||
mock_proxy_logging.slack_alerting_instance = MagicMock()
|
||
mock_proxy_config = AsyncMock()
|
||
|
||
with (
|
||
patch("litellm.proxy.proxy_server.proxy_config", mock_proxy_config),
|
||
patch("litellm.proxy.proxy_server.store_model_in_db", False),
|
||
patch("litellm.proxy.proxy_server.get_secret_bool", return_value=False),
|
||
):
|
||
await ProxyStartupEvent.initialize_scheduled_background_jobs(
|
||
general_settings={},
|
||
prisma_client=mock_prisma_client,
|
||
proxy_budget_rescheduler_min_time=1,
|
||
proxy_budget_rescheduler_max_time=2,
|
||
proxy_batch_write_at=5,
|
||
proxy_logging_obj=mock_proxy_logging,
|
||
)
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
# store_model_in_db should now be True (overridden by DB)
|
||
assert ps.store_model_in_db is True
|
||
|
||
# add_deployment and get_credentials should have been called
|
||
# since store_model_in_db is now True
|
||
assert mock_proxy_config.add_deployment.call_count == 1
|
||
assert mock_proxy_config.get_credentials.call_count == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_store_model_in_db_db_check_skipped_when_already_true(monkeypatch):
|
||
"""
|
||
Verify the early DB check is skipped when store_model_in_db is already True.
|
||
The DB query for the early check should not be called.
|
||
"""
|
||
monkeypatch.delenv("STORE_MODEL_IN_DB", raising=False)
|
||
from litellm.proxy.proxy_server import ProxyStartupEvent
|
||
from litellm.proxy.utils import ProxyLogging
|
||
|
||
mock_prisma_client = MagicMock()
|
||
mock_prisma_client.db.litellm_config.find_first = AsyncMock(return_value=None)
|
||
|
||
mock_proxy_logging = MagicMock(spec=ProxyLogging)
|
||
mock_proxy_logging.slack_alerting_instance = MagicMock()
|
||
mock_proxy_config = AsyncMock()
|
||
|
||
with (
|
||
patch("litellm.proxy.proxy_server.proxy_config", mock_proxy_config),
|
||
patch("litellm.proxy.proxy_server.store_model_in_db", True),
|
||
patch("litellm.proxy.proxy_server.get_secret_bool", return_value=True),
|
||
):
|
||
await ProxyStartupEvent.initialize_scheduled_background_jobs(
|
||
general_settings={},
|
||
prisma_client=mock_prisma_client,
|
||
proxy_budget_rescheduler_min_time=1,
|
||
proxy_budget_rescheduler_max_time=2,
|
||
proxy_batch_write_at=5,
|
||
proxy_logging_obj=mock_proxy_logging,
|
||
)
|
||
|
||
# The early DB check uses find_first with param_name="general_settings".
|
||
# When store_model_in_db is already True, the early check should be skipped.
|
||
# However, add_deployment may also call find_first.
|
||
# We just verify that store_model_in_db stays True and jobs are scheduled.
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
assert ps.store_model_in_db is True
|
||
assert mock_proxy_config.add_deployment.call_count == 1
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_store_model_in_db_db_failure_graceful(monkeypatch):
|
||
"""
|
||
Verify the early DB check handles DB failures gracefully
|
||
without crashing and keeps store_model_in_db as False.
|
||
"""
|
||
monkeypatch.delenv("STORE_MODEL_IN_DB", raising=False)
|
||
from litellm.proxy.proxy_server import ProxyStartupEvent
|
||
from litellm.proxy.utils import ProxyLogging
|
||
|
||
mock_prisma_client = MagicMock()
|
||
# Simulate DB failure
|
||
mock_prisma_client.db.litellm_config.find_first = AsyncMock(
|
||
side_effect=Exception("DB connection error")
|
||
)
|
||
|
||
mock_proxy_logging = MagicMock(spec=ProxyLogging)
|
||
mock_proxy_logging.slack_alerting_instance = MagicMock()
|
||
mock_proxy_config = AsyncMock()
|
||
|
||
with (
|
||
patch("litellm.proxy.proxy_server.proxy_config", mock_proxy_config),
|
||
patch("litellm.proxy.proxy_server.store_model_in_db", False),
|
||
patch("litellm.proxy.proxy_server.get_secret_bool", return_value=False),
|
||
):
|
||
# Should not raise an exception
|
||
await ProxyStartupEvent.initialize_scheduled_background_jobs(
|
||
general_settings={},
|
||
prisma_client=mock_prisma_client,
|
||
proxy_budget_rescheduler_min_time=1,
|
||
proxy_budget_rescheduler_max_time=2,
|
||
proxy_batch_write_at=5,
|
||
proxy_logging_obj=mock_proxy_logging,
|
||
)
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
# store_model_in_db should remain False
|
||
assert ps.store_model_in_db is False
|
||
|
||
# add_deployment should NOT have been called since store_model_in_db is False
|
||
mock_proxy_config.add_deployment.assert_not_called()
|
||
|
||
|
||
# =====================================================================
|
||
# Spend counter tests (v2 — Redis-backed spend counters)
|
||
# =====================================================================
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_current_spend_reads_redis_first():
|
||
"""get_current_spend should prefer Redis over in-memory."""
|
||
from litellm.caching.dual_cache import DualCache
|
||
|
||
counter_cache = DualCache()
|
||
|
||
# In-memory has stale value
|
||
counter_cache.in_memory_cache.set_cache(key="spend:key:test", value=0.30)
|
||
|
||
# Mock Redis with cross-pod authoritative value
|
||
mock_redis = AsyncMock()
|
||
mock_redis.async_get_cache = AsyncMock(return_value=0.90)
|
||
counter_cache.redis_cache = mock_redis
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
original = ps.spend_counter_cache
|
||
ps.spend_counter_cache = counter_cache
|
||
|
||
try:
|
||
from litellm.proxy.proxy_server import get_current_spend
|
||
|
||
result = await get_current_spend(
|
||
counter_key="spend:key:test",
|
||
fallback_spend=0.0,
|
||
)
|
||
# Should return Redis value (0.90), not in-memory (0.30)
|
||
assert result == 0.90
|
||
mock_redis.async_get_cache.assert_called_once_with(key="spend:key:test")
|
||
finally:
|
||
ps.spend_counter_cache = original
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_current_spend_fallback_to_in_memory():
|
||
"""When Redis is not configured, get_current_spend uses in-memory."""
|
||
from litellm.caching.dual_cache import DualCache
|
||
|
||
counter_cache = DualCache() # no redis_cache
|
||
counter_cache.in_memory_cache.set_cache(key="spend:key:test", value=0.50)
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
original = ps.spend_counter_cache
|
||
ps.spend_counter_cache = counter_cache
|
||
|
||
try:
|
||
from litellm.proxy.proxy_server import get_current_spend
|
||
|
||
result = await get_current_spend(
|
||
counter_key="spend:key:test",
|
||
fallback_spend=0.0,
|
||
)
|
||
assert result == 0.50
|
||
finally:
|
||
ps.spend_counter_cache = original
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_increment_spend_counters_initializes_and_increments():
|
||
"""Counter should initialize from cached object spend, then increment.
|
||
|
||
Uses a pre-hashed token to match production: metadata["user_api_key"]
|
||
is always hashed by the auth flow before reaching the cost callback.
|
||
"""
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy._types import LiteLLM_VerificationTokenView, hash_token
|
||
|
||
key_cache = DualCache()
|
||
counter_cache = DualCache()
|
||
|
||
# In production, the auth flow hashes the raw key before it reaches
|
||
# the cost callback. Simulate that by passing the hashed token.
|
||
hashed_token = hash_token("sk-test-token-for-counter")
|
||
|
||
# Simulate a cached key object with existing spend from DB
|
||
cached_key = LiteLLM_VerificationTokenView(
|
||
token=hashed_token,
|
||
spend=5.0,
|
||
max_budget=10.0,
|
||
)
|
||
key_cache.in_memory_cache.set_cache(key=hashed_token, value=cached_key)
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
original_key_cache = ps.user_api_key_cache
|
||
original_counter_cache = ps.spend_counter_cache
|
||
ps.user_api_key_cache = key_cache
|
||
ps.spend_counter_cache = counter_cache
|
||
|
||
try:
|
||
from litellm.proxy.proxy_server import increment_spend_counters
|
||
|
||
# Pass pre-hashed token (as the cost callback would in production)
|
||
await increment_spend_counters(
|
||
token=hashed_token,
|
||
team_id=None,
|
||
user_id=None,
|
||
response_cost=0.50,
|
||
)
|
||
|
||
# Counter should be: base(5.0) + increment(0.50) = 5.50
|
||
counter = counter_cache.in_memory_cache.get_cache(
|
||
key=f"spend:key:{hashed_token}"
|
||
)
|
||
assert counter == 5.50
|
||
|
||
# Second increment — counter already exists, just increment
|
||
await increment_spend_counters(
|
||
token=hashed_token,
|
||
team_id=None,
|
||
user_id=None,
|
||
response_cost=0.25,
|
||
)
|
||
|
||
counter = counter_cache.in_memory_cache.get_cache(
|
||
key=f"spend:key:{hashed_token}"
|
||
)
|
||
assert counter == 5.75
|
||
finally:
|
||
ps.user_api_key_cache = original_key_cache
|
||
ps.spend_counter_cache = original_counter_cache
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_increment_spend_counters_team_and_member():
|
||
"""Counter should track team and team member spend separately."""
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy._types import LiteLLM_TeamTable
|
||
|
||
key_cache = DualCache()
|
||
counter_cache = DualCache()
|
||
|
||
# Cached team object
|
||
team_obj = LiteLLM_TeamTable(team_id="team-1", spend=2.0)
|
||
key_cache.in_memory_cache.set_cache(key="team_id:team-1", value=team_obj)
|
||
|
||
# Cached team membership
|
||
key_cache.in_memory_cache.set_cache(
|
||
key="team_membership:user-1:team-1",
|
||
value={"user_id": "user-1", "team_id": "team-1", "spend": 1.0},
|
||
)
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
original_key_cache = ps.user_api_key_cache
|
||
original_counter_cache = ps.spend_counter_cache
|
||
ps.user_api_key_cache = key_cache
|
||
ps.spend_counter_cache = counter_cache
|
||
|
||
try:
|
||
from litellm.proxy.proxy_server import increment_spend_counters
|
||
|
||
await increment_spend_counters(
|
||
token=None,
|
||
team_id="team-1",
|
||
user_id="user-1",
|
||
response_cost=0.30,
|
||
)
|
||
|
||
team_counter = counter_cache.in_memory_cache.get_cache(key="spend:team:team-1")
|
||
assert team_counter == 2.30
|
||
|
||
member_counter = counter_cache.in_memory_cache.get_cache(
|
||
key="spend:team_member:user-1:team-1"
|
||
)
|
||
assert member_counter == 1.30
|
||
finally:
|
||
ps.user_api_key_cache = original_key_cache
|
||
ps.spend_counter_cache = original_counter_cache
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_init_and_increment_spend_counter_reseeds_from_db_on_counter_miss():
|
||
"""When the Redis counter is missing, the reseed path reads the
|
||
authoritative spend from the DB (not a stale cache), so the next
|
||
increment continues from the correct base value."""
|
||
from litellm.caching.dual_cache import DualCache
|
||
|
||
counter_cache = DualCache()
|
||
recorded_increments: list = []
|
||
|
||
async def record_increment(key, value, ttl=None, **kwargs):
|
||
recorded_increments.append({"key": key, "value": value, "ttl": ttl})
|
||
return value
|
||
|
||
fake_redis = AsyncMock()
|
||
fake_redis.async_increment = AsyncMock(side_effect=record_increment)
|
||
fake_redis.async_get_cache = AsyncMock(return_value=None) # counter missing
|
||
fake_redis.async_set_cache = AsyncMock(return_value=True) # SET NX wins
|
||
counter_cache.redis_cache = fake_redis
|
||
|
||
# Prisma returns spend=42.0 (authoritative) while the stale cached
|
||
# value (would be read only if prisma is None) is 10.0. The counter
|
||
# must seed from 42, not 10.
|
||
db_row = MagicMock()
|
||
db_row.spend = 42.0
|
||
fake_prisma = MagicMock()
|
||
fake_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=db_row)
|
||
|
||
stale_cache = DualCache()
|
||
stale_team = MagicMock()
|
||
stale_team.spend = 10.0
|
||
stale_cache.in_memory_cache.set_cache(key="team_id:team-9", value=stale_team)
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
from litellm.proxy.proxy_server import _init_and_increment_spend_counter
|
||
|
||
orig_user, orig_counter, orig_prisma = (
|
||
ps.user_api_key_cache,
|
||
ps.spend_counter_cache,
|
||
ps.prisma_client,
|
||
)
|
||
ps.user_api_key_cache = stale_cache
|
||
ps.spend_counter_cache = counter_cache
|
||
ps.prisma_client = fake_prisma
|
||
try:
|
||
await _init_and_increment_spend_counter(
|
||
counter_key="spend:team:team-9",
|
||
source_cache_key="team_id:team-9",
|
||
increment=1.5,
|
||
)
|
||
|
||
fake_prisma.db.litellm_teamtable.find_unique.assert_awaited_once_with(
|
||
where={"team_id": "team-9"}
|
||
)
|
||
# Seed uses SET NX with db_spend (42) — cross-pod safe, no INCR of 42.
|
||
# Only the per-request delta (1.5) goes through INCRBYFLOAT.
|
||
fake_redis.async_set_cache.assert_awaited_once_with(
|
||
key="spend:team:team-9", value=42.0, nx=True
|
||
)
|
||
writes = [(c["key"], c["value"]) for c in recorded_increments]
|
||
assert writes == [("spend:team:team-9", 1.5)]
|
||
finally:
|
||
ps.user_api_key_cache = orig_user
|
||
ps.spend_counter_cache = orig_counter
|
||
ps.prisma_client = orig_prisma
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_primary_spend_counter_redis_concurrent_seed_does_not_double_seed():
|
||
"""Two pods both observing a missing Redis counter must not both
|
||
INCRBYFLOAT the full DB spend. SpendCounterReseed.coalesced uses SET NX
|
||
so the loser reads the winner's value; final Redis = db_spend, not
|
||
2 * db_spend.
|
||
|
||
The per-counter asyncio.Lock is per-process, so it does NOT coordinate
|
||
across pods. We simulate two pods by patching _get_lock to return a
|
||
fresh lock per call (each "pod" has its own lock registry in real life).
|
||
"""
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy.db.spend_counter_reseed import SpendCounterReseed
|
||
|
||
counter_key = "spend:team:team-concurrent-seed"
|
||
redis_store: dict = {}
|
||
db_read_count = 0
|
||
set_results: list = []
|
||
get_after_set_count = 0
|
||
set_completed_count = 0
|
||
|
||
async def redis_set_cache(key, value, nx=False, **_):
|
||
# Yield BEFORE the membership check so two concurrent callers
|
||
# interleave the way real atomic Redis SET NX does: the first
|
||
# to resume runs check + write atomically and wins; the second
|
||
# resumes after the key exists and loses. Yielding *after* the
|
||
# check would let both callers pass the empty-store check before
|
||
# either writes, so neither would ever lose.
|
||
await asyncio.sleep(0)
|
||
if nx and key in redis_store:
|
||
set_results.append(False)
|
||
return False
|
||
redis_store[key] = float(value)
|
||
set_results.append(True)
|
||
nonlocal set_completed_count
|
||
set_completed_count += 1
|
||
return True
|
||
|
||
async def redis_get_cache(key):
|
||
# Track reads that happen after at least one SET NX has completed
|
||
# — those are the loser-path fallback reads we want to verify.
|
||
if set_completed_count > 0:
|
||
nonlocal get_after_set_count
|
||
get_after_set_count += 1
|
||
return redis_store.get(key)
|
||
|
||
fake_redis = AsyncMock()
|
||
fake_redis.async_get_cache = AsyncMock(side_effect=redis_get_cache)
|
||
fake_redis.async_set_cache = AsyncMock(side_effect=redis_set_cache)
|
||
|
||
async def slow_find_unique(**_):
|
||
nonlocal db_read_count
|
||
db_read_count += 1
|
||
# Both pods read DB before either's SET NX lands.
|
||
await asyncio.sleep(0)
|
||
row = MagicMock()
|
||
row.spend = 506.0
|
||
return row
|
||
|
||
fake_prisma = MagicMock()
|
||
fake_prisma.db.litellm_teamtable.find_unique = AsyncMock(
|
||
side_effect=slow_find_unique
|
||
)
|
||
|
||
pod_a = DualCache()
|
||
pod_a.redis_cache = fake_redis
|
||
pod_b = DualCache()
|
||
pod_b.redis_cache = fake_redis
|
||
|
||
# Each "pod" has its own per-process lock registry. Patch _get_lock to
|
||
# always return a fresh lock so the two coalesced calls do not serialize
|
||
# via one in-process lock (which is what would happen across pods).
|
||
async def fresh_lock(_counter_key):
|
||
return asyncio.Lock()
|
||
|
||
with patch.object(SpendCounterReseed, "_get_lock", side_effect=fresh_lock):
|
||
results = await asyncio.gather(
|
||
SpendCounterReseed.coalesced(
|
||
prisma_client=fake_prisma,
|
||
spend_counter_cache=pod_a,
|
||
counter_key=counter_key,
|
||
),
|
||
SpendCounterReseed.coalesced(
|
||
prisma_client=fake_prisma,
|
||
spend_counter_cache=pod_b,
|
||
counter_key=counter_key,
|
||
),
|
||
)
|
||
|
||
assert all(r == 506.0 for r in results), results
|
||
assert redis_store[counter_key] == pytest.approx(506.0), redis_store
|
||
# Both pods read the DB and both attempted SET NX; exactly one wrote
|
||
# (winner) and one was rejected (loser).
|
||
assert db_read_count == 2
|
||
assert fake_redis.async_set_cache.await_count == 2
|
||
nx_writes = [
|
||
call
|
||
for call in fake_redis.async_set_cache.await_args_list
|
||
if call.kwargs.get("nx") is True
|
||
]
|
||
assert len(nx_writes) == 2
|
||
assert sorted(set_results) == [
|
||
False,
|
||
True,
|
||
], f"expected exactly one SET NX winner and one loser, got {set_results}"
|
||
# Loser path executed: after the winner's SET NX returned True, the
|
||
# losing coalesced() call falls back to async_get_cache to read the
|
||
# winner's value rather than re-seeding.
|
||
assert (
|
||
get_after_set_count >= 1
|
||
), "loser branch (else: read back winner's value) was never exercised"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_reseed_spend_from_db_user_and_org_prefixes():
|
||
"""User and org counters reseed from their own DB tables.
|
||
|
||
End-user and tag counters use the already fetched auth objects passed as
|
||
fallback_spend, so this reseed helper must not add extra per-request DB
|
||
reads for them.
|
||
"""
|
||
from litellm.proxy.db.spend_counter_reseed import SpendCounterReseed
|
||
|
||
user_row = MagicMock()
|
||
user_row.spend = 17.0
|
||
org_row = MagicMock()
|
||
org_row.spend = 305.0
|
||
|
||
fake_prisma = MagicMock()
|
||
fake_prisma.db.litellm_usertable.find_unique = AsyncMock(return_value=user_row)
|
||
fake_prisma.db.litellm_endusertable.find_unique = AsyncMock()
|
||
fake_prisma.db.litellm_tagtable.find_unique = AsyncMock()
|
||
fake_prisma.db.litellm_organizationtable.find_unique = AsyncMock(
|
||
return_value=org_row
|
||
)
|
||
|
||
assert await SpendCounterReseed.from_db(fake_prisma, "spend:user:alice") == 17.0
|
||
fake_prisma.db.litellm_usertable.find_unique.assert_awaited_once_with(
|
||
where={"user_id": "alice"}
|
||
)
|
||
|
||
assert (
|
||
await SpendCounterReseed.from_db(
|
||
fake_prisma,
|
||
"spend:end_user:customer-1",
|
||
)
|
||
is None
|
||
)
|
||
fake_prisma.db.litellm_endusertable.find_unique.assert_not_awaited()
|
||
|
||
assert await SpendCounterReseed.from_db(fake_prisma, "spend:tag:paid-tag") is None
|
||
fake_prisma.db.litellm_tagtable.find_unique.assert_not_awaited()
|
||
|
||
assert await SpendCounterReseed.from_db(fake_prisma, "spend:org:acme") == 305.0
|
||
fake_prisma.db.litellm_organizationtable.find_unique.assert_awaited_once_with(
|
||
where={"organization_id": "acme"}
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_reseed_spend_from_db_skips_window_variant_keys():
|
||
"""Window counters (spend:*:window:{duration}) share prefixes with
|
||
primary counters but don't correspond to a DB row. The guard must
|
||
short-circuit without querying the DB."""
|
||
from litellm.proxy.db.spend_counter_reseed import SpendCounterReseed
|
||
|
||
fake_prisma = MagicMock()
|
||
fake_prisma.db.litellm_verificationtoken.find_unique = AsyncMock()
|
||
fake_prisma.db.litellm_teamtable.find_unique = AsyncMock()
|
||
|
||
assert (
|
||
await SpendCounterReseed.from_db(fake_prisma, "spend:key:sk-abc:window:1h")
|
||
is None
|
||
)
|
||
assert (
|
||
await SpendCounterReseed.from_db(fake_prisma, "spend:team:team-1:window:1d")
|
||
is None
|
||
)
|
||
fake_prisma.db.litellm_verificationtoken.find_unique.assert_not_awaited()
|
||
fake_prisma.db.litellm_teamtable.find_unique.assert_not_awaited()
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_window_spend_counter_reseeds_from_spend_logs_on_counter_miss():
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy.proxy_server import _init_and_increment_window_spend_counter
|
||
|
||
counter_cache = DualCache()
|
||
window_start = datetime.now(timezone.utc) - timedelta(hours=1)
|
||
fake_prisma = MagicMock()
|
||
fake_prisma.db.litellm_spendlogs.group_by = AsyncMock(
|
||
return_value=[{"api_key": "key-window", "_sum": {"spend": 2.25}}]
|
||
)
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
orig_counter, orig_prisma = ps.spend_counter_cache, ps.prisma_client
|
||
ps.spend_counter_cache = counter_cache
|
||
ps.prisma_client = fake_prisma
|
||
try:
|
||
await _init_and_increment_window_spend_counter(
|
||
counter_key="spend:key:key-window:window:1h",
|
||
entity_type="Key",
|
||
entity_id="key-window",
|
||
window_start=window_start,
|
||
increment=0.5,
|
||
)
|
||
|
||
fake_prisma.db.litellm_spendlogs.group_by.assert_awaited_once_with(
|
||
by=["api_key"],
|
||
where={"api_key": "key-window", "startTime": {"gte": window_start}},
|
||
sum={"spend": True},
|
||
)
|
||
assert counter_cache.in_memory_cache.get_cache(
|
||
key="spend:key:key-window:window:1h"
|
||
) == pytest.approx(2.75)
|
||
finally:
|
||
ps.spend_counter_cache = orig_counter
|
||
ps.prisma_client = orig_prisma
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_init_spend_counter_redis_clean_miss_skips_stale_in_memory():
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy.proxy_server import _init_and_increment_spend_counter
|
||
|
||
counter_cache = DualCache()
|
||
counter_key = "spend:team:team-stale-local"
|
||
counter_cache.in_memory_cache.set_cache(key=counter_key, value=10.0)
|
||
|
||
redis_store: dict = {}
|
||
|
||
async def redis_increment(key, value, **_):
|
||
redis_store[key] = (redis_store.get(key) or 0.0) + value
|
||
return redis_store[key]
|
||
|
||
async def redis_set_cache(key, value, nx=False, **_):
|
||
if nx and key in redis_store:
|
||
return False
|
||
redis_store[key] = float(value)
|
||
return True
|
||
|
||
fake_redis = AsyncMock()
|
||
fake_redis.async_get_cache = AsyncMock(return_value=None)
|
||
fake_redis.async_increment = AsyncMock(side_effect=redis_increment)
|
||
fake_redis.async_set_cache = AsyncMock(side_effect=redis_set_cache)
|
||
counter_cache.redis_cache = fake_redis
|
||
|
||
db_row = MagicMock()
|
||
db_row.spend = 42.0
|
||
fake_prisma = MagicMock()
|
||
fake_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=db_row)
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
orig_counter, orig_prisma, orig_user = (
|
||
ps.spend_counter_cache,
|
||
ps.prisma_client,
|
||
ps.user_api_key_cache,
|
||
)
|
||
ps.spend_counter_cache = counter_cache
|
||
ps.prisma_client = fake_prisma
|
||
ps.user_api_key_cache = DualCache()
|
||
try:
|
||
await _init_and_increment_spend_counter(
|
||
counter_key=counter_key,
|
||
source_cache_key="team_id:team-stale-local",
|
||
increment=1.5,
|
||
)
|
||
|
||
fake_prisma.db.litellm_teamtable.find_unique.assert_awaited_once_with(
|
||
where={"team_id": "team-stale-local"}
|
||
)
|
||
# Seed via SET NX (42) + delta via INCRBYFLOAT (1.5) = 43.5.
|
||
assert redis_store[counter_key] == pytest.approx(43.5)
|
||
assert counter_cache.in_memory_cache.get_cache(
|
||
key=counter_key
|
||
) == pytest.approx(43.5)
|
||
finally:
|
||
ps.spend_counter_cache = orig_counter
|
||
ps.prisma_client = orig_prisma
|
||
ps.user_api_key_cache = orig_user
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_window_spend_counter_redis_clean_miss_skips_stale_in_memory():
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy.proxy_server import _init_and_increment_window_spend_counter
|
||
|
||
counter_cache = DualCache()
|
||
counter_key = "spend:key:key-window-stale-local:window:1h"
|
||
counter_cache.in_memory_cache.set_cache(key=counter_key, value=100.0)
|
||
window_start = datetime.now(timezone.utc) - timedelta(hours=1)
|
||
|
||
redis_store: dict = {}
|
||
|
||
async def redis_increment(key, value, **_):
|
||
redis_store[key] = (redis_store.get(key) or 0.0) + value
|
||
return redis_store[key]
|
||
|
||
async def redis_set_cache(key, value, **_):
|
||
if key in redis_store:
|
||
return False
|
||
redis_store[key] = value
|
||
return True
|
||
|
||
fake_redis = AsyncMock()
|
||
fake_redis.async_get_cache = AsyncMock(return_value=None)
|
||
fake_redis.async_set_cache = AsyncMock(side_effect=redis_set_cache)
|
||
fake_redis.async_increment = AsyncMock(side_effect=redis_increment)
|
||
counter_cache.redis_cache = fake_redis
|
||
|
||
fake_prisma = MagicMock()
|
||
fake_prisma.db.litellm_spendlogs.group_by = AsyncMock(
|
||
return_value=[{"api_key": "key-window-stale-local", "_sum": {"spend": 2.25}}]
|
||
)
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
orig_counter, orig_prisma = ps.spend_counter_cache, ps.prisma_client
|
||
ps.spend_counter_cache = counter_cache
|
||
ps.prisma_client = fake_prisma
|
||
try:
|
||
await _init_and_increment_window_spend_counter(
|
||
counter_key=counter_key,
|
||
entity_type="Key",
|
||
entity_id="key-window-stale-local",
|
||
window_start=window_start,
|
||
increment=0.5,
|
||
)
|
||
|
||
fake_prisma.db.litellm_spendlogs.group_by.assert_awaited_once_with(
|
||
by=["api_key"],
|
||
where={
|
||
"api_key": "key-window-stale-local",
|
||
"startTime": {"gte": window_start},
|
||
},
|
||
sum={"spend": True},
|
||
)
|
||
assert redis_store[counter_key] == pytest.approx(2.75)
|
||
assert counter_cache.in_memory_cache.get_cache(
|
||
key=counter_key
|
||
) == pytest.approx(2.75)
|
||
finally:
|
||
ps.spend_counter_cache = orig_counter
|
||
ps.prisma_client = orig_prisma
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_window_spend_counter_redis_concurrent_seed_does_not_double_seed():
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy.proxy_server import _init_and_increment_window_spend_counter
|
||
|
||
counter_cache = DualCache()
|
||
counter_key = "spend:key:key-window-concurrent-seed:window:1h"
|
||
window_start = datetime.now(timezone.utc) - timedelta(hours=1)
|
||
redis_store = {counter_key: 2.75}
|
||
redis_reads = 0
|
||
|
||
async def redis_get_cache(key):
|
||
nonlocal redis_reads
|
||
redis_reads += 1
|
||
if redis_reads <= 2:
|
||
return None
|
||
return redis_store.get(key)
|
||
|
||
async def redis_increment(key, value, **_):
|
||
redis_store[key] = (redis_store.get(key) or 0.0) + value
|
||
return redis_store[key]
|
||
|
||
fake_redis = AsyncMock()
|
||
fake_redis.async_get_cache = AsyncMock(side_effect=redis_get_cache)
|
||
fake_redis.async_set_cache = AsyncMock(return_value=False)
|
||
fake_redis.async_increment = AsyncMock(side_effect=redis_increment)
|
||
counter_cache.redis_cache = fake_redis
|
||
|
||
fake_prisma = MagicMock()
|
||
fake_prisma.db.litellm_spendlogs.group_by = AsyncMock(
|
||
return_value=[
|
||
{"api_key": "key-window-concurrent-seed", "_sum": {"spend": 2.25}}
|
||
]
|
||
)
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
orig_counter, orig_prisma = ps.spend_counter_cache, ps.prisma_client
|
||
ps.spend_counter_cache = counter_cache
|
||
ps.prisma_client = fake_prisma
|
||
try:
|
||
await _init_and_increment_window_spend_counter(
|
||
counter_key=counter_key,
|
||
entity_type="Key",
|
||
entity_id="key-window-concurrent-seed",
|
||
window_start=window_start,
|
||
increment=0.5,
|
||
)
|
||
|
||
fake_redis.async_set_cache.assert_awaited_once_with(
|
||
key=counter_key,
|
||
value=2.25,
|
||
nx=True,
|
||
)
|
||
assert redis_store[counter_key] == pytest.approx(3.25)
|
||
assert counter_cache.in_memory_cache.get_cache(
|
||
key=counter_key
|
||
) == pytest.approx(3.25)
|
||
finally:
|
||
ps.spend_counter_cache = orig_counter
|
||
ps.prisma_client = orig_prisma
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_window_spend_counter_skips_invalid_window_start():
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy.proxy_server import _init_and_increment_window_spend_counter
|
||
|
||
counter_cache = DualCache()
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
orig_counter = ps.spend_counter_cache
|
||
ps.spend_counter_cache = counter_cache
|
||
try:
|
||
await _init_and_increment_window_spend_counter(
|
||
counter_key="spend:key:key-invalid-window:window:not-a-duration",
|
||
entity_type="Key",
|
||
entity_id="key-invalid-window",
|
||
window_start=None,
|
||
increment=0.5,
|
||
)
|
||
|
||
assert (
|
||
counter_cache.in_memory_cache.get_cache(
|
||
key="spend:key:key-invalid-window:window:not-a-duration"
|
||
)
|
||
is None
|
||
)
|
||
finally:
|
||
ps.spend_counter_cache = orig_counter
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_window_spend_counter_does_not_seed_zero_when_db_unavailable():
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy.proxy_server import _ensure_window_spend_counter_initialized
|
||
|
||
counter_cache = DualCache()
|
||
counter_key = "spend:key:key-window-db-unavailable:window:1h"
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
orig_counter, orig_prisma = ps.spend_counter_cache, ps.prisma_client
|
||
ps.spend_counter_cache = counter_cache
|
||
ps.prisma_client = None
|
||
try:
|
||
initialized = await _ensure_window_spend_counter_initialized(
|
||
counter_key=counter_key,
|
||
entity_type="Key",
|
||
entity_id="key-window-db-unavailable",
|
||
window_start=datetime.now(timezone.utc) - timedelta(hours=1),
|
||
)
|
||
|
||
assert initialized is False
|
||
assert counter_cache.in_memory_cache.get_cache(key=counter_key) is None
|
||
finally:
|
||
ps.spend_counter_cache = orig_counter
|
||
ps.prisma_client = orig_prisma
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_increment_spend_counters_finalizes_after_unreserved_increments():
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy.proxy_server import increment_spend_counters
|
||
|
||
counter_cache = DualCache()
|
||
counter_cache.in_memory_cache.set_cache(
|
||
key="spend:key:key-finalize-after-increments",
|
||
value=0.5,
|
||
)
|
||
budget_reservation = {
|
||
"reserved_cost": 0.5,
|
||
"entries": [
|
||
{
|
||
"counter_key": "spend:key:key-finalize-after-increments",
|
||
"entity_type": "Key",
|
||
"entity_id": "key-finalize-after-increments",
|
||
"reserved_cost": 0.5,
|
||
"applied_adjustment": 0.0,
|
||
}
|
||
],
|
||
"finalized": False,
|
||
}
|
||
incremented_counters = []
|
||
|
||
async def assert_reservation_not_finalized_yet(**kwargs):
|
||
assert budget_reservation["finalized"] is False
|
||
incremented_counters.append(kwargs["counter_key"])
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
orig_counter, orig_user = ps.spend_counter_cache, ps.user_api_key_cache
|
||
ps.spend_counter_cache = counter_cache
|
||
ps.user_api_key_cache = DualCache()
|
||
try:
|
||
with patch(
|
||
"litellm.proxy.proxy_server._init_and_increment_spend_counter",
|
||
new=AsyncMock(side_effect=assert_reservation_not_finalized_yet),
|
||
):
|
||
await increment_spend_counters(
|
||
token="key-finalize-after-increments",
|
||
team_id="team-finalize-after-increments",
|
||
user_id=None,
|
||
response_cost=0.25,
|
||
budget_reservation=budget_reservation,
|
||
)
|
||
|
||
assert incremented_counters == ["spend:team:team-finalize-after-increments"]
|
||
assert budget_reservation["finalized"] is True
|
||
assert counter_cache.in_memory_cache.get_cache(
|
||
key="spend:key:key-finalize-after-increments"
|
||
) == pytest.approx(0.25)
|
||
finally:
|
||
ps.spend_counter_cache = orig_counter
|
||
ps.user_api_key_cache = orig_user
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_increment_spend_counters_finalizes_none_cost_reservation():
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy.proxy_server import increment_spend_counters
|
||
|
||
counter_cache = DualCache()
|
||
counter_cache.in_memory_cache.set_cache(
|
||
key="spend:key:key-finalize-none-cost",
|
||
value=0.5,
|
||
)
|
||
budget_reservation = {
|
||
"reserved_cost": 0.5,
|
||
"entries": [
|
||
{
|
||
"counter_key": "spend:key:key-finalize-none-cost",
|
||
"entity_type": "Key",
|
||
"entity_id": "key-finalize-none-cost",
|
||
"reserved_cost": 0.5,
|
||
"applied_adjustment": 0.0,
|
||
}
|
||
],
|
||
"finalized": False,
|
||
}
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
orig_counter = ps.spend_counter_cache
|
||
ps.spend_counter_cache = counter_cache
|
||
try:
|
||
await increment_spend_counters(
|
||
token="key-finalize-none-cost",
|
||
team_id=None,
|
||
user_id=None,
|
||
response_cost=None,
|
||
budget_reservation=budget_reservation,
|
||
)
|
||
|
||
assert budget_reservation["finalized"] is True
|
||
assert counter_cache.in_memory_cache.get_cache(
|
||
key="spend:key:key-finalize-none-cost"
|
||
) == pytest.approx(0.0)
|
||
finally:
|
||
ps.spend_counter_cache = orig_counter
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_increment_spend_counters_falls_back_to_direct_increment_on_bad_reserved_counter():
|
||
"""When the reservation reconcile fails, the reserved counters are
|
||
invalidated and the actual response cost must still be written via the
|
||
direct increment fallback. Leaving the counter at ``None`` lets the next
|
||
request reseed a stale value from the DB and silently stops budget gating,
|
||
which is the bug this fix addresses."""
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy.proxy_server import increment_spend_counters
|
||
|
||
counter_cache = DualCache()
|
||
budget_reservation = {
|
||
"reserved_cost": 0.5,
|
||
"entries": [
|
||
{
|
||
"counter_key": "spend:key:key-bad-reserved-counter",
|
||
"entity_type": "Key",
|
||
"entity_id": "key-bad-reserved-counter",
|
||
"reserved_cost": 0.5,
|
||
"applied_adjustment": 0.0,
|
||
}
|
||
],
|
||
"finalized": False,
|
||
}
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
orig_counter = ps.spend_counter_cache
|
||
ps.spend_counter_cache = counter_cache
|
||
try:
|
||
with patch(
|
||
"litellm.proxy.proxy_server.verbose_proxy_logger.warning"
|
||
) as mock_warning:
|
||
await increment_spend_counters(
|
||
token="key-bad-reserved-counter",
|
||
team_id=None,
|
||
user_id=None,
|
||
response_cost=0.25,
|
||
budget_reservation=budget_reservation,
|
||
)
|
||
|
||
mock_warning.assert_called_once()
|
||
assert budget_reservation["finalized"] is True
|
||
assert (
|
||
counter_cache.in_memory_cache.get_cache(
|
||
key="spend:key:key-bad-reserved-counter"
|
||
)
|
||
== 0.25
|
||
)
|
||
finally:
|
||
ps.spend_counter_cache = orig_counter
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_increment_spend_counter_invalidates_stale_cache_on_redis_failure():
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy.proxy_server import _increment_spend_counter_cache
|
||
|
||
counter_cache = DualCache()
|
||
counter_cache.in_memory_cache.set_cache(key="spend:team:redis-fail", value=4.0)
|
||
fake_redis = AsyncMock()
|
||
fake_redis.async_increment = AsyncMock(side_effect=RuntimeError("redis down"))
|
||
fake_redis.async_delete_cache = AsyncMock()
|
||
counter_cache.redis_cache = fake_redis
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
orig_counter = ps.spend_counter_cache
|
||
ps.spend_counter_cache = counter_cache
|
||
try:
|
||
with pytest.raises(RuntimeError):
|
||
await _increment_spend_counter_cache(
|
||
counter_key="spend:team:redis-fail",
|
||
increment=0.5,
|
||
)
|
||
|
||
assert (
|
||
counter_cache.in_memory_cache.get_cache(key="spend:team:redis-fail") is None
|
||
)
|
||
fake_redis.async_delete_cache.assert_awaited_once_with(
|
||
key="spend:team:redis-fail"
|
||
)
|
||
finally:
|
||
ps.spend_counter_cache = orig_counter
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_current_spend_reseeds_from_db_when_counter_missing():
|
||
"""
|
||
When both the Redis and in-memory counters are missing, the enforcement
|
||
read path must reseed from the authoritative DB, not fall back to the
|
||
caller-supplied stale value. Otherwise, every Redis TTL expiry lets a
|
||
request through against a stale in-process `team_membership.spend`.
|
||
"""
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy.proxy_server import get_current_spend
|
||
|
||
counter_cache = DualCache()
|
||
recorded_seeds: list = []
|
||
|
||
async def record_set_cache(key, value, nx=False, **kwargs):
|
||
recorded_seeds.append({"key": key, "value": value, "nx": nx})
|
||
return True
|
||
|
||
fake_redis = AsyncMock()
|
||
fake_redis.async_set_cache = AsyncMock(side_effect=record_set_cache)
|
||
fake_redis.async_get_cache = AsyncMock(return_value=None)
|
||
counter_cache.redis_cache = fake_redis
|
||
|
||
# DB has authoritative spend=362.0; caller hands us stale fallback=30.0
|
||
# (the in-process team_membership.spend that hasn't caught up to DB).
|
||
db_row = MagicMock()
|
||
db_row.spend = 362.0
|
||
fake_prisma = MagicMock()
|
||
fake_prisma.db.litellm_teammembership.find_unique = AsyncMock(return_value=db_row)
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
orig_counter, orig_prisma = ps.spend_counter_cache, ps.prisma_client
|
||
ps.spend_counter_cache = counter_cache
|
||
ps.prisma_client = fake_prisma
|
||
try:
|
||
spend = await get_current_spend(
|
||
counter_key="spend:team_member:user-1:team-1",
|
||
fallback_spend=30.0,
|
||
)
|
||
assert spend == 362.0, (
|
||
f"expected DB reseed to return 362.0, got {spend} "
|
||
f"(fallback would have returned 30.0 and caused bypass)"
|
||
)
|
||
# Counter warmed via SET NX so subsequent reads are fast.
|
||
assert ("spend:team_member:user-1:team-1", 362.0, True) in [
|
||
(s["key"], s["value"], s["nx"]) for s in recorded_seeds
|
||
]
|
||
assert counter_cache.in_memory_cache.get_cache(
|
||
key="spend:team_member:user-1:team-1"
|
||
) == pytest.approx(362.0)
|
||
finally:
|
||
ps.spend_counter_cache = orig_counter
|
||
ps.prisma_client = orig_prisma
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_current_spend_uses_fallback_when_db_unavailable():
|
||
"""
|
||
If prisma is unavailable and both counters are missing, the read path
|
||
must degrade to the caller-supplied fallback rather than raising.
|
||
"""
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy.proxy_server import get_current_spend
|
||
|
||
counter_cache = DualCache()
|
||
fake_redis = AsyncMock()
|
||
fake_redis.async_get_cache = AsyncMock(return_value=None)
|
||
counter_cache.redis_cache = fake_redis
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
orig_counter, orig_prisma = ps.spend_counter_cache, ps.prisma_client
|
||
ps.spend_counter_cache = counter_cache
|
||
ps.prisma_client = None # simulate prisma unavailable
|
||
try:
|
||
spend = await get_current_spend(
|
||
counter_key="spend:team_member:user-1:team-1",
|
||
fallback_spend=15.5,
|
||
)
|
||
assert spend == 15.5
|
||
finally:
|
||
ps.spend_counter_cache = orig_counter
|
||
ps.prisma_client = orig_prisma
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_current_spend_coalesces_concurrent_reseeds():
|
||
"""
|
||
When several concurrent calls hit a cold counter on the same pod,
|
||
only one DB query should fire. The rest should wait for the lock,
|
||
re-check the warmed counter, and return without hitting the DB.
|
||
"""
|
||
import asyncio as _asyncio
|
||
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy.proxy_server import get_current_spend
|
||
|
||
counter_cache = DualCache()
|
||
counter_key = "spend:team_member:user-1:team-coalesce"
|
||
|
||
# Track DB query calls and inject a small delay so the concurrent
|
||
# callers actually overlap in the lock-acquire window.
|
||
db_call_count = 0
|
||
|
||
async def slow_find_unique(**kwargs):
|
||
nonlocal db_call_count
|
||
db_call_count += 1
|
||
await _asyncio.sleep(0.05)
|
||
row = MagicMock()
|
||
row.spend = 100.0
|
||
return row
|
||
|
||
fake_redis = AsyncMock()
|
||
redis_store: dict = {}
|
||
|
||
async def redis_get(key, **_):
|
||
return redis_store.get(key)
|
||
|
||
async def redis_increment(key, value, **_):
|
||
redis_store[key] = (redis_store.get(key) or 0.0) + value
|
||
return redis_store[key]
|
||
|
||
async def redis_set_cache(key, value, nx=False, **_):
|
||
if nx and key in redis_store:
|
||
return False
|
||
redis_store[key] = float(value)
|
||
return True
|
||
|
||
fake_redis.async_get_cache = AsyncMock(side_effect=redis_get)
|
||
fake_redis.async_increment = AsyncMock(side_effect=redis_increment)
|
||
fake_redis.async_set_cache = AsyncMock(side_effect=redis_set_cache)
|
||
counter_cache.redis_cache = fake_redis
|
||
|
||
fake_prisma = MagicMock()
|
||
fake_prisma.db.litellm_teammembership.find_unique = AsyncMock(
|
||
side_effect=slow_find_unique
|
||
)
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
orig_counter, orig_prisma = ps.spend_counter_cache, ps.prisma_client
|
||
ps.spend_counter_cache = counter_cache
|
||
ps.prisma_client = fake_prisma
|
||
try:
|
||
results = await _asyncio.gather(
|
||
*[
|
||
get_current_spend(counter_key=counter_key, fallback_spend=0.0)
|
||
for _ in range(5)
|
||
]
|
||
)
|
||
assert results == [100.0] * 5, f"all callers should see DB value, got {results}"
|
||
assert (
|
||
db_call_count == 1
|
||
), f"expected exactly 1 DB query for 5 concurrent reseeds, got {db_call_count}"
|
||
finally:
|
||
ps.spend_counter_cache = orig_counter
|
||
ps.prisma_client = orig_prisma
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_current_spend_uses_db_zero_over_stale_fallback():
|
||
"""
|
||
When DB returns spend=0 (e.g. just after a budget period reset), the
|
||
authoritative DB value must win over a stale non-zero fallback. The
|
||
fallback in production is the in-process team_membership.spend, which
|
||
can still hold the pre-reset value across pods.
|
||
"""
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy.proxy_server import get_current_spend
|
||
|
||
counter_cache = DualCache()
|
||
fake_redis = AsyncMock()
|
||
fake_redis.async_get_cache = AsyncMock(return_value=None)
|
||
counter_cache.redis_cache = fake_redis
|
||
|
||
db_row = MagicMock()
|
||
db_row.spend = 0.0
|
||
fake_prisma = MagicMock()
|
||
fake_prisma.db.litellm_teammembership.find_unique = AsyncMock(return_value=db_row)
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
orig_counter, orig_prisma = ps.spend_counter_cache, ps.prisma_client
|
||
ps.spend_counter_cache = counter_cache
|
||
ps.prisma_client = fake_prisma
|
||
try:
|
||
spend = await get_current_spend(
|
||
counter_key="spend:team_member:user-1:team-after-reset",
|
||
fallback_spend=42.0,
|
||
)
|
||
assert (
|
||
spend == 0.0
|
||
), f"DB authoritative 0 must override stale fallback 42, got {spend}"
|
||
finally:
|
||
ps.spend_counter_cache = orig_counter
|
||
ps.prisma_client = orig_prisma
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_concurrent_read_and_write_paths_share_one_db_query():
|
||
"""
|
||
The read path (`get_current_spend`) and the write path
|
||
(`_init_and_increment_spend_counter`) both reseed cold counters from
|
||
the DB. They must share the per-counter lock so a concurrent pre-call
|
||
enforcement read and post-call increment for the same counter collapse
|
||
to one DB query, not two.
|
||
"""
|
||
import asyncio as _asyncio
|
||
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy.proxy_server import (
|
||
_init_and_increment_spend_counter,
|
||
get_current_spend,
|
||
)
|
||
|
||
counter_cache = DualCache()
|
||
counter_key = "spend:team_member:user-1:team-cross-path"
|
||
|
||
db_call_count = 0
|
||
|
||
async def slow_find_unique(**kwargs):
|
||
nonlocal db_call_count
|
||
db_call_count += 1
|
||
await _asyncio.sleep(0.05)
|
||
row = MagicMock()
|
||
row.spend = 50.0
|
||
return row
|
||
|
||
redis_store: dict = {}
|
||
|
||
async def redis_get(key, **_):
|
||
return redis_store.get(key)
|
||
|
||
async def redis_increment(key, value, **_):
|
||
redis_store[key] = (redis_store.get(key) or 0.0) + value
|
||
return redis_store[key]
|
||
|
||
async def redis_set_cache(key, value, nx=False, **_):
|
||
if nx and key in redis_store:
|
||
return False
|
||
redis_store[key] = float(value)
|
||
return True
|
||
|
||
fake_redis = AsyncMock()
|
||
fake_redis.async_get_cache = AsyncMock(side_effect=redis_get)
|
||
fake_redis.async_increment = AsyncMock(side_effect=redis_increment)
|
||
fake_redis.async_set_cache = AsyncMock(side_effect=redis_set_cache)
|
||
counter_cache.redis_cache = fake_redis
|
||
|
||
fake_prisma = MagicMock()
|
||
fake_prisma.db.litellm_teammembership.find_unique = AsyncMock(
|
||
side_effect=slow_find_unique
|
||
)
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
orig_counter, orig_prisma, orig_user = (
|
||
ps.spend_counter_cache,
|
||
ps.prisma_client,
|
||
ps.user_api_key_cache,
|
||
)
|
||
ps.spend_counter_cache = counter_cache
|
||
ps.prisma_client = fake_prisma
|
||
ps.user_api_key_cache = DualCache()
|
||
try:
|
||
results = await _asyncio.gather(
|
||
get_current_spend(counter_key=counter_key, fallback_spend=0.0),
|
||
_init_and_increment_spend_counter(
|
||
counter_key=counter_key,
|
||
source_cache_key="ignored",
|
||
increment=1.5,
|
||
),
|
||
get_current_spend(counter_key=counter_key, fallback_spend=0.0),
|
||
)
|
||
assert (
|
||
db_call_count == 1
|
||
), f"expected 1 DB query for concurrent read+write+read, got {db_call_count}"
|
||
# Read-path callers see the warmed counter; the write path's
|
||
# increment may or may not have landed by then, so accept either
|
||
# the seeded value or seeded+increment.
|
||
assert results[0] in (50.0, 51.5), f"got {results[0]}"
|
||
assert results[2] in (50.0, 51.5), f"got {results[2]}"
|
||
finally:
|
||
ps.spend_counter_cache = orig_counter
|
||
ps.prisma_client = orig_prisma
|
||
ps.user_api_key_cache = orig_user
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_reseed_locks_dict_is_bounded():
|
||
"""
|
||
`SpendCounterReseed._locks` is an LRU bounded at
|
||
`SPEND_COUNTER_RESEED_LOCKS_MAX_SIZE` to prevent unbounded growth in
|
||
long-lived deployments with high counter-key churn. Inserting more
|
||
than the cap evicts the oldest entries.
|
||
"""
|
||
import litellm.constants as constants
|
||
from litellm.proxy.db.spend_counter_reseed import SpendCounterReseed
|
||
|
||
orig_locks = SpendCounterReseed._locks.copy()
|
||
SpendCounterReseed._locks.clear()
|
||
orig_max = constants.SPEND_COUNTER_RESEED_LOCKS_MAX_SIZE
|
||
constants.SPEND_COUNTER_RESEED_LOCKS_MAX_SIZE = 5
|
||
# The class reads the constant via module-level import, so patch the
|
||
# module-level name on the spend_counter_reseed module too.
|
||
import litellm.proxy.db.spend_counter_reseed as scr
|
||
|
||
orig_module_max = scr.SPEND_COUNTER_RESEED_LOCKS_MAX_SIZE
|
||
scr.SPEND_COUNTER_RESEED_LOCKS_MAX_SIZE = 5
|
||
try:
|
||
for i in range(7):
|
||
await SpendCounterReseed._get_lock(f"spend:key:test-key-{i}")
|
||
assert (
|
||
len(SpendCounterReseed._locks) == 5
|
||
), f"got {len(SpendCounterReseed._locks)}"
|
||
# Oldest two evicted
|
||
assert "spend:key:test-key-0" not in SpendCounterReseed._locks
|
||
assert "spend:key:test-key-1" not in SpendCounterReseed._locks
|
||
# Most recent retained
|
||
assert "spend:key:test-key-6" in SpendCounterReseed._locks
|
||
finally:
|
||
constants.SPEND_COUNTER_RESEED_LOCKS_MAX_SIZE = orig_max
|
||
scr.SPEND_COUNTER_RESEED_LOCKS_MAX_SIZE = orig_module_max
|
||
SpendCounterReseed._locks.clear()
|
||
SpendCounterReseed._locks.update(orig_locks)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_reseed_warms_cache_even_on_zero_db_spend():
|
||
"""
|
||
When DB returns 0.0 (fresh entity / just after reset), the cache must
|
||
still be warmed so subsequent reads hit the cache instead of issuing
|
||
another DB query. Skipping the warm causes O(requests) DB load on
|
||
zero-spend entities.
|
||
"""
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy.proxy_server import get_current_spend
|
||
|
||
counter_cache = DualCache()
|
||
counter_key = "spend:team_member:user-1:team-zero-warm"
|
||
redis_store: dict = {}
|
||
|
||
async def redis_get(key, **_):
|
||
return redis_store.get(key)
|
||
|
||
async def redis_increment(key, value, **_):
|
||
redis_store[key] = (redis_store.get(key) or 0.0) + value
|
||
return redis_store[key]
|
||
|
||
async def redis_set_cache(key, value, nx=False, **_):
|
||
if nx and key in redis_store:
|
||
return False
|
||
redis_store[key] = float(value)
|
||
return True
|
||
|
||
fake_redis = AsyncMock()
|
||
fake_redis.async_get_cache = AsyncMock(side_effect=redis_get)
|
||
fake_redis.async_increment = AsyncMock(side_effect=redis_increment)
|
||
fake_redis.async_set_cache = AsyncMock(side_effect=redis_set_cache)
|
||
counter_cache.redis_cache = fake_redis
|
||
|
||
db_call_count = 0
|
||
|
||
async def find_unique(**kwargs):
|
||
nonlocal db_call_count
|
||
db_call_count += 1
|
||
row = MagicMock()
|
||
row.spend = 0.0
|
||
return row
|
||
|
||
fake_prisma = MagicMock()
|
||
fake_prisma.db.litellm_teammembership.find_unique = AsyncMock(
|
||
side_effect=find_unique
|
||
)
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
orig_counter, orig_prisma = ps.spend_counter_cache, ps.prisma_client
|
||
ps.spend_counter_cache = counter_cache
|
||
ps.prisma_client = fake_prisma
|
||
try:
|
||
# First call: cold cache, hits DB, returns 0.
|
||
spend1 = await get_current_spend(counter_key=counter_key, fallback_spend=0.0)
|
||
# Second call: cache should be warmed at 0, no second DB query.
|
||
spend2 = await get_current_spend(counter_key=counter_key, fallback_spend=0.0)
|
||
assert spend1 == 0.0 and spend2 == 0.0
|
||
assert (
|
||
db_call_count == 1
|
||
), f"second read should hit warmed cache, got {db_call_count} DB queries"
|
||
assert redis_store.get(counter_key) == 0.0, "cache must be warmed at 0"
|
||
finally:
|
||
ps.spend_counter_cache = orig_counter
|
||
ps.prisma_client = orig_prisma
|
||
|
||
|
||
# -----------------------------------------------------------------------------
|
||
# /config/update — critical paths only.
|
||
#
|
||
# These exercise the four behaviors that broke or changed in the rewrite of
|
||
# update_config (litellm/proxy/proxy_server.py): targeted per-section writes,
|
||
# the removal of the store_model_in_db gate, env var encryption, and the
|
||
# success_callback / litellm_settings merge semantics. All other branches
|
||
# (auth, missing-DB, slack auto-enable, router_settings merge) are covered
|
||
# implicitly or by upstream tests.
|
||
# -----------------------------------------------------------------------------
|
||
|
||
|
||
class _FakeRow:
|
||
def __init__(self, param_name, param_value):
|
||
self.param_name = param_name
|
||
self.param_value = param_value
|
||
|
||
|
||
class _FakeLitellmConfig:
|
||
def __init__(self, initial_rows=None):
|
||
self.rows = dict(initial_rows or {})
|
||
self.upsert_calls: list = []
|
||
self.find_first = AsyncMock(side_effect=self._find_first)
|
||
self.upsert = AsyncMock(side_effect=self._upsert)
|
||
|
||
async def _find_first(self, where=None):
|
||
if where and "param_name" in where:
|
||
name = where["param_name"]
|
||
if name in self.rows:
|
||
return _FakeRow(name, self.rows[name])
|
||
return None
|
||
|
||
async def _upsert(self, where=None, data=None):
|
||
name = where["param_name"]
|
||
raw = data["update"]["param_value"]
|
||
value = json.loads(raw) if isinstance(raw, str) else raw
|
||
self.rows[name] = value
|
||
self.upsert_calls.append((name, value))
|
||
|
||
|
||
class _FakePrismaClient:
|
||
def __init__(self, initial_rows=None):
|
||
self.db = mock.MagicMock()
|
||
self.db.litellm_config = _FakeLitellmConfig(initial_rows=initial_rows)
|
||
self.jsonify_object = lambda obj: obj
|
||
|
||
|
||
@pytest.fixture
|
||
def _update_config_setup(monkeypatch):
|
||
"""Install fakes for the /config/update endpoint and return (client, prisma)."""
|
||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth as auth_dep
|
||
|
||
def _install(initial_rows=None, store_model_in_db=True):
|
||
prisma = _FakePrismaClient(initial_rows=initial_rows)
|
||
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", prisma)
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.proxy_server.store_model_in_db", store_model_in_db
|
||
)
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.proxy_server.encrypt_value_helper",
|
||
lambda value, **_: f"enc:{value}",
|
||
)
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.proxy_server.invalidate_config_param",
|
||
AsyncMock(return_value=None),
|
||
)
|
||
from litellm.proxy.proxy_server import proxy_config as real_proxy_config
|
||
|
||
monkeypatch.setattr(
|
||
real_proxy_config, "add_deployment", AsyncMock(return_value=None)
|
||
)
|
||
|
||
original_overrides = app.dependency_overrides.copy()
|
||
app.dependency_overrides[auth_dep] = lambda: UserAPIKeyAuth(
|
||
user_id="test_admin",
|
||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||
api_key="sk-1234",
|
||
)
|
||
client = TestClient(app)
|
||
|
||
def _restore():
|
||
app.dependency_overrides = original_overrides
|
||
|
||
return client, prisma, _restore
|
||
|
||
return _install
|
||
|
||
|
||
def test_update_config_writes_only_sent_section(_update_config_setup):
|
||
"""A request that only touches general_settings must not write any other
|
||
section row, and must leave previously-written rows byte-identical."""
|
||
client, prisma, restore = _update_config_setup(
|
||
initial_rows={
|
||
"litellm_settings": {"drop_params": True},
|
||
"environment_variables": {"FOO": "enc:bar"},
|
||
}
|
||
)
|
||
try:
|
||
resp = client.post(
|
||
"/config/update",
|
||
json={"general_settings": {"store_prompts_in_spend_logs": True}},
|
||
)
|
||
assert resp.status_code == 200
|
||
written = {name for name, _ in prisma.db.litellm_config.upsert_calls}
|
||
assert written == {"general_settings"}
|
||
assert prisma.db.litellm_config.rows["litellm_settings"] == {
|
||
"drop_params": True
|
||
}
|
||
assert prisma.db.litellm_config.rows["environment_variables"] == {
|
||
"FOO": "enc:bar"
|
||
}
|
||
finally:
|
||
restore()
|
||
|
||
|
||
def test_update_config_env_var_round_trip_not_double_encrypted(
|
||
_update_config_setup, monkeypatch
|
||
):
|
||
"""Endpoint-level regression for the /config/update double-encryption bug.
|
||
|
||
The Admin UI reads config back via /get/config/callbacks (which returns
|
||
the stored, still-encrypted value) and re-POSTs it on the next save. The
|
||
handler must NOT stack a second encryption layer on the re-submitted
|
||
ciphertext, and must leave untouched keys byte-identical.
|
||
|
||
Uses an invertible fake encrypt/decrypt pair ("enc:" prefix) so the
|
||
decrypt-then-encrypt chokepoint round-trips faithfully. On the pre-fix
|
||
code this stored "enc:enc:..."; the assertions below would fail there.
|
||
"""
|
||
|
||
def _fake_decrypt(
|
||
value, key=None, exception_type="error", return_original_value=False
|
||
):
|
||
if isinstance(value, str) and value.startswith("enc:"):
|
||
return value[len("enc:") :]
|
||
return value if return_original_value else None
|
||
|
||
monkeypatch.setattr(
|
||
"litellm.proxy.proxy_server.decrypt_value_helper", _fake_decrypt
|
||
)
|
||
|
||
client, prisma, restore = _update_config_setup(
|
||
initial_rows={"environment_variables": {"PREEXISTING_KEY": "enc:keepme"}}
|
||
)
|
||
try:
|
||
# First write: plaintext in -> single-encrypted at rest.
|
||
resp = client.post(
|
||
"/config/update",
|
||
json={"environment_variables": {"LANGFUSE_SECRET_KEY": "sk-secret"}},
|
||
)
|
||
assert resp.status_code == 200
|
||
stored = prisma.db.litellm_config.rows["environment_variables"]
|
||
assert stored["LANGFUSE_SECRET_KEY"] == "enc:sk-secret"
|
||
|
||
# UI round-trip: re-POST the stored ciphertext (no field change).
|
||
resp = client.post(
|
||
"/config/update",
|
||
json={
|
||
"environment_variables": {
|
||
"LANGFUSE_SECRET_KEY": stored["LANGFUSE_SECRET_KEY"]
|
||
}
|
||
},
|
||
)
|
||
assert resp.status_code == 200
|
||
stored = prisma.db.litellm_config.rows["environment_variables"]
|
||
|
||
# The bug: this would be "enc:enc:sk-secret". The fix keeps it single.
|
||
assert stored["LANGFUSE_SECRET_KEY"] == "enc:sk-secret"
|
||
assert (
|
||
_fake_decrypt(stored["LANGFUSE_SECRET_KEY"], return_original_value=True)
|
||
== "sk-secret"
|
||
)
|
||
|
||
# Untouched key preserved byte-for-byte (only sent keys rewritten).
|
||
assert stored["PREEXISTING_KEY"] == "enc:keepme"
|
||
finally:
|
||
restore()
|
||
|
||
|
||
def test_update_config_can_flip_store_model_in_db_when_currently_false(
|
||
_update_config_setup,
|
||
):
|
||
"""The endpoint used to refuse all writes when store_model_in_db was
|
||
False, blocking the very request that would flip it to True."""
|
||
client, prisma, restore = _update_config_setup(store_model_in_db=False)
|
||
try:
|
||
resp = client.post(
|
||
"/config/update", json={"general_settings": {"store_model_in_db": True}}
|
||
)
|
||
assert resp.status_code == 200
|
||
assert (
|
||
prisma.db.litellm_config.rows["general_settings"]["store_model_in_db"]
|
||
is True
|
||
)
|
||
finally:
|
||
restore()
|
||
|
||
|
||
def test_update_config_environment_variables_encrypted_before_write(
|
||
_update_config_setup,
|
||
):
|
||
"""env var values must be encrypted before they hit the DB row."""
|
||
client, prisma, restore = _update_config_setup()
|
||
try:
|
||
resp = client.post(
|
||
"/config/update",
|
||
json={"environment_variables": {"OPENAI_API_KEY": "sk-secret"}},
|
||
)
|
||
assert resp.status_code == 200
|
||
stored = prisma.db.litellm_config.rows["environment_variables"]
|
||
assert stored == {"OPENAI_API_KEY": "enc:sk-secret"}
|
||
finally:
|
||
restore()
|
||
|
||
|
||
def test_update_config_litellm_settings_request_wins_for_non_callback_keys(
|
||
_update_config_setup,
|
||
):
|
||
"""Sending {"drop_params": False} when the row holds drop_params: True
|
||
must persist False (request wins). Untouched keys preserved."""
|
||
client, prisma, restore = _update_config_setup(
|
||
initial_rows={
|
||
"litellm_settings": {"drop_params": True, "set_verbose": True},
|
||
}
|
||
)
|
||
try:
|
||
resp = client.post(
|
||
"/config/update", json={"litellm_settings": {"drop_params": False}}
|
||
)
|
||
assert resp.status_code == 200
|
||
stored = prisma.db.litellm_config.rows["litellm_settings"]
|
||
assert stored["drop_params"] is False
|
||
assert stored["set_verbose"] is True
|
||
finally:
|
||
restore()
|
||
|
||
|
||
def test_update_config_success_callback_normalizes_existing_mixed_case(
|
||
_update_config_setup,
|
||
):
|
||
"""Existing mixed-case callback names (written elsewhere) must be
|
||
normalized to lowercase before union, otherwise the union dedup misses
|
||
against the lowercase incoming entry and delete_callback (lowercase
|
||
lookup) cannot find the original."""
|
||
client, prisma, restore = _update_config_setup(
|
||
initial_rows={"litellm_settings": {"success_callback": ["Langfuse", "SQS"]}}
|
||
)
|
||
try:
|
||
resp = client.post(
|
||
"/config/update",
|
||
json={"litellm_settings": {"success_callback": ["langfuse"]}},
|
||
)
|
||
assert resp.status_code == 200
|
||
stored = prisma.db.litellm_config.rows["litellm_settings"]["success_callback"]
|
||
assert set(stored) == {"langfuse", "sqs"}
|
||
finally:
|
||
restore()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Lazy feature loading (LazyFeatureMiddleware) — verifies that optional
|
||
# routers are NOT imported at module load and ARE imported on first request
|
||
# to a matching path prefix. The same module isn't re-imported on subsequent
|
||
# requests.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestLazyFeatureRegistry:
|
||
"""Sanity checks on the registry shape — guards against accidental edits."""
|
||
|
||
def test_registry_entries_have_required_fields(self):
|
||
from litellm.proxy._lazy_features import LAZY_FEATURES, LazyFeature
|
||
|
||
assert len(LAZY_FEATURES) > 0
|
||
for feat in LAZY_FEATURES:
|
||
assert isinstance(feat, LazyFeature)
|
||
assert feat.name
|
||
assert feat.module_path
|
||
assert feat.path_prefixes
|
||
assert all(p.startswith("/") for p in feat.path_prefixes)
|
||
assert callable(feat.register_fn)
|
||
|
||
def test_registry_names_unique(self):
|
||
from litellm.proxy._lazy_features import LAZY_FEATURES
|
||
|
||
names = [f.name for f in LAZY_FEATURES]
|
||
assert len(names) == len(set(names)), "duplicate feature names"
|
||
|
||
def test_matches_covers_prefix_and_suffix(self):
|
||
"""``matches`` is the single matcher shared by the middleware (request
|
||
paths) and the warm endpoint (registered route paths), so a route that
|
||
only matches via suffix — e.g. ``/v1/a2a/{id}/message/send`` against the
|
||
``/a2a`` prefix — must still be claimed by the feature."""
|
||
from litellm.proxy._lazy_features import LazyFeature
|
||
|
||
feat = LazyFeature(
|
||
name="a2a",
|
||
module_path="json",
|
||
path_prefixes=("/a2a",),
|
||
path_suffixes=("/message/send",),
|
||
)
|
||
assert feat.matches("/a2a/abc/message/send")
|
||
assert feat.matches("/v1/a2a/abc/message/send")
|
||
assert feat.matches("/a2a/abc/.well-known/agent-card.json")
|
||
assert not feat.matches("/v1/a2a/discover")
|
||
assert not feat.matches("/unrelated")
|
||
|
||
|
||
class TestLazyFeaturesNotImportedAtStartup:
|
||
"""
|
||
The whole point of the refactor: gated feature modules must NOT be
|
||
present in `sys.modules` immediately after `proxy_server` imports.
|
||
"""
|
||
|
||
def test_heavy_modules_absent_at_startup(self):
|
||
# Static scan of proxy_server.py source — catches any top-level
|
||
# `from <lazy_module> import` that would defeat lazy loading.
|
||
# Importing proxy_server in a subprocess and diffing sys.modules
|
||
# would also work, but takes 60-120 s and flakes on slow CI runners.
|
||
import re
|
||
from pathlib import Path
|
||
|
||
from litellm.proxy._lazy_features import LAZY_FEATURES
|
||
|
||
proxy_server_src = (
|
||
Path(__file__).resolve().parents[3] / "litellm/proxy/proxy_server.py"
|
||
).read_text()
|
||
|
||
leaks = []
|
||
for feat in LAZY_FEATURES:
|
||
# Anchor at column 0 — indented imports inside function bodies
|
||
# are fine (deferred until the function runs).
|
||
pattern = (
|
||
rf"^(from\s+{re.escape(feat.module_path)}\s+import|"
|
||
rf"import\s+{re.escape(feat.module_path)})"
|
||
)
|
||
if re.search(pattern, proxy_server_src, re.MULTILINE):
|
||
leaks.append(feat.module_path)
|
||
|
||
assert not leaks, (
|
||
"proxy_server.py top-level imports a lazy feature module — these "
|
||
f"should be loaded via LazyFeatureMiddleware: {leaks}"
|
||
)
|
||
|
||
|
||
class TestLazyFeatureMiddleware:
|
||
"""Behavior of the middleware itself, exercised in isolation."""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_first_request_triggers_load_subsequent_does_not(self):
|
||
from fastapi import FastAPI
|
||
|
||
from litellm.proxy._lazy_features import (
|
||
LazyFeature,
|
||
LazyFeatureMiddleware,
|
||
)
|
||
|
||
loads = []
|
||
|
||
def fake_register(app, module):
|
||
loads.append(getattr(module, "__name__", "?"))
|
||
|
||
feat = LazyFeature(
|
||
name="dummy",
|
||
module_path="json", # any always-importable stdlib module
|
||
path_prefixes=("/dummy",),
|
||
register_fn=fake_register,
|
||
)
|
||
|
||
# Build a minimal ASGI receiver to satisfy the middleware contract
|
||
async def downstream(scope, receive, send):
|
||
# echo back; no-op handler
|
||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||
await send({"type": "http.response.body", "body": b""})
|
||
|
||
target_app = FastAPI()
|
||
mw = LazyFeatureMiddleware(downstream, fastapi_app=target_app, features=(feat,))
|
||
|
||
async def receive():
|
||
return {"type": "http.request", "body": b"", "more_body": False}
|
||
|
||
sent: list = []
|
||
|
||
async def send(message):
|
||
sent.append(message)
|
||
|
||
# First request matching the prefix triggers register
|
||
await mw(
|
||
{"type": "http", "path": "/dummy/x", "method": "GET", "headers": []},
|
||
receive,
|
||
send,
|
||
)
|
||
assert loads == ["json"]
|
||
|
||
# Second matching request must NOT re-register
|
||
sent.clear()
|
||
await mw(
|
||
{"type": "http", "path": "/dummy/y", "method": "GET", "headers": []},
|
||
receive,
|
||
send,
|
||
)
|
||
assert loads == ["json"], "register_fn called twice for the same feature"
|
||
|
||
# Non-matching path must not trigger anything
|
||
await mw(
|
||
{"type": "http", "path": "/unrelated", "method": "GET", "headers": []},
|
||
receive,
|
||
send,
|
||
)
|
||
assert loads == ["json"]
|
||
|
||
@pytest.mark.asyncio
|
||
@pytest.mark.parametrize(
|
||
"server_root_path,request_path,should_load,case",
|
||
[
|
||
# SERVER_ROOT_PATH set: incoming path includes prefix → strip and match.
|
||
("/api/v1", "/api/v1/dummy/x", True, "root_path strip + match"),
|
||
# Trailing-slash env var must be normalized.
|
||
("/api/v1/", "/api/v1/dummy/x", True, "trailing-slash env normalization"),
|
||
# Reverse proxy already stripped the prefix → original path still matches.
|
||
("/api/v1", "/dummy/x", True, "pre-stripped path still loads"),
|
||
# No SERVER_ROOT_PATH set → unchanged behavior.
|
||
("", "/dummy/x", True, "no root path"),
|
||
# SERVER_ROOT_PATH=/ must be a no-op (not strip every leading slash).
|
||
("/", "/dummy/x", True, "root_path='/' is no-op"),
|
||
# Boundary check: /apiv2 must not match root /api.
|
||
("/api", "/apiv2/foo", False, "boundary check prevents false match"),
|
||
# Genuine non-match under root_path.
|
||
("/api/v1", "/api/v1/unrelated", False, "unrelated path under root"),
|
||
],
|
||
)
|
||
async def test_root_path_handling(
|
||
self, monkeypatch, server_root_path, request_path, should_load, case
|
||
):
|
||
"""
|
||
The middleware must strip SERVER_ROOT_PATH before prefix-matching so
|
||
lazy features load under deployments that set a server root path,
|
||
while handling boundary, trailing-slash, and reverse-proxy edge cases
|
||
correctly.
|
||
"""
|
||
from fastapi import FastAPI
|
||
|
||
from litellm.proxy._lazy_features import (
|
||
LazyFeature,
|
||
LazyFeatureMiddleware,
|
||
)
|
||
|
||
monkeypatch.setenv("SERVER_ROOT_PATH", server_root_path)
|
||
|
||
loads = []
|
||
|
||
def fake_register(app, module):
|
||
loads.append(getattr(module, "__name__", "?"))
|
||
|
||
feat = LazyFeature(
|
||
name=f"dummy_{case}",
|
||
module_path="json",
|
||
path_prefixes=("/dummy",),
|
||
register_fn=fake_register,
|
||
)
|
||
|
||
async def downstream(scope, receive, send):
|
||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||
await send({"type": "http.response.body", "body": b""})
|
||
|
||
target_app = FastAPI()
|
||
mw = LazyFeatureMiddleware(downstream, fastapi_app=target_app, features=(feat,))
|
||
|
||
async def receive():
|
||
return {"type": "http.request", "body": b"", "more_body": False}
|
||
|
||
async def send(message):
|
||
pass
|
||
|
||
await mw(
|
||
{
|
||
"type": "http",
|
||
"path": request_path,
|
||
"method": "GET",
|
||
"headers": [],
|
||
},
|
||
receive,
|
||
send,
|
||
)
|
||
if should_load:
|
||
assert loads == ["json"], f"{case}: expected feature to load"
|
||
else:
|
||
assert loads == [], f"{case}: feature must not load"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_concurrent_first_requests_only_register_once(self):
|
||
"""
|
||
Two requests to the same prefix arriving in parallel must result in
|
||
exactly one `register_fn` invocation — the lock prevents the import +
|
||
register from racing with itself.
|
||
"""
|
||
from fastapi import FastAPI
|
||
|
||
from litellm.proxy._lazy_features import (
|
||
LazyFeature,
|
||
LazyFeatureMiddleware,
|
||
)
|
||
|
||
loads = []
|
||
|
||
def slow_register(app, module):
|
||
loads.append(getattr(module, "__name__", "?"))
|
||
|
||
feat = LazyFeature(
|
||
name="dummy_concurrent",
|
||
module_path="json",
|
||
path_prefixes=("/dummy_c",),
|
||
register_fn=slow_register,
|
||
)
|
||
|
||
async def downstream(scope, receive, send):
|
||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||
await send({"type": "http.response.body", "body": b""})
|
||
|
||
target_app = FastAPI()
|
||
mw = LazyFeatureMiddleware(downstream, fastapi_app=target_app, features=(feat,))
|
||
|
||
async def receive():
|
||
return {"type": "http.request", "body": b"", "more_body": False}
|
||
|
||
sent: list = []
|
||
|
||
async def send(message):
|
||
sent.append(message)
|
||
|
||
async def hit():
|
||
await mw(
|
||
{
|
||
"type": "http",
|
||
"path": "/dummy_c/x",
|
||
"method": "GET",
|
||
"headers": [],
|
||
},
|
||
receive,
|
||
send,
|
||
)
|
||
|
||
await asyncio.gather(hit(), hit(), hit(), hit(), hit())
|
||
assert loads == [
|
||
"json"
|
||
], f"expected one registration despite concurrent first hits, got {loads}"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_failing_import_does_not_loop(self):
|
||
"""
|
||
If a feature's module can't be imported, the middleware should mark it
|
||
loaded anyway so subsequent requests don't repeatedly retry the failing
|
||
import (which would amplify the cost on every request).
|
||
"""
|
||
from fastapi import FastAPI
|
||
|
||
from litellm.proxy._lazy_features import (
|
||
LazyFeature,
|
||
LazyFeatureMiddleware,
|
||
)
|
||
|
||
attempts = []
|
||
|
||
def fail_register(app, module):
|
||
attempts.append("called")
|
||
raise RuntimeError("boom")
|
||
|
||
feat = LazyFeature(
|
||
name="failing",
|
||
module_path="json",
|
||
path_prefixes=("/fail",),
|
||
register_fn=fail_register,
|
||
)
|
||
|
||
async def downstream(scope, receive, send):
|
||
await send({"type": "http.response.start", "status": 200, "headers": []})
|
||
await send({"type": "http.response.body", "body": b""})
|
||
|
||
target_app = FastAPI()
|
||
mw = LazyFeatureMiddleware(downstream, fastapi_app=target_app, features=(feat,))
|
||
|
||
async def receive():
|
||
return {"type": "http.request", "body": b"", "more_body": False}
|
||
|
||
sent: list = []
|
||
|
||
async def send(message):
|
||
sent.append(message)
|
||
|
||
for _ in range(3):
|
||
await mw(
|
||
{"type": "http", "path": "/fail/x", "method": "GET", "headers": []},
|
||
receive,
|
||
send,
|
||
)
|
||
assert attempts == [
|
||
"called"
|
||
], f"failing register_fn should be invoked once, not on every request; got {attempts}"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_current_spend_redis_clean_miss_skips_stale_in_memory():
|
||
"""When Redis is reachable and cleanly returns None (TTL expired,
|
||
counter genuinely absent), the read must reseed from DB - NOT fall
|
||
through to per-pod in-memory which only contains this pod's writes.
|
||
|
||
Pre-fix in multi-pod deployments, in-memory contained a stale local
|
||
subset (e.g. $30) while DB had the true cross-pod total ($500). The
|
||
fall-through returned $30, enforcement passed, bypass.
|
||
"""
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy.proxy_server import get_current_spend
|
||
|
||
counter_cache = DualCache()
|
||
counter_key = "spend:team_member:user-1:team-1"
|
||
|
||
# Per-pod stale in-memory: only this pod's writes, not cross-pod truth.
|
||
counter_cache.in_memory_cache.set_cache(key=counter_key, value=30.0)
|
||
|
||
# Redis cleanly returns None (key expired or never written on this pod).
|
||
fake_redis = AsyncMock()
|
||
fake_redis.async_get_cache = AsyncMock(return_value=None)
|
||
fake_redis.async_increment = AsyncMock(return_value=500.0)
|
||
counter_cache.redis_cache = fake_redis
|
||
|
||
# DB has the authoritative cross-pod spend.
|
||
db_row = MagicMock()
|
||
db_row.spend = 500.0
|
||
fake_prisma = MagicMock()
|
||
fake_prisma.db.litellm_teammembership.find_unique = AsyncMock(return_value=db_row)
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
orig_counter, orig_prisma = ps.spend_counter_cache, ps.prisma_client
|
||
ps.spend_counter_cache = counter_cache
|
||
ps.prisma_client = fake_prisma
|
||
try:
|
||
spend = await get_current_spend(counter_key=counter_key, fallback_spend=0.0)
|
||
assert spend == 500.0, (
|
||
f"expected DB-authoritative 500.0 on clean Redis miss, got {spend} "
|
||
f"(stale per-pod in-memory $30 would have caused multi-pod bypass)"
|
||
)
|
||
finally:
|
||
ps.spend_counter_cache = orig_counter
|
||
ps.prisma_client = orig_prisma
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_current_spend_redis_error_falls_back_to_in_memory():
|
||
"""When Redis raises, the read should still degrade to in-memory rather
|
||
than going straight to DB - in-memory is at least same-pod-fresh and
|
||
cheaper than a DB query during a Redis outage."""
|
||
from litellm.caching.dual_cache import DualCache
|
||
from litellm.proxy.proxy_server import get_current_spend
|
||
|
||
counter_cache = DualCache()
|
||
counter_key = "spend:team_member:user-1:team-1"
|
||
|
||
counter_cache.in_memory_cache.set_cache(key=counter_key, value=42.0)
|
||
|
||
fake_redis = AsyncMock()
|
||
fake_redis.async_get_cache = AsyncMock(side_effect=ConnectionError("redis down"))
|
||
counter_cache.redis_cache = fake_redis
|
||
|
||
fake_prisma = MagicMock()
|
||
fake_prisma.db.litellm_teammembership.find_unique = AsyncMock(
|
||
return_value=MagicMock(spend=999.0)
|
||
)
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
|
||
orig_counter, orig_prisma = ps.spend_counter_cache, ps.prisma_client
|
||
ps.spend_counter_cache = counter_cache
|
||
ps.prisma_client = fake_prisma
|
||
try:
|
||
spend = await get_current_spend(counter_key=counter_key, fallback_spend=0.0)
|
||
assert spend == 42.0, (
|
||
f"expected in-memory fallback 42.0 on Redis error, got {spend} "
|
||
f"(should not have hit DB when Redis errored)"
|
||
)
|
||
# DB query should NOT have fired - in-memory short-circuits.
|
||
fake_prisma.db.litellm_teammembership.find_unique.assert_not_awaited()
|
||
finally:
|
||
ps.spend_counter_cache = orig_counter
|
||
ps.prisma_client = orig_prisma
|
||
|
||
|
||
def test_realtime_websocket_route_aliases_registered():
|
||
"""Realtime sessions reach the proxy via three path aliases stacked on
|
||
`realtime_websocket_endpoint`. Dropping any of them silently 405s
|
||
WebSocket upgrades because the catch-all `/openai/{endpoint:path}`
|
||
HTTP passthrough only declares HTTP methods. The aliases must also be
|
||
in `LiteLLMRoutes.openai_routes` (so non-admin / team / key-scoped
|
||
auth allows them) and in `API_ROUTE_TO_CALL_TYPES` (so call-type-aware
|
||
logic such as guardrails can resolve the realtime call type)."""
|
||
from starlette.routing import WebSocketRoute
|
||
|
||
from litellm.proxy._types import LiteLLMRoutes
|
||
from litellm.proxy.proxy_server import app
|
||
from litellm.types.utils import API_ROUTE_TO_CALL_TYPES, CallTypes
|
||
|
||
websocket_paths = {
|
||
route.path for route in app.routes if isinstance(route, WebSocketRoute)
|
||
}
|
||
openai_routes = LiteLLMRoutes.openai_routes.value
|
||
|
||
for expected in ("/openai/v1/realtime", "/v1/realtime", "/realtime"):
|
||
assert expected in websocket_paths, (
|
||
f"{expected!r} missing from registered WebSocket routes; the "
|
||
f"realtime endpoint will 405 for clients hitting this path."
|
||
)
|
||
assert expected in openai_routes, (
|
||
f"{expected!r} missing from LiteLLMRoutes.openai_routes; "
|
||
f"non-admin / team / key-scoped users will get 403 on this path."
|
||
)
|
||
assert API_ROUTE_TO_CALL_TYPES.get(expected) == [CallTypes.arealtime], (
|
||
f"{expected!r} missing from API_ROUTE_TO_CALL_TYPES; call-type "
|
||
f"resolution will return None and break call-type-aware features."
|
||
)
|
||
|
||
|
||
class TestTransformRequestBannedParams:
|
||
"""
|
||
/utils/transform_request applies the same banned-param check as LLM endpoints.
|
||
|
||
Without this check, any authenticated user could supply aws_sts_endpoint,
|
||
api_base, etc. and have the server forward its credentials to an
|
||
attacker-controlled endpoint during SDK credential resolution.
|
||
"""
|
||
|
||
@pytest.fixture
|
||
def client(self):
|
||
mock_auth = UserAPIKeyAuth(
|
||
user_id="test-internal",
|
||
user_role=LitellmUserRoles.INTERNAL_USER,
|
||
)
|
||
original = app.dependency_overrides.copy()
|
||
app.dependency_overrides[user_api_key_auth] = lambda: mock_auth
|
||
try:
|
||
yield TestClient(app)
|
||
finally:
|
||
app.dependency_overrides = original
|
||
|
||
@pytest.mark.parametrize(
|
||
"banned",
|
||
[
|
||
"aws_sts_endpoint",
|
||
"api_base",
|
||
"aws_web_identity_token",
|
||
"vertex_credentials",
|
||
],
|
||
)
|
||
def test_banned_params_rejected_for_all_users(self, client, banned):
|
||
"""Banned params must be blocked for any authenticated user."""
|
||
response = client.post(
|
||
"/utils/transform_request",
|
||
json={
|
||
"call_type": "completion",
|
||
"request_body": {
|
||
"model": "gpt-3.5-turbo",
|
||
banned: "https://attacker.example",
|
||
},
|
||
},
|
||
)
|
||
assert response.status_code == 400, (
|
||
f"Expected 400 for banned param '{banned}', "
|
||
f"got {response.status_code}: {response.json()}"
|
||
)
|
||
|
||
|
||
class TestSortModelsByDisplayName:
|
||
"""Regression: team BYOK rows persist an internal `model_name` like
|
||
`model_name_{team_id}_{uuid}` and expose the user-facing name via
|
||
`model_info.team_public_model_name`. Sorting must use the displayed
|
||
name so BYOK rows interleave with non-BYOK rows alphabetically —
|
||
otherwise they clump at the end on their opaque IDs even though the
|
||
UI shows them under a normal-looking name.
|
||
"""
|
||
|
||
def test_byok_models_sort_by_team_public_model_name(self):
|
||
from litellm.proxy.proxy_server import _sort_models
|
||
|
||
models = [
|
||
{"model_name": "claude-haiku-4-5", "model_info": {}},
|
||
{
|
||
# Opaque internal name; UI displays team_public_model_name.
|
||
"model_name": "model_name_team-1_abc123",
|
||
"model_info": {"team_public_model_name": "anthropic/claude"},
|
||
},
|
||
{"model_name": "gpt-4o", "model_info": {}},
|
||
]
|
||
|
||
sorted_models = _sort_models(
|
||
all_models=models, sort_by="model_name", sort_order="asc"
|
||
)
|
||
displayed_order = [
|
||
m["model_info"].get("team_public_model_name") or m["model_name"]
|
||
for m in sorted_models
|
||
]
|
||
assert displayed_order == [
|
||
"anthropic/claude",
|
||
"claude-haiku-4-5",
|
||
"gpt-4o",
|
||
]
|
||
|
||
def test_byok_models_sort_descending_by_display_name(self):
|
||
from litellm.proxy.proxy_server import _sort_models
|
||
|
||
models = [
|
||
{"model_name": "claude-haiku-4-5", "model_info": {}},
|
||
{
|
||
"model_name": "model_name_team-1_zzz",
|
||
"model_info": {"team_public_model_name": "zeta/model"},
|
||
},
|
||
{"model_name": "gpt-4o", "model_info": {}},
|
||
]
|
||
|
||
sorted_models = _sort_models(
|
||
all_models=models, sort_by="model_name", sort_order="desc"
|
||
)
|
||
displayed_order = [
|
||
m["model_info"].get("team_public_model_name") or m["model_name"]
|
||
for m in sorted_models
|
||
]
|
||
assert displayed_order == [
|
||
"zeta/model",
|
||
"gpt-4o",
|
||
"claude-haiku-4-5",
|
||
]
|
||
|
||
def test_empty_team_public_model_name_falls_back_to_model_name(self):
|
||
# Empty string for team_public_model_name (not None) must still
|
||
# fall back to model_name — otherwise BYOK rows with a blank
|
||
# display name would sort to the top.
|
||
from litellm.proxy.proxy_server import _sort_models
|
||
|
||
models = [
|
||
{"model_name": "alpha", "model_info": {"team_public_model_name": ""}},
|
||
{"model_name": "beta", "model_info": {}},
|
||
]
|
||
|
||
sorted_models = _sort_models(
|
||
all_models=models, sort_by="model_name", sort_order="asc"
|
||
)
|
||
assert [m["model_name"] for m in sorted_models] == ["alpha", "beta"]
|
||
|
||
|
||
class TestDeleteDeploymentSync:
|
||
@pytest.mark.asyncio
|
||
async def test_delete_deployment_evicts_model_when_all_db_models_deleted(self):
|
||
"""
|
||
Regression test for #28443.
|
||
When all DB models are deleted, _delete_deployment must evict them from
|
||
the router. The old code returned 0 early when db_models was empty.
|
||
"""
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
mock_router = MagicMock()
|
||
mock_router.get_model_ids.return_value = ["model-id-to-evict"]
|
||
mock_router.delete_deployment.return_value = MagicMock()
|
||
|
||
with patch("litellm.proxy.proxy_server.llm_router", mock_router):
|
||
with patch.object(
|
||
proxy_config, "get_config", AsyncMock(return_value={"model_list": []})
|
||
):
|
||
count = await proxy_config._delete_deployment(db_models=[])
|
||
|
||
mock_router.delete_deployment.assert_called_once_with(id="model-id-to-evict")
|
||
assert count == 1
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_update_llm_router_skips_update_on_db_fetch_failure(self):
|
||
"""
|
||
When _get_models_from_db returns None (transient DB failure), _update_llm_router
|
||
must return early without touching the router.
|
||
"""
|
||
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
mock_router = MagicMock()
|
||
|
||
with patch("litellm.proxy.proxy_server.llm_router", mock_router):
|
||
with patch.object(proxy_config, "get_config", AsyncMock(return_value={})):
|
||
await proxy_config._update_llm_router(
|
||
new_models=None, proxy_logging_obj=MagicMock()
|
||
)
|
||
|
||
mock_router.delete_deployment.assert_not_called()
|
||
mock_router.upsert_deployment.assert_not_called()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_get_models_from_db_returns_none_on_exception(self):
|
||
"""
|
||
_get_models_from_db must return None (not []) when the DB raises an exception,
|
||
so callers can distinguish a transient failure from a genuinely empty DB.
|
||
"""
|
||
from unittest.mock import AsyncMock, MagicMock
|
||
|
||
from litellm.proxy.proxy_server import ProxyConfig
|
||
|
||
proxy_config = ProxyConfig()
|
||
mock_prisma = MagicMock()
|
||
mock_prisma.db.litellm_proxymodeltable.find_many = AsyncMock(
|
||
side_effect=Exception("DB connection lost")
|
||
)
|
||
|
||
result = await proxy_config._get_models_from_db(prisma_client=mock_prisma)
|
||
|
||
assert (
|
||
result is None
|
||
), f"Expected None on DB failure to signal fetch error, got {result!r}"
|
||
|
||
|
||
def test_get_config_list_includes_cancel_on_disconnect(monkeypatch):
|
||
"""Follow-up to #30223: the flag must be discoverable via /config/list,
|
||
which requires both the ConfigGeneralSettings field and the allowed_args
|
||
entry in get_config_list; missing either silently hides it from the UI."""
|
||
import types
|
||
from unittest.mock import AsyncMock, MagicMock
|
||
|
||
from fastapi.testclient import TestClient
|
||
|
||
import litellm.proxy.proxy_server as ps
|
||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||
from litellm.proxy.proxy_server import app
|
||
|
||
mock_prisma = MagicMock()
|
||
mock_config_table = MagicMock()
|
||
mock_config_table.find_first = AsyncMock(return_value=None)
|
||
mock_prisma.db = types.SimpleNamespace(litellm_config=mock_config_table)
|
||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||
user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN
|
||
)
|
||
try:
|
||
client = TestClient(app)
|
||
resp = client.get("/config/list", params={"config_type": "general_settings"})
|
||
assert resp.status_code == 200, resp.text
|
||
fields = {item["field_name"]: item for item in resp.json()}
|
||
assert "cancel_on_disconnect" in fields
|
||
assert fields["cancel_on_disconnect"]["field_type"] == "Boolean"
|
||
finally:
|
||
app.dependency_overrides.clear()
|