diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 23fe7af9994..f0cefb2d55b 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -287,6 +287,8 @@ class LiteLLMRoutes(enum.Enum): "/chat/completions", "/v1/chat/completions", "/cursor/chat/completions", + "/cursor/models", + "/cursor/v1/models", # completions "/engines/{model}/completions", "/openai/deployments/{model}/completions", diff --git a/litellm/proxy/response_api_endpoints/endpoints.py b/litellm/proxy/response_api_endpoints/endpoints.py index 9601e2d4fde..80abdab8583 100644 --- a/litellm/proxy/response_api_endpoints/endpoints.py +++ b/litellm/proxy/response_api_endpoints/endpoints.py @@ -22,6 +22,8 @@ from litellm.types.responses.main import DeleteResponseResult router = APIRouter() +_user_api_key_auth_dep = Depends(user_api_key_auth) + @router.post( "/v1/responses", @@ -283,6 +285,33 @@ async def responses_api( ) +@router.get( + "/cursor/models", + dependencies=[Depends(user_api_key_auth)], + tags=["responses"], +) +@router.get( + "/cursor/v1/models", + dependencies=[Depends(user_api_key_auth)], + tags=["responses"], +) +async def cursor_model_list( + user_api_key_dict: UserAPIKeyAuth = _user_api_key_auth_dep, +): + """ + OpenAI-compatible model listing for the Cursor BYOK base URL. + + Clients pointed at `/cursor` as an OpenAI-compatible base URL resolve and + verify models via `GET {base}/models` (the OpenAI SDK contract). Without this + route those requests fall through to the Cursor Cloud Agents passthrough, which + demands a Cursor API key and 401s, so key verification silently fails before any + chat request is ever sent. Delegates to the standard `/v1/models` handler. + """ + from litellm.proxy.proxy_server import model_list + + return await model_list(user_api_key_dict=user_api_key_dict) + + @router.post( "/cursor/chat/completions", dependencies=[Depends(user_api_key_auth)], diff --git a/tests/test_litellm/proxy/response_api_endpoints/test_endpoints.py b/tests/test_litellm/proxy/response_api_endpoints/test_endpoints.py index 0cc79658b6a..c41de4a8e40 100644 --- a/tests/test_litellm/proxy/response_api_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/response_api_endpoints/test_endpoints.py @@ -881,3 +881,29 @@ def test_cursor_chat_completions_input_body_uses_responses_pipeline_and_strips_s followup_body = asyncio.run(real_read_request_body(request=captured_requests[0])) assert followup_body.get("stream_options") == {"include_usage": True} assert followup_body.get("input") == [{"role": "user", "content": "hello"}] + + +def test_cursor_models_route_delegates_to_model_list(): + """Clients pointed at /cursor as an OpenAI-compatible base URL resolve and + verify keys via GET {base}/models (the OpenAI SDK contract). Without a dedicated + route those requests fall through to the Cursor Cloud Agents passthrough and 401 + for lack of a Cursor API key, so BYOK verification fails before any chat request + is sent. Both /cursor/models and /cursor/v1/models must serve the standard model + list instead.""" + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + import litellm.proxy.proxy_server as ps + + model_payload = {"data": [{"id": "gpt-5.6", "object": "model"}], "object": "list"} + + app.dependency_overrides[user_api_key_auth] = _auth_override + try: + with patch.object(ps, "model_list", AsyncMock(return_value=model_payload)) as mock_model_list: + client = TestClient(app) + for path in ("/cursor/models", "/cursor/v1/models"): + response = client.get(path, headers={"Authorization": "Bearer sk-test-cursor"}) + assert response.status_code == 200, f"{path}: {response.text}" + assert response.json() == model_payload + assert mock_model_list.call_count == 2 + finally: + app.dependency_overrides.pop(user_api_key_auth, None) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 4e0b9b88995..1bc8839976d 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -2645,6 +2645,58 @@ export interface paths { patch?: never; trace?: never; }; + "/cursor/models": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Cursor Model List + * @description OpenAI-compatible model listing for the Cursor BYOK base URL. + * + * Clients pointed at `/cursor` as an OpenAI-compatible base URL resolve and + * verify models via `GET {base}/models` (the OpenAI SDK contract). Without this + * route those requests fall through to the Cursor Cloud Agents passthrough, which + * demands a Cursor API key and 401s, so key verification silently fails before any + * chat request is ever sent. Delegates to the standard `/v1/models` handler. + */ + get: operations["cursor_model_list_cursor_models_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/cursor/v1/models": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Cursor Model List + * @description OpenAI-compatible model listing for the Cursor BYOK base URL. + * + * Clients pointed at `/cursor` as an OpenAI-compatible base URL resolve and + * verify models via `GET {base}/models` (the OpenAI SDK contract). Without this + * route those requests fall through to the Cursor Cloud Agents passthrough, which + * demands a Cursor API key and 401s, so key verification silently fails before any + * chat request is ever sent. Delegates to the standard `/v1/models` handler. + */ + get: operations["cursor_model_list_cursor_v1_models_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/cursor/{endpoint}": { parameters: { query?: never; @@ -38506,6 +38558,46 @@ export interface operations { }; }; }; + cursor_model_list_cursor_models_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + cursor_model_list_cursor_v1_models_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; cursor_proxy_route_cursor__endpoint__get: { parameters: { query?: never;