Skip to content

[Bug]: keys with key_type="llm_api" get 403 on /model/info, /v1/model/info, /v2/model/info and /model_group/info #37810

Description

@duhd-vnpay

What happened

A key created with key_type="llm_api" can call models but cannot read model metadata. handle_key_type sets allowed_routes=["llm_api_routes"] (litellm/proxy/management_endpoints/key_management_endpoints.py:476-477), and LiteLLMRoutes.llm_api_routes (litellm/proxy/_types.py:518-528) covers /models and /v1/models but none of the model-metadata endpoints, which live in info_routes (litellm/proxy/_types.py:529-551):

/model/info, /v1/model/info, /v2/model/info, /model_group/info

RouteChecks.is_virtual_key_allowed_to_call_route therefore returns 403 for all four, so an OpenAI-compatible client that discovers pricing, mode or context-window limits from /model_group/info gets nothing while /v1/chat/completions on the very same key works. The check runs in should_call_route before any role branching (litellm/proxy/auth/user_api_key_auth.py:2536), so this applies to every caller holding such a key, including one whose user row is proxy_admin

This looks like a gap rather than a deliberate restriction: the same key group already reaches /v1/models, and llm_api_routes already carries a method-aware carve-out for the read-only MCP server discovery endpoints (litellm/proxy/auth/route_checks.py:155-166), which is the same shape of problem

Reproduction

Proxy on localhost:4000 with a Postgres backend, one deployment in config.yaml, master_key: sk-e2e-1234

# 1. create a key with the llm_api preset
KEY=$(curl -s -X POST http://127.0.0.1:4000/key/generate \
  -H "Authorization: Bearer sk-e2e-1234" -H 'Content-Type: application/json' \
  -d '{"key_type":"llm_api","key_alias":"repro"}' | jq -r .key)

# 2. confirm the preset that /key/generate applied
curl -s "http://127.0.0.1:4000/key/info?key=$KEY" -H "Authorization: Bearer sk-e2e-1234" \
  | jq '{key_type: .info.key_type, allowed_routes: .info.allowed_routes}'

# 3. metadata endpoints vs /v1/models with the same key
for r in /v1/model/info /model/info /v2/model/info /model_group/info /v1/models; do
  printf '%-20s %s\n' "$r" "$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $KEY" "http://127.0.0.1:4000$r")"
done

Output:

{"key_type":"llm_api","allowed_routes":["llm_api_routes"]}

/v1/model/info       403
/model/info          403
/v2/model/info       403
/model_group/info    403
/v1/models           200

Response body on any of the four:

{"detail":"Virtual key is not allowed to call this route. Only allowed to call routes: ['llm_api_routes']. Tried to call route: /model_group/info"}

Expected behavior

A key scoped to llm_api_routes should be able to read metadata for the models it is already allowed to call, the same way it can list them through /v1/models

Suggested fix

A GET-only carve-out inside the virtual-key check, mirroring _is_get_mcp_server_discovery_route, keeps the blast radius at the ACL layer:

_MODEL_DISCOVERY_ROUTES = frozenset(
    ("/model/info", "/v1/model/info", "/v2/model/info", "/model_group/info")
)

@staticmethod
def _is_get_model_discovery_route(route: str, request: Optional[Request]) -> bool:
    if request is None or request.method.upper() != "GET":
        return False
    return route in _MODEL_DISCOVERY_ROUTES

called next to the existing MCP carve-out under if allowed_route == "llm_api_routes":

Worth noting why simply appending the four paths to LiteLLMRoutes.llm_api_routes is a heavier change: RouteChecks.is_llm_api_route membership also drives budget reservation (litellm/proxy/spend_tracking/budget_reservation.py:141), cost tracking (litellm/proxy/hooks/proxy_track_cost_callback.py:74), tag routing and the global-spend gate (litellm/proxy/auth/auth_checks.py:341,359), plus _check_llm_api_route_access (litellm/proxy/auth/route_checks.py:779), none of which should treat a metadata GET as an LLM call. Both /models and /v1/models already need explicit exclusions in two of those call sites for the same reason

The writes on the shared prefix (/model/new, /model/update, /model/delete) are separate paths, so neither variant reaches them, and both /model/info and /model_group/info already scope their response to the caller's model access

One thing worth a maintainer's call: remove_sensitive_info_from_deployment (litellm/proxy/common_utils/openai_endpoint_utils.py:29-47) strips api_key, client_secret, vertex_credentials and the AWS keys, but leaves api_base in the /model/info response. If that is considered infrastructure detail an LLM-API key should not see, the carve-out could cover /model_group/info only, whose response carries no api_base

Related observability gap

should_call_route is called outside the try/except that routes authorization failures through UserAPIKeyAuthExceptionHandler._handle_authentication_error (litellm/proxy/auth/user_api_key_auth.py:2536 vs 2543-2559), so a route denial raises straight to FastAPI and never gets the log line that budget or model-access denials get. In practice a 403 from this path leaves nothing in the proxy log beyond the uvicorn access line, which makes it hard to tell which key was denied. Happy to file that separately if it is worth its own issue

Relevant log output

INFO:     10.245.4.246:33766 - "GET /v2/model/info HTTP/1.1" 403 Forbidden

Are you a ML Ops Team?

No

What LiteLLM version are you on?

v1.94.2

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions