mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-18 16:18:09 +00:00
Development environment setup (#22432)
* feat: add Cursor Cloud Agents as a native pass-through provider
- Add CURSOR to LlmProviders enum
- Add /cursor/{endpoint:path} pass-through route with Basic Auth
- Add /cursor to mapped_pass_through_routes for proper routing
- Create CursorPassthroughLoggingHandler for Logs page visibility
- Classifies operations (agent:create, agent:list, models:list, etc.)
- Logs model as cursor/cursor:<operation> for clean Logs display
- Tracks cost as $0 (subscription-based, no per-request pricing)
- Add Cursor to UI: provider enum, logo, credential fields
- Add provider_create_fields.json entry for LLM Credentials UI
- Add 18 unit tests covering route, auth, logging, and classification
Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
* fix: use correct Cursor logo from lobehub, add documentation page
- Replace placeholder Cursor logo with official hexagonal logo from lobehub
- Add docs/pass_through/cursor.md with full tutorial matching a2a_cost_tracking style
- Quick Start: add creds on UI, start proxy, launch agent, view logs
- Examples: all Cursor Cloud Agents API endpoints
- Advanced: virtual key usage
- Screenshots: credential form, logs page, log detail view
- Add Cursor to sidebars.js under Pass-through Endpoints
- Add screenshots to docs/my-website/img/
Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
* docs: simplify Cursor doc - UI-only flow, no config.yaml needed
Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
* fix: cursor pass-through reads credentials from UI (litellm.credential_list)
The pass-through route now checks litellm.credential_list as a fallback
when CURSOR_API_KEY env var is not set. This means adding credentials
via the UI (Models + Endpoints → LLM Credentials) works without any
config.yaml or environment variable setup.
Credential lookup order:
1. passthrough_endpoint_router (config.yaml with use_in_pass_through)
2. litellm.credential_list (credentials added via UI)
3. CURSOR_API_KEY environment variable
Also respects api_base from UI credentials if set.
Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
---------
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Ishaan Jaff <ishaan-jaff@users.noreply.github.com>
This commit is contained in:
co-authored by
Cursor Agent
Ishaan Jaff
parent
bc9c28eb80
commit
e3756252a8
@@ -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).
|
||||
|
||||
<Image img={require('../../img/cursor_add_credential.png')} alt="Add Cursor credential with logo" style={{maxWidth: '800px'}} />
|
||||
|
||||
### 2. Launch a Cursor Agent
|
||||
|
||||
```bash
|
||||
curl -X POST http://0.0.0.0:4000/cursor/v0/agents \
|
||||
-H "Authorization: Bearer <your-litellm-key>" \
|
||||
-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.
|
||||
|
||||
<Image img={require('../../img/cursor_logs.png')} alt="Cursor requests in Logs page" style={{maxWidth: '800px'}} />
|
||||
|
||||
Click on any log entry to see full request details including provider, API base, and metadata.
|
||||
|
||||
<Image img={require('../../img/cursor_log_detail.png')} alt="Cursor log entry detail" style={{maxWidth: '800px'}} />
|
||||
|
||||
## 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 <your-litellm-key>"` (LiteLLM Virtual Key) |
|
||||
|
||||
### List Available Models
|
||||
|
||||
```bash
|
||||
curl http://0.0.0.0:4000/cursor/v0/models \
|
||||
-H "Authorization: Bearer <your-litellm-key>"
|
||||
```
|
||||
|
||||
### Check Agent Status
|
||||
|
||||
```bash
|
||||
curl http://0.0.0.0:4000/cursor/v0/agents/bc_abc123 \
|
||||
-H "Authorization: Bearer <your-litellm-key>"
|
||||
```
|
||||
|
||||
### List All Agents
|
||||
|
||||
```bash
|
||||
curl http://0.0.0.0:4000/cursor/v0/agents \
|
||||
-H "Authorization: Bearer <your-litellm-key>"
|
||||
```
|
||||
|
||||
### Add Follow-up to Agent
|
||||
|
||||
```bash
|
||||
curl -X POST http://0.0.0.0:4000/cursor/v0/agents/bc_abc123/followup \
|
||||
-H "Authorization: Bearer <your-litellm-key>" \
|
||||
-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 <your-litellm-key>"
|
||||
```
|
||||
|
||||
### Delete an Agent
|
||||
|
||||
```bash
|
||||
curl -X DELETE http://0.0.0.0:4000/cursor/v0/agents/bc_abc123 \
|
||||
-H "Authorization: Bearer <your-litellm-key>"
|
||||
```
|
||||
|
||||
### Get API Key Info
|
||||
|
||||
```bash
|
||||
curl http://0.0.0.0:4000/cursor/v0/me \
|
||||
-H "Authorization: Bearer <your-litellm-key>"
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [Cursor Cloud Agents API Docs](https://docs.cursor.com/account/api)
|
||||
- [Pass-through Endpoints Overview](./intro.md)
|
||||
- [Virtual Keys](../proxy/virtual_keys.md)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
@@ -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",
|
||||
|
||||
@@ -383,6 +383,7 @@ class LiteLLMRoutes(enum.Enum):
|
||||
"/vertex-ai",
|
||||
"/vertex_ai",
|
||||
"/cohere",
|
||||
"/cursor",
|
||||
"/gemini",
|
||||
"/anthropic",
|
||||
"/langfuse",
|
||||
|
||||
@@ -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,
|
||||
|
||||
+141
@@ -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,
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
+116
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Cursor</title><path d="M22.106 5.68L12.5.135a.998.998 0 00-.998 0L1.893 5.68a.84.84 0 00-.419.726v11.186c0 .3.16.577.42.727l9.607 5.547a.999.999 0 00.998 0l9.608-5.547a.84.84 0 00.42-.727V6.407a.84.84 0 00-.42-.726zm-.603 1.176L12.228 22.92c-.063.108-.228.064-.228-.061V12.34a.59.59 0 00-.295-.51l-9.11-5.26c-.107-.062-.063-.228.062-.228h18.55c.264 0 .428.286.296.514z"></path></svg>
|
||||
|
After Width: | Height: | Size: 548 B |
@@ -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<string, string> = {
|
||||
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<string, string> = {
|
||||
[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";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user