From bcec56b2f4edfd19334ceaafd3c9a859a758e48f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 23:21:08 +0000 Subject: [PATCH 1/2] Add Cursor Cloud Agents as a first-class AI provider Wire dashboard crsr_ keys to the Cloud Agents API using short-lived no-repo agents for digests and drafts. Split OpenAI-compatible gateways into a separate provider, with legacy cursor+gateway settings mapped automatically. Test probes /v1/me for fast key validation. Co-authored-by: Damon --- CHANGELOG.md | 4 +- apps/api/src/ai.cursor.test.ts | 105 +++++++++++++- apps/api/src/ai.ts | 102 +++++++++++--- apps/api/src/aiEstimate.ts | 5 +- apps/api/src/cursorCloud.ts | 219 +++++++++++++++++++++++++++++ apps/api/src/index.ts | 41 ++++-- apps/ui/src/pages/SettingsPage.tsx | 86 ++++++++--- docs/ai/AI_INTEGRATION.md | 8 +- docs/product/FEATURES.md | 2 +- 9 files changed, 519 insertions(+), 53 deletions(-) create mode 100644 apps/api/src/cursorCloud.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fb99904..eaea6e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,17 +12,19 @@ Versions follow [SemVer](https://semver.org/) (`0.1.0-alpha.x` while the public - Dedicated **Desktop Installers** and **Release** GitHub Actions workflows - Public-release checklist (`docs/PUBLIC_RELEASE.md`) and clearer vulnerability reporting guidance - Default **PE Engineering Career Framework** (Graduate → CTO) seeded on empty and demo workspaces +- **Cursor Cloud Agents** AI provider — use dashboard `crsr_…` keys via the Cloud Agents API (no-repo agents for digests/drafts) ### Changed - Settings → Check for updates explains missing GitHub Releases (404) instead of a generic failure - MCP / AGENTS docs no longer imply a silent `PRM_PASSWORD=workbench` default +- Settings → AI: OpenAI-compatible gateways are a separate provider from Cursor Cloud Agents ### Fixed - Committed Tauri `gen/schemas/capabilities.json` aligned with source capabilities (no `shell:allow-open`) - Windows packaging uses NSIS only — WiX `.msi` rejects semver pre-releases like `0.1.0-alpha.1` -- Desktop AI: stop implying Cursor `crsr_` keys are a chat API; clearer network errors; Test saves then probes with visible status +- Desktop AI: clearer network errors; Test saves then probes with visible status - Empty-workspace onboarding: optional EM name at create, single setup checklist, less Home clutter - Settings / long-page scroll jank: section nav no longer forces layout on scroll; drop sticky chrome backdrop blur diff --git a/apps/api/src/ai.cursor.test.ts b/apps/api/src/ai.cursor.test.ts index 0fe1b95..5f1f5ef 100644 --- a/apps/api/src/ai.cursor.test.ts +++ b/apps/api/src/ai.cursor.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { defaultCursorBaseUrl, resolveCursorBaseUrl } from "../src/ai.js"; +import { defaultCursorBaseUrl, normalizeAiProvider, resolveCursorBaseUrl } from "../src/ai.js"; +import { formatCloudAgentPrompt, runCursorCloudAgent } from "../src/cursorCloud.js"; describe("cursor / openai-compatible provider base URL", () => { it("does not invent a silent local gateway when unset", () => { @@ -34,3 +35,105 @@ describe("cursor / openai-compatible provider base URL", () => { } }); }); + +describe("normalizeAiProvider", () => { + it("maps bare cursor to Cloud Agents", () => { + assert.equal(normalizeAiProvider("cursor", null), "cursor"); + }); + + it("maps legacy cursor + gateway to openai_compatible", () => { + assert.equal(normalizeAiProvider("cursor", "https://gw.example/v1"), "openai_compatible"); + }); + + it("keeps explicit openai_compatible", () => { + assert.equal(normalizeAiProvider("openai_compatible", "https://gw.example/v1"), "openai_compatible"); + }); +}); + +describe("Cursor Cloud Agents client", () => { + it("formats a grounded prompt", () => { + const text = formatCloudAgentPrompt( + [ + { role: "system", content: "Cite ids." }, + { role: "user", content: "Summarize [ach_1]." }, + ], + "evidence_digest", + ); + assert.match(text, /feature: evidence_digest/); + assert.match(text, /=== SYSTEM ===/); + assert.match(text, /Cite ids/); + assert.match(text, /Summarize \[ach_1\]/); + }); + + it("creates a no-repo agent, polls to FINISHED, archives", async () => { + const calls: Array<{ url: string; method: string; body?: unknown }> = []; + let poll = 0; + const fetchFn: typeof fetch = async (input, init) => { + const url = String(input); + const method = (init?.method ?? "GET").toUpperCase(); + const body = init?.body ? JSON.parse(String(init.body)) : undefined; + calls.push({ url, method, body }); + + if (method === "POST" && url.endsWith("/agents")) { + assert.equal(body.prompt.text.includes("ping"), true); + assert.equal(body.repos, undefined); + return new Response( + JSON.stringify({ + agent: { + id: "bc-1", + url: "https://cursor.com/agents/bc-1", + latestRunId: "run-1", + }, + run: { id: "run-1", agentId: "bc-1", status: "CREATING" }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + if (method === "GET" && url.includes("/runs/run-1")) { + poll += 1; + const status = poll === 1 ? "RUNNING" : "FINISHED"; + return new Response( + JSON.stringify({ + id: "run-1", + agentId: "bc-1", + status, + result: status === "FINISHED" ? "OK from cloud" : null, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + if (method === "POST" && url.endsWith("/archive")) { + return new Response(JSON.stringify({ id: "bc-1", status: "ARCHIVED" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + return new Response("unexpected", { status: 500 }); + }; + + let t = 0; + const result = await runCursorCloudAgent( + { + apiKey: "crsr_test", + messages: [{ role: "user", content: "ping" }], + feature: "evidence_digest", + model: "composer-2", + pollMs: 1, + timeoutMs: 5_000, + }, + { + fetchFn, + sleep: async () => undefined, + now: () => { + t += 10; + return t; + }, + }, + ); + + assert.equal(result.text, "OK from cloud"); + assert.equal(result.model, "cursor-cloud:composer-2"); + assert.equal(result.agentId, "bc-1"); + assert.ok(calls.some((c) => c.method === "POST" && c.url.endsWith("/archive"))); + }); +}); diff --git a/apps/api/src/ai.ts b/apps/api/src/ai.ts index 50288ac..9f755fe 100644 --- a/apps/api/src/ai.ts +++ b/apps/api/src/ai.ts @@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"; import { aiSettings } from "@prm/db"; import { decryptSecret, getDb, getWorkspaceSecret, id, logActivity, nowIso } from "./store.js"; import { aiGenerations } from "@prm/db"; +import { CURSOR_CLOUD_API_BASE, cursorCloudMe, runCursorCloudAgent } from "./cursorCloud.js"; export type AiFeature = | "evidence_digest" @@ -17,6 +18,9 @@ export type AiFeature = export type ChatMessage = { role: "system" | "user" | "assistant"; content: string }; +/** Providers stored in ai_settings.provider (plus legacy aliases). */ +export type AiProviderId = "anthropic" | "openai" | "cursor" | "openai_compatible" | "ollama"; + function formatFetchFailure(label: string, baseUrl: string, err: unknown): Error { const detail = err instanceof Error ? err.message : String(err); const cause = @@ -30,9 +34,7 @@ function formatFetchFailure(label: string, baseUrl: string, err: unknown): Error /fetch failed|ECONNREFUSED|ENOTFOUND|ECONNRESET|network|socket|timed out/i.test(combined); if (isNetwork) { return new Error( - `${label} could not reach ${baseUrl} (${combined || "network error"}). ` + - `Cursor dashboard keys (crsr_…) are for Cloud Agents — they are not a chat API. ` + - `Use Anthropic, OpenAI, or Ollama, or point this field at a running OpenAI-compatible /v1 gateway.`, + `${label} could not reach ${baseUrl} (${combined || "network error"}). Check the URL and that the service is running.`, ); } return new Error(`${label} request failed: ${combined || "unknown error"}`); @@ -137,11 +139,43 @@ export function resolveCursorBaseUrl(stored: string | null | undefined): string return defaultCursorBaseUrl(); } +/** + * Normalize stored provider id. + * Legacy: `provider=cursor` + a custom gateway URL meant OpenAI-compatible chat — + * map that to `openai_compatible`. Bare `cursor` means Cloud Agents (`crsr_` keys). + */ +export function normalizeAiProvider( + stored: string | null | undefined, + gatewayBaseUrl: string | null, +): AiProviderId { + const raw = (stored ?? "anthropic").trim(); + if (raw === "openai_compatible") return "openai_compatible"; + if (raw === "openai") return "openai"; + if (raw === "ollama") return "ollama"; + if (raw === "anthropic") return "anthropic"; + if (raw === "cursor") { + if (gatewayBaseUrl) return "openai_compatible"; + return "cursor"; + } + return "anthropic"; +} + +function defaultModelsFor(provider: AiProviderId): { digest: string; draft: string } { + if (provider === "cursor") return { digest: "", draft: "" }; // Cursor account default + if (provider === "openai" || provider === "openai_compatible") { + return { digest: "gpt-4o-mini", draft: "gpt-4o-mini" }; + } + if (provider === "ollama") return { digest: "llama3.1", draft: "llama3.1" }; + return { digest: "claude-sonnet-4-5", draft: "claude-sonnet-4-5" }; +} + export function getAiConfig() { const db = getDb(); const row = db.select().from(aiSettings).where(eq(aiSettings.id, "default")).all()[0]; const secret = getWorkspaceSecret(); - const provider = row?.provider ?? "anthropic"; + const gatewayBaseUrl = resolveCursorBaseUrl(row?.ollamaBaseUrl); + const provider = normalizeAiProvider(row?.provider, gatewayBaseUrl); + const defaults = defaultModelsFor(provider); let apiKey: string | null = null; if (row?.apiKeyEncrypted && secret) { try { @@ -150,23 +184,42 @@ export function getAiConfig() { apiKey = null; } } - if (!apiKey && provider === "cursor") { + if (!apiKey && (provider === "cursor" || provider === "openai_compatible")) { apiKey = process.env.CURSOR_API_KEY?.trim() || null; } return { enabled: Boolean(row?.enabled), + /** Effective provider after legacy migration. */ provider, - modelDigest: row?.modelDigest ?? (provider === "cursor" ? "gpt-4o-mini" : "claude-sonnet-4-5"), - modelDraft: row?.modelDraft ?? (provider === "cursor" ? "gpt-4o-mini" : "claude-sonnet-4-5"), + /** Raw value in DB (may still be legacy `cursor` + gateway). */ + storedProvider: row?.provider ?? "anthropic", + modelDigest: row?.modelDigest ?? defaults.digest, + modelDraft: row?.modelDraft ?? defaults.draft, localOnly: Boolean(row?.localOnly), ollamaBaseUrl: row?.ollamaBaseUrl ?? DEFAULT_OLLAMA_BASE, - cursorBaseUrl: resolveCursorBaseUrl(row?.ollamaBaseUrl), + /** OpenAI-compatible gateway base (null when using Cloud Agents / Anthropic / etc.). */ + openaiCompatibleBaseUrl: provider === "openai_compatible" ? gatewayBaseUrl : null, + /** @deprecated alias — same as openaiCompatibleBaseUrl */ + cursorBaseUrl: provider === "openai_compatible" ? gatewayBaseUrl : null, privateNotesEgress: Boolean(row?.privateNotesEgress), meetingSummaryEgress: Boolean(row?.meetingSummaryEgress), documentExtractEgress: Boolean(row?.documentExtractEgress), apiKey, hasApiKey: Boolean(apiKey), - apiKeyFromEnv: provider === "cursor" && !row?.apiKeyEncrypted && Boolean(process.env.CURSOR_API_KEY?.trim()), + apiKeyFromEnv: + (provider === "cursor" || provider === "openai_compatible") && + !row?.apiKeyEncrypted && + Boolean(process.env.CURSOR_API_KEY?.trim()), + }; +} + +export async function probeCursorCloudKey(apiKey: string) { + const me = await cursorCloudMe(apiKey); + return { + ok: true as const, + model: `cursor-cloud:${me.apiKeyName ?? "api-key"}`, + sample: me.userEmail ? `Authenticated as ${me.userEmail}` : `Key OK (${me.apiKeyName ?? "unnamed"})`, + baseUrlTried: CURSOR_CLOUD_API_BASE, }; } @@ -183,8 +236,10 @@ export async function runChat(feature: AiFeature, messages: ChatMessage[]) { if (!cfg.apiKey) { throw new Error( cfg.provider === "cursor" - ? "No Cursor API key — paste one in Settings or set CURSOR_API_KEY" - : "No API key configured", + ? "No Cursor API key — paste a crsr_… key from cursor.com/dashboard/api (or set CURSOR_API_KEY)" + : cfg.provider === "openai_compatible" + ? "No API key — paste a gateway key in Settings or set CURSOR_API_KEY" + : "No API key configured", ); } const model = feature === "evidence_digest" ? cfg.modelDigest : cfg.modelDraft; @@ -192,21 +247,34 @@ export async function runChat(feature: AiFeature, messages: ChatMessage[]) { const text = await callOpenAI(cfg.apiKey, model || "gpt-4o-mini", messages); return { text, model: `openai:${model}`, provider: "openai" as const }; } - if (cfg.provider === "cursor") { - if (!cfg.cursorBaseUrl) { + if (cfg.provider === "openai_compatible") { + if (!cfg.openaiCompatibleBaseUrl) { throw new Error( - "OpenAI-compatible base URL required. Cursor dashboard API keys (crsr_…) talk to Cloud Agents, not /v1/chat/completions. " + - "Prefer Anthropic, OpenAI, or Ollama — or set a gateway URL that implements OpenAI Chat Completions.", + "OpenAI-compatible base URL required. Set a gateway that implements /v1/chat/completions, " + + "or switch provider to Cursor Cloud Agents to use a crsr_… dashboard key.", ); } const text = await callOpenAI( cfg.apiKey, model || "gpt-4o-mini", messages, - cfg.cursorBaseUrl, + cfg.openaiCompatibleBaseUrl, "OpenAI-compatible", ); - return { text, model: `cursor:${model || "gpt-4o-mini"}`, provider: "cursor" as const }; + return { + text, + model: `openai_compatible:${model || "gpt-4o-mini"}`, + provider: "openai_compatible" as const, + }; + } + if (cfg.provider === "cursor") { + const result = await runCursorCloudAgent({ + apiKey: cfg.apiKey, + messages, + feature, + model: model || null, + }); + return { text: result.text, model: result.model, provider: "cursor" as const }; } const text = await callAnthropic(cfg.apiKey, model || "claude-sonnet-4-5", messages, maxTokens); return { text, model: `anthropic:${model}`, provider: "anthropic" as const }; diff --git a/apps/api/src/aiEstimate.ts b/apps/api/src/aiEstimate.ts index dd3f30c..aa32de0 100644 --- a/apps/api/src/aiEstimate.ts +++ b/apps/api/src/aiEstimate.ts @@ -17,6 +17,7 @@ const RATES: Record = { anthropic: { input: 3, output: 15 }, openai: { input: 0.15, output: 0.6 }, cursor: null, + openai_compatible: null, ollama: null, }; @@ -47,7 +48,7 @@ export function buildAiEstimate(opts: { const model = opts.feature === "evidence_digest" ? cfg.modelDigest - : cfg.modelDraft || (provider === "cursor" ? "composer-2.5" : "claude-sonnet-4-5"); + : cfg.modelDraft || (provider === "cursor" ? "default" : "claude-sonnet-4-5"); const inputTokensApprox = estimateTokensFromMessages(opts.messages); const outputTokensMax = maxOutputTokensForFeature(opts.feature); const costUsdApprox = estimateCostUsd({ @@ -66,7 +67,7 @@ export function buildAiEstimate(opts: { dataClassesSent: opts.dataClassesSent ?? [], note: costUsdApprox == null - ? "Token estimate only — Cursor/Ollama pricing is not modeled." + ? "Token estimate only — Cursor Cloud Agents / OpenAI-compatible / Ollama pricing is not modeled." : "Approximate list-price estimate; actual bill depends on provider discounts and true tokenizer.", }; } diff --git a/apps/api/src/cursorCloud.ts b/apps/api/src/cursorCloud.ts new file mode 100644 index 0000000..f677e5c --- /dev/null +++ b/apps/api/src/cursorCloud.ts @@ -0,0 +1,219 @@ +/** + * Cursor Cloud Agents API client (https://api.cursor.com/v1). + * Dashboard keys (`crsr_…`) authenticate here — not OpenAI chat completions. + * + * PRM uses no-repo agents so digests/drafts can run without a GitHub repo. + * @see https://cursor.com/docs/cloud-agent/api/endpoints + */ + +export const CURSOR_CLOUD_API_BASE = "https://api.cursor.com/v1"; + +export type CursorCloudRunStatus = + | "CREATING" + | "RUNNING" + | "FINISHED" + | "ERROR" + | "CANCELLED" + | "EXPIRED"; + +export type CursorCloudAgentCreateResponse = { + agent: { id: string; name?: string; url?: string; latestRunId?: string }; + run: { id: string; agentId: string; status: CursorCloudRunStatus }; +}; + +export type CursorCloudRun = { + id: string; + agentId: string; + status: CursorCloudRunStatus; + result?: string | null; + durationMs?: number | null; +}; + +export type CursorCloudMe = { + apiKeyName?: string; + userEmail?: string | null; + createdAt?: string; +}; + +export type CursorCloudDeps = { + fetchFn?: typeof fetch; + sleep?: (ms: number) => Promise; + now?: () => number; +}; + +const TERMINAL: ReadonlySet = new Set([ + "FINISHED", + "ERROR", + "CANCELLED", + "EXPIRED", +]); + +function authHeaders(apiKey: string): HeadersInit { + return { + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + }; +} + +async function readError(res: Response): Promise { + const text = await res.text(); + return text.slice(0, 400) || res.statusText; +} + +export function formatCloudAgentPrompt( + messages: Array<{ role: string; content: string }>, + feature: string, +): string { + const parts = [ + `You are answering a single request from Performance Review Manager (feature: ${feature}).`, + "This is a no-repo text task: reply with the final answer only.", + "Do not invent evidence. Follow any citation / grounding rules in the system instructions.", + "", + ]; + for (const m of messages) { + const label = + m.role === "system" ? "SYSTEM" : m.role === "assistant" ? "ASSISTANT" : "USER"; + parts.push(`=== ${label} ===`, m.content.trim(), ""); + } + return parts.join("\n").trim(); +} + +export async function cursorCloudMe( + apiKey: string, + deps: CursorCloudDeps = {}, +): Promise { + const fetchFn = deps.fetchFn ?? fetch; + const res = await fetchFn(`${CURSOR_CLOUD_API_BASE}/me`, { + method: "GET", + headers: authHeaders(apiKey), + }); + if (!res.ok) { + throw new Error(`Cursor Cloud Agents auth failed: ${res.status} ${await readError(res)}`); + } + return (await res.json()) as CursorCloudMe; +} + +async function createNoRepoAgent( + apiKey: string, + opts: { prompt: string; model?: string | null; name?: string }, + deps: CursorCloudDeps, +): Promise { + const fetchFn = deps.fetchFn ?? fetch; + const body: Record = { + prompt: { text: opts.prompt }, + name: (opts.name ?? "PRM AI").slice(0, 100), + }; + const modelId = opts.model?.trim(); + if (modelId) body.model = { id: modelId }; + + const res = await fetchFn(`${CURSOR_CLOUD_API_BASE}/agents`, { + method: "POST", + headers: authHeaders(apiKey), + body: JSON.stringify(body), + }); + if (!res.ok) { + throw new Error(`Cursor Cloud Agents create failed: ${res.status} ${await readError(res)}`); + } + return (await res.json()) as CursorCloudAgentCreateResponse; +} + +async function getRun( + apiKey: string, + agentId: string, + runId: string, + deps: CursorCloudDeps, +): Promise { + const fetchFn = deps.fetchFn ?? fetch; + const res = await fetchFn(`${CURSOR_CLOUD_API_BASE}/agents/${agentId}/runs/${runId}`, { + method: "GET", + headers: authHeaders(apiKey), + }); + if (!res.ok) { + throw new Error(`Cursor Cloud Agents run failed: ${res.status} ${await readError(res)}`); + } + return (await res.json()) as CursorCloudRun; +} + +async function archiveAgent(apiKey: string, agentId: string, deps: CursorCloudDeps): Promise { + const fetchFn = deps.fetchFn ?? fetch; + try { + await fetchFn(`${CURSOR_CLOUD_API_BASE}/agents/${agentId}/archive`, { + method: "POST", + headers: authHeaders(apiKey), + }); + } catch { + // best-effort cleanup + } +} + +export type RunCursorCloudAgentOpts = { + apiKey: string; + messages: Array<{ role: string; content: string }>; + feature: string; + /** Cursor model id from GET /v1/models; omit to use account default. */ + model?: string | null; + /** Polling timeout (ms). Default 180s. */ + timeoutMs?: number; + /** Poll interval (ms). Default 2s. */ + pollMs?: number; +}; + +/** + * Launch a no-repo Cloud Agent, poll until terminal, return assistant text, then archive. + */ +export async function runCursorCloudAgent( + opts: RunCursorCloudAgentOpts, + deps: CursorCloudDeps = {}, +): Promise<{ text: string; model: string; agentId: string; runId: string; agentUrl?: string }> { + const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const now = deps.now ?? Date.now; + const timeoutMs = opts.timeoutMs ?? 180_000; + const pollMs = opts.pollMs ?? 2_000; + const prompt = formatCloudAgentPrompt(opts.messages, opts.feature); + const created = await createNoRepoAgent( + opts.apiKey, + { + prompt, + model: opts.model, + name: `PRM ${opts.feature}`.slice(0, 100), + }, + deps, + ); + const agentId = created.agent.id; + const runId = created.run.id; + const modelLabel = opts.model?.trim() || "default"; + + try { + const deadline = now() + timeoutMs; + let run = created.run as CursorCloudRun; + while (!TERMINAL.has(run.status)) { + if (now() > deadline) { + throw new Error( + `Cursor Cloud Agent timed out after ${Math.round(timeoutMs / 1000)}s (status ${run.status}). ` + + `Open ${created.agent.url ?? "https://cursor.com/agents"} to inspect the run.`, + ); + } + await sleep(pollMs); + run = await getRun(opts.apiKey, agentId, runId, deps); + } + if (run.status !== "FINISHED") { + throw new Error( + `Cursor Cloud Agent ended with ${run.status}` + + (run.result ? `: ${String(run.result).slice(0, 200)}` : ""), + ); + } + const text = (run.result ?? "").trim(); + if (!text) { + throw new Error("Cursor Cloud Agent finished with empty result"); + } + return { + text, + model: `cursor-cloud:${modelLabel}`, + agentId, + runId, + agentUrl: created.agent.url, + }; + } finally { + await archiveAgent(opts.apiKey, agentId, deps); + } +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 687b233..8a3193c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -97,11 +97,14 @@ import { buildSystemPrompt, getAiConfig, localDigest, + normalizeAiProvider, + probeCursorCloudKey, resolveCursorBaseUrl, runChat, storeGeneration, type ChatMessage, } from "./ai.js"; +import { CURSOR_CLOUD_API_BASE } from "./cursorCloud.js"; import { extractCitationIds, filterCitationsAgainstAllowlist } from "./citations.js"; import { runBiasToneLint } from "./bias.js"; import { mergeLintFindings, runFaithfulnessLint } from "./faithfulness.js"; @@ -2218,20 +2221,25 @@ app.post("/api/people/:id/feedback", async (c) => { app.get("/api/ai/settings", (c) => { const db = getDb(); const row = db.select().from(aiSettings).where(eq(aiSettings.id, "default")).all()[0]; - const provider = row?.provider ?? "anthropic"; + const gatewayBaseUrl = resolveCursorBaseUrl(row?.ollamaBaseUrl); + const provider = normalizeAiProvider(row?.provider, gatewayBaseUrl); const hasStoredKey = Boolean(row?.apiKeyEncrypted); - const hasEnvCursorKey = provider === "cursor" && Boolean(process.env.CURSOR_API_KEY?.trim()); - const cursorBaseUrl = resolveCursorBaseUrl(row?.ollamaBaseUrl); + const hasEnvCursorKey = + (provider === "cursor" || provider === "openai_compatible") && + Boolean(process.env.CURSOR_API_KEY?.trim()); return c.json({ enabled: Boolean(row?.enabled), provider, modelDigest: row?.modelDigest, modelDraft: row?.modelDraft, localOnly: Boolean(row?.localOnly), - // For OpenAI-compatible provider, expose the gateway URL in the shared field (never invent 18789). ollamaBaseUrl: - provider === "cursor" ? cursorBaseUrl ?? "" : (row?.ollamaBaseUrl ?? "http://127.0.0.1:11434"), - cursorBaseUrl, + provider === "openai_compatible" + ? gatewayBaseUrl ?? "" + : provider === "ollama" + ? (row?.ollamaBaseUrl ?? "http://127.0.0.1:11434") + : (row?.ollamaBaseUrl ?? "http://127.0.0.1:11434"), + cursorBaseUrl: provider === "openai_compatible" ? gatewayBaseUrl : null, privateNotesEgress: Boolean(row?.privateNotesEgress), meetingSummaryEgress: Boolean(row?.meetingSummaryEgress), documentExtractEgress: Boolean(row?.documentExtractEgress), @@ -3272,10 +3280,12 @@ app.post("/api/ai/test", async (c) => { cfg.localOnly || cfg.provider === "ollama" ? cfg.ollamaBaseUrl : cfg.provider === "cursor" - ? cfg.cursorBaseUrl - : cfg.provider === "openai" - ? "https://api.openai.com/v1" - : "https://api.anthropic.com"; + ? CURSOR_CLOUD_API_BASE + : cfg.provider === "openai_compatible" + ? cfg.openaiCompatibleBaseUrl + : cfg.provider === "openai" + ? "https://api.openai.com/v1" + : "https://api.anthropic.com"; try { if (!cfg.enabled) { return c.json( @@ -3307,6 +3317,17 @@ app.post("/api/ai/test", async (c) => { 400, ); } + // Cloud Agents: validate the key via /v1/me (fast). Full digests launch a no-repo agent. + if (cfg.provider === "cursor" && cfg.apiKey) { + const probe = await probeCursorCloudKey(cfg.apiKey); + return c.json({ + ok: true, + model: probe.model, + sample: probe.sample, + provider: cfg.provider, + baseUrlTried: probe.baseUrlTried, + }); + } const text = await runChat("evidence_digest", [ { role: "system", content: "Reply with exactly: OK" }, { role: "user", content: "ping" }, diff --git a/apps/ui/src/pages/SettingsPage.tsx b/apps/ui/src/pages/SettingsPage.tsx index dbbd49c..469a73f 100644 --- a/apps/ui/src/pages/SettingsPage.tsx +++ b/apps/ui/src/pages/SettingsPage.tsx @@ -116,6 +116,10 @@ export function SettingsPage({ method: "PUT", body: JSON.stringify({ ...settings, + ollamaBaseUrl: + settings.provider === "cursor" + ? "http://127.0.0.1:11434" + : settings.ollamaBaseUrl, apiKey: apiKey ? apiKey : undefined, }), }); @@ -126,6 +130,7 @@ export function SettingsPage({ model?: string; provider?: string; baseUrlTried?: string | null; + sample?: string; }>("/api/ai/test", { method: "POST", body: "{}", @@ -133,7 +138,9 @@ export function SettingsPage({ if (res.ok) { setMsgTone("ok"); setMsg( - `Connection OK (${res.model ?? "unknown"})${res.baseUrlTried ? ` via ${res.baseUrlTried}` : ""}`, + `Connection OK (${res.model ?? "unknown"})${ + res.sample ? ` — ${res.sample}` : "" + }${res.baseUrlTried ? ` via ${res.baseUrlTried}` : ""}`, ); } else { setMsgTone("danger"); @@ -376,7 +383,7 @@ export function SettingsPage({ Save AI settings} /> ) : settings.enabled ? ( @@ -420,6 +427,11 @@ export function SettingsPage({ method: "PUT", body: JSON.stringify({ ...settings, + // Avoid legacy cursor+gateway mis-detection when choosing Cloud Agents. + ollamaBaseUrl: + settings.provider === "cursor" + ? "http://127.0.0.1:11434" + : settings.ollamaBaseUrl, apiKey: apiKey ? apiKey : undefined, }), }); @@ -448,7 +460,7 @@ export function SettingsPage({ setSettings({ ...settings, provider, - ...(provider === "cursor" + ...(provider === "openai_compatible" ? { modelDigest: settings.modelDigest || "gpt-4o-mini", modelDraft: settings.modelDraft || "gpt-4o-mini", @@ -457,23 +469,45 @@ export function SettingsPage({ ? settings.ollamaBaseUrl : settings.cursorBaseUrl || "", } - : {}), + : provider === "cursor" + ? { + modelDigest: settings.modelDigest || "", + modelDraft: settings.modelDraft || "", + } + : provider === "ollama" + ? { + ollamaBaseUrl: settings.ollamaBaseUrl || "http://127.0.0.1:11434", + } + : {}), }); }} > - + + {settings.provider === "cursor" && (

- For a local or third-party gateway that speaks OpenAI{" "} - /v1/chat/completions. Cursor dashboard API keys (crsr_…) are for{" "} - Cloud Agents only — they will not work here. Prefer Anthropic, OpenAI, or - Ollama unless you already run a compatible proxy. Optional env:{" "} - CURSOR_API_BASE_URL / CURSOR_API_KEY. + Uses your Cursor dashboard API key (crsr_…) with the{" "} + + Cloud Agents API + + . Digests and drafts launch a short-lived no-repo cloud agent (slower than + Anthropic/OpenAI chat). Get a key at{" "} + + cursor.com/dashboard/api + + . Optional env: CURSOR_API_KEY. Leave model fields blank to use your Cursor default. +

+ )} + {settings.provider === "openai_compatible" && ( +

+ For a local or third-party gateway that speaks OpenAI /v1/chat/completions. This is{" "} + not Cursor Cloud Agents — use the Cursor Cloud Agents provider for crsr_…{" "} + keys. Optional env: CURSOR_API_BASE_URL / CURSOR_API_KEY.

)}
@@ -490,24 +524,30 @@ export function SettingsPage({ value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder={ - settings.hasApiKey ? "••••••••" : settings.provider === "cursor" ? "gateway key…" : "sk-…" + settings.hasApiKey + ? "••••••••" + : settings.provider === "cursor" + ? "crsr_…" + : settings.provider === "openai_compatible" + ? "gateway key…" + : "sk-…" } />
- {(settings.provider === "ollama" || settings.provider === "cursor") && ( + {(settings.provider === "ollama" || settings.provider === "openai_compatible") && (
setSettings({ ...settings, ollamaBaseUrl: e.target.value })} placeholder={ - settings.provider === "cursor" + settings.provider === "openai_compatible" ? "https://your-gateway.example/v1" : "http://127.0.0.1:11434" } - required={settings.provider === "cursor"} + required={settings.provider === "openai_compatible"} />
)} @@ -516,7 +556,13 @@ export function SettingsPage({ setSettings({ ...settings, modelDigest: e.target.value })} - placeholder={settings.provider === "cursor" ? "gpt-4o-mini" : undefined} + placeholder={ + settings.provider === "cursor" + ? "(account default)" + : settings.provider === "openai_compatible" + ? "gpt-4o-mini" + : undefined + } />
@@ -524,7 +570,13 @@ export function SettingsPage({ setSettings({ ...settings, modelDraft: e.target.value })} - placeholder={settings.provider === "cursor" ? "gpt-4o-mini" : undefined} + placeholder={ + settings.provider === "cursor" + ? "(account default)" + : settings.provider === "openai_compatible" + ? "gpt-4o-mini" + : undefined + } />
diff --git a/docs/ai/AI_INTEGRATION.md b/docs/ai/AI_INTEGRATION.md index 4ff146e..c83aa48 100644 --- a/docs/ai/AI_INTEGRATION.md +++ b/docs/ai/AI_INTEGRATION.md @@ -21,13 +21,13 @@ Workbench trust: [TRUST_MODEL.md](../architecture/TRUST_MODEL.md). **Settings → AI** (workspace admin): - Enable AI -- Provider: Anthropic / OpenAI / **OpenAI-compatible (custom URL)** / Ollama +- Provider: Anthropic / OpenAI / **Cursor Cloud Agents** / OpenAI-compatible (custom URL) / Ollama - API key → encrypted with workspace secret (masked in UI); Test connection saves then probes - Anthropic / OpenAI: standard cloud keys + - **Cursor Cloud Agents**: dashboard key (`crsr_…` from [cursor.com/dashboard/api](https://cursor.com/dashboard/api)); digests/drafts launch a short-lived **no-repo** agent via `https://api.cursor.com/v1` (Test probes `/v1/me`) - OpenAI-compatible: any gateway that implements `/v1/chat/completions` (set Base URL explicitly) - - **Cursor dashboard keys (`crsr_…`) are for Cloud Agents only** — they are not a chat API for this app - - Optional env for custom gateways: `CURSOR_API_BASE_URL` / `CURSOR_API_KEY` -- Models per capability + - Optional env: `CURSOR_API_KEY` (Cloud Agents or gateway); `CURSOR_API_BASE_URL` (gateway only) +- Models per capability (blank Cursor models → account default) - **Local-only** mode - **Private-tier egress matrix** (per class: private notes, meeting summaries, document extracts) — default **deny** for cloud - Surface provider retention/training **facts** (not unkeepable guarantees) diff --git a/docs/product/FEATURES.md b/docs/product/FEATURES.md index dc74efc..740418e 100644 --- a/docs/product/FEATURES.md +++ b/docs/product/FEATURES.md @@ -148,7 +148,7 @@ Per-participant phase status is source of truth; cycle banner is derived. Overla | Feature | Priority | Notes | |---------|----------|-------| -| Settings → AI (keys, provider, models, test) | P0 | Anthropic / OpenAI / OpenAI-compatible gateway / Ollama; Cursor `crsr_` keys are Cloud Agents only (not chat) | +| Settings → AI (keys, provider, models, test) | P0 | Anthropic / OpenAI / Cursor Cloud Agents (`crsr_…`, no-repo agents) / OpenAI-compatible gateway / Ollama | | Evidence digest, draft sections, peer synthesize | P0 | | | Role gap analysis + promo draft | P0 | | | Bias / tone lint | P0 | Advisory; ephemeral findings by default | From 6dbf3bfc0a46204af876502ee80020c480d9487a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 10 Aug 2026 23:21:46 +0000 Subject: [PATCH 2/2] Clear gateway URL when saving Cursor Cloud Agents provider Co-authored-by: Damon --- apps/api/src/index.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8a3193c..b102972 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2269,14 +2269,21 @@ app.put("/api/ai/settings", async (c) => { else if (body.apiKey && secret) apiKeyEncrypted = encryptSecret(body.apiKey, secret); else if (body.apiKey && !secret) return c.json({ error: "Workspace secret unavailable — re-unlock" }, 400); + const provider = body.provider ?? current?.provider ?? "anthropic"; + let ollamaBaseUrl = body.ollamaBaseUrl ?? current?.ollamaBaseUrl; + // Cloud Agents must not keep a leftover custom gateway URL (legacy cursor+URL → openai_compatible). + if (provider === "cursor") { + ollamaBaseUrl = "http://127.0.0.1:11434"; + } + db.update(aiSettings) .set({ enabled: body.enabled === undefined ? current?.enabled ?? 0 : body.enabled ? 1 : 0, - provider: body.provider ?? current?.provider ?? "anthropic", + provider, modelDigest: body.modelDigest ?? current?.modelDigest, modelDraft: body.modelDraft ?? current?.modelDraft, localOnly: body.localOnly === undefined ? current?.localOnly ?? 0 : body.localOnly ? 1 : 0, - ollamaBaseUrl: body.ollamaBaseUrl ?? current?.ollamaBaseUrl, + ollamaBaseUrl, privateNotesEgress: body.privateNotesEgress === undefined ? current?.privateNotesEgress ?? 0 : body.privateNotesEgress ? 1 : 0, meetingSummaryEgress: