diff --git a/better-agent-gateway/frontend/src/App.css b/better-agent-gateway/frontend/src/App.css index 131da32..b308cbf 100644 --- a/better-agent-gateway/frontend/src/App.css +++ b/better-agent-gateway/frontend/src/App.css @@ -387,3 +387,132 @@ header h1 { .perm-error { background: #fff5f5; } + +/* --- CursorConfig --- */ + +.cursor-config { + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 8px; + padding: 1.25rem 1.5rem; + margin-bottom: 2rem; +} + +.cursor-config h2 { + font-size: 1.1rem; + margin: 0 0 0.75rem; +} + +.cursor-config-steps { + font-size: 0.9rem; + color: #495057; + margin: 0 0 1rem; + padding-left: 1.4rem; + line-height: 1.7; +} + +.cursor-config-steps code { + background: #f0f0f0; + padding: 0.15em 0.4em; + border-radius: 3px; + font-size: 0.88em; + font-family: 'SF Mono', 'Fira Code', monospace; +} + +.cursor-config-code { + background: #1e1e1e; + color: #d4d4d4; + border-radius: 6px; + padding: 1rem 1.25rem; + font-family: 'SF Mono', 'Fira Code', monospace; + font-size: 0.82rem; + line-height: 1.5; + overflow-x: auto; + margin: 0 0 1rem; + white-space: pre; +} + +.cursor-config-code code { + background: none; + color: inherit; + font-size: inherit; + font-family: inherit; + padding: 0; +} + +.cursor-config-actions { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.copy-btn { + padding: 0.4rem 1rem; + border: 1px solid #0d6efd; + background: #0d6efd; + color: #fff; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: background 0.15s; +} + +.copy-btn:hover { + background: #0b5ed7; +} + +.copy-btn.copied { + background: #155724; + border-color: #155724; +} + +.cursor-config-validation { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.cursor-config-check { + display: flex; + align-items: baseline; + gap: 0.5rem; + padding: 0.4rem 0.75rem; + border-radius: 6px; + font-size: 0.88rem; +} + +.cursor-config-check .check-icon { + font-weight: 700; + flex-shrink: 0; + width: 1em; + text-align: center; +} + +.cursor-config-check .check-name { + font-weight: 600; +} + +.cursor-config-check .check-message { + color: inherit; + opacity: 0.85; + font-size: 0.85em; +} + +.cursor-config-check.pass { + background: #d4edda; + color: #155724; +} + +.cursor-config-check.fail { + background: #f8d7da; + color: #721c24; +} + +.cursor-config-check.info { + background: #f0f4ff; + color: #1a3a6b; +} diff --git a/better-agent-gateway/frontend/src/App.tsx b/better-agent-gateway/frontend/src/App.tsx index 7b01d79..4944bb5 100644 --- a/better-agent-gateway/frontend/src/App.tsx +++ b/better-agent-gateway/frontend/src/App.tsx @@ -2,6 +2,7 @@ import { ModelTable } from './components/ModelTable' import { HealthBadge } from './components/HealthBadge' import { UserInfo } from './components/UserInfo' import { PermissionComparison } from './components/PermissionComparison' +import { CursorConfig } from './components/CursorConfig' import './App.css' function App() { @@ -21,6 +22,7 @@ function App() { + diff --git a/better-agent-gateway/frontend/src/components/CursorConfig.tsx b/better-agent-gateway/frontend/src/components/CursorConfig.tsx new file mode 100644 index 0000000..6521b06 --- /dev/null +++ b/better-agent-gateway/frontend/src/components/CursorConfig.tsx @@ -0,0 +1,127 @@ +import { useEffect, useState } from 'react' + +interface CursorConfigResponse { + instructions: string + config: { + apiKey: string + baseUrl: string + models: string[] + } + notes: string[] +} + +interface ValidationCheck { + name: string + status: 'pass' | 'fail' | 'info' + detail: string +} + +interface ValidationResponse { + valid: boolean + checks: ValidationCheck[] +} + +export function CursorConfig() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [copied, setCopied] = useState(false) + const [validation, setValidation] = useState(null) + const [validating, setValidating] = useState(false) + const [validationError, setValidationError] = useState(null) + + useEffect(() => { + fetch('/api/v1/config/cursor') + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`) + return r.json() + }) + .then(setData) + .catch((e) => setError(e.message)) + }, []) + + const handleCopy = () => { + if (!data) return + const text = JSON.stringify(data.config, null, 2) + navigator.clipboard.writeText(text).then(() => { + setCopied(true) + setTimeout(() => setCopied(false), 2000) + }) + } + + const handleValidate = () => { + setValidating(true) + setValidation(null) + setValidationError(null) + fetch('/api/v1/config/cursor/validate', { method: 'POST' }) + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`) + return r.json() + }) + .then(setValidation) + .catch((e) => setValidationError(e.message)) + .finally(() => setValidating(false)) + } + + if (error) return
Failed to load Cursor config: {error}
+ if (!data) return
Loading Cursor config...
+ + const configJson = JSON.stringify(data.config, null, 2) + + return ( +
+

Cursor IDE Setup

+ +
    +
  1. Open Cursor Settings (Cmd+,)
  2. +
  3. Go to Models > OpenAI API Compatible
  4. +
  5. Paste the configuration below
  6. +
  7. Click Validate Connection to verify
  8. +
+ +
{configJson}
+ +
    + {data.notes.map((note, i) => ( +
  • {note}
  • + ))} +
+ +
+ + +
+ + {validationError && ( +
Validation failed: {validationError}
+ )} + + {validation && ( +
    + {validation.checks.map((check, i) => { + const icon = check.status === 'pass' ? '✓' : check.status === 'fail' ? '✗' : 'ℹ' + return ( +
  • + {icon} + {check.name} + {check.detail && ( + {check.detail} + )} +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/better-agent-gateway/server/routes/__init__.py b/better-agent-gateway/server/routes/__init__.py index 2d1aad8..7374eda 100644 --- a/better-agent-gateway/server/routes/__init__.py +++ b/better-agent-gateway/server/routes/__init__.py @@ -3,6 +3,7 @@ from .audit_routes import router as audit_router from .auth import router as auth_router from .chat import router as chat_router +from .cursor_config import router as cursor_config_router from .health import router as health_router from .models import router as models_router from .permissions import router as permissions_router @@ -12,6 +13,7 @@ api_router.include_router(audit_router, tags=["audit"]) api_router.include_router(auth_router, tags=["auth"]) api_router.include_router(chat_router, tags=["chat"]) +api_router.include_router(cursor_config_router, tags=["config"]) api_router.include_router(health_router, tags=["health"]) api_router.include_router(models_router, tags=["models"]) api_router.include_router(permissions_router, tags=["permissions"]) diff --git a/better-agent-gateway/server/routes/cursor_config.py b/better-agent-gateway/server/routes/cursor_config.py new file mode 100644 index 0000000..2a2f048 --- /dev/null +++ b/better-agent-gateway/server/routes/cursor_config.py @@ -0,0 +1,114 @@ +from fastapi import APIRouter, Request + +router = APIRouter() + + +def _get_alias_registry(): + from ..alias_registry import get_registry + return get_registry() + + +def _get_gateway_base_url(request: Request) -> str: + """Derive the public-facing gateway URL. + + Priority: DATABRICKS_APP_URL env var (set by the Databricks App runtime), + then X-Forwarded-Host proxy header, then the request's own base URL. + """ + import os + + app_url = os.getenv("DATABRICKS_APP_URL") + if app_url: + return app_url.rstrip("/") + + forwarded_host = request.headers.get("x-forwarded-host") + forwarded_proto = request.headers.get("x-forwarded-proto", "https") + if forwarded_host: + return f"{forwarded_proto}://{forwarded_host}" + + base = request.base_url + return f"{base.scheme}://{base.netloc}" + + +@router.get("/v1/config/cursor") +def get_cursor_config(request: Request): + registry = _get_alias_registry() + aliases = registry.list_aliases() + model_names = sorted(aliases.keys()) + + gateway_url = _get_gateway_base_url(request) + + return { + "instructions": "Copy this configuration into Cursor Settings > Models > OpenAI API Compatible", + "config": { + "apiKey": "databricks-oauth", + "baseUrl": f"{gateway_url}/api/v1", + "models": model_names, + }, + "notes": [ + "Authentication: Visit the gateway URL in your browser first to establish a session, OR use a Databricks OAuth token as the API key", + "The apiKey value shown is a placeholder — Cursor requires a non-empty value but the gateway authenticates via your browser session or Bearer token", + "Models listed are auto-discovered aliases that resolve to the latest version of each model family", + ], + } + + +@router.post("/v1/config/cursor/validate") +def validate_cursor_config(request: Request): + checks = [] + + # Check 1: gateway health (inline — same process) + checks.append({ + "name": "gateway_health", + "status": "pass", + "detail": "Gateway is healthy", + }) + + # Check 2: models available + registry = _get_alias_registry() + aliases = registry.list_aliases() + alias_count = len(aliases) + if alias_count > 0: + checks.append({ + "name": "models_available", + "status": "pass", + "detail": f"Found {alias_count} model aliases", + }) + else: + checks.append({ + "name": "models_available", + "status": "fail", + "detail": "No model aliases found — registry may not be populated yet", + }) + + # Check 3: auth configured + from ..auth import extract_request_context + x_forwarded_access_token = request.headers.get("x-forwarded-access-token") + x_forwarded_user_id = request.headers.get("x-forwarded-user-id") + x_forwarded_email = request.headers.get("x-forwarded-email") + authorization = request.headers.get("authorization") + + try: + ctx = extract_request_context( + x_forwarded_access_token=x_forwarded_access_token, + x_forwarded_user_id=x_forwarded_user_id, + x_forwarded_email=x_forwarded_email, + authorization=authorization, + ) + checks.append({ + "name": "auth_configured", + "status": "pass", + "detail": f"Authenticated as {ctx.user_id} via {ctx.auth_mode}", + }) + except ValueError: + checks.append({ + "name": "auth_configured", + "status": "info", + "detail": "No authentication detected — visit the gateway URL in your browser or provide a Bearer token", + }) + + overall_valid = all(c["status"] in ("pass", "info") for c in checks) + + return { + "valid": overall_valid, + "checks": checks, + } diff --git a/better-agent-gateway/server/routes/models.py b/better-agent-gateway/server/routes/models.py index 5a2024f..be586cc 100644 --- a/better-agent-gateway/server/routes/models.py +++ b/better-agent-gateway/server/routes/models.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter +from fastapi import APIRouter, Request router = APIRouter() @@ -11,7 +11,61 @@ def _get_alias_registry(): @router.get("/v1/models") def list_models(): registry = _get_alias_registry() + aliases = registry.list_aliases() + endpoints = registry.list_endpoints() + + # Build OpenAI-standard model objects for all endpoints and aliases + seen: set[str] = set() + data: list[dict] = [] + + for alias_name in sorted(aliases.keys()): + if alias_name not in seen: + seen.add(alias_name) + data.append({ + "id": alias_name, + "object": "model", + "created": 0, + "owned_by": "databricks", + }) + + for ep_name in endpoints: + if ep_name not in seen: + seen.add(ep_name) + data.append({ + "id": ep_name, + "object": "model", + "created": 0, + "owned_by": "databricks", + }) + + return { + "object": "list", + "data": data, + "aliases": aliases, + "endpoints": endpoints, + } + + +@router.get("/v1/config/cursor") +def cursor_config(request: Request): + """Return a Cursor-compatible configuration snippet. + + Lists all available model IDs (aliases + endpoints) so Cursor + users can configure the gateway as an OpenAI-compatible provider. + """ + registry = _get_alias_registry() + aliases = registry.list_aliases() + endpoints = registry.list_endpoints() + + # Collect all usable model names: aliases first, then endpoints + models: list[str] = sorted(aliases.keys()) + [ + ep for ep in endpoints if ep not in aliases.values() + ] + + base_url = str(request.base_url).rstrip("/") + "/api/v1" + return { - "aliases": registry.list_aliases(), - "endpoints": registry.list_endpoints(), + "models": models, + "base_url": base_url, + "provider": "databricks", } diff --git a/better-agent-gateway/tests/test_cursor_config.py b/better-agent-gateway/tests/test_cursor_config.py new file mode 100644 index 0000000..7b1f70c --- /dev/null +++ b/better-agent-gateway/tests/test_cursor_config.py @@ -0,0 +1,117 @@ +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from fastapi.testclient import TestClient + +from app import app + +client = TestClient(app) + + +def test_get_cursor_config_returns_expected_structure(): + response = client.get("/api/v1/config/cursor") + assert response.status_code == 200 + data = response.json() + assert "instructions" in data + assert "config" in data + assert "notes" in data + + config = data["config"] + assert "apiKey" in config + assert "baseUrl" in config + assert "models" in config + assert isinstance(config["models"], list) + + +def test_get_cursor_config_base_url_uses_request_host(): + response = client.get("/api/v1/config/cursor") + assert response.status_code == 200 + base_url = response.json()["config"]["baseUrl"] + assert base_url.endswith("/api/v1") + assert base_url.startswith("http") + + +def test_get_cursor_config_base_url_respects_forwarded_headers(): + response = client.get( + "/api/v1/config/cursor", + headers={ + "x-forwarded-host": "my-gateway.aws.databricksapps.com", + "x-forwarded-proto": "https", + }, + ) + assert response.status_code == 200 + base_url = response.json()["config"]["baseUrl"] + assert base_url == "https://my-gateway.aws.databricksapps.com/api/v1" + + +def test_get_cursor_config_base_url_prefers_databricks_app_url(monkeypatch): + monkeypatch.setenv("DATABRICKS_APP_URL", "https://my-app.aws.databricksapps.com") + response = client.get("/api/v1/config/cursor") + assert response.status_code == 200 + base_url = response.json()["config"]["baseUrl"] + assert base_url == "https://my-app.aws.databricksapps.com/api/v1" + + +def test_get_cursor_config_notes_is_list(): + response = client.get("/api/v1/config/cursor") + assert response.status_code == 200 + notes = response.json()["notes"] + assert isinstance(notes, list) + assert len(notes) > 0 + + +def test_validate_cursor_config_returns_valid_structure(): + response = client.post("/api/v1/config/cursor/validate") + assert response.status_code == 200 + data = response.json() + assert "valid" in data + assert "checks" in data + assert isinstance(data["valid"], bool) + assert isinstance(data["checks"], list) + + +def test_validate_cursor_config_has_required_checks(): + response = client.post("/api/v1/config/cursor/validate") + assert response.status_code == 200 + checks = response.json()["checks"] + check_names = {c["name"] for c in checks} + assert "gateway_health" in check_names + assert "models_available" in check_names + assert "auth_configured" in check_names + + +def test_validate_cursor_config_check_structure(): + response = client.post("/api/v1/config/cursor/validate") + assert response.status_code == 200 + for check in response.json()["checks"]: + assert "name" in check + assert "status" in check + assert "detail" in check + assert check["status"] in ("pass", "fail", "info") + + +def test_validate_cursor_config_gateway_health_passes(): + response = client.post("/api/v1/config/cursor/validate") + assert response.status_code == 200 + checks = {c["name"]: c for c in response.json()["checks"]} + assert checks["gateway_health"]["status"] == "pass" + + +def test_validate_cursor_config_auth_info_without_token(): + response = client.post("/api/v1/config/cursor/validate") + assert response.status_code == 200 + checks = {c["name"]: c for c in response.json()["checks"]} + # Without auth headers, auth_configured should be "info" (not a hard failure) + assert checks["auth_configured"]["status"] in ("pass", "info") + + +def test_validate_cursor_config_auth_pass_with_bearer_token(): + response = client.post( + "/api/v1/config/cursor/validate", + headers={"Authorization": "Bearer fake-test-token"}, + ) + assert response.status_code == 200 + checks = {c["name"]: c for c in response.json()["checks"]} + assert checks["auth_configured"]["status"] == "pass" diff --git a/better-agent-gateway/tests/test_openai_compat.py b/better-agent-gateway/tests/test_openai_compat.py new file mode 100644 index 0000000..9e9f29c --- /dev/null +++ b/better-agent-gateway/tests/test_openai_compat.py @@ -0,0 +1,134 @@ +"""Tests for OpenAI-compatible /v1/models response format. + +Cursor and the OpenAI SDK expect the standard OpenAI list format: + {"object": "list", "data": [{"id": "...", "object": "model", ...}]} + +These tests verify our /v1/models endpoint returns this format while +also preserving backward-compatible aliases/endpoints metadata. +""" +from fastapi.testclient import TestClient + +from app import app + +client = TestClient(app) + + +def test_models_response_has_openai_list_format(): + """GET /api/v1/models returns top-level 'object' and 'data' keys.""" + response = client.get("/api/v1/models") + assert response.status_code == 200 + body = response.json() + assert body["object"] == "list", "top-level 'object' must be 'list'" + assert isinstance(body["data"], list), "'data' must be a list" + + +def test_models_data_items_have_openai_model_schema(): + """Each item in 'data' must have id, object, created, owned_by.""" + # Seed the registry so there's at least one model + from server.alias_registry import get_registry + registry = get_registry() + registry.refresh_from_endpoints([ + {"name": "databricks-claude-sonnet-4-6", "state": {"ready": "READY"}}, + ]) + + response = client.get("/api/v1/models") + body = response.json() + assert len(body["data"]) > 0, "data should contain at least one model" + + for model in body["data"]: + assert "id" in model, "each model must have 'id'" + assert model["object"] == "model", "each model object must be 'model'" + assert "created" in model, "each model must have 'created'" + assert "owned_by" in model, "each model must have 'owned_by'" + + +def test_models_includes_aliases_and_endpoints(): + """Both aliases (as model entries) and raw endpoints appear in data.""" + from server.alias_registry import get_registry + registry = get_registry() + registry.refresh_from_endpoints([ + {"name": "databricks-claude-sonnet-4-6", "state": {"ready": "READY"}}, + ]) + + response = client.get("/api/v1/models") + body = response.json() + model_ids = {m["id"] for m in body["data"]} + # The endpoint itself should appear + assert "databricks-claude-sonnet-4-6" in model_ids + # The alias (claude-sonnet-latest) should also appear + assert "claude-sonnet-latest" in model_ids + + +def test_models_preserves_backward_compat_keys(): + """Response still includes 'aliases' and 'endpoints' for the frontend.""" + from server.alias_registry import get_registry + registry = get_registry() + registry.refresh_from_endpoints([ + {"name": "databricks-claude-sonnet-4-6", "state": {"ready": "READY"}}, + ]) + + response = client.get("/api/v1/models") + body = response.json() + assert "aliases" in body, "backward-compat 'aliases' key must be present" + assert "endpoints" in body, "backward-compat 'endpoints' key must be present" + assert body["aliases"]["claude-sonnet-latest"] == "databricks-claude-sonnet-4-6" + assert "databricks-claude-sonnet-4-6" in body["endpoints"] + + +def test_openai_sdk_models_response_format(): + """Validate the response can be parsed as an OpenAI models list response. + + The OpenAI SDK expects: {"object": "list", "data": [...]} where each + data item has at minimum: id, object, created, owned_by. + """ + from server.alias_registry import get_registry + registry = get_registry() + registry.refresh_from_endpoints([ + {"name": "databricks-claude-sonnet-4-6", "state": {"ready": "READY"}}, + {"name": "databricks-gpt-4o", "state": {"ready": "READY"}}, + ]) + + response = client.get("/api/v1/models") + body = response.json() + + # Validate full OpenAI schema + assert body["object"] == "list" + assert isinstance(body["data"], list) + assert len(body["data"]) >= 2 # at least the 2 endpoints + + for item in body["data"]: + assert isinstance(item["id"], str) + assert item["object"] == "model" + assert isinstance(item["created"], int) + assert isinstance(item["owned_by"], str) + + +def test_cursor_config_models_match_openai_models(): + """Fetch cursor config, build an OpenAI-style client config, and verify + the models listed in cursor config appear in /v1/models.""" + from server.alias_registry import get_registry + registry = get_registry() + registry.refresh_from_endpoints([ + {"name": "databricks-claude-sonnet-4-6", "state": {"ready": "READY"}}, + ]) + + # Get cursor config + config_resp = client.get("/api/v1/config/cursor") + assert config_resp.status_code == 200, "cursor config endpoint must exist" + config = config_resp.json() + + # Cursor config should list models (nested under config.models) + assert "config" in config, "cursor config must include 'config'" + assert "models" in config["config"], "cursor config.config must include 'models'" + cursor_models = config["config"]["models"] + assert len(cursor_models) > 0, "cursor config must list at least one model" + + # Now verify those models appear in /v1/models + models_resp = client.get("/api/v1/models") + models_body = models_resp.json() + available_ids = {m["id"] for m in models_body["data"]} + + for model_name in cursor_models: + assert model_name in available_ids, ( + f"cursor config model '{model_name}' must appear in /v1/models data" + )