diff --git a/docs/my-website/docs/pass_through/cursor.md b/docs/my-website/docs/pass_through/cursor.md new file mode 100644 index 0000000000..5726c6bae2 --- /dev/null +++ b/docs/my-website/docs/pass_through/cursor.md @@ -0,0 +1,157 @@ +import Image from '@theme/IdealImage'; + +# Cursor Cloud Agents + +Pass-through endpoints for the [Cursor Cloud Agents API](https://docs.cursor.com/account/api) — launch and manage cloud agents that work on your repositories, in native format (no translation). + +| Feature | Supported | Notes | +|---------|-----------|-------| +| Cost Tracking | ✅ | Logged as $0.00 (subscription-based, no per-request pricing) | +| Logging | ✅ | All requests logged with operation classification | +| End-user Tracking | ❌ | [Tell us if you need this](https://github.com/BerriAI/litellm/issues/new) | +| Streaming | ❌ | Cursor API does not use streaming | + +Just replace `https://api.cursor.com` with `LITELLM_PROXY_BASE_URL/cursor` 🚀 + +**Supported endpoints:** + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/v0/agents` | GET | List agents | +| `/v0/agents` | POST | Launch an agent | +| `/v0/agents/{id}` | GET | Agent status | +| `/v0/agents/{id}` | DELETE | Delete an agent | +| `/v0/agents/{id}/conversation` | GET | Agent conversation | +| `/v0/agents/{id}/followup` | POST | Add follow-up | +| `/v0/agents/{id}/stop` | POST | Stop an agent | +| `/v0/me` | GET | API key info | +| `/v0/models` | GET | List models | +| `/v0/repositories` | GET | List GitHub repositories | + +## Quick Start + +### 1. Add Cursor API Key on the UI + +Navigate to **Models + Endpoints → LLM Credentials** and click **Add Credential**. Select **Cursor** from the provider dropdown — you'll see the Cursor logo. Enter your API key from [cursor.com/settings](https://cursor.com/settings). + +Add Cursor credential with logo + +### 2. Launch a Cursor Agent + +```bash +curl -X POST http://0.0.0.0:4000/cursor/v0/agents \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "prompt": { + "text": "Add a README.md with installation instructions" + }, + "source": { + "repository": "https://github.com/your-org/your-repo", + "ref": "main" + }, + "target": { + "autoCreatePr": true + } + }' +``` + +**Expected Response:** + +```json +{ + "id": "bc_abc123", + "name": "Add README Documentation", + "status": "CREATING", + "source": { + "repository": "https://github.com/your-org/your-repo", + "ref": "main" + }, + "target": { + "branchName": "cursor/add-readme-1234", + "url": "https://cursor.com/agents?id=bc_abc123", + "autoCreatePr": true + }, + "createdAt": "2024-01-15T10:30:00Z" +} +``` + +### 3. View Logs + +Navigate to **Logs** in the sidebar. Filter by "cursor" to see your agent requests. Each request shows the operation type (e.g., `cursor/cursor:agent:create`), status, duration, and cost. + +Cursor requests in Logs page + +Click on any log entry to see full request details including provider, API base, and metadata. + +Cursor log entry detail + +## Examples + +Anything after `http://0.0.0.0:4000/cursor` is treated as a provider-specific route, and handled accordingly. + +| **Original Endpoint** | **Replace With** | +|---|---| +| `https://api.cursor.com` | `http://0.0.0.0:4000/cursor` (LITELLM_PROXY_BASE_URL) | +| `-u YOUR_API_KEY:` (Basic Auth) | `-H "Authorization: Bearer "` (LiteLLM Virtual Key) | + +### List Available Models + +```bash +curl http://0.0.0.0:4000/cursor/v0/models \ + -H "Authorization: Bearer " +``` + +### Check Agent Status + +```bash +curl http://0.0.0.0:4000/cursor/v0/agents/bc_abc123 \ + -H "Authorization: Bearer " +``` + +### List All Agents + +```bash +curl http://0.0.0.0:4000/cursor/v0/agents \ + -H "Authorization: Bearer " +``` + +### Add Follow-up to Agent + +```bash +curl -X POST http://0.0.0.0:4000/cursor/v0/agents/bc_abc123/followup \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "prompt": { + "text": "Also add a section about troubleshooting" + } + }' +``` + +### Stop an Agent + +```bash +curl -X POST http://0.0.0.0:4000/cursor/v0/agents/bc_abc123/stop \ + -H "Authorization: Bearer " +``` + +### Delete an Agent + +```bash +curl -X DELETE http://0.0.0.0:4000/cursor/v0/agents/bc_abc123 \ + -H "Authorization: Bearer " +``` + +### Get API Key Info + +```bash +curl http://0.0.0.0:4000/cursor/v0/me \ + -H "Authorization: Bearer " +``` + +## Related + +- [Cursor Cloud Agents API Docs](https://docs.cursor.com/account/api) +- [Pass-through Endpoints Overview](./intro.md) +- [Virtual Keys](../proxy/virtual_keys.md) diff --git a/docs/my-website/img/cursor_add_credential.png b/docs/my-website/img/cursor_add_credential.png new file mode 100644 index 0000000000..5b0eb1ffe5 Binary files /dev/null and b/docs/my-website/img/cursor_add_credential.png differ diff --git a/docs/my-website/img/cursor_log_detail.png b/docs/my-website/img/cursor_log_detail.png new file mode 100644 index 0000000000..5fdb1dd4a1 Binary files /dev/null and b/docs/my-website/img/cursor_log_detail.png differ diff --git a/docs/my-website/img/cursor_logs.png b/docs/my-website/img/cursor_logs.png new file mode 100644 index 0000000000..ab5aeafc77 Binary files /dev/null and b/docs/my-website/img/cursor_logs.png differ diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index b01bb53cfe..60325d0efc 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -635,6 +635,7 @@ const sidebars = { "pass_through/bedrock", "pass_through/azure_passthrough", "pass_through/cohere", + "pass_through/cursor", "pass_through/google_ai_studio", "pass_through/langfuse", "pass_through/mistral", diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index afedb6c8e7..e408abb3c1 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -383,6 +383,7 @@ class LiteLLMRoutes(enum.Enum): "/vertex-ai", "/vertex_ai", "/cohere", + "/cursor", "/gemini", "/anthropic", "/langfuse", diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 028edea8c3..13f78f30fa 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -2086,6 +2086,93 @@ class BaseOpenAIPassThroughHandler: return joined_path_str +@router.api_route( + "/cursor/{endpoint:path}", + methods=["GET", "POST", "PUT", "DELETE", "PATCH"], + tags=["Cursor Pass-through", "pass-through"], +) +async def cursor_proxy_route( + endpoint: str, + request: Request, + fastapi_response: Response, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), +): + """ + Pass-through endpoint for the Cursor Cloud Agents API. + + Supports all Cursor Cloud Agents endpoints: + - GET /v0/agents — List agents + - POST /v0/agents — Launch an agent + - GET /v0/agents/{id} — Agent status + - GET /v0/agents/{id}/conversation — Agent conversation + - POST /v0/agents/{id}/followup — Add follow-up + - POST /v0/agents/{id}/stop — Stop an agent + - DELETE /v0/agents/{id} — Delete an agent + - GET /v0/me — API key info + - GET /v0/models — List models + - GET /v0/repositories — List GitHub repositories + + Uses Basic Authentication (base64-encoded `API_KEY:`). + + Credential lookup order: + 1. passthrough_endpoint_router (config.yaml deployments with use_in_pass_through) + 2. litellm.credential_list (credentials added via UI) + 3. CURSOR_API_KEY environment variable + """ + import base64 + + base_target_url = os.getenv("CURSOR_API_BASE") or "https://api.cursor.com" + + cursor_api_key = passthrough_endpoint_router.get_credentials( + custom_llm_provider="cursor", + region_name=None, + ) + + if cursor_api_key is None: + for credential in litellm.credential_list: + if ( + credential.credential_info + and credential.credential_info.get("custom_llm_provider") == "cursor" + ): + cursor_api_key = credential.credential_values.get("api_key") + credential_api_base = credential.credential_values.get("api_base") + if credential_api_base: + base_target_url = credential_api_base + break + + if cursor_api_key is None: + raise HTTPException( + status_code=401, + detail="Cursor API key not found. Add Cursor credentials via the UI (Models + Endpoints → LLM Credentials) or set CURSOR_API_KEY environment variable.", + ) + + encoded_endpoint = httpx.URL(endpoint).path + + if not encoded_endpoint.startswith("/"): + encoded_endpoint = "/" + encoded_endpoint + + base_url = httpx.URL(base_target_url) + updated_url = base_url.copy_with(path=encoded_endpoint) + + auth_value = base64.b64encode( + f"{cursor_api_key}:".encode("utf-8") + ).decode("ascii") + + endpoint_func = create_pass_through_route( + endpoint=endpoint, + target=str(updated_url), + custom_headers={"Authorization": f"Basic {auth_value}"}, + custom_llm_provider="cursor", + ) + received_value = await endpoint_func( + request, + fastapi_response, + user_api_key_dict, + ) + + return received_value + + async def vertex_ai_live_websocket_passthrough( websocket: WebSocket, model: Optional[str] = None, diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/cursor_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/cursor_passthrough_logging_handler.py new file mode 100644 index 0000000000..2d687928d9 --- /dev/null +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/cursor_passthrough_logging_handler.py @@ -0,0 +1,141 @@ +""" +Cursor Cloud Agents API - Pass-through Logging Handler + +Transforms Cursor API responses into standardized logging payloads +so they appear cleanly in the LiteLLM Logs page. +""" + +from datetime import datetime +from typing import Dict + +import httpx + +from litellm._logging import verbose_proxy_logger +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.litellm_core_utils.litellm_logging import ( + get_standard_logging_object_payload, +) +from litellm.proxy._types import PassThroughEndpointLoggingTypedDict +from litellm.types.utils import StandardPassThroughResponseObject + + +CURSOR_AGENT_ENDPOINTS: Dict[str, str] = { + "POST /v0/agents": "cursor:agent:create", + "GET /v0/agents": "cursor:agent:list", + "POST /v0/agents/{id}/followup": "cursor:agent:followup", + "POST /v0/agents/{id}/stop": "cursor:agent:stop", + "DELETE /v0/agents/{id}": "cursor:agent:delete", + "GET /v0/agents/{id}/conversation": "cursor:agent:conversation", + "GET /v0/agents/{id}": "cursor:agent:status", + "GET /v0/me": "cursor:account:info", + "GET /v0/models": "cursor:models:list", + "GET /v0/repositories": "cursor:repositories:list", +} + + +def _classify_cursor_request(method: str, path: str) -> str: + """Classify a Cursor API request into a readable operation name.""" + normalized = path.rstrip("/") + + for pattern, operation in CURSOR_AGENT_ENDPOINTS.items(): + pat_method, pat_path = pattern.split(" ", 1) + if method.upper() != pat_method: + continue + + pat_parts = pat_path.strip("/").split("/") + req_parts = normalized.strip("/").split("/") + + if len(pat_parts) != len(req_parts): + continue + + match = True + for pp, rp in zip(pat_parts, req_parts): + if pp.startswith("{") and pp.endswith("}"): + continue + if pp != rp: + match = False + break + if match: + return operation + + return f"cursor:{method.lower()}:{normalized}" + + +class CursorPassthroughLoggingHandler: + """Handles logging for Cursor Cloud Agents pass-through requests.""" + + @staticmethod + def cursor_passthrough_handler( + httpx_response: httpx.Response, + response_body: dict, + logging_obj: LiteLLMLoggingObj, + url_route: str, + result: str, + start_time: datetime, + end_time: datetime, + cache_hit: bool, + request_body: dict, + **kwargs, + ) -> PassThroughEndpointLoggingTypedDict: + """ + Transform a Cursor API response into a standard logging payload. + """ + try: + method = httpx_response.request.method + path = httpx.URL(url_route).path + operation = _classify_cursor_request(method, path) + + agent_id = response_body.get("id", "") + agent_name = response_body.get("name", "") + agent_status = response_body.get("status", "") + + model_name = f"cursor/{operation}" + + summary_parts = [] + if agent_id: + summary_parts.append(f"id={agent_id}") + if agent_name: + summary_parts.append(f"name={agent_name}") + if agent_status: + summary_parts.append(f"status={agent_status}") + + response_summary = ", ".join(summary_parts) if summary_parts else result + + kwargs["model"] = model_name + kwargs["response_cost"] = 0.0 + logging_obj.model_call_details["model"] = model_name + logging_obj.model_call_details["custom_llm_provider"] = "cursor" + logging_obj.model_call_details["response_cost"] = 0.0 + + standard_logging_object = get_standard_logging_object_payload( + kwargs=kwargs, + init_response_obj=StandardPassThroughResponseObject( + response=response_summary + ), + start_time=start_time, + end_time=end_time, + logging_obj=logging_obj, + status="success", + ) + kwargs["standard_logging_object"] = standard_logging_object + + verbose_proxy_logger.debug( + "Cursor passthrough logging: operation=%s, agent_id=%s", + operation, + agent_id, + ) + + return { + "result": StandardPassThroughResponseObject( + response=response_summary + ), + "kwargs": kwargs, + } + except Exception as e: + verbose_proxy_logger.exception( + "Error in Cursor passthrough logging handler: %s", e + ) + return { + "result": StandardPassThroughResponseObject(response=result), + "kwargs": kwargs, + } diff --git a/litellm/proxy/pass_through_endpoints/success_handler.py b/litellm/proxy/pass_through_endpoints/success_handler.py index 41b92c5611..271c2d7a48 100644 --- a/litellm/proxy/pass_through_endpoints/success_handler.py +++ b/litellm/proxy/pass_through_endpoints/success_handler.py @@ -22,6 +22,9 @@ from .llm_provider_handlers.assembly_passthrough_logging_handler import ( from .llm_provider_handlers.cohere_passthrough_logging_handler import ( CoherePassthroughLoggingHandler, ) +from .llm_provider_handlers.cursor_passthrough_logging_handler import ( + CursorPassthroughLoggingHandler, +) from .llm_provider_handlers.gemini_passthrough_logging_handler import ( GeminiPassthroughLoggingHandler, ) @@ -60,6 +63,9 @@ class PassThroughEndpointLogging: # Gemini self.TRACKED_GEMINI_ROUTES = ["generateContent", "streamGenerateContent", "predictLongRunning"] + # Cursor Cloud Agents + self.TRACKED_CURSOR_ROUTES = ["/v0/agents", "/v0/me", "/v0/models", "/v0/repositories"] + # Vertex AI Live API WebSocket self.TRACKED_VERTEX_AI_LIVE_ROUTES = ["/vertex_ai/live"] @@ -223,6 +229,25 @@ class PassThroughEndpointLogging: ) kwargs = openai_passthrough_logging_handler_result["kwargs"] + elif self.is_cursor_route(url_route, custom_llm_provider): + cursor_passthrough_logging_handler_result = ( + CursorPassthroughLoggingHandler.cursor_passthrough_handler( + httpx_response=httpx_response, + response_body=response_body or {}, + logging_obj=logging_obj, + url_route=url_route, + result=result, + start_time=start_time, + end_time=end_time, + cache_hit=cache_hit, + request_body=request_body, + **kwargs, + ) + ) + standard_logging_response_object = ( + cursor_passthrough_logging_handler_result["result"] + ) + kwargs = cursor_passthrough_logging_handler_result["kwargs"] elif self.is_vertex_ai_live_route(url_route): from .llm_provider_handlers.vertex_ai_live_passthrough_logging_handler import ( VertexAILivePassthroughLoggingHandler, @@ -385,6 +410,22 @@ class PassThroughEndpointLogging: return True return False + def is_cursor_route( + self, url_route: str, custom_llm_provider: Optional[str] = None + ): + """Check if the URL route is a Cursor Cloud Agents API route.""" + if custom_llm_provider == "cursor": + return True + parsed_url = urlparse(url_route) + if parsed_url.hostname and "api.cursor.com" in parsed_url.hostname: + return True + for route in self.TRACKED_CURSOR_ROUTES: + if route in url_route: + path = parsed_url.path if parsed_url.scheme else url_route + if path.startswith("/v0/"): + return custom_llm_provider == "cursor" + return False + def is_openai_route(self, url_route: str): """Check if the URL route is an OpenAI API route.""" if not url_route: diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index ec1c461952..dda8e49d4c 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -3025,5 +3025,33 @@ } ], "default_model_placeholder": "gpt-3.5-turbo" + }, + { + "provider": "CURSOR", + "provider_display_name": "Cursor", + "litellm_provider": "cursor", + "credential_fields": [ + { + "key": "api_key", + "label": "Cursor API Key", + "placeholder": "Your Cursor API key from cursor.com/settings", + "tooltip": "Get your API key from the Cursor Dashboard at cursor.com/settings", + "required": true, + "field_type": "password", + "options": null, + "default_value": null + }, + { + "key": "api_base", + "label": "API Base", + "placeholder": "https://api.cursor.com", + "tooltip": "Cursor API base URL (defaults to https://api.cursor.com)", + "required": false, + "field_type": "text", + "options": null, + "default_value": "https://api.cursor.com" + } + ], + "default_model_placeholder": "cursor/claude-4-sonnet" } ] diff --git a/litellm/types/utils.py b/litellm/types/utils.py index dda32d9838..1b7089d7f1 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3185,6 +3185,7 @@ class LlmProviders(str, Enum): CHUTES = "chutes" XIAOMI_MIMO = "xiaomi_mimo" LITELLM_AGENT = "litellm_agent" + CURSOR = "cursor" # Create a set of all provider values for quick lookup diff --git a/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_cursor_passthrough_logging_handler.py b/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_cursor_passthrough_logging_handler.py new file mode 100644 index 0000000000..2d025a871b --- /dev/null +++ b/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_cursor_passthrough_logging_handler.py @@ -0,0 +1,116 @@ +import os +import sys +from datetime import datetime +from unittest.mock import MagicMock + +import httpx +import pytest + +sys.path.insert(0, os.path.abspath("../../../../..")) + +from litellm.proxy.pass_through_endpoints.llm_provider_handlers.cursor_passthrough_logging_handler import ( + CursorPassthroughLoggingHandler, + _classify_cursor_request, +) +from litellm.proxy.pass_through_endpoints.success_handler import ( + PassThroughEndpointLogging, +) + + +class TestClassifyCursorRequest: + def test_should_classify_create_agent(self): + assert _classify_cursor_request("POST", "/v0/agents") == "cursor:agent:create" + + def test_should_classify_list_agents(self): + assert _classify_cursor_request("GET", "/v0/agents") == "cursor:agent:list" + + def test_should_classify_agent_status(self): + assert ( + _classify_cursor_request("GET", "/v0/agents/bc_abc123") + == "cursor:agent:status" + ) + + def test_should_classify_agent_conversation(self): + assert ( + _classify_cursor_request("GET", "/v0/agents/bc_abc123/conversation") + == "cursor:agent:conversation" + ) + + def test_should_classify_agent_followup(self): + assert ( + _classify_cursor_request("POST", "/v0/agents/bc_abc123/followup") + == "cursor:agent:followup" + ) + + def test_should_classify_agent_stop(self): + assert ( + _classify_cursor_request("POST", "/v0/agents/bc_abc123/stop") + == "cursor:agent:stop" + ) + + def test_should_classify_agent_delete(self): + assert ( + _classify_cursor_request("DELETE", "/v0/agents/bc_abc123") + == "cursor:agent:delete" + ) + + def test_should_classify_me_endpoint(self): + assert _classify_cursor_request("GET", "/v0/me") == "cursor:account:info" + + def test_should_classify_models_endpoint(self): + assert _classify_cursor_request("GET", "/v0/models") == "cursor:models:list" + + def test_should_classify_repositories_endpoint(self): + assert ( + _classify_cursor_request("GET", "/v0/repositories") + == "cursor:repositories:list" + ) + + +class TestCursorRouteDetection: + def test_should_detect_cursor_route_by_custom_llm_provider(self): + handler = PassThroughEndpointLogging() + assert handler.is_cursor_route("https://api.cursor.com/v0/agents", "cursor") + + def test_should_detect_cursor_route_by_hostname(self): + handler = PassThroughEndpointLogging() + assert handler.is_cursor_route("https://api.cursor.com/v0/agents") + + def test_should_not_detect_non_cursor_route(self): + handler = PassThroughEndpointLogging() + assert not handler.is_cursor_route("https://api.openai.com/v1/completions") + + +class TestCursorPassthroughHandler: + def test_should_log_agent_creation_response(self): + mock_response = MagicMock(spec=httpx.Response) + mock_response.request = MagicMock() + mock_response.request.method = "POST" + mock_response.text = '{"id": "bc_abc123"}' + + mock_logging_obj = MagicMock() + mock_logging_obj.litellm_call_id = "test-call-id" + mock_logging_obj.model_call_details = {} + + response_body = { + "id": "bc_abc123", + "name": "Test Agent", + "status": "CREATING", + } + + result = CursorPassthroughLoggingHandler.cursor_passthrough_handler( + httpx_response=mock_response, + response_body=response_body, + logging_obj=mock_logging_obj, + url_route="https://api.cursor.com/v0/agents", + result='{"id": "bc_abc123"}', + start_time=datetime.now(), + end_time=datetime.now(), + cache_hit=False, + request_body={"prompt": {"text": "Add README"}}, + ) + + assert result["result"] is not None + assert result["kwargs"]["model"] == "cursor/cursor:agent:create" + assert result["kwargs"]["response_cost"] == 0.0 + assert mock_logging_obj.model_call_details["custom_llm_provider"] == "cursor" diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index fdc821ef8b..8acfa2231c 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -20,6 +20,7 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( RouteChecks, bedrock_llm_proxy_route, create_pass_through_route, + cursor_proxy_route, llm_passthrough_factory_proxy_route, milvus_proxy_route, openai_proxy_route, @@ -2377,3 +2378,188 @@ class TestOpenAIPassthroughRoute: # Verify result assert result == {"id": "asst_123", "object": "assistant"} + + +class TestCursorProxyRoute: + """Tests for the Cursor Cloud Agents pass-through route.""" + + @pytest.mark.asyncio + async def test_cursor_proxy_route_creates_pass_through_with_basic_auth(self): + """should create a pass-through route with Basic Auth header for Cursor API""" + import base64 + + mock_request = MagicMock(spec=Request) + mock_request.method = "GET" + mock_request.query_params = {} + mock_request.headers = {} + mock_response = MagicMock(spec=Response) + mock_user_api_key_dict = MagicMock() + + test_api_key = "test-cursor-api-key-123" + + with patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router.get_credentials", + return_value=test_api_key, + ), patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.create_pass_through_route" + ) as mock_create_route: + mock_endpoint_func = AsyncMock( + return_value={"agents": [], "nextCursor": None} + ) + mock_create_route.return_value = mock_endpoint_func + + result = await cursor_proxy_route( + endpoint="v0/agents", + request=mock_request, + fastapi_response=mock_response, + user_api_key_dict=mock_user_api_key_dict, + ) + + mock_create_route.assert_called_once() + call_args = mock_create_route.call_args[1] + assert call_args["target"] == "https://api.cursor.com/v0/agents" + + expected_auth = base64.b64encode( + f"{test_api_key}:".encode("utf-8") + ).decode("ascii") + assert call_args["custom_headers"]["Authorization"] == f"Basic {expected_auth}" + + assert result == {"agents": [], "nextCursor": None} + + @pytest.mark.asyncio + async def test_cursor_proxy_route_raises_on_missing_api_key(self): + """should raise 401 when no Cursor API key is available""" + mock_request = MagicMock(spec=Request) + mock_request.method = "POST" + mock_request.query_params = {} + mock_request.headers = {} + mock_response = MagicMock(spec=Response) + mock_user_api_key_dict = MagicMock() + + with patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router.get_credentials", + return_value=None, + ), patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.litellm.credential_list", + [], + ): + with pytest.raises(Exception) as exc_info: + await cursor_proxy_route( + endpoint="v0/agents", + request=mock_request, + fastapi_response=mock_response, + user_api_key_dict=mock_user_api_key_dict, + ) + assert exc_info.value.status_code == 401 + + @pytest.mark.asyncio + async def test_cursor_proxy_route_uses_ui_credential(self): + """should use credentials added via UI (litellm.credential_list) when env var is not set""" + from litellm.types.utils import CredentialItem + + mock_request = MagicMock(spec=Request) + mock_request.method = "GET" + mock_request.query_params = {} + mock_request.headers = {} + mock_response = MagicMock(spec=Response) + mock_user_api_key_dict = MagicMock() + + ui_credential = CredentialItem( + credential_name="my-cursor-key", + credential_values={"api_key": "crsr_ui_test_key", "api_base": "https://api.cursor.com"}, + credential_info={"custom_llm_provider": "cursor"}, + ) + + with patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router.get_credentials", + return_value=None, + ), patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.litellm.credential_list", + [ui_credential], + ), patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.create_pass_through_route" + ) as mock_create_route: + mock_endpoint_func = AsyncMock(return_value={"models": []}) + mock_create_route.return_value = mock_endpoint_func + + result = await cursor_proxy_route( + endpoint="v0/models", + request=mock_request, + fastapi_response=mock_response, + user_api_key_dict=mock_user_api_key_dict, + ) + + call_args = mock_create_route.call_args[1] + assert call_args["target"] == "https://api.cursor.com/v0/models" + + import base64 + expected_auth = base64.b64encode(b"crsr_ui_test_key:").decode("ascii") + assert call_args["custom_headers"]["Authorization"] == f"Basic {expected_auth}" + + @pytest.mark.asyncio + async def test_cursor_proxy_route_custom_api_base(self): + """should use CURSOR_API_BASE env var when set""" + mock_request = MagicMock(spec=Request) + mock_request.method = "GET" + mock_request.query_params = {} + mock_request.headers = {} + mock_response = MagicMock(spec=Response) + mock_user_api_key_dict = MagicMock() + + with patch.dict( + os.environ, {"CURSOR_API_BASE": "https://custom-cursor.example.com"} + ), patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router.get_credentials", + return_value="test-key", + ), patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.create_pass_through_route" + ) as mock_create_route: + mock_endpoint_func = AsyncMock(return_value={}) + mock_create_route.return_value = mock_endpoint_func + + await cursor_proxy_route( + endpoint="v0/me", + request=mock_request, + fastapi_response=mock_response, + user_api_key_dict=mock_user_api_key_dict, + ) + + call_args = mock_create_route.call_args[1] + assert call_args["target"] == "https://custom-cursor.example.com/v0/me" + + @pytest.mark.asyncio + async def test_cursor_proxy_route_launch_agent(self): + """should handle POST to launch an agent through the pass-through""" + mock_request = MagicMock(spec=Request) + mock_request.method = "POST" + mock_request.query_params = {} + mock_request.headers = {"content-type": "application/json"} + mock_response = MagicMock(spec=Response) + mock_user_api_key_dict = MagicMock() + + with patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router.get_credentials", + return_value="test-key", + ), patch( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.create_pass_through_route" + ) as mock_create_route: + mock_endpoint_func = AsyncMock( + return_value={ + "id": "bc_abc123", + "name": "Test Agent", + "status": "CREATING", + } + ) + mock_create_route.return_value = mock_endpoint_func + + result = await cursor_proxy_route( + endpoint="v0/agents", + request=mock_request, + fastapi_response=mock_response, + user_api_key_dict=mock_user_api_key_dict, + ) + + call_args = mock_create_route.call_args[1] + assert call_args["target"] == "https://api.cursor.com/v0/agents" + assert result["id"] == "bc_abc123" + assert result["status"] == "CREATING" diff --git a/ui/litellm-dashboard/public/assets/logos/cursor.svg b/ui/litellm-dashboard/public/assets/logos/cursor.svg new file mode 100644 index 0000000000..79b44c5e83 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/cursor.svg @@ -0,0 +1 @@ +Cursor diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index 246b91f864..8568e79b1e 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -9,6 +9,7 @@ export enum Providers { Azure_AI_Studio = "Azure AI Foundry (Studio)", Cerebras = "Cerebras", Cohere = "Cohere", + Cursor = "Cursor", Dashscope = "Dashscope", Databricks = "Databricks (Qwen API)", DeepInfra = "DeepInfra", @@ -60,6 +61,7 @@ export const provider_map: Record = { MiniMax: "minimax", MistralAI: "mistral", Cohere: "cohere", + Cursor: "cursor", OpenAI_Compatible: "openai", OpenAI_Text_Compatible: "text-completion-openai", Vertex_AI: "vertex_ai", @@ -107,6 +109,7 @@ export const providerLogoMap: Record = { [Providers.SageMaker]: `${asset_logos_folder}bedrock.svg`, [Providers.Cerebras]: `${asset_logos_folder}cerebras.svg`, [Providers.Cohere]: `${asset_logos_folder}cohere.svg`, + [Providers.Cursor]: `${asset_logos_folder}cursor.svg`, [Providers.Databricks]: `${asset_logos_folder}databricks.svg`, [Providers.Dashscope]: `${asset_logos_folder}dashscope.svg`, [Providers.Deepseek]: `${asset_logos_folder}deepseek.svg`, @@ -206,6 +209,8 @@ export const getPlaceholder = (selectedProvider: string): string => { return "runwayml/gen4_turbo"; } else if (selectedProvider === Providers.Watsonx) { return "watsonx/ibm/granite-3-3-8b-instruct"; + } else if (selectedProvider === Providers.Cursor) { + return "cursor/claude-4-sonnet"; } else { return "gpt-3.5-turbo"; }