Repository navigation
fix(proxy): hide model allowlist from client-facing model access denied errors - #41310
Conversation
Add litellm_settings.model_access_denied_message, a template ({model} placeholder) returned to clients instead of the detailed "can only access models=[...]" text on key/team/user/org/project and team-member model access denials. The full denial reason is still written to the proxy logs at WARNING. Unset keeps the existing detailed message, status codes and error types are unchanged.
Expose the new setting and the existing expose_router_debug_in_errors flag in the Admin UI general settings (String editor, Boolean toggle with an explicit True default) and allow both as safe DB overrides so they persist and propagate across workers.
Resolves LIT-5283
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
|
|
Greptile SummaryThis PR hardens model-access denial handling by separating fixed client-facing messages from detailed internal diagnostics.
Confidence Score: 4/5The hardening behavior appears functionally sound, but the repository’s compatibility requirement must be satisfied with a user-controlled rollout path before merging. The fixed message prevents policy details from reaching the reviewed client paths and preserves response status and type contracts, but it unconditionally changes established error text in violation of an explicit compatibility rule. The earlier JWT redaction, TypedDict, test-docstring, mutable-set, shared-test-state, and failed-reset threads were manually resolved without explanation; the JWT scope-shape concern was correctly conceded after the existing stringification behavior and parity test were confirmed. Files Needing Attention: litellm/proxy/auth/model_access_denied.py, litellm/proxy/auth/auth_checks.py, litellm/proxy/auth/handle_jwt.py Important Files Changed
Reviews (6): Last reviewed commit: "fix(mcp): return fixed client message on..." | Re-trigger Greptile |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
… log, await dashboard saves Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
bugbot run |
… boundary Move the WARNING that carries the internal denial detail out of the message formatter and into the auth exception handler. The denial exceptions now carry internal_message so access-group probes and fallback paths that catch and recover from the denial no longer log a false denial for an allowed request Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
bugbot run |
… boundary Post-auth denials from can_key_call_resolved_model (per-request alias rewrite, MCP sampling, realtime) never reach the auth exception handler, so the internal allowlist reason was dropped when model_access_denied_message was set. Log it once from the ProxyException response handler and the realtime rejection path instead, and convert JWT ModelAccessDeniedHTTPException into the specialized ProxyException so the same boundary covers it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…ing denials MCP sampling catches the denial itself and returns ErrorData, so the central ProxyException handler never sees it. Log the sanitized internal reason there and share the CR/LF stripping through ModelAccessDeniedProxyException.sanitized_internal_message Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…n conversion Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
bugbot run |
…h a fixed clean client message
Drop the model_access_denied_message setting, its {model} template, the DB
override entry and the Admin UI field. Model access denials now always return
the fixed client message while the allowlist diagnostic is logged at the final
HTTP, realtime and MCP boundaries
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
bugbot run |
| internal_message: Final = ( | ||
| f"{object_type} not allowed to access model. This {object_type} can only access models={models}. " | ||
| f"Tried to access {model}" | ||
| ) | ||
| raise ModelAccessDeniedProxyException( | ||
| message=model_access_denied_client_message(model=model), |
There was a problem hiding this comment.
Unconditional Error Contract Change
These model-access denials now always replace the established client-visible details with a fixed generic message. The repository requires backward-incompatible behavior changes to have a user-controlled flag, so an opt-in setting or another compatibility path must be provided before merging. The same unconditional change also affects the related user, team-member, and JWT denial paths.
Rule Used: What: avoid backwards-incompatible changes without user-controlled flags Why: This breaks current behaviour for users using existing functionality Example of BAD: this PR (#22164) introduced run_post_custom... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
There was a problem hiding this comment.
✅ Bugbot reviewed your changes and found no new issues!
Comment @cursor review or bugbot run to trigger another review on this PR
Reviewed by Cursor Bugbot for commit b085a3c. Configure here.
…denied_message Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> # Conflicts: # tests/test_litellm/proxy/auth/test_handle_jwt.py
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…iAI#39996 and BerriAI#41310 Every one of these fails on main's own scheduled CircleCI run with the same assertion as on any PR, and each traces to a merged behavior change that never updated the test that pinned the old behavior - tests/integration/_support/client.py: BerriAI#41311 made /key/info serve deleted keys from the archive with status deleted, so the scenario teardown asserts the live row is gone and the readback reports deleted instead of a 404. This alone accounts for nine integration-management and one integration-providers failure - tests/integration/authorization/test_warmed_policy.py: BerriAI#39996 made team admins unable to edit any team field unless a proxy admin allow-lists it, and tpm_limit is the only field it accepts today. The demotion test now enables tpm_limit for the scenario and edits that instead of team_alias - tests/llm_responses_api_testing/test_base_responses_api_streaming_iterator.py: BerriAI#41337 reads usage off the terminal response and copies the event when it is missing, which a Mock(spec=ResponsesAPIResponse) cannot survive. The four mocks now carry a usage object - tests/test_openai_endpoints.py: BerriAI#41310 lengthened the access-denied message, and the test matched against the ExceptionInfo repr, which saferepr truncates in the middle. It now matches the exception text - tests/local_testing/test_text_completion.py: Together no longer serves Qwen2-1.5B serverless, the cheapest cost-map row. The test mocks the completions call and asserts the request litellm builds, so a vendor catalog rotation cannot fail it again test_router_fallbacks_with_cooldowns_and_dynamic_credentials is deliberately untouched: it passes and fails on main with identical code, and the failing path is a product question about whether dynamic-credential 429s cool down
The team access-group fixture polled a 403 until its message enumerated the team's allow-list, because registering a team-scoped deployment appends that deployment to the list and the fixture has to wait for the reset to land. #41310 replaced that message with a fixed client-facing one, so the poll never matched and both tests errored at setup The allow-list is now read back from /team/info until it holds exactly the access group
The team access-group fixture polled a 403 until its message enumerated the team's allow-list, because registering a team-scoped deployment appends that deployment to the list and the fixture has to wait for the reset to land. #41310 replaced that message with a fixed client-facing one, so the poll never matched and both tests errored at setup The allow-list is now read back from /team/info until it holds exactly the access group
The team access-group fixture polled a 403 until its message enumerated the team's allow-list, because registering a team-scoped deployment appends that deployment to the list and the fixture has to wait for the reset to land. #41310 replaced that message with a fixed client-facing one, so the poll never matched and both tests errored at setup The allow-list is now read back from /team/info until it holds exactly the access group
TLDR
Problem this solves:
How it solves it:
User Flow
Before: a developer with a restricted key sees the proxy's internal access groups in the error
"model": "anthropic-sonnet-4-5"and a key that is only allowed theinternal-modelsaccess groupkey not allowed to access model. This key can only access models=['internal-models']. Tried to access anthropic-sonnet-4-5Role=internal_user not allowed to call model=anthropic-sonnet-4-5. Allowed models=['anthropic-haiku-4-5']After: the client sees a plain message and the internal detail stays in the proxy log
"model": "anthropic-sonnet-4-5"and the restricted keyThe requested model 'anthropic-sonnet-4-5' is not available for this API key, or the model name is invalid. Check the models available to you and try again., samekey_model_access_deniederror type as beforeauth_errortypekey not allowed to access model. This key can only access models=['internal-models']. Tried to access anthropic-sonnet-4-5(or the role or MCP equivalent) for each denial that reached the clientRelevant issues
Affected release
Linear ticket
Resolves LIT-5283
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Shared setup: real Postgres, real Anthropic API, proxy started with
PYTHONPATH=<checkout> python -m litellm.proxy.proxy_cli --config config.yaml --port 4000.config.yamlhasanthropic-haiku-4-5in access groupinternal-modelsandanthropic-sonnet-4-5in access groupgeneral-models;$Kis a key generated with"models": ["internal-models"]and$Ga key generated with"models": ["sonnet-group"], a DB access group containinganthropic-sonnet-4-5. The JWT cases useconfig_jwt.yaml(base on port 4011, head on port 4010) withenable_jwt_auth, a local JWKS server,jwt_role_based_models: internal_user: [anthropic-haiku-4-5], and$JWTminted forroles: [internal_user]. The MCP sampling cases useconfig_mcp.yaml(base on port 4017, head on port 4016) with a local MCP server onhttp://127.0.0.1:8765/mcpregistered assampling_demowithallow_sampling: true, whoseask_modeltool asks the proxy to sample from the model it is given;$Mis a key generated with"models": ["internal-models"]and access to that server, called throughPOST /mcp-rest/tools/call. Same fixtures on both sides, both trees started withPYTHONPATHpointing at their own checkoutBefore (1b8daf2, the merge base before
origin/mainwas merged in; the merged commits do not touch the denial, JWT or MCP sampling paths)Control: restricted key calls a model it is allowed
curl -s -w "HTTP %{http_code}\n" http://localhost:4000/v1/chat/completions -H "Authorization: Bearer $K" -H "Content-Type: application/json" -d '{"model":"anthropic-haiku-4-5","messages":[{"role":"user","content":"Reply with the single word ok"}]}'HTTP 200,{"id": "chatcmpl-6fa470f7-3a02-4f31-a26f-c7395535be21", "model": "anthropic-haiku-4-5", "content": "ok"}Restricted key calls a model outside its access group
curl -s -w "\nHTTP %{http_code}\n" http://localhost:4000/v1/chat/completions -H "Authorization: Bearer $K" -H "Content-Type: application/json" -d '{"model":"anthropic-sonnet-4-5","messages":[{"role":"user","content":"hi"}]}'{"error":{"message":"key not allowed to access model. This key can only access models=['internal-models']. Tried to access anthropic-sonnet-4-5","type":"key_model_access_denied","param":"model","code":"403"}}HTTP 403Key granted through a DB access group calls the model
curl -s -w "HTTP %{http_code}\n" http://localhost:4000/v1/chat/completions -H "Authorization: Bearer $G" -H "Content-Type: application/json" -d '{"model":"anthropic-sonnet-4-5","messages":[{"role":"user","content":"Reply with the single word ok"}],"max_tokens":5}'HTTP 200with"content": "ok"Denied model name carries CR/LF
curl -s -w "\nHTTP %{http_code}\n" http://localhost:4000/v1/chat/completions -H "Authorization: Bearer $K" -H "Content-Type: application/json" -d '{"model":"gpt-x\r\nWARNING forged log line","messages":[{"role":"user","content":"hi"}]}'HTTP 403with the allowlist text ending inTried to access gpt-x\r\nWARNING forged log lineJWT role calls a model outside its role
curl -s -w "\nHTTP %{http_code}\n" http://localhost:4011/v1/chat/completions -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" -d '{"model":"anthropic-haiku-4-5","messages":[{"role":"user","content":"Reply with the single word ok"}]}'returnsHTTP 200with"content": "ok"(control)"model":"anthropic-sonnet-4-5":{"error":{"message":"Role=internal_user not allowed to call model=anthropic-sonnet-4-5. Allowed models=['anthropic-haiku-4-5']","type":"auth_error","param":"None","code":"403"}}HTTP 403MCP sampling from a restricted key for a model outside its access group
curl -s -w "\nHTTP %{http_code}\n" -X POST http://localhost:4017/mcp-rest/tools/call -H "Authorization: Bearer $M" -H "Content-Type: application/json" -d '{"server_id":"<server_id>","name":"ask_model","arguments":{"question":"Reply with the single word ok","model":"anthropic-haiku-4-5"}}'returnsHTTP 200with"text":"model=claude-haiku-4-5-20251001 text=ok"and"isError":false(real Anthropic call through MCP sampling)"model":"anthropic-sonnet-4-5":HTTP 200with"isError":trueand"text":"Error executing tool ask_model: Model access denied: the API key is not authorized to use model 'anthropic-sonnet-4-5'. key not allowed to access model. This key can only access models=['internal-models']. Tried to access anthropic-sonnet-4-5", so the allowlist reaches the MCP server and its userAfter (b085a3c)
Control: restricted key calls a model it is allowed
HTTP 200,{"id": "chatcmpl-eb731f2d-5cf8-4b19-b546-e148e19e51d2", "model": "anthropic-haiku-4-5", "content": "ok"}Restricted key calls a model outside its access group
{"error":{"message":"The requested model 'anthropic-sonnet-4-5' is not available for this API key, or the model name is invalid. Check the models available to you and try again.","type":"key_model_access_denied","param":"model","code":"403"}}HTTP 403LiteLLM Proxy:WARNING: proxy_server.py:1687 - key not allowed to access model. This key can only access models=['internal-models']. Tried to access anthropic-sonnet-4-5Key granted through a DB access group calls the model
HTTP 200,{"id": "chatcmpl-201de679-1ca7-434a-9882-f4a22bbcaa2f", "model": "anthropic-sonnet-4-5", "content": "ok"}rg -c "not allowed to access model" proxy.logsince the request:0. The native key check fails and the access group check then passes, and that recovered denial is no longer loggedDenied model name carries CR/LF
{"error":{"message":"The requested model 'gpt-x\r\nWARNING forged log line' is not available for this API key, or the model name is invalid. Check the models available to you and try again.","type":"key_model_access_denied","param":"model","code":"403"}}HTTP 403, the model string is untouched in the responsekey not allowed to access model. This key can only access models=['internal-models']. Tried to access gpt-xWARNING forged log line, CR and LF removed before logging so the value cannot start a new log recordJWT role calls a model outside its role
HTTP 200,{"id": "chatcmpl-dc268e4b-0759-418d-ae69-0a06628c2bbe", "model": "anthropic-haiku-4-5", "content": "ok"}{"error":{"message":"The requested model 'anthropic-sonnet-4-5' is not available for this API key, or the model name is invalid. Check the models available to you and try again.","type":"auth_error","param":"None","code":"403"}}HTTP 403LiteLLM Proxy:WARNING: proxy_server.py:1687 - Role=internal_user not allowed to call model=anthropic-sonnet-4-5. Allowed models=['anthropic-haiku-4-5']MCP sampling from a restricted key for a model outside its access group
HTTP 200with"text":"model=claude-haiku-4-5-20251001 text=ok"and"isError":falseHTTP 200with"isError":trueand"text":"Error executing tool ask_model: The requested model 'anthropic-sonnet-4-5' is not available for this API key, or the model name is invalid. Check the models available to you and try again.", no allowlist in the MCP resultLiteLLM:WARNING: sampling_handler.py:889 - MCP sampling: model access denied for model=anthropic-sonnet-4-5: key not allowed to access model. This key can only access models=['internal-models']. Tried to access anthropic-sonnet-4-5Type
🐛 Bug Fix
Caveats (if any)
Medium
Invalid model nametextLow
ModelAccessDeniedProxyException,ModelAccessDeniedHTTPException) and is logged once where the denial leaves the proxy: theProxyExceptionhandler for HTTP routes, the realtime websocket rejection, and the MCP sampling access check that turns it into an MCP error result. Access group fallbacks and probes that catch the denial and then allow the request do not log anything. CR and LF are stripped from the internal reason before logging; the client-facing text keeps the model string as sent/key/generateand/key/updatemodel checks against the team,/team/updatemoving a team into an organization,/organization/new, the batch input file model check, vector store routing hints) return the fixed text but are not logged with the internal reason. They were not logged before this PR eitherHTTPExceptionshape (auth_error, and{"error": ...}for scopes) and only swap the message textexpose_router_debug_in_errorsis untouched; fallback error wording is out of scope for this PRTaxonomy audit (D, P, Z, T, H, C, M): D2/Z7 not applicable, the message is built after the existing checks have already decided, alias and access-group resolution is untouched. D4/Z2 not applicable, no new lookup, nothing on the path can widen access. P4: restricted keys can no longer distinguish unknown from denied models, which is the requested behavior. C1: no default flips. C2: no status code or error type changes, verified above. C3: no public symbols removed,
ProxyExceptionandHTTPExceptioncatchers still catch the subclasses. M/N: the MCP sampling result now carries only the fixed text; the detailed reason is logged on the proxy, never returned to the upstream server. T1: every new test fails on the unfixed code (mutations tried: put the internal reason back in the public message, drop the handler log line, drop the CR/LF strip, route the JWT role check and the JWT scope check around the helper, returninternal_messagefrom the MCP sampling check; each made at least one test fail, all restored and verified withcmp -s). T17: no leaked global state. H1/H2: no docs or comments added. H3: no em dashes in copy. H4/H5:str | None, noAny. H6: the message constant lives in the leaf helper module with the exception class that uses it, nolitellm.proxy.*import in that module. H8: no new Pydantic fields, no schema change, no dashboard changeLive base-vs-head sweep on
1b8daf20e0vs this head: the raise sites inauth_checks.pyandhandle_jwt.py, theProxyExceptionhandler, the auth exception handler conversion, and the MCP sampling access check were driven through/v1/chat/completions(key allowlist, DB access group, CR/LF) on port 4000, JWT role on port 4010, and MCP sampling through a real upstream MCP server on port 4016, all against real Anthropic. The realtime rejection was not driven live (no realtime model in the rig) and is covered bytests/test_litellm/proxy/test_proxy_server.py, which fails when the log call is removed. Not driven live:/v1/responses,/v1/messagesand the other LLM routes that share_can_object_call_model, which reach the same raise sites through the same auth call and are covered by the unit tests, and the management, batch and vector store callers listed under Low, whose only change is the exception subclassNote
Medium Risk
Changes user-visible 403 error text across auth, JWT, realtime, and MCP paths without altering authorization logic; operators and clients that parsed old denial messages may need to adjust.
Overview
Stops leaking allowlists and policy details when the proxy denies model access. Clients (HTTP, JWT, realtime close reasons, MCP sampling) now get one fixed message that only names the requested model; the previous verbose text (allowed models, access groups, roles, team member lists) is kept in
internal_messageand written to proxy logs once at the boundary.Adds
ModelAccessDeniedProxyException/ModelAccessDeniedHTTPExceptionandmodel_access_denied_client_message(), and switches key/team/user/member checks inauth_checks.pyand JWT role/scope checks inhandle_jwt.pyto raise them. TheProxyExceptionhandler and realtime model-access rejection log sanitized internal reasons (CR/LF stripped); MCP_check_model_accessreturns the generic message for the new exception type. 403 types and shapes are unchanged; tests assert the new client copy and that allowlists stay out of responses.Reviewed by Cursor Bugbot for commit b085a3c. Bugbot is set up for automated code reviews on this repo. Configure here.