mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
CodSpeed Benchmarks / benchmarks (push) Has been cancelled
Helm unit test / unit-test (push) Has been cancelled
Read Version from pyproject.toml / read-version (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Unit Tests: Caching (Redis) / caching-redis (push) Has been cancelled
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Has been cancelled
Unit Tests: Security / security (push) Has been cancelled
GitHub Actions Security Analysis / zizmor (push) Has been cancelled
Unit Tests: Proxy DB Operations / auth-checks (push) Has been cancelled
Unit Tests: Proxy DB Operations / budgets (push) Has been cancelled
Unit Tests: Proxy DB Operations / custom-logging (push) Has been cancelled
Unit Tests: Proxy DB Operations / db-and-spend (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-runtime (push) Has been cancelled
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Has been cancelled
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Has been cancelled
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Has been cancelled
Unit Tests: Proxy DB Operations / key-generation (push) Has been cancelled
Unit Tests: Proxy DB Operations / logging-misc (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-server-core (push) Has been cancelled
Unit Tests: Proxy DB Operations / schema-migration (push) Has been cancelled
Unit Tests: Proxy DB Operations / proxy-utils (push) Has been cancelled
* default requested_model to empty string on litellm-side rejects * Update litellm/router.py Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> * fix: scope key access_group_ids override by team's assigned groups A team member could set any access_group_ids on their key (e.g. a group assigned only to a different team) and override the team's model restriction. Intersect the key's access_group_ids with team_object.access_group_ids in _key_access_group_grants_model so foreign groups are dropped before model expansion. Adds a regression test that asserts expansion is never called for foreign groups. * [Fix] Proxy: Skip Personal Budget Hook When Reservation Covers Counter The reservation path (PR #26845) atomically pre-fills `spend:user:{user_id}` and admits at the strict-`<` boundary. The legacy `_PROXY_MaxBudgetLimiter` pre-call hook re-reads the same counter with `>=`, so a reservation that fills the counter to exactly `max_budget` (e.g. a request without a `max_tokens` cap that falls back to reserving the smallest remaining headroom) is rejected by the hook even though the reservation already admitted it. Skip the hook when the request's active `budget_reservation` covers `spend:user:{user_id}`. The reservation is the source of truth for that counter cross-pod; the legacy `>=` path remains in place for requests without a reservation (e.g. paths that bypass the reservation entirely). Reproduces as `tests/otel_tests/test_prometheus.py::test_user_budget_metrics` on a fresh user with `max_budget=10` calling `fake-openai-endpoint` without `max_tokens`. Adds focused unit coverage in `tests/test_litellm/proxy/hooks/test_max_budget_limiter.py`. * harden bedrock file bucket validation * Fix syntax errors from botched merge in router.py * Fix Vertex batch output edge cases * [Fix] RBAC: Drop management_routes Write Fallback for Admin Viewer Greptile P1: the unsafe-method branch of `_check_proxy_admin_viewer_access` ended with a blanket `if route in management_routes: return`. That set is a mix of reads (info/list — handled via the safe-method GET branch above) and writes. The fallback let Admin Viewer POST to write endpoints not enumerated in `_ADMIN_VIEWER_BLOCKED_WRITE_ROUTES`, including: - /team/block, /team/unblock, /team/permissions_update - /jwt/key/mapping/{new,update,delete} - /key/bulk_update - /key/{key_id}/reset_spend Remove the fallback. The two remaining allow sets (admin_viewer_routes and global_spend_tracking_routes) are both read-only, so removal does not affect the legitimate POST-as-read cases (e.g. /spend/calculate, which is in spend_tracking_routes ⊂ admin_viewer_routes). Tests: - 8 new parametrized cases pinning each previously-leaking management write endpoint to 403 on POST for PROXY_ADMIN_VIEW_ONLY. * fix(tests): anchor VCR redis cassette key to repo root `os.path.relpath` with no `start` arg uses the current working directory, so running pytest from a subdirectory produced a different Redis key than running from the repo root. CI-recorded cassettes and locally-replayed runs would silently miss each other's cache. Anchor the path to the repo root (derived from `__file__`) so the key is stable regardless of CWD. https://claude.ai/code/session_018uCx7pcrkdUJZrCVMaTdPx * fix: gate key access_group override on group's own assignment Replaces the previous intersect-with-team.access_group_ids check, which made the override unreachable in practice (the team-gate fallback already covered every case the intersection allowed). The override now resolves each of the key's access_group_ids via get_access_object and accepts the group only if its assigned_team_ids includes the key's team_id, or its assigned_key_ids includes the key's token. This fulfills the original ask (a key can extend a team's allow-list via a group the admin granted to that team or that specific key) while still rejecting foreign groups referenced by team members of other teams. * [Fix] Proxy/Key Management: Honor team_member_permissions /key/list In /key/list Endpoint When a team grants /key/list via team_member_permissions, non-admin members should see all keys for that team — same as a team admin. Previously the classification in list_keys() only checked admin status, so permitted members fell into the service-account-only path and could not see other members' personal keys. Routes those members into the full-visibility set. * Fix access-group bypass via litellm-model fallback path When _get_all_deployments returns 0 candidates and the litellm-model fallback branch (_get_deployment_by_litellm_model) finds deployments that the access-group filter then empties, _access_group_filter_emptied_candidates remained False (it was captured before that branch ran). The router would then proceed to default fallbacks; the fallback model could have no access_groups and short-circuit the filter, silently serving a caller blocked by access-group restrictions. Update the flag inside the litellm-model branch when filtering empties a non-empty candidate set so the default-fallback guard still triggers. * fix(proxy): redact MCP server URL and headers for non-admin viewers (VERIA-8) Many MCP integrations (Zapier, etc.) embed an upstream API key directly in the server URL, e.g. ``https://actions.zapier.com/mcp/<api-key>/sse``. The list and single-server endpoints were returning the full URL to any authenticated user — `_redact_mcp_credentials` only stripped the explicit ``credentials`` field, and `_sanitize_mcp_server_for_virtual_key` only ran for restricted virtual keys. Non-admin internal users could read the dashboard, click the unmask toggle, and exfiltrate the raw token. Add `_sanitize_mcp_server_for_non_admin` that runs on top of the existing credential redaction and clears the credential-bearing fields: - ``url`` (the primary leak vector) - ``spec_path`` (OpenAPI spec URLs that may carry tokens) - ``static_headers`` / ``extra_headers`` (Authorization) - ``env`` (arbitrary secrets) - ``authorization_url`` / ``token_url`` / ``registration_url`` Identity fields (``server_id``, ``alias``, ``mcp_info``, etc.) are preserved so the UI can still list servers a non-admin's team has access to. Apply the new sanitizer in `fetch_all_mcp_servers` and the per-server fetch path right after the existing virtual-key branch. Update the existing `test_list_mcp_servers_non_admin_user_filtered` assertions that previously checked URL visibility. Frontend defense-in-depth: hide the URL unmask toggle on `mcp_server_view.tsx` unless the viewer is a proxy admin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Fix runtime policy attachment initialization Mark runtime-created policies and attachments initialized so global policy attachments created from the policy builder apply immediately without requiring a restart. Co-authored-by: Cursor <cursoragent@cursor.com> * test(router): cover _try_early_resolve_deployments_for_model_not_in_names The router_code_coverage CI check requires every function in router.py to be referenced by at least one test under tests/{local_testing, router_unit_tests,test_litellm} in a file with "router" in its name. The recently-extracted helper had no direct test, so the check failed with "0.45% of functions in router.py are not tested". Add a focused test that exercises the four return paths: model already in self.model_names, no fallback applies, pattern-router match, and default_deployment substitution (also asserting the stored default isn't mutated). https://claude.ai/code/session_019AVp1XL7RT9RxRe4qRLkay * Fix policy registry teardown in tests Reset the policy ID index during policy engine test cleanup so stale policy versions cannot leak between tests. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(batches): count non-chat tokens, validate batch-file model access (VERIA-39) (#27015) * fix(batches): count non-chat tokens and validate every model in batch file Two security control bypasses on POST /v1/batches: 1. `_get_batch_job_input_file_usage` only summed tokens for `body.messages` (chat completions). Embedding (`input`) and text completion (`prompt`) batches reported zero, letting massive non-chat workloads slip past TPM rate limits. Extend the counter to handle string and list shapes for both fields. 2. The batch input file was forwarded to the upstream provider without inspecting the models named inside the JSONL — only the outer `model` query parameter was checked against the caller's allowlist. A caller restricted to gpt-3.5 could submit a batch targeting gpt-4o and the upstream would execute it under the proxy's shared API key. Add `_get_models_from_batch_input_file_content` (returns the distinct `body.model` values) and call it from `_enforce_batch_file_model_access` in the pre-call hook, which runs each model through `can_key_call_model` so the same allowlist semantics (wildcards, access groups, all-proxy-models, team aliases) the proxy enforces on `/chat/completions` apply here too. Any unauthorized model raises a 403 before the file is forwarded. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(batches): count pre-tokenized prompt/input shapes, classify 403 logs Two follow-ups from the Greptile review on the batch validation PR: 1. P1 TPM bypass via integer token arrays. The OpenAI batch schema accepts ``prompt`` and ``input`` as ``list[int]`` (a single pre-tokenized prompt) or ``list[list[int]]`` (multiple) in addition to the string and ``list[str]`` shapes. Pre-fix only the string shapes were counted, so a caller could submit a batch with hundreds of millions of pre-tokenized tokens and the rate limiter would record zero. Extract the per-field logic into ``_count_prompt_or_input_tokens`` and count each int as one token. 2. P2 access-denial logs were indistinguishable from I/O failures. ``count_input_file_usage`` caught every exception under a generic "Error counting input file usage" message, so an intentional 403 from ``_enforce_batch_file_model_access`` looked the same in the logs as a missing file or a Prisma timeout. Catch ``HTTPException`` separately and log 403s at WARNING level with a security-relevant message before re-raising. Tests cover the new shapes: single ``list[int]``, ``list[list[int]]`` (the worst-case bypass vector), and embeddings ``input`` with pre-tokenized arrays. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proxy): re-validate user_id after /user/info re-parses query (#27009) * fix(proxy): re-validate user_id ownership after /user/info re-parses query The route-level access check in `RouteChecks.non_proxy_admin_allowed_routes_check` reads `request.query_params.get("user_id")`, which decodes literal `+` to spaces. The endpoint then re-parses the raw query string with `urllib.unquote` in `get_user_id_from_request` to preserve `+` characters (so plus-addressed emails work as user_ids). Those two paths produce different ids: a caller who registered a user_id containing a literal space could pass the route check and then read another user's row by sending the encoded `+` form. Add `_enforce_user_info_access` and call it after `_normalize_user_info_user_id` returns the final id. Proxy admin / view-only admin still bypass; everyone else must match the resolved user_id (or have no user_id, which falls back to the caller's own id later in the handler). Tests cover the admin bypass, owner-match path, and the cross-user lookup that this change blocks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proxy): apply user_info ownership check to PROXY_ADMIN_VIEW_ONLY `_enforce_user_info_access` was bypassing both PROXY_ADMIN and PROXY_ADMIN_VIEW_ONLY, but the upstream route check in `RouteChecks.non_proxy_admin_allowed_routes_check` only treats PROXY_ADMIN as a true admin for the `/user/info` route — view-only admins go through the `user_id == valid_token.user_id` enforcement along with regular users. Mirroring that asymmetry left the same encoded-`+` bypass open for view-only admins whose user_id contains a literal space. Drop the PROXY_ADMIN_VIEW_ONLY exemption so the post-decode re-check matches the upstream rule. Update tests: a view-only admin must now be blocked from cross-user lookups but still allowed to read their own row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: yuneng-jiang <yuneng@berri.ai> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(spend-logs): opt-in suppression of stack traces in spend-tracking error logs Adds LITELLM_SUPPRESS_SPEND_LOG_TRACEBACKS env var. When set to true and the proxy log level is INFO or above, spend-tracking error paths emit a single ERROR line without the full traceback. Stack traces are preserved at DEBUG and the Sentry / proxy_logging_obj.failure_handler path is unchanged. The new spend_log_error helper is wired through the spend write hot path: - DBSpendUpdateWriter (update_database, _update_*_db, batch upsert, redis-commit fallbacks) - _ProxyDBLogger._PROXY_track_cost_callback - get_logging_payload exception path - update_spend / update_daily_tag_spend / spend logs queue monitor Resolves LIT-2704. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * fix(spend-logs): preserve no-traceback behavior for update_daily_tag_spend This call site previously logged a single-line error via verbose_proxy_logger.error() with no traceback. Switching it to spend_log_error(..., exc=e) caused a full stack trace to render by default (when LITELLM_SUPPRESS_SPEND_LOG_TRACEBACKS is unset), which contradicts the PR goal of leaving default behavior unchanged. Revert this specific site to the original error log call. * fix(spend-logs): preserve no-traceback behavior for update_daily_tag_spend Bugbot caught a regression: the previous error log here was a single-line verbose_proxy_logger.error(...) with no traceback. spend_log_error attaches the active exception's traceback by default (when the suppression env var is unset), so swapping it in changed default behavior. Revert this one site to its original .error() call to keep the PR strictly opt-in. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * feat(spend-logs): suppress traceback in SpendLogs error_information row Extend LITELLM_SUPPRESS_SPEND_LOG_TRACEBACKS to the failure callback so the per-row Metadata pane in the UI no longer shows the stack trace when the opt-in env var is set, matching the existing console-side suppression. https://claude.ai/code/session_014dztoRbRnRvq54HL9EyHx6 * [Fix] Proxy: Repair Merge Fallout In Router-Override Fallback Auth Conflict resolution for #26968 dropped the `Iterator` typing import (NameError at module load), left a dead `fallback_models = cast(...)` block, and the new tests called `_enforce_key_and_fallback_model_access` without the now-required `request` kwarg. * isolate dual OTEL handlers * harden cloud file compatibility path * harden cloud file compatibility path * [Fix] Proxy/Key Management: Align Key-Org Membership Checks On Generate And Regenerate Mirrors the membership rule on /key/update so that /key/generate and /key/{key}/regenerate apply the same `_validate_caller_can_assign_key_org` gate when the caller specifies an `organization_id`. Proxy admins bypass. The check no-ops when `organization_id` is not being set. * thread trusted params through vertex file content * trust only server legacy file flag * chore(proxy): keep public AI hub unauthenticated * fix(proxy): preserve low-detail readiness status * [Test] Anthropic: Replace Legacy Claude-4-Sonnet Alias With Haiku 4.5 Three live-API tests pinned to claude-4-sonnet-20250514, which is a non-canonical alias of claude-sonnet-4-20250514. Anthropic's main API no longer resolves the legacy form under freshly issued keys, so the tests fail with not_found_error. The token counter test pinned to claude-sonnet-4-20250514 itself (deprecation_date 2026-05-14, two weeks out) was on borrowed time too. Bump all four to claude-haiku-4-5-20251001 — capability superset for what these tests exercise (streaming, parallel tool calling, extended thinking, token counting), no upcoming deprecation, cheaper per-token. * chore(proxy): move URL-valued model/file_id guard from SDK to proxy The previous per-provider guards in HuggingFace, Oobabooga, and Gemini files lived in the SDK layer, breaking SDK callers who legitimately pass URL-valued model identifiers. Move the check to the proxy boundary in add_litellm_data_to_request so SDK users keep working while proxy users default-deny URL-valued model and file_id, with admin opt-in via litellm.provider_url_destination_allowed_hosts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [Chore] Proxy/UI: Drop stray _experimental/out/chat/index.html This file is a regenerable UI build artifact that should not be tracked in source. Removing so the merge into litellm_internal_staging stays clean. * [Test] Anthropic Passthrough: Bump Streaming Cost-Injection Test To Haiku 4.5 test_anthropic_messages_streaming_cost_injection hits the proxy's /v1/messages route, which routes via the anthropic/* wildcard to api.anthropic.com. The 404 surfaced in the test was Anthropic's own not_found_error propagated back through the proxy (visible from the x-litellm-model-id hash on the response — the proxy did route). Same root cause as the prior commit: the legacy claude-4-sonnet-20250514 alias is no longer recognized by Anthropic's main API under the new key. Swap to claude-haiku-4-5-20251001 — same routing path, canonical model. * fix(proxy): handle ownership-recording failures after upstream create If record_container_owner raises after the upstream container is created, the user previously got a 500 with no usable container — they were billed for an unreachable resource. Move ownership recording into the create path's exception handling and split the two failure modes: - HTTPException from the recorder (auth conflicts) propagates verbatim so the client sees the real status code, not a generic LLM error. - Unexpected exceptions are logged and swallowed; the response is returned to the caller so they aren't billed for a container they can't address. The DB row stays untracked until an operator reconciles. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(guardrails): close post-call coverage gaps * fix(types): add /team/permissions_bulk_update to management_routes The blocklist check in _check_proxy_admin_viewer_access only fires for routes that match LiteLLMRoutes.management_routes — the bulk-update endpoint was missing from that list, so the test for view-only admins on /team/permissions_bulk_update fell through to "allow." Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * [Test] Anthropic Passthrough: Bump Thinking Tests Off Legacy Sonnet 4 Alias base_anthropic_messages_test.test_anthropic_messages_with_thinking and test_anthropic_streaming_with_thinking still pinned to claude-4-sonnet-20250514 — the same legacy alias Anthropic no longer recognizes under freshly issued keys. The other four tests in this base class already use claude-sonnet-4-5-20250929; these two were missed. Bump to claude-haiku-4-5-20251001 (supports_reasoning=true, no upcoming deprecation). Subclasses including TestAnthropicPassthroughBasic inherit these methods. * fix(guardrails): cover multi-choice output variants * fix(proxy): preserve public ai hub ui setting * fix(scim): cascade FK cleanup on user delete and surface block status in UI SCIM DELETE /Users/{id} previously called litellm_usertable.delete without clearing rows that FK back to the user, so Postgres rejected the delete with LiteLLM_InvitationLink_user_id_fkey and the SCIM caller saw a 500. Add a helper to drop invitation_link, organization_membership, and team_membership rows before the user delete (mirrors /user/delete in internal_user_endpoints). Also add a Status column to the Virtual Keys and Internal Users tables so admins can see at a glance which keys are blocked and which users SCIM has deactivated. SCIM-blocked keys carry a tooltip explaining the origin. Pin the dashboard's Node version to 20 via .nvmrc to match CI. * chore: update Next.js build artifacts (2026-05-02 03:21 UTC, node v20.20.2) * perf(proxy): cache container/skill ownership reads on the hot path Container ownership and skill rows are looked up on every retrieve / delete / list / file-content / chat-completion-with-skill call. The new stores wrapped raw Prisma queries with no cache, putting one DB round-trip on each request. Add an in-process TTL'd cache mirroring the _byok_cred_cache pattern in mcp_server/server.py: per-key (value, monotonic_timestamp), 60s TTL, 10000-entry cap with full-clear on overflow, invalidated by every write. Negative results (`None`) are cached too so untracked-resource checks also skip the DB. Tests cover: cache-after-first-hit, negative caching, write invalidation, no-caching-on-DB-error, TTL expiry, capacity eviction. 56 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: update Next.js build artifacts (2026-05-02 03:39 UTC, node v20.20.2) * fix: remove traceback key instead of it being "" * fix: linting error * fix(scim): preserve scim_active on PUT when client omits the field A SCIM PUT may legally omit `active` (full-replace with the field absent). Pydantic fills the SCIMUser.active default of True, so the PUT handler was overwriting metadata.scim_active with True even when the client never sent it — silently reactivating a previously SCIM-blocked user and unblocking their keys. Use model_fields_set to detect whether the client actually sent `active`. If omitted, preserve the prior scim_active value and skip the cascade to virtual keys. Also drop comments added in this PR that just narrate what the code does; keep only the docstrings and the SQL-NULL pitfall note that explain non-obvious behaviour. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * fix(proxy): use set lookup for permitted agent filters * fix(mcp): redact command fields for non-admin server views * fix(proxy): forward decoded container ids after ownership checks * fix(caching): handle stale isolated Redis semantic index * fix(cloudflare): support response_text in streaming chunk parser Newer Cloudflare Workers AI models (e.g. Nemotron) emit 'response_text' instead of 'response' on streamed chunks. The non-streaming path was already updated to fall back to 'response_text' (#26385), but the streaming chunk parser still only read 'response', which caused streaming requests against those models to silently produce empty content. Mirror the non-streaming fallback in CloudflareChatResponseIterator.chunk_parser and add a streaming test for the response_text shape. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * Fix code qa * Address bugbot: drop dead encode/decode helpers; preserve empty custom_id - Remove unused _encode_gcp_label_value / _decode_gcp_label_value singular helpers; only the _chunks variants are actually called. - Use 'is not None' check for custom_id so empty-string custom_ids are still labeled and round-trip through batch outputs. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * Forward Vertex file content logging context * test vertex file content logging forwarding Co-authored-by: Sameer Kankute <Sameerlite@users.noreply.github.com> * Fix Vertex batch output logging mutation * fix: don't mutate caller's logging_obj in _try_transform_vertex_batch_output_to_openai The method was overwriting logging_obj.optional_params, logging_obj.model, and logging_obj.start_time on the caller's Logging instance. When invoked from llm_http_handler.py's generic framework path, the framework's own logging_obj (which already went through pre_call) had its properties clobbered, causing model and start_time to reflect the last batch line's values rather than the original call context. Fix: create a fresh local Logging instance for the per-line transformation instead of mutating the incoming logging_obj. The caller's object is now left entirely untouched regardless of whether a logging_obj was passed in or not. Regression tests added to verify model, start_time, and optional_params are not mutated on the caller's logging_obj. Co-authored-by: Sameer Kankute <Sameerlite@users.noreply.github.com> * feat: add opt-out flag for Vertex batch output transformation Adds litellm.disable_vertex_batch_output_transformation (default False). When True, afile_content returns raw Vertex predictions.jsonl untouched so users that parse candidates/modelVersion directly are not broken. * fix(anthropic,bedrock): omit thinking/output_config when reasoning_effort="none" Setting reasoning_effort="none" on Anthropic chat models (direct, Bedrock Invoke, Bedrock Converse, Vertex AI Anthropic, Azure AI Anthropic) crashed LiteLLM with: litellm.APIConnectionError: 'NoneType' object has no attribute 'get' Both the Anthropic chat transformation and Bedrock Converse called ``AnthropicConfig._map_reasoning_effort`` and assigned the ``None`` it returns for ``"none"`` directly to ``optional_params["thinking"]``. Downstream ``is_thinking_enabled`` then did ``optional_params["thinking"].get("type")`` and crashed. Pop ``thinking`` (and on Claude 4.6/4.7, ``output_config``) instead of assigning ``None``, restoring the documented contract that ``reasoning_effort="none"`` means "do not enable thinking". This also prevents downstream Anthropic 400s ("thinking: Input should be an object", "output_config.effort: Input should be ...") if the bug were ever masked. Verified end-to-end against the live Anthropic API and Bedrock Converse on claude-opus-4-{5,6,7} and claude-sonnet-4-6, plus Bedrock Invoke for Claude 4.5/4.6. Vertex AI Anthropic and Azure AI Anthropic inherit the fixed ``map_openai_params`` from ``AnthropicConfig`` and need no further changes. * fix(vertex-ai): set response=null on batch error entries per OpenAI spec The Vertex batch output transformer was emitting both a populated 'response' and 'error' for failed batch entries. The OpenAI Batch output spec defines them as mutually exclusive: on error 'response' MUST be null. This broke any consumer using 'result["response"] is None' to detect failures. * test(vertex-ai): cover transformation_error path emits response=null * fix(security): sandbox jinja2 in gitlab/arize/bitbucket prompt managers DotpromptManager was hardened to render through ImmutableSandboxedEnvironment. The three sibling managers (gitlab, arize, bitbucket) were missed and still instantiate plain jinja2.Environment(), leaving the same attribute-traversal SSTI primitive open: a template fetched from a GitLab/BitBucket repo or Arize Phoenix workspace can reach __class__.__init__.__globals__ and execute arbitrary Python on the proxy host. Match the dotprompt pattern by switching all three to ImmutableSandboxedEnvironment. The sandbox blocks the dunder-traversal chain while leaving normal {{ var }} substitution intact, so the template surface is unchanged for legitimate use. Adds tests/test_litellm/integrations/test_prompt_manager_ssti.py (18 cases) verifying each manager's jinja_env is a sandbox, that classic SSTI payloads raise SecurityError, and that ordinary variable rendering still works. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(proxy): drop client-supplied pricing fields from request bodies The proxy currently forwards request-body pricing parameters (the fields on `CustomPricingLiteLLMParams`, plus `metadata.model_info`) into the core call path. Those fields belong to deployment configuration, not to per-request input — sending them from a client mutates the request's recorded cost and, via `litellm.completion` → `register_model`, the process-wide `litellm.model_cost` map for every later caller in the worker. Strip them at the boundary. The strip set is built from `CustomPricingLiteLLMParams.model_fields` so pricing fields added later are covered automatically. Operators who do want clients to supply per-request pricing can opt back in per key or team via `metadata.allow_client_pricing_override = true`, mirroring the existing `allow_client_mock_response` and `allow_client_message_redaction_opt_out` flags. Tests cover the strip set's coverage, root and metadata strips, the opt-in skip on both key and team metadata, and a regression check that the global `litellm.model_cost` map is unmutated after a stripped request. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(proxy): log stripped pricing fields at debug for operator visibility Operators upgrading would otherwise see client-supplied pricing overrides silently stop applying with no diagnostic. Emit a debug-level line listing the dropped fields and pointing at the opt-in flag when any are stripped; stay silent on the no-op path so the log isn't filled with noise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(proxy): move pricing strip below the litellm_metadata JSON-string parse The strip ran before the proxy parses ``litellm_metadata`` from a JSON string into a dict (a path used by multipart/form-data and ``extra_body`` callers), so ``isinstance(metadata, dict)`` was False and ``model_info`` survived the strip. Move the call to the same post-parse position the ``user_api_key_*`` strip already uses for the same reason. Adds a regression test exercising the JSON-string ``litellm_metadata`` path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(responses): replace legacy claude-4-sonnet alias in multiturn tool-call test Anthropic's main API no longer resolves the non-canonical 'claude-4-sonnet-20250514' alias for freshly issued keys, returning 404 not_found_error. PR #27031 already swept three other live tests pinned to this alias to claude-haiku-4-5-20251001 but missed test_multiturn_tool_calls in the responses API suite, which is now failing reliably on PR CI runs (e.g. PR #27074, job 1603363). Bump the two model references in test_multiturn_tool_calls to the same claude-haiku-4-5-20251001 snapshot used by PR #27031 -- it covers everything this test exercises (tool calling, multi-turn) and isn't on a deprecation schedule. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * chore(proxy): close callback-config and observability-credential side channels Two related gaps in the proxy's request bouncer: 1. ``is_request_body_safe`` (auth_utils.py) walked the request-body root and the ``litellm_embedding_config`` nested dict, but not ``metadata`` or ``litellm_metadata``. The same fields it bans at root — Langfuse / Langsmith / Arize / PostHog / Braintrust / Phoenix / W&B Weave / GCS / Humanloop / Lunary credentials and routing — were silently accepted when the caller put them inside metadata, retargeting observability callbacks to a caller-controlled host with caller-supplied creds. Walk both metadata containers (and parse the JSON-string form sent via multipart / ``extra_body``) through the same banned-params helper, so the existing ``allow_client_side_credentials`` opt-in covers both paths consistently. 2. The banned-params list was hand-maintained and lagged the canonical ``_supported_callback_params`` allow-list in ``initialize_dynamic_callback_params``. Derive the observability bans from that allow-list (minus a small ``_SAFE_CLIENT_CALLBACK_PARAMS`` set for informational fields like ``langfuse_prompt_version`` and ``langsmith_sampling_rate``) so future integrations are covered automatically; ``_EXTRA_BANNED_OBSERVABILITY_PARAMS`` carries the handful of fields integrations read but the allow-list hasn't caught up to. A guard test fails CI if a new entry is added to ``_supported_callback_params`` without an explicit safe-list decision. Separately in ``litellm_pre_call_utils.py``: add ``callbacks``, ``service_callback``, ``logger_fn``, and ``litellm_disabled_callbacks`` to ``_UNTRUSTED_ROOT_CONTROL_FIELDS``. The first three are appended to worker-wide ``litellm.{input,success,failure,_async_*,service}_callback`` lists / ``litellm.user_logger_fn`` from inside ``function_setup`` — one request poisons every subsequent caller in that worker. The last is the inverse primitive: the legitimate path reads it from key/team metadata, the request-body version silently disables admin-configured audit / observability for the call. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(auth): per-param allow must continue, not return early A pre-existing logic bug in ``_check_banned_params``: when the deployment-level ``configurable_clientside_auth_params`` permitted one banned field, the loop ``return``-ed on the first match instead of ``continue``-ing, so any other banned param later in the same body or metadata dict was never checked. This PR's metadata walk multiplies the surface where that bypass matters — a body pairing an allowed ``api_base`` with an observability credential like ``langfuse_host`` would silently pass. Proxy-wide ``allow_client_side_credentials`` keeps ``return`` (it's a global opt-in for every banned param). The per-param branch becomes ``continue`` so only the one explicitly-permitted field is skipped. Adds a regression test that exercises the api_base + langfuse_host pair. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(vector_store): resolve embedding config at request time, never persist creds The vector store create/update path previously called ``_resolve_embedding_config`` against the admin-configured router/DB model and persisted the resolved ``litellm_embedding_config`` dict (``api_key`` / ``api_base`` / ``api_version``) into the ``litellm_managedvectorstorestable.litellm_params`` column. Because the resolver expanded ``os.environ/...`` references via ``get_secret``, the DB row carried cleartext provider credentials, and the ``/vector_store/{new,info,update,list}`` responses returned them to any authenticated caller who could supply a known admin model name. Move the auto-resolve out of ``create_vector_store_in_db`` and out of the update path. Persist only the user-supplied ``litellm_embedding_model`` reference. Resolve at request-handling time inside ``_update_request_data_with_litellm_managed_vector_store_registry`` so the resolved config lives in the per-request ``data`` dict and is garbage-collected after the response. Legacy rows that were created by an earlier proxy version and already carry a resolved ``litellm_embedding_config`` skip the re-resolution and pass through unchanged so embedding calls keep working. The ``new_vector_store`` response now also runs the existing ``_redact_sensitive_litellm_params`` masker (already used by ``info``, ``update``, and ``list``), defending against caller-supplied cleartext on the create path and against legacy rows whose persisted credentials are still in the database. Existing tests that asserted the old write-time-resolve behaviour are updated to assert the new persistence shape (no embedding config stored, just the model reference). Two new tests cover the use-time path: one asserting fresh resolution happens when a row carries only the model reference, the other asserting legacy rows with persisted config skip re-resolution and continue to work. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(vector_store): tighten registry-mutation comment and dedupe test helpers * fix(vector_store): cache use-time embedding-config resolution Hold the resolved config in a process-memory TTL cache so the request-handling path doesn't run litellm_proxymodeltable.find_first on every vector-store call. * fix(anthropic,bedrock,vertex): forward output_config.effort + 400 on garbage reasoning_effort Follow-up bugs surfaced by the QA sweep on PR #27039 (https://github.com/BerriAI/litellm/pull/27039#issuecomment-4363363610). 1. Stop stripping output_config.effort on Bedrock + Vertex adaptive routes. - Vertex AI Claude 4.6/4.7 accepts output_config.effort on rawPredict (verified end-to-end against us-east5 / global). The strip helper now no-ops for effort. - Bedrock Converse routes output_config into additionalModelRequestFields for anthropic base models so the requested adaptive tier (low/medium/ high/xhigh/max) actually reaches the wire instead of all collapsing to identical thinking. - Bedrock Invoke chat transformation (AmazonAnthropicClaudeConfig) stops popping output_config from the post-AnthropicConfig request body. - Bedrock Invoke /v1/messages allowlist (BedrockInvokeAnthropicMessagesRequest) now lists output_config so the runtime allowlist filter forwards it. 2. Validate effort across Bedrock Converse so 'disabled' / 'invalid' / '' / unsupported tiers (xhigh/max on Sonnet 4.6 or budget-mode 4.5 models) surface as a clean 400 BadRequestError instead of 500. 3. ValueError -> BadRequestError throughout (AnthropicConfig.map_openai_params, _apply_output_config, AmazonConverseConfig._handle_reasoning_effort_parameter). Empty-string effort is now rejected (was silently passing the 'if effort and ...' short-circuit). 4. Floor reasoning_effort='minimal' at the Anthropic provider minimum (1024 budget_tokens) via new ANTHROPIC_MIN_THINKING_BUDGET_TOKENS so it's a usable tier on direct Anthropic / Azure AI Anthropic / Vertex AI Anthropic / Bedrock Invoke (all of which 400 below 1024). 5. model_prices: dedupe duplicate supports_max_reasoning_effort key on claude-opus-4-7 / claude-opus-4-7-20260416. Adds regression tests across all five affected paths; existing tests asserting the silent-strip behavior were updated to reflect the new pass-through and clean 400 surfaces. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * fix(constants): make ANTHROPIC_MIN_THINKING_BUDGET_TOKENS a plain constant The documentation CI test (tests/documentation_tests/test_env_keys.py) asserts every os.getenv() key in the source has a matching entry in the litellm-docs config_settings.md table. ANTHROPIC_MIN_THINKING_BUDGET_TOKENS tracks Anthropic's published wire-protocol minimum (1024) — it's not a user-tunable, so making it env-overridable was wrong anyway. Drop the os.getenv() wrapper; the value is now a plain literal. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * fix(anthropic,bedrock): correct effort error message and dedupe effort_map - Remove 'none' from the Bedrock _validate_anthropic_adaptive_effort error message; it was listed as a valid value but rejected by the membership check, leaving users in a feedback loop if they tried 'none'. - Hoist the duplicated reasoning_effort -> output_config.effort mapping out of AnthropicConfig.map_openai_params and AmazonConverseConfig._handle_reasoning_effort_parameter into a single AnthropicConfig.REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT class constant so the two routes cannot drift. * fix(anthropic): translate reasoning_effort on /v1/messages route Closes the remaining QA-sweep gap on PR #27074: Bedrock Invoke /v1/messages was silently ignoring ``reasoning_effort`` because the shared param filter only kept native Anthropic keys, so every effort tier collapsed to the same behavior on the wire (27/231 cells failing across opus-4-5 / opus-4-6 / sonnet-4-6). Map ``reasoning_effort`` to native Anthropic ``thinking`` / ``output_config.effort`` at the ``AnthropicMessagesConfig`` layer so all four /v1/messages routes (direct Anthropic, Azure AI, Vertex AI, Bedrock Invoke) inherit the same translation: - Add ``reasoning_effort`` to ``AnthropicMessagesRequestOptionalParams`` so the param filter in ``AnthropicMessagesRequestUtils.get_requested_anthropic_messages_optional_param`` no longer drops it before the transformation runs. - Add ``_translate_reasoning_effort_to_anthropic`` and call it from ``transform_anthropic_messages_request``. Mirrors ``AnthropicConfig.map_openai_params`` on the chat completion path (re-uses ``_map_reasoning_effort`` and ``REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT``) so the two routes cannot drift. Pops ``reasoning_effort`` so it never reaches the wire. - Caller-supplied native ``thinking`` / ``output_config.effort`` always win — same precedence as ``_translate_legacy_thinking_for_adaptive_model``. - Garbage values (``""``, ``"disabled"``, ``"invalid"``) raise ``AnthropicError(status_code=400)`` instead of falling through and surfacing as 500s from the provider. - ``"none"`` clears thinking + output_config so callers can opt out per request. Also restores the non-adaptive-model test coverage on Bedrock Invoke /v1/messages that the previous commit lost when ``test_bedrock_messages_strips_output_config`` was renamed to the ``forwards`` variant on Opus 4.7. Adds a new test file ``test_reasoning_effort_translation.py`` covering the translation at the shared config level (adaptive + non-adaptive models, none, garbage, caller precedence) so all four /v1/messages routes are exercised by a single suite. Adds parametrized + behavioral tests on the Bedrock Invoke /v1/messages suite covering: minimal/low/medium/high/xhigh/max mapping for adaptive models, thinking-budget mapping for non-adaptive Opus 4.5, ``none`` clears both, garbage raises 400, explicit ``output_config`` wins. Refs: https://github.com/BerriAI/litellm/pull/27074 * fix(anthropic,bedrock): reject unmapped reasoning_effort at mapping site Both the chat completion path (AnthropicConfig.map_openai_params) and the Bedrock Converse path (_handle_reasoning_effort_parameter) used REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT.get(value, value) which falls back to the raw input on unmapped keys. Combined with _map_reasoning_effort returning type='adaptive' for any string on Claude 4.6/4.7, garbage values (e.g. 'disabled') could leak into optional_params['output_config']['effort'] unvalidated if map_openai_params ran without the downstream transform_request or _validate_anthropic_adaptive_effort check. Mirror the /v1/messages pattern: use .get(value) (no fallback) and raise BadRequestError immediately when the value is unmapped, co-locating validation with the mapping for defense in depth. * style: black formatting Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * fix(anthropic): stop class-attr leak; gate xhigh/max on every route The reasoning-effort mapping dict was a public class attribute on AnthropicConfig, so BaseConfig.get_config returned it as a request parameter and every Anthropic-backed call (Anthropic / Azure / Vertex / Bedrock Invoke) hit a 400 'REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT: Extra inputs are not permitted' from the provider. Move the mapping to a module-level constant. _supports_effort_level only looked the model up under custom_llm_provider='anthropic', so bedrock-prefixed model ids (e.g. bedrock/invoke/us.anthropic.claude-opus-4-7) returned False for both 'max' and 'xhigh' even when the underlying model entry has the flag set. Strip known provider prefixes and retry the lookup against litellm.model_cost directly so per-model gating works on every route. Mirror the per-model xhigh/max gate from AnthropicConfig._apply_output_config in AnthropicMessagesConfig._translate_reasoning_effort_to_anthropic so the /v1/messages route also raises a clean 400 instead of forwarding the unsupported tier. * feat(anthropic,bedrock): strip output_config under drop_params for non-effort models When a proxy fronts Claude Code (which always sends `output_config.effort`) at a pre-4.5 Anthropic model — haiku-3, sonnet-3.5, opus-3, sonnet-4 — the forwarded knob causes a forced 400 the client can't fix. Gating a strip behind the existing `drop_params` flag lets operators opt into silent fixup once and stop worrying about per-model param hygiene. Default (`drop_params=False`) still forwards and surfaces the provider's error, preserving the strict, debuggable contract from #27074. Per https://platform.claude.com/docs/en/build-with-claude/effort the supporting set is Opus 4.5+, Sonnet 4.6+, and Mythos Preview; everything else is dropped (with a verbose_logger warning so the strip is visible). Recognition uses model-name patterns plus a fallback to any `supports_*_reasoning_effort` flag in the model map for forward compatibility with new entries. https://claude.ai/code/session_01WjHq31rvXT6xYNdVmSJvRp (cherry picked from commit1233943e78) * fix(base_llm): filter all _-prefixed class attrs from get_config The drop_params strip work added `AnthropicConfig._EFFORT_SUPPORTING_MODEL_PATTERNS` as a private class-level lookup tuple. `BaseConfig.get_config()` only filtered the `__`-prefixed names plus `_abc` / `_is_base_class`, so `_EFFORT_SUPPORTING_MODEL_PATTERNS` would have leaked into the request body the same way `REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT` did before the previous commit. Generalize the existing `_abc` / `_is_base_class` carve-outs to skip every `_`-prefixed name. `AmazonConverseConfig.get_config()` overrides the base method, so apply the same change there. Also unblocks future internal helpers from accidentally serialising into the wire body. * fix(anthropic): drive output_config.effort support from model map flags Replace hardcoded _EFFORT_SUPPORTING_MODEL_PATTERNS with a JSON-backed check that uses supports_*_reasoning_effort flags from the model map. Add supports_minimal_reasoning_effort: true to opus-4-5 and mythos-preview entries (which previously only carried supports_reasoning) so the JSON remains the single source of truth for effort capability. * fix(anthropic,bedrock,databricks): four reasoning_effort follow-ups - claude-sonnet-4-6 + reasoning_effort=max no longer 400s. Renamed _is_opus_4_6_model to _is_claude_4_6_model at three sites and added supports_max_reasoning_effort: true to 12 model entries in the JSON cost map (10 sonnet 4.6 ids + OpenRouter opus 4.6/4.7). - _map_reasoning_effort now raises BadRequestError(400) directly with llm_provider, instead of letting Databricks (and similar callers) surface its raw ValueError as a 500. - output_config.effort on Opus 4.5 over Bedrock no longer 400s for missing effort-2025-11-24 beta. Flipped JSON to "effort-2025-11-24" for bedrock + bedrock_converse and added an auto-attach branch in _process_tools_and_beta for non-adaptive Anthropic + output_config on Converse. - reasoning_effort=xhigh / =max on legacy budget-mode models (Haiku 4.5, Sonnet 4.5, Opus 4.5) now map to thinking.budget_tokens 8192 / 16384 instead of returning 400. Added two constants in litellm/constants.py. Tests updated for all four flips. Validated end-to-end via 306-cell live proxy matrix (6 model families x 3 routes x 17 effort cases), all pass. * fix(databricks): validate reasoning_effort and set output_config on adaptive Claude The Databricks path called `AnthropicConfig._map_reasoning_effort` for Claude models but never validated the effort string nor set `output_config.effort` for adaptive models (Claude 4.6/4.7). Since `_map_reasoning_effort` returns `type=adaptive` for ANY non-None / non-"none" string on adaptive models (including "disabled", "invalid", ""), Databricks silently accepted garbage and emitted a request without an `output_config.effort`, collapsing every adaptive tier to identical behavior. Match the Anthropic native, Bedrock Converse, Bedrock Invoke, and /v1/messages paths: when the resolved `thinking` is non-None on a 4.6/4.7 model, look up the value in `REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT` and either raise a clean `BadRequestError` or set `optional_params["output_config"]`. * fix(azure): omit model from image generation and image edit deployment requests Azure OpenAI routes image gen/edit by deployment in the URL; sending the deployment id in model breaks gpt-image-2 (invalid_value). Strip model from JSON for deployments/.../images/generations and from multipart data for .../images/edits. Non-deployment URLs (e.g. Azure AI FLUX) unchanged. Fixes #26316. Co-authored-by: Cursor <cursoragent@cursor.com> * test(azure): exercise image gen JSON filter via HTTP client; dedupe image edit URL - Image generation tests patch HTTPHandler.post / get_async_httpx_client so make_*_azure_httpx_request runs and wire json is asserted on call kwargs. - Azure image edit: strip model in finalize_image_edit_multipart_data using the same URL string the handler passes to POST (no second get_complete_url in transform). BaseImageEditConfig default finalize is a no-op. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(azure_ai/anthropic): promote output_config out of extra_body so validation runs `azure_ai` is registered in `litellm.openai_compatible_providers`, so `add_provider_specific_params_to_optional_params` (litellm/utils.py) auto-stuffs any non-OpenAI kwarg (e.g. `output_config={"effort": "..."}`) into `optional_params["extra_body"]`. `AzureAnthropicConfig.transform_request` then strips `extra_body` entirely on the way out, silently dropping the param — and `AnthropicConfig._apply_output_config` never sees it, so `effort="invalid"` / `effort="xhigh"` on a non-supporting model quietly reaches the model with default behavior instead of returning a clean 400 (as the native `anthropic` provider does). Promote the keys back to top-level `optional_params` (using `setdefault` so explicit top-level values win) before delegating to the parent `AnthropicConfig`. Apply in both `validate_environment` and `transform_request` so flag detection (`is_mcp_server_used`, etc.) and output-config validation both run. Surfaced by the QA matrix expansion on PR #27074: 20 cells where Azure returned 200 while `anthropic` returned 400 — all `output_config` mode across haiku_4_5, sonnet_4_5, opus_4_5, sonnet_4_6, opus_4_6, opus_4_7 families with `effort` in {invalid, xhigh, max, low, medium, high}. Tests: * `test_output_config_promoted_from_extra_body`: valid effort reaches data * `test_invalid_output_config_effort_raises_via_extra_body`: 400 on bad effort * `test_unsupported_effort_xhigh_raises_via_extra_body`: 400 on xhigh-on-Sonnet-4.6 * `test_extra_body_promotion_does_not_clobber_top_level`: setdefault semantics * test(image_gen): expect no model in Azure image edit multipart (#26316) Align test_azure_image_edit_litellm_sdk with deployment-scoped Azure edits. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(anthropic): extract _validate_effort_for_model to prevent drift The chat completion path (`_apply_output_config`) and the /v1/messages pass-through (`AnthropicMessagesConfig._translate_reasoning_effort_to_anthropic`) both gate `max` / `xhigh` per model. The two sites had diverged from near-identical copies into separately maintained blocks, creating a real drift risk when a new model tier (e.g. Claude 4.8) lands -- a contributor could update one site and miss the other. Centralise the gating in `AnthropicConfig._validate_effort_for_model`, which returns an error message string or `None`. Each call site keeps its own provider-appropriate exception type (`BadRequestError` for the chat path, `AnthropicError` for the /v1/messages pass-through) but the gating decision now comes from one place. Net -11 LOC. Adds a parametrised unit test exercising the helper directly across 4.5 / 4.6 / 4.7 model families and `max` / `xhigh` / lower-effort inputs. Existing tests at both call sites continue to pass unchanged. Addresses Greptile finding on PR #27074. * fix(databricks): narrow reasoning_effort_value to str for mypy `non_default_params.get("reasoning_effort")` returns `Any | None`, but `REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT.get()` expects `str`. Mypy flagged this on the strict pass. Narrow with `isinstance` before the lookup; non-strings fall through to the existing `BadRequestError` below with a clean validation message, so behavior is unchanged. Fixes a regression introduced by1a10746e95in this PR. * feat(proxy): add health_check_reasoning_effort for model health checks Co-authored-by: Cursor <cursoragent@cursor.com> * test(image_gen): align Azure image gen fixture with body omitting model Expected JSON matches deployment-scoped Azure POST (#26316). Co-authored-by: Cursor <cursoragent@cursor.com> * test(anthropic/chat): force PR-local model_cost map via autouse fixture CI runs without LITELLM_LOCAL_MODEL_COST_MAP=True, so litellm.model_cost is loaded from main-branch JSON (default model_cost_map_url) instead of the PR's checked-out model_prices_and_context_window.json. Tests that assert per-model flags added in this PR (supports_max_reasoning_effort, supports_xhigh_reasoning_effort) therefore pass locally but fail in CI with 'AssertionError: assert False is True' on 5 cases: - test_anthropic_model_supports_effort_param_recognizes_supporting_models [anthropic.claude-mythos-preview, bedrock/.../mythos-preview, claude-opus-4-5-20251101] - test_supports_effort_level_handles_provider_prefixes [bedrock/invoke/us.anthropic.claude-sonnet-4-6-max-True, claude-sonnet-4-6-max-True] Add an autouse fixture at tests/test_litellm/llms/anthropic/chat/conftest.py that monkey-patches litellm.model_cost to the PR-local JSON for every test in this directory. The parent conftest already snapshots+restores litellm.model_cost per-function, so the mutation is contained. This is a scoped workaround. The proper fix is to set the env var globally in the test workflow once the ~10 inline self-set test files are audited; tracking that as a follow-up issue. * [Fix] Docker: Pin Wolfi And Uv To Multi-Arch Index Digests The previous pins resolved to single-platform amd64 manifests, so buildx pulled the same amd64 base for both linux/amd64 and linux/arm64 targets. The published OCI index then advertised an arm64 entry whose layers are byte-identical to amd64 -- arm64 users got an amd64 binary. Switch all three Dockerfiles to the multi-arch image-index digests: - cgr.dev/chainguard/wolfi-base (index has linux/amd64 + linux/arm64) - ghcr.io/astral-sh/uv:0.11.7 (index has linux/amd64 + linux/arm64) Resolved with `docker buildx imagetools inspect <ref>` -- that returns the index digest. `docker pull` + `docker inspect` returns the per-host platform digest, which is what slipped in last time. * [Fix] Docker: Pin Uv To Multi-Arch Index Digest In Remaining Dockerfiles Apply the same fix to the three Dockerfiles not in the release pipeline today (alpine, dev, health_check) so they stay correct if/when they're built for arm64 in the future. Wolfi pins are not present in these files; the python:3.11-alpine and python:3.13-slim digests they already use are multi-arch indexes that include arm64/v8, so only the uv pin needed swapping. * fix(xai): fold reasoning_tokens into completion_tokens to satisfy OpenAI invariant xAI's chat completions API accounts reasoning_tokens separately from completion_tokens, but rolls them into total_tokens. This breaks the OpenAI invariant total_tokens == prompt_tokens + completion_tokens that downstream consumers (including litellm's own _usage_format_tests in tests/llm_translation/base_llm_unit_tests.py:58) rely on. Live capture (grok-3-mini-beta, 2026-05-04): prompt=14, completion=10, total=336, reasoning=312 14 + 10 = 24, NOT 336. OpenAI's o1/o3 reasoning models include reasoning_tokens in completion_tokens, leaving the prompt+completion=total invariant intact. xAI deviates. This patch aligns xAI to OpenAI semantics by folding reasoning_tokens into completion_tokens after the parent OpenAI parser runs. The fold is idempotent and defensive: - Only fires when total_tokens == prompt_tokens + completion_tokens + reasoning_tokens (the documented xAI shape). Refuses to fold if the gap doesn't match, guarding against silent corruption when xAI changes accounting. - Skips if completion_tokens already covers the gap (already normalised — e.g. cost calc replays a previously-folded Usage). xai.cost_calculator.cost_per_token already added reasoning_tokens to the visible completion count for billing. Post-fold the Usage block now satisfies that invariant directly, so the cost calc would double-bill. Updated cost_per_token to detect the OpenAI-normalised shape (total == prompt + completion) and skip the reasoning add-on in that case, falling through to the legacy raw-shape behaviour for callers that bypass the transformation (e.g. proxy log replay). Tests: - Adds TestXAIReasoningTokenFolding covering: gap-explained-fold, idempotent-no-double-fold, no-reasoning-skip, gap-mismatch-skip. - Adds test_already_normalised_usage_does_not_double_count_reasoning to lock the cost-calc idempotency. - Updates 7 pre-existing cost-calc tests whose total_tokens was internally inconsistent (used the OpenAI-normalised total but kept reasoning_tokens external) to use the documented xAI raw shape total = prompt + visible completion + reasoning. Pre-existing values masked the missing-fold by accident. Verified end-to-end against the live xAI API: LITELLM_LOCAL_MODEL_COST_MAP=False (CI default) + XAI_API_KEY set + pytest tests/llm_translation/test_xai.py::TestXAIChat::test_prompt_caching -> PASSED in 18.81s (was: AssertionError on usage.total_tokens == usage.prompt_tokens + usage.completion_tokens) 20/20 tests in tests/test_litellm/llms/xai/test_xai_cost_calculator.py and 8/8 in tests/test_litellm/llms/xai/test_xai_chat_transformation.py pass. * refactor(bedrock/converse): delegate effort gating to AnthropicConfig._validate_effort_for_model Removes the duplicated max/xhigh gating logic in _validate_anthropic_adaptive_effort and the now-unused _supports_effort_level_on_bedrock helper. Per-model gating now flows through the centralized AnthropicConfig._validate_effort_for_model (whose _supports_effort_level already strips Bedrock prefixes), so the chat completion, /v1/messages, and Bedrock Converse paths can't drift when a new gated effort tier is added. * Implement normalize_nonempty_secret_str function to trim whitespace from secrets and treat empty values as unset. Update proxy_server to use this function for Grafana credentials. Enhance tests to validate the new normalization behavior. * Fix qdrant semantic cache miss metadata * chore(deps): refresh dependency locks * chore(deps): authorize pytest license * fix: preserve tokenizer decode round trips * refactor(anthropic): drive adaptive-thinking gate via supports_adaptive_thinking flag Three of greptile's open comments on #27074 (P2 converse:512, P1 databricks:361, and the underlying capability-flag policy rule) flagged the same pattern: _is_claude_4_6_model(...) or _is_claude_4_7_model(...) used inline as a runtime 'is this an adaptive-thinking model?' check. That requires a code release each time a new adaptive Claude lands. Consolidate the inline gating to AnthropicModelInfo._is_adaptive_thinking_model, and switch the helper itself to read a new supports_adaptive_thinking flag from `model_prices_and_context_window.json` via `_supports_factory`, falling back to the family pattern only when the model-map entry doesn't carry the flag (preserves OpenRouter / Vercel / Bedrock-prefixed variants that route through the same code path with non-canonical ids). Adds `supports_adaptive_thinking: true` to the four 4.6/4.7 anthropic entries (opus-4-6 + dated, opus-4-7 + dated, sonnet-4-6). Bedrock-prefixed and Vertex-prefixed entries don't need the flag because both fall back through the family pattern (the helper short-circuits early on True from either path) and the bedrock/vertex Claude IDs all match the existing opus-4-{6,7} / sonnet-4-{6,7} pattern. Affected call sites: - `bedrock/chat/converse_transformation.py:_handle_reasoning_effort_parameter` - `anthropic/chat/transformation.py:_map_reasoning_effort` - `anthropic/chat/transformation.py:map_openai_params` (output_config branch) - `databricks/chat/transformation.py:map_openai_params` (output_config branch) The remaining `_is_claude_4_6_model` / `_is_claude_4_7_model` references in `AnthropicConfig._validate_effort_for_model` and `AnthropicConfig.get_supported_openai_params` are intentionally retained: they're per-model gating fallbacks for variants whose model-map entries don't yet carry the `supports_max_reasoning_effort` / `supports_reasoning` flag. Those are documented in-place. Tests: 537 anthropic/bedrock/databricks/vertex/messages tests pass. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * chore(deps): address dependency review notes * test(model_prices): add supports_adaptive_thinking to schema `test_aaamodel_prices_and_context_window_json_is_valid` validates the model-map JSON against an explicit schema with `additionalProperties`, so the new `supports_adaptive_thinking` flag added in98ced0ae43needs a matching schema entry. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * refactor: remove unnecessary comments from #27074 Strip out the explanatory and historical comments that don't carry business-logic justification. Comments that simply narrate what code does — or that explain prior behavior, what was changed, or which PR introduced a fix — are removed. Docstrings are reduced to a one-line summary where the long form repeated information already evident from the code or test data. No code-behavior changes. All 643 affected unit tests still pass. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * test: keep decode token test local * chore(deps): align dashboard node engine * feat: selectively apply routing strategy according to model name * style: make _model_supports_effort_param more concise * refactor(anthropic,bedrock): hoist drop_params output_config warning to module constant Three call sites (anthropic chat, bedrock converse, bedrock invoke messages) emitted the same '...Effort is only supported on Opus 4.5+, Sonnet 4.6+, and Mythos Preview' warning verbatim. Extract DROP_UNSUPPORTED_OUTPUT_CONFIG_WARNING in litellm/llms/anthropic/chat/transformation.py and import it from the bedrock sites so future copy edits live in one place. Addresses Michael's review on PR #27074. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * refactor(anthropic,bedrock,databricks): factor BadRequestError for unknown reasoning_effort Three call sites raised the same BadRequestError("Invalid reasoning_effort: ... Must be one of 'minimal', 'low', ...") block when REASONING_EFFORT_TO_OUTPUT_CONFIG_EFFORT returned None: anthropic chat map_openai_params, bedrock converse _handle_reasoning_effort_parameter, and databricks chat reasoning_effort path. Extract AnthropicConfig._raise_invalid_reasoning_effort(model, value, llm_provider) so future copy edits / valid-set changes happen in one place. Typed as NoReturn so type-checkers correctly narrow control flow at call sites. Addresses Michael's review on PR #27074. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * Clean up Redis semantic cache isolation fallback * fix(guardrails): align banned_keywords + azure_content_safety call_type gates with runtime route_type The hooks gated on ``call_type == "completion"`` but the proxy ingress passes ``route_type`` straight through as ``call_type`` — ``"acompletion"`` for /v1/chat/completions and ``"aresponses"`` for /v1/responses. Tests passed because they used the literal sync ``"completion"`` value, masking the gap. Switch both hooks to ``is_text_content_call_type`` (matches the canonical runtime values: completion / acompletion / aresponses) and update existing tests to assert against runtime values, plus parametrize a regression test that pins the gate. * fix: remove unused import * Add semantic cache legacy migration flag * Treat 0 team_member_budget as no cap * chore(caching): annotate qdrant quantization_params dict type Mypy infers the dict's value type from the first branch (Dict[str, bool]) which clashes with the scalar branch's mixed-type inner dict. Explicit Dict[str, Any] annotation lifts the inference. * chore(caching): remove allow_legacy_unscoped_cache_hits opt-in The flag was an opt-in escape hatch for the cross-tenant leak the rest of the patch closes — flipping it on (env var or constructor param) re-enables exactly the VERIA-54 primitive on either backend. There is no operational need that the secure path doesn't already meet: - Qdrant: legacy points without ``litellm_cache_key`` payload are excluded by the must-clause filter and treated as misses; new sets populate the cache key, so cold-start lasts only as long as the natural cache rebuild. - Redis: existing unscoped index can't carry the new schema; the init path falls back to ``{name}_isolated`` (and recreates it on stale schema), leaving the legacy index untouched. Drop the constructor param, env-var fallback, ``_using_legacy_unscoped_index`` flag, the legacy-reuse branch in ``_init_semantic_cache``, and the matching guards in set/get paths. Update tests to drop the legacy-mode cases and assert the secure-only behaviour. * fix(container): keep ownership-filter exceptions out of the LLM-error path filter_container_list_response runs after the upstream call has already succeeded; treating an ownership-lookup failure as an LLM-API error fires post_call_failure_hook for a successful upstream call and returns a misleading provider-shaped error to the client. Run the filter outside the try/except so genuine LLM errors stay scoped to the upstream call. * chore(container,skills): LRU eviction for owner caches; widen file_purpose Literal Two cleanups from the /simplify pass: * ``_CONTAINER_OWNER_CACHE`` and ``_SKILL_CACHE`` now LRU-evict via ``OrderedDict.popitem(last=False)`` instead of full ``clear()`` at capacity. Full clears converted a steady-state cached workload into a periodic full-DB-load oscillation as the cache repopulated from zero and cleared again. Reads now ``move_to_end`` so the just-touched entry survives the next eviction. Mirrors the pre-existing LRU pattern in ``_remember_container_owner``. * ``LiteLLM_ManagedObjectTable.file_purpose`` Literal now includes ``"container"`` so Pydantic validation accepts rows written by the ownership store. * chore(container,skills): drop legacy-access opt-out env vars LITELLM_ALLOW_UNTRACKED_CONTAINER_ACCESS and LITELLM_ALLOW_UNOWNED_SKILL_ACCESS were operator-toggleable opt-outs for the cross-tenant access primitive this PR closes — flipping either on re-enabled exactly the VERIA-20 read path. Default-secure with no escape hatch matches sibling fixes (vector-store cred isolation, semantic cache key isolation, user_config strip): all rejected the opt-out-of-security pattern. Untracked containers and unowned skills (rows that pre-date this enforcement) are admin-only. Non-admin owners need to either re-create via the now-tracked flow or have an admin assign ``created_by`` on the existing row. Update tests to assert the strict-only behaviour. * fix(ownership): reject identity-less callers instead of sharing a sentinel scope UNSCOPED_RESOURCE_OWNER_SCOPE collapsed every caller without an identity field (no user_id / team_id / org_id / api_key / token) into a single shared owner — a cross-tenant access primitive: any two such callers could see and delete each other's containers and skills. Drop the sentinel. ``get_primary_resource_owner_scope`` returns ``None`` and ``get_resource_owner_scopes`` returns ``[]`` for identity-less callers. ``record_container_owner`` and ``LiteLLMSkillsHandler.create_skill`` now reject creates from identity-less callers with a 403 instead of stamping the placeholder. Read paths already deny ``owner is None`` correctly so legacy rows (if any) are admin-only. * fix(proxy): include request-blocked callback params in auth bans * fix: keep skills handler FastAPI-free; fold gcs deny list into the body bouncer Two cleanups: * ``LiteLLMSkillsHandler.create_skill`` raised ``HTTPException`` for identity-less callers, importing FastAPI from a ``litellm/llms/`` module — that violates the project rule that FastAPI lives only under ``proxy/``. Switch to ``ValueError`` (the same shape the rest of the handler uses for not-found/forbidden) and update the test. * The proxy-auth body bouncer derived its observability ban list from ``_supported_callback_params`` only, missing ``_request_blocked_callback_params`` (where ``gcs_bucket_name`` and ``gcs_path_service_account`` live). Two recently-merged sibling PRs (#27019 added the deny list, #27081 added the test asserting these are rejected at the request body root) crossed without folding them together. Union the GCS deny list into the bouncer's derivation so the single source of truth covers both code paths. * fix(proxy): normalize managed resource team owner field * chore: simplify ownership tracking — drop thin stores, in-memory fallback, hand-rolled cache Substantial reduction (~765 LOC) without changing the security boundary: * Drop ContainerOwnershipStore and LiteLLMSkillsStore — both were one-method-per-Prisma-call wrappers. Inline the calls instead, matching the established pattern in vector_store_endpoints, agent_endpoints, and mcp_server/db.py. * Drop the prisma_client is None in-memory fallback. Production deploys always have Prisma; running ownership-critical paths on a process-local dict is a security footgun in the dev-mode case it was meant to support, and complicates every code path with a branch. Fail-secure: skip recording if Prisma is unavailable, and treat reads as "not found" (admin-only). * Drop the hand-rolled module-level cache. Replace with the existing litellm.caching.in_memory_cache.InMemoryCache, which already has TTL + max-size + eviction tested in its own module. Sentinel string for negative caching since InMemoryCache can't disambiguate "miss" from "cached as None". * Tests: drop coverage for removed code paths (in-memory fallback, hand-rolled cache internals). Keep tests for actual behavior (cache hit-rate, negative caching, owner check, list filtering, identity-less reject, admin bypass). * fix(container): cache list-allow-set, track admin-created containers Address Greptile P2 follow-ups from the prior round: * Cache ``_get_allowed_container_ids`` (60s LRU/TTL keyed by sorted owner-scope tuple) so ``GET /v1/containers`` doesn't issue a fresh ``find_many`` against ``litellm_managedobjecttable`` on every list call. Invalidate the caller's own cache entry when they record a new owner so the just-created container shows up on their next list. * Tighten the admin early-return in ``record_container_owner`` to skip ONLY when there's literally no container ID to stamp. An admin with identity (the master-key path populates ``user_id`` + ``api_key``) flows through the normal record path so admin-created containers are tracked like any other caller's. The truly-identity-less admin case still falls through to the 403 below — correct fail-secure default. Skill-cache invalidation gap (also flagged by Greptile) is moot: there is no skill update endpoint exposed; ownership-affecting mutations are only delete (already invalidates) and create (new ID, no cache entry to update). * chore(container): use delete_cache, json-encode scope key, clean test /simplify follow-ups: * Replace the two-``pop`` reach into ``cache_dict``/``ttl_dict`` with the existing public ``InMemoryCache.delete_cache(key)`` — the same idiom used elsewhere in the proxy. Bonus: ``delete_cache`` calls ``_remove_key`` which also handles ``expiration_heap`` consistency the direct pops were silently leaking. * JSON-encode the sorted scope list for the cache key instead of ``"|".join``. ``user_id`` / ``team_id`` / ``org_id`` / ``api_key`` are free-form strings and could contain a literal ``|`` — JSON quoting escapes any in-string separator unambiguously. * Extract ``_allowed_container_ids_cache_key()`` so the read and invalidation sites compute the key the same way. * Fix a placeholder-then-overwrite test construction: the ``__module__.split(".")[0] and "proxy_admin"`` line evaluated to a literal string that was immediately overwritten with the real enum value. Hoist the import and construct directly. * [Fix] Tests: Replace deprecated openrouter/claude-3.7-sonnet with claude-sonnet-4.5 OpenRouter has dropped active endpoints for anthropic/claude-3.7-sonnet, causing test_reasoning_content_completion to fail with a 404 "No endpoints found" error. Switch to anthropic/claude-sonnet-4.5, which is current and supports reasoning streaming. * feat: routing groups ui * fix(security): prevent secret_fields from leaking into spend logs secret_fields (containing raw HTTP headers including Authorization Bearer tokens) was being included in proxy_server_request['body'] because the body snapshot was a copy.copy(data) of the full request dict. This body gets serialized and persisted in the LiteLLM_SpendLogs table, exposing user credentials in the database. Root cause: data['secret_fields'] was set before the body snapshot at data['proxy_server_request']['body'] = copy.copy(data), so the full raw headers (including auth tokens) ended up in the snapshot. Fix (defense in depth): 1. Exclude 'secret_fields' when creating the body snapshot in litellm_pre_call_utils.py (primary fix) 2. Strip 'secret_fields' in _sanitize_request_body_for_spend_logs_payload as a secondary safeguard secret_fields remains available on the live data dict for legitimate downstream consumers (MCP, Responses API). Co-authored-by: Krrish Dholakia <krrish-berri-2@users.noreply.github.com> * chore: update Next.js build artifacts (2026-05-05 02:13 UTC, node v20.20.2) * [Fix] Proxy: Break managed-resources import cycle on Python 3.13 The Python 3.13 CCI smoke matrix surfaces a partially-initialized-module ImportError when loading the managed files hook chain: litellm.proxy.hooks/__init__ (mid-import) -> enterprise.enterprise_hooks -> litellm_enterprise.proxy.hooks.managed_files -> litellm.llms.base_llm.managed_resources.isolation -> litellm.proxy.management_endpoints.common_utils -> litellm.proxy.utils (re-enters litellm.proxy.hooks) The except ImportError block in hooks/__init__.py silently swallowed the failure, leaving managed_files unregistered and POST /files returning 500 "Managed files hook not found". Two-layer fix: - Inline the 3-line _user_has_admin_view check in isolation.py instead of importing it from litellm.proxy.management_endpoints.common_utils. litellm.llms.* should not depend on litellm.proxy.* — removing this layering violation breaks the cycle at its root. - Define PROXY_HOOKS and get_proxy_hook before the conditional enterprise import in litellm/proxy/hooks/__init__.py, so any future re-entry resolves the public names instead of hitting an ImportError on a partially-initialized module. Also fold in two unrelated CCI repairs surfaced in the same staging run: - tests/otel_tests/test_key_logging_callbacks.py: per-key gcs_bucket_name / gcs_path_service_account are now stripped by initialize_dynamic_callback_params, so the GCS client falls through to the env-only branch. Update the assertion to match the new "GCS_BUCKET_NAME is not set" message. - .circleci/config.yml: tests/pass_through_tests now resolves google-auth-library@10.x via the @google-cloud/vertexai 1.12.0 bump, which uses dynamic ESM imports Jest 29 cannot load without --experimental-vm-modules. Pass that flag in the Vertex JS test step. Adds tests/test_litellm/proxy/hooks/test_proxy_hooks_init.py as a regression guard: managed_files / managed_vector_stores must register, and isolation.py must not transitively import litellm.proxy.utils. * [Fix] Proxy: Address Greptile feedback on hook-cycle PR - Move _user_has_admin_view to litellm.proxy._types as user_api_key_has_admin_view (single source of truth). common_utils.py and isolation.py both import from there now, removing the duplicated role-check that could silently diverge if new admin roles are added. - Add pytest.importorskip("litellm_enterprise") to the two regression tests that assert managed_files / managed_vector_stores are registered; those keys come from ENTERPRISE_PROXY_HOOKS so the tests would fail unconditionally in a checkout without the enterprise extra installed. * [Fix] Lint: Mark _user_has_admin_view re-export in common_utils Ruff F401 flagged the aliased import as unused within common_utils.py because the name is consumed only by external modules (~15 callers across guardrails, spend tracking, MCP, agents, management endpoints). Add `# noqa: F401 re-exported` so the alias survives lint while keeping a single source of truth in litellm.proxy._types. * refactor(azure): move image gen JSON helper; rename image edit finalize hook - Add image_generation/http_utils.azure_deployment_image_generation_json_body; call from azure.py (keeps AzureChatCompletion focused on chat). - Rename finalize_image_edit_multipart_data to finalize_image_edit_request_data with docstring covering multipart and JSON POST payloads (review feedback). Co-authored-by: Cursor <cursoragent@cursor.com> * test(proxy): cover health_check_reasoning_effort for completion mode Co-authored-by: Cursor <cursoragent@cursor.com> * [Fix] Tests: Use master key for /otel-spans in test_chat_completion_check_otel_spans /otel-spans now requires proxy admin (returns 401 'Only proxy admin can be used to generate, delete, update info for new keys/users/teams. Route=/otel-spans' for non-admin callers). Switch the GET call to use the master key sk-1234 while keeping the generated key for the chat-completion request that produces the spans. * [Fix] Tests: Pick chat-completion OTEL trace by content, not recency The /otel-spans endpoint returns process-wide spans and tags most_recent_parent by max start_time. After tightening that route to proxy_admin (sk-1234), the GET /otel-spans request itself emits auth spans that beat the chat-completion spans on start_time, so most_recent_parent now points at the request's own auth trace (['postgres', 'postgres']) and the >=5-span assertion fails. Pick the chat-completion trace by content: it is the only trace whose span list is a superset of {postgres, redis, raw_gen_ai_request, batch_write_to_db}. Verified locally end-to-end against otel_test_config.yaml + OTEL_EXPORTER=in_memory: 3/3 runs green. * [Fix] CI: Enable VCR replay for test_azure_o_series The Azure o-series tests were excluded from the conftest's VCR auto-marker because of a respx/vcrpy transport-patching conflict, but the only respx reference in the file was an unused `MockRouter` import. Drop the dead import and remove the file from the conflict set so cassettes record on first run and replay thereafter, eliminating the 60-95s live Azure latency that was crashing xdist workers under --timeout=120 thread-mode timeouts. * [Fix] Tests: Restore /metrics access for prometheus test suite /metrics now requires auth by default; tests/otel_tests/test_prometheus.py makes 4+ unauthenticated GETs against http://0.0.0.0:4000/metrics, so every prometheus test in CI now fails the metric assertion. Set require_auth_for_metrics_endpoint: false in otel_test_config.yaml to opt out for this test job, which scrapes /metrics directly. Verified locally: 8/8 prometheus tests green (one flaky retry on test_proxy_success_metrics that pre-dates this PR). Also drop the -x stop-on-first-failure flag from the otel test command so all failures in the job surface in a single CI run rather than hiding behind whichever one trips first. * [Perf] CI: Skip Redundant Playwright Apt Install in E2E UI Job The cimg/python:3.12-browsers base image already ships every Chromium system dependency Playwright needs (libnss3, libatk-bridge2.0-0, libcups2, etc. — the install log shows them all as "already the newest version"). Passing --with-deps to `npx playwright install` therefore runs an apt-get update + install for nothing, but pays the full cost of hitting Ubuntu mirrors. On a recent run those mirrors stalled hard: apt-get update alone took 6m53s at 81.5 kB/s with several archives returning connection refused. Drop --with-deps and persist ~/.cache/ms-playwright alongside node_modules so the Chromium binary is also reused across runs. Bump the cache key to v2 so the existing v1 entry (which only contained node_modules) is not loaded and skipped over the new browser path. * [Fix] Docker: Remove Hardcoded Prisma Binary Target For Multi-Arch Builds PRISMA_CLI_BINARY_TARGETS="debian-openssl-3.0.x" was hardcoded in docker/Dockerfile.non_root by #17695. On a buildx linux/arm64 leg this forces prisma to download the amd64 schema-engine into an arm64 image, so 'prisma migrate deploy' fails at startup with 'Could not find schema-engine binary'. Removing the env lets prisma auto-detect per build platform: amd64 builds still resolve to debian-openssl-3.0.x (Wolfi falls back to debian, same binary as before), and arm64 builds now correctly fetch linux-arm64-openssl-3.0.x. The offline-cache pre-warm goal of #17695 is preserved — only which binaries fill the cache changes. Fixes #19458 * [Fix] UI: Clear Admin Session Cookies Before Establishing Invited User's Session (#27227) The invite-signup form was writing the new user's token via raw `document.cookie` at `path=/`, while the rest of the auth surface uses `storeLoginToken` (which writes at `path=/ui` and mirrors to sessionStorage). After signup the inviter's `path=/ui` cookie kept winning path-specificity matching, and sessionStorage still held the inviter's token, so the dashboard rendered as the inviter rather than the newly created user. Treat invite signup as a principal-change boundary — clear prior session cookies first, then store the new token via the canonical helper. * test: add 24hr Redis-backed VCR cache to additional test suites (#27159) * test: add 24hr Redis-backed VCR cache to additional test suites Extracts the existing llm_translation VCR plumbing into a reusable helper (tests/_vcr_conftest_common.py) and wires it into the conftest.py files of the test directories listed in LIT-2787: audio_tests, batches_tests, guardrails_tests, image_gen_tests, litellm_utils_tests, local_testing, logging_callback_tests, pass_through_unit_tests, router_unit_tests, unified_google_tests The same helper is also adopted by the pre-existing llm_translation and llm_responses_api_testing conftests to remove the copy-pasted VCR setup. Each consuming conftest: - registers the Redis persister via pytest_recording_configure - auto-marks collected tests with pytest.mark.vcr (skipping respx-using files where applicable, since respx and vcrpy both patch httpx) - gates cassette writes on test success via _vcr_outcome_gate The cache is opt-in via CASSETTE_REDIS_URL; when unset, VCR is disabled and tests hit live providers as before. LITELLM_VCR_DISABLE=1 still forces a bypass for ad-hoc local runs. Test directories that run LiteLLM proxy in Docker (build_and_test, proxy_logging_guardrails_model_info_tests, proxy_store_model_in_db_tests) are intentionally not included: VCR.py patches the in-process httpx transport and cannot intercept calls made from inside a Docker container. The installing_litellm_on_python* jobs make no LLM calls and don't benefit from caching. https://linear.app/litellm-ai/issue/LIT-2787/add-24hr-caching-to-additional-test-suites * test(vcr): add safe-body matcher to handle JSONL and binary request bodies vcrpy's stock body matcher inspects Content-Type and unconditionally runs json.loads on application/json bodies. JSON Lines payloads (used by the Bedrock batch S3 PUT and other upload paths) crash that with json.JSONDecodeError: Extra data, before the matcher can return 'not a match'. This was the root cause of the batches_testing CI job failing on test_async_create_file once VCR auto-marking was applied to the batches_tests directory. Add a conservative byte-equality body matcher and use it in place of 'body' in the shared match_on tuple. The matcher is strictly more conservative than vcrpy's default — the only thing it gives up is 'different JSON key order is treated as the same body', which doesn't apply to deterministic litellm-built request payloads. It can never produce a false positive that the default would have rejected, so there is no cross-contamination risk. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * test(vcr): exclude tests that VCR replay actively breaks A few tests are incompatible with cassette replay and were failing on the latest CI run after VCR auto-marking was extended to local_testing and logging_callback_tests: - test_amazing_s3_logs.py (logging_callback_tests): the test asserts on a per-run response_id that should round-trip through a real S3 PUT/LIST. vcrpy's boto3 stub intercepts the PUT and the LIST replays stale keys, so the freshly-generated id is never found. - test_async_embedding_azure (logging_callback_tests) and test_amazing_sync_embedding (local_testing): the failure branches deliberately pass api_key='my-bad-key' to assert that the failure callback fires. We scrub auth headers from cassettes (so the bad-key request matches the prior good-key request), and vcrpy replays the recorded 200 — the failure callback never fires. - test_assistants.py (local_testing): the OpenAI Assistants polling APIs mint fresh thread/run IDs every recording session and then poll until status=='completed'. Replays of those polled GETs can never match a freshly-generated run id, so every CI run effectively re-records and the suite blows past the 15m no_output_timeout. Skip these from VCR auto-marking so they continue to hit live providers as they did before this change. The remaining tests in each directory still get cached. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * test(vcr): expand skip lists for second batch of incompatible tests Followup to the previous commit. After re-running CI on the rebuilt branch, three more tests surfaced as VCR-replay-incompatible: - litellm_utils_testing :: test_get_valid_models_from_dynamic_api_key Calls GET /v1/models with api_key='123' to assert the result is empty. We scrub auth headers, so the bad-key request matches the prior good-key cassette and replays the recorded model list. - litellm_utils_testing :: test_litellm_overhead.py Measures litellm_overhead_time_ms as a percentage of total wall-clock time. With cached responses the upstream 'network' time collapses to microseconds, blowing past the 40%% threshold the test asserts on. Skip the whole file (every parametrization is at risk). - local_testing_part1 :: test_async_custom_handler_completion and test_async_custom_handler_embedding Same bad-key failure-callback pattern as the already-skipped test_amazing_sync_embedding. - litellm_router_testing :: test_router_caching.py Asserts on litellm's own router-level response cache by comparing response1.id to response2.id across repeat upstream calls (test bypasses litellm cache via ttl=0 and expects upstream to return a *new* id). With VCR replay both upstream calls return the same cassette body, so the ids are identical. Skip the whole file. - logging_callback_tests :: test_async_chat_azure (preemptive) Same shape as already-skipped test_async_embedding_azure; was masked by upstream OpenAI rate-limit failures on baseline. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * test(vcr): use item.path and tighten matcher docstring - Replace pytest's deprecated item.fspath with item.path in apply_vcr_auto_marker_to_items so we don't emit deprecation warnings under pytest 8. - Clarify _safe_body_matcher docstring to reflect actual behavior (direct == first, then UTF-8 bytes comparison, no repr fallback). Addresses Greptile review feedback on PR #27159. * test(vcr): swallow all RedisError on cassette save/load Cassette persistence is strictly best-effort: any Redis-side failure (connection blip, timeout, OutOfMemoryError when the maxmemory cap is hit, READONLY replicas, etc.) should degrade to 'test passed but cassette not cached' rather than fail the test on teardown. Previously the persister only caught ConnectionError and TimeoutError, so OutOfMemoryError — which Redis Cloud raises when the cassette cache hits its memory cap and there are no evictable keys — propagated out of vcrpy's autouse fixture and ERRORed otherwise-passing tests on teardown. This caused the litellm_utils_testing CircleCI job to fail on the latest commit's run, even though the underlying test was a unit test that used mock_response and produced no real upstream traffic (the cassette was dirtied by a background langfuse callback). The rerun only succeeded because Redis evictions happened to free enough room before the SET — i.e. it was timing-dependent flakiness. Catch redis.exceptions.RedisError (the common base of all server- and client-side Redis exceptions) on both save and load, and parametrize the regression tests across ConnectionError, TimeoutError, and OutOfMemoryError to pin the new behavior. * test(vcr): surface cassette-cache failures with warnings + session banner When the persister silently swallows a Redis OOM (or any RedisError) on save/load there is otherwise no visible signal that the cache is degraded — tests pass, the cassette just isn't persisted, and the next session still hits the same Redis at the same near-cap memory. Add three layers of observability so that failure mode is loud: 1. Per-process health counters ("save_failures", "load_failures", and the last error string for each), exposed via cassette_cache_health() and reset via reset_cassette_cache_health(). The persister increments these in addition to logging. 2. VCRCassetteCacheWarning (UserWarning subclass) emitted via warnings.warn() inside the persister's except block. Pytest's built-in warnings summary at session end automatically lists every such warning, so the failure is visible in CI logs without any conftest-level wiring. 3. Session-end banner via emit_cassette_cache_session_banner() and a stderr-fallback atexit handler registered from register_persister_if_enabled(). Two states: - red "VCR CASSETTE CACHE DEGRADED" when save_failures or load_failures > 0 - yellow "VCR CASSETTE CACHE NEAR CAPACITY" (no failures, but used_memory >= 85% of maxmemory) so the next session knows the Redis is approaching OOM before any SET actually fails Capacity comes from a best-effort INFO memory probe (cassette_cache_capacity_snapshot) that returns None on any failure or when maxmemory is uncapped. The atexit handler skips xdist workers so only the controller emits. Tests: parametrize the existing save/load swallow-error tests across ConnectionError/TimeoutError/OutOfMemoryError, add direct tests for the health counters and warning emission, and a new test_vcr_conftest_common_banner.py covering banner output for every state (silent/red/yellow/disabled/xdist-worker). * test(vcr): bucket cassettes by API key fingerprint, drop bad-key skips Tests that deliberately call an LLM API with a bad key (e.g. to assert that the failure callback fires, or that check_valid_key returns False) were being silently served the prior good-key cassette: we scrub the real Authorization / x-api-key header from the cassette before storing it, so a follow-up bad-key call is byte-identical to the good-key call under the existing match_on tuple. Add a 'key_fingerprint' custom matcher that distinguishes requests by the SHA-256 of their API-key headers. The fingerprint is stamped into a synthetic 'x-litellm-key-fp' header by a new before_record_request hook, which then strips the real auth headers (we have to do the scrubbing here instead of via vcrpy's filter_headers knob, because filter_headers runs *first* and would erase the value we want to hash). Bad-key requests now get a different cassette bucket than good-key requests, so vcrpy will not replay a recorded 200 in place of the expected 401. The fingerprint is a one-way hash of the secret, so cassettes never contain the key. This permanently removes the 'bad-key' category of skips: - tests/local_testing: dropped ::test_amazing_sync_embedding, ::test_async_custom_handler_completion, ::test_async_custom_handler_embedding - tests/logging_callback_tests: dropped ::test_async_chat_azure, ::test_async_embedding_azure - tests/litellm_utils_tests: dropped ::test_get_valid_models_from_dynamic_api_key Coverage: 7 new unit tests in tests/test_litellm/test_vcr_safe_body_matcher.py covering header stripping, fingerprint determinism, no-auth bucketing, good-vs-bad key discrimination, x-api-key (Anthropic/Azure) discrimination, and idempotence under replay. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * test(vcr): drop redundant comments and docstrings Trim narration of code that is already self-evident from function and variable names. Keep the two genuinely non-obvious bits: - ordering constraint between filter_headers and before_record_request, which would invite a maintainer to re-introduce the bug if removed - the per-directory _VCR_INCOMPATIBLE_FILES rationale, since 'why exactly is this skipped' is not knowable from the test name alone Also drop the 40-line commented-out drop-in conftest snippet at the bottom of _vcr_conftest_common.py — the consuming conftests are the canonical reference. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * test(vcr): make _before_record_request idempotent vcrpy invokes before_record_request more than once per request: can_play_response_for calls it, then __contains__ / _responses (reached via play_response) call it again on the result. The second invocation sees a request whose auth headers we already stripped, so a naive recompute yields "no-key" and overwrites the real fingerprint stored in the header. This makes can_play_response_for and play_response disagree on matchability — the former says "yes, we have a stored response for this" (matching no-key to no-key) and the latter throws UnhandledHTTPRequestError because it computes a fresh real fingerprint that doesn't match the stored no-key. In CI this manifested as ~30 failing tests across guardrails_testing, audio_testing, batches_testing, image_gen_testing, llm_responses_api, litellm_router_unit_testing, etc. Skip the recompute when the header is already set, so re-applying the hook is a no-op. Adds a regression test that fires the hook twice on the same dict and asserts the fingerprint stays put. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * test(vcr): drop more redundant docstrings and headers * test(vcr): enable 24hr cache for ocr_tests and search_tests These two directories were the only non-dockerized test suites in the build_and_test workflow that make live LLM/provider API calls but were not VCR-enabled by this PR. Together they account for 96 tests: - tests/ocr_tests/ (31): Mistral OCR, Azure AI OCR, Azure Document Intelligence, Vertex AI OCR. Pure-unit tests inside the same files (e.g. TestAzureDocumentIntelligencePagesParam) make no HTTP calls and become benign VCR NOOPs. - tests/search_tests/ (65): Brave, DataForSEO, DuckDuckGo, Exa, Firecrawl, Google PSE, Linkup, Parallel.ai, Perplexity, SearchAPI, Searxng, Serper, Tavily. Both directories use the canonical minimal conftest pattern from tests/audio_tests/conftest.py with no skip lists. None of the test files use respx, none assert on per-call upstream non-determinism (no response1.id != response2.id, no overhead-as-fraction-of-total, no live polling), so the default match_on tuple should cache cleanly. If a flake surfaces during the first cassette-recording CI run, we can add a targeted skip the same way we did for the other dirs. Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> * [Fix] Team UI: handle legacy dict shape for metadata.guardrails (#27224) * [Fix] Team UI: handle legacy dict shape for metadata.guardrails A team can have metadata.guardrails stored as {"modify_guardrails": bool} (the permission-flag shape introduced in PR #4810) rather than the expected string[]. The opt-out logic added in PR #25575 calls .filter() on this field, which throws TypeError on a dict and crashes the team detail page. Add a safeGuardrailsList helper that returns [] when the field is not an array, and route the three read sites through it. * [Fix] Team UI: inline Array.isArray guards for guardrails metadata Replace the safeGuardrailsList helper with inline Array.isArray checks at each call site, and apply the same guard to opted_out_global_guardrails for consistency. No known legacy dict rows for opted_out_global_guardrails, but the unguarded `|| []` pattern is the same shape risk. Six call sites now defended directly: three for metadata.guardrails and three for metadata.opted_out_global_guardrails. * chore: update Next.js build artifacts (2026-05-05 22:45 UTC, node v20.20.2) (#27240) * [Infra] Bump deps (#27157) * bump: version 0.4.70 → 0.4.71 * bump: version 0.1.39 → 0.1.40 * uv lock --------- Co-authored-by: Michael Riad Zaky <michaelr@Mac.localdomain> Co-authored-by: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: ryan-crabbe-berri <ryan@berri.ai> Co-authored-by: user <70670632+stuxf@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: shivam <shivam@berri.ai> Co-authored-by: Mateo Wang <mateo-berri@users.noreply.github.com> Co-authored-by: Yassin Kortam <yassin@berri.ai> Co-authored-by: Krrish Dholakia <krrish+github@berri.ai> Co-authored-by: shin-berri <shin-laptop@berri.ai> Co-authored-by: Sameer Kankute <sameer@berri.ai> Co-authored-by: Sameer Kankute <Sameerlite@users.noreply.github.com> Co-authored-by: Michael-RZ-Berri <michael@berri.ai> Co-authored-by: harish-berri <harish@berri.ai> Co-authored-by: Yassin Kortam <yassinkortam@g.ucla.edu> Co-authored-by: Michael Riad Zaky <michaelr@Michaels-MacBook-Air.local> Co-authored-by: Krrish Dholakia <krrish-berri-2@users.noreply.github.com>
6515 lines
235 KiB
Python
6515 lines
235 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.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_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_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()
|
|
|
|
pc.get_config = MagicMock(
|
|
return_value={
|
|
"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},
|
|
},
|
|
]
|
|
}
|
|
)
|
|
|
|
# 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)
|
|
|
|
# Mock get_config to return empty config (no config models)
|
|
async def mock_get_config(config_file_path):
|
|
return {}
|
|
|
|
pc.get_config = MagicMock(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=[])
|
|
|
|
# Assertions: Models 12345678 and 12345679 should NOT be deleted
|
|
# because they exist in combined_id_list (as integers) even though
|
|
# router has them as strings
|
|
|
|
# The function should delete the other 2 models that are not in combined_id_list
|
|
assert deleted_count == 0, f"Expected 0 deletions, got {deleted_count}"
|
|
|
|
# Verify that 12345678 and 12345679 were NOT deleted
|
|
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_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_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.get_model_names.return_value = ["oci-grok-test"]
|
|
mock_router.get_model_access_groups.return_value = {}
|
|
mock_router.get_model_list.return_value = [mock_model_data]
|
|
|
|
# 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.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 == {}
|
|
|
|
|
|
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_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_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
|
|
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"}
|
|
)
|
|
# Two increments keyed on the counter: seed ($42) then request ($1.50).
|
|
writes = [(c["key"], c["value"]) for c in recorded_increments]
|
|
assert ("spend:team:team-9", 42.0) in writes
|
|
assert ("spend:team:team-9", 1.5) in writes
|
|
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_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]
|
|
|
|
fake_redis = AsyncMock()
|
|
fake_redis.async_get_cache = AsyncMock(return_value=None)
|
|
fake_redis.async_increment = AsyncMock(side_effect=redis_increment)
|
|
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"}
|
|
)
|
|
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_invalidates_bad_reserved_counter_without_failing():
|
|
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"
|
|
)
|
|
is None
|
|
)
|
|
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_warms: list = []
|
|
|
|
async def record_increment(key, value, ttl=None, **kwargs):
|
|
recorded_warms.append({"key": key, "value": value})
|
|
return value
|
|
|
|
fake_redis = AsyncMock()
|
|
fake_redis.async_increment = AsyncMock(side_effect=record_increment)
|
|
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 so subsequent reads are fast
|
|
assert ("spend:team_member:user-1:team-1", 362.0) in [
|
|
(w["key"], w["value"]) for w in recorded_warms
|
|
]
|
|
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]
|
|
|
|
fake_redis.async_get_cache = AsyncMock(side_effect=redis_get)
|
|
fake_redis.async_increment = AsyncMock(side_effect=redis_increment)
|
|
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]
|
|
|
|
fake_redis = AsyncMock()
|
|
fake_redis.async_get_cache = AsyncMock(side_effect=redis_get)
|
|
fake_redis.async_increment = AsyncMock(side_effect=redis_increment)
|
|
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]
|
|
|
|
fake_redis = AsyncMock()
|
|
fake_redis.async_get_cache = AsyncMock(side_effect=redis_get)
|
|
fake_redis.async_increment = AsyncMock(side_effect=redis_increment)
|
|
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_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"
|
|
|
|
|
|
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
|
|
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
|