diff --git a/.changeset/fn-7696-cursor-cli-models-in-picker.md b/.changeset/fn-7696-cursor-cli-models-in-picker.md new file mode 100644 index 0000000000..2fdb5ff5fe --- /dev/null +++ b/.changeset/fn-7696-cursor-cli-models-in-picker.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Cursor CLI models now appear in Fusion's model picker when the Cursor CLI provider is enabled. +category: fix +dev: /api/models additively merges `cursor-agent` model discovery under the `cursor-cli` provider via a short-TTL, single-flight cache (no per-request CLI spawn), and adds `cursor-cli` to configuredProviders when useCursorCli is on so the rows survive the final provider filter. Rows are deduped by provider/id and never displace existing entries. Pattern mirrors FN-7636 (Hermes). diff --git a/docs/settings-reference.md b/docs/settings-reference.md index fed20c3657..4becc9470f 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -966,6 +966,8 @@ Z.ai's built-in provider uses the existing `zai` auth entry / `ZAI_API_KEY` envi When the Hermes Runtime plugin (`fusion-plugin-hermes-runtime`) is installed and the local `hermes` CLI has configured profiles (`hermes profile list`), those profiles are surfaced additively in `/api/models` under the `hermes` provider — one row per profile, id/name derived from the profile name and its configured model. This surfacing is read-only (Fusion does not create or edit Hermes profiles) and is fetched through a short-TTL, single-flight cache so the model picker never spawns the `hermes` CLI on every request; a missing/failed `hermes` binary simply yields zero Hermes rows without affecting other providers. +When the Cursor Runtime plugin (`fusion-plugin-cursor-runtime`) is installed and the `useCursorCli` toggle is enabled (Settings → Authentication), Cursor CLI-discovered models (`cursor-agent models --json`, with text/`model list` fallbacks) are surfaced additively in `/api/models` under the `cursor-cli` provider — id/name derived from the discovered model id/label. This surfacing is fetched through a short-TTL, single-flight cache so the model picker never spawns `cursor-agent` on every request; a missing/failed/unavailable Cursor CLI binary simply yields zero `cursor-cli` rows without affecting other providers. Disabling `useCursorCli` hides all `cursor-cli` rows. + ### Planning model 1. Per-task `planningModelProvider` + `planningModelId` diff --git a/packages/dashboard/src/__tests__/cursor-model-cache.test.ts b/packages/dashboard/src/__tests__/cursor-model-cache.test.ts new file mode 100644 index 0000000000..41399c96e5 --- /dev/null +++ b/packages/dashboard/src/__tests__/cursor-model-cache.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CursorModelDiscoveryResult } from "../runtime-provider-probes.js"; + +vi.mock("../runtime-provider-probes.js", () => ({ + discoverCursorCliModels: vi.fn(), +})); + +import { discoverCursorCliModels } from "../runtime-provider-probes.js"; +import { + __resetCursorPickerModelsCacheForTests, + cursorDiscoveryToModels, + getCursorPickerModels, +} from "../cursor-model-cache.js"; + +const mockedDiscover = vi.mocked(discoverCursorCliModels); + +afterEach(() => { + vi.clearAllMocks(); + __resetCursorPickerModelsCacheForTests(); +}); + +describe("cursorDiscoveryToModels", () => { + it("maps a discovered model with a label", () => { + const models = cursorDiscoveryToModels([{ id: "cursor/gpt-5", label: "GPT-5" }]); + expect(models).toEqual([ + { provider: "cursor-cli", id: "cursor/gpt-5", name: "GPT-5", reasoning: false, contextWindow: 0 }, + ]); + }); + + it("falls back to the id as the name when no label is provided", () => { + const models = cursorDiscoveryToModels([{ id: "cursor/sonnet" }]); + expect(models).toEqual([ + { provider: "cursor-cli", id: "cursor/sonnet", name: "cursor/sonnet", reasoning: false, contextWindow: 0 }, + ]); + }); + + it("maps multiple models preserving order", () => { + const models = cursorDiscoveryToModels([{ id: "cursor/gpt-5" }, { id: "cursor/sonnet" }]); + expect(models.map((m) => m.id)).toEqual(["cursor/gpt-5", "cursor/sonnet"]); + }); + + it("de-duplicates entries that map to the same stable id, keeping the first occurrence", () => { + const models = cursorDiscoveryToModels([ + { id: "cursor/gpt-5", label: "First" }, + { id: "cursor/gpt-5", label: "Second" }, + ]); + expect(models).toHaveLength(1); + expect(models[0]?.name).toBe("First"); + }); + + it("returns an empty array for an empty model list", () => { + expect(cursorDiscoveryToModels([])).toEqual([]); + }); +}); + +describe("getCursorPickerModels caching", () => { + it("fetches once and returns mapped models", async () => { + mockedDiscover.mockResolvedValue({ + models: [{ id: "cursor/gpt-5", label: "GPT-5" }], + source: "json", + fallbackUsed: false, + }); + + const models = await getCursorPickerModels({ binaryPath: "cursor-test-1" }); + + expect(models).toEqual([ + { provider: "cursor-cli", id: "cursor/gpt-5", name: "GPT-5", reasoning: false, contextWindow: 0 }, + ]); + expect(mockedDiscover).toHaveBeenCalledTimes(1); + }); + + it("serves subsequent requests within the TTL window from cache with no additional spawn", async () => { + mockedDiscover.mockResolvedValue({ models: [{ id: "cursor/sonnet" }], source: "json", fallbackUsed: false }); + let clock = 1000; + const now = () => clock; + + await getCursorPickerModels({ binaryPath: "cursor-test-2", ttlMs: 60_000, now }); + clock += 30_000; // still inside the 60s TTL + await getCursorPickerModels({ binaryPath: "cursor-test-2", ttlMs: 60_000, now }); + + expect(mockedDiscover).toHaveBeenCalledTimes(1); + }); + + it("refreshes after the TTL window expires", async () => { + mockedDiscover.mockResolvedValue({ models: [{ id: "cursor/sonnet" }], source: "json", fallbackUsed: false }); + let clock = 1000; + const now = () => clock; + + await getCursorPickerModels({ binaryPath: "cursor-test-3", ttlMs: 1_000, now }); + clock += 1_001; // past the 1s TTL + await getCursorPickerModels({ binaryPath: "cursor-test-3", ttlMs: 1_000, now }); + + expect(mockedDiscover).toHaveBeenCalledTimes(2); + }); + + it("single-flights concurrent requests for the same binaryPath", async () => { + let resolveFetch: (v: CursorModelDiscoveryResult) => void = () => {}; + mockedDiscover.mockImplementation( + () => + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + + const p1 = getCursorPickerModels({ binaryPath: "cursor-test-4" }); + const p2 = getCursorPickerModels({ binaryPath: "cursor-test-4" }); + + resolveFetch({ models: [{ id: "cursor/sonnet" }], source: "json", fallbackUsed: false }); + const [r1, r2] = await Promise.all([p1, p2]); + + expect(r1).toEqual(r2); + expect(mockedDiscover).toHaveBeenCalledTimes(1); + }); + + it("degrades to an empty array (never throws) when the CLI fetch rejects, and caches the empty result", async () => { + mockedDiscover.mockRejectedValue(new Error("cursor-agent models --json failed: binary not found")); + let clock = 1000; + const now = () => clock; + + const first = await getCursorPickerModels({ binaryPath: "cursor-test-5", ttlMs: 60_000, now }); + expect(first).toEqual([]); + + clock += 10; // still inside TTL + const second = await getCursorPickerModels({ binaryPath: "cursor-test-5", ttlMs: 60_000, now }); + expect(second).toEqual([]); + + // The failure result is cached too — only one spawn attempt within the TTL window. + expect(mockedDiscover).toHaveBeenCalledTimes(1); + }); + + it("degrades to an empty array when discovery reports the binary unavailable (fallbackUsed, empty models)", async () => { + mockedDiscover.mockResolvedValue({ + models: [], + source: "probe", + fallbackUsed: true, + reason: "binary unavailable", + }); + + const models = await getCursorPickerModels({ binaryPath: "cursor-test-6" }); + expect(models).toEqual([]); + }); + + it("defaults binaryPath to cursor-agent when not explicitly provided", async () => { + mockedDiscover.mockResolvedValue({ models: [], source: "probe", fallbackUsed: true }); + + await getCursorPickerModels(); + expect(mockedDiscover).toHaveBeenCalledWith({ binaryPath: "cursor-agent" }); + }); + + it("caches distinct binaryPaths independently", async () => { + mockedDiscover.mockResolvedValue({ models: [{ id: "cursor/sonnet" }], source: "json", fallbackUsed: false }); + + await getCursorPickerModels({ binaryPath: "cursor-test-7a" }); + await getCursorPickerModels({ binaryPath: "cursor-test-7b" }); + + expect(mockedDiscover).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/dashboard/src/__tests__/register-model-routes-cursor-cli.test.ts b/packages/dashboard/src/__tests__/register-model-routes-cursor-cli.test.ts index 8dd2416858..9fef18d084 100644 --- a/packages/dashboard/src/__tests__/register-model-routes-cursor-cli.test.ts +++ b/packages/dashboard/src/__tests__/register-model-routes-cursor-cli.test.ts @@ -1,17 +1,31 @@ -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, access: vi.fn().mockResolvedValue(undefined), - readFile: vi.fn().mockResolvedValue('{"anthropic":{},"openai":{},"cursor-cli":{}}'), + // FNXC:ModelCatalog 2026-07-08-00:05 (FN-7696): this fixture intentionally + // omits a "cursor-cli" key so the toggle path (useCursorCli -> + // configuredProviders.add) is proven on its own, not masked by an + // auth.json entry. Before the fix, cursor-cli rows were dropped by the + // final configuredProviders filter regardless of this fixture. + readFile: vi.fn().mockResolvedValue('{"anthropic":{},"openai":{}}'), }; }); + +vi.mock("../cursor-model-cache.js", () => ({ + getCursorPickerModels: vi.fn(), + CURSOR_PICKER_PROVIDER_ID: "cursor-cli", +})); + import type { Router } from "express"; +import { getCursorPickerModels } from "../cursor-model-cache.js"; import { registerModelRoutes } from "../routes/register-model-routes.js"; -function setup(useCursorCli?: boolean) { +const mockedGetCursorPickerModels = vi.mocked(getCursorPickerModels); + +function setup(useCursorCli?: boolean, registryModels?: Array<{ provider: string; id: string; name: string; reasoning: boolean; contextWindow: number }>) { const getHandlers = new Map void }) => Promise>(); const router = { get: vi.fn((path: string, handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise) => { @@ -32,10 +46,10 @@ function setup(useCursorCli?: boolean) { const modelRegistry = { refresh: vi.fn(), - getAvailable: vi.fn(() => [ - { provider: "cursor-cli", id: "cursor/gpt-5", name: "Cursor GPT-5", reasoning: true, contextWindow: 128000 }, - { provider: "openai", id: "gpt-5", name: "GPT-5", reasoning: true, contextWindow: 128000 }, - ]), + getAvailable: vi.fn( + () => + registryModels ?? [{ provider: "openai", id: "gpt-5", name: "GPT-5", reasoning: true, contextWindow: 128000 }], + ), }; registerModelRoutes({ @@ -48,20 +62,103 @@ function setup(useCursorCli?: boolean) { return getHandlers.get("/models")!; } -describe("registerModelRoutes cursor-cli filter", () => { - it("filters cursor-cli models when useCursorCli is false", async () => { +async function invoke(handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise) { + const json = vi.fn(); + await handler({}, { json }); + return json.mock.calls[0][0] as { models: Array<{ provider: string; id: string; name: string }> }; +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("registerModelRoutes cursor-cli merge and filter", () => { + it("filters cursor-cli models when useCursorCli is false, even when discovery would return some", async () => { + mockedGetCursorPickerModels.mockResolvedValue([ + { provider: "cursor-cli", id: "cursor/gpt-5", name: "GPT-5", reasoning: false, contextWindow: 0 }, + ]); const handler = setup(false); - const json = vi.fn(); - await handler({}, { json }); - const response = json.mock.calls[0][0] as { models: Array<{ provider: string }> }; + const response = await invoke(handler); expect(response.models.some((model) => model.provider === "cursor-cli")).toBe(false); + // Discovery must not even be attempted when the toggle is off. + expect(mockedGetCursorPickerModels).not.toHaveBeenCalled(); }); - it("includes cursor-cli models when useCursorCli is true", async () => { + it("includes discovered cursor-cli models when useCursorCli is true, via the toggle alone (no auth.json entry needed)", async () => { + mockedGetCursorPickerModels.mockResolvedValue([ + { provider: "cursor-cli", id: "cursor/gpt-5", name: "GPT-5", reasoning: false, contextWindow: 0 }, + { provider: "cursor-cli", id: "cursor/sonnet", name: "Sonnet", reasoning: false, contextWindow: 0 }, + ]); const handler = setup(true); - const json = vi.fn(); - await handler({}, { json }); - const response = json.mock.calls[0][0] as { models: Array<{ provider: string }> }; - expect(response.models.some((model) => model.provider === "cursor-cli")).toBe(true); + const response = await invoke(handler); + const cursorRows = response.models.filter((m) => m.provider === "cursor-cli"); + expect(cursorRows.map((m) => m.id).sort()).toEqual(["cursor/gpt-5", "cursor/sonnet"]); + }); + + it("preserves all pre-existing rows (openai, anthropic-style) alongside newly-surfaced cursor-cli rows", async () => { + mockedGetCursorPickerModels.mockResolvedValue([ + { provider: "cursor-cli", id: "cursor/gpt-5", name: "GPT-5", reasoning: false, contextWindow: 0 }, + ]); + const registryModels = [ + { provider: "openai", id: "gpt-5", name: "GPT-5", reasoning: true, contextWindow: 128000 }, + { provider: "droid-cli", id: "droid-1", name: "Droid 1", reasoning: false, contextWindow: 0 }, + ]; + const handler = setup(true, registryModels); + const response = await invoke(handler); + expect(response.models.some((m) => m.provider === "openai" && m.id === "gpt-5")).toBe(true); + expect(response.models.some((m) => m.provider === "cursor-cli" && m.id === "cursor/gpt-5")).toBe(true); + }); + + it("dedupes by provider/id when a discovered id collides with an existing registry row — existing row wins", async () => { + const registryModels = [ + { provider: "cursor-cli", id: "cursor/gpt-5", name: "Registry GPT-5 (pre-existing)", reasoning: true, contextWindow: 128000 }, + ]; + mockedGetCursorPickerModels.mockResolvedValue([ + { provider: "cursor-cli", id: "cursor/gpt-5", name: "Discovered GPT-5 (should be dropped)", reasoning: false, contextWindow: 0 }, + ]); + const handler = setup(true, registryModels); + const response = await invoke(handler); + const cursorRows = response.models.filter((m) => m.provider === "cursor-cli" && m.id === "cursor/gpt-5"); + expect(cursorRows).toHaveLength(1); + expect(cursorRows[0]?.name).toBe("Registry GPT-5 (pre-existing)"); + }); + + it("degrades to zero cursor-cli rows (HTTP 200, existing rows intact) when discovery returns empty", async () => { + mockedGetCursorPickerModels.mockResolvedValue([]); + const handler = setup(true); + const response = await invoke(handler); + expect(response.models.some((m) => m.provider === "cursor-cli")).toBe(false); + expect(response.models.some((m) => m.provider === "openai" && m.id === "gpt-5")).toBe(true); + }); + + it("degrades to zero cursor-cli rows (never rejects the handler) when discovery throws", async () => { + mockedGetCursorPickerModels.mockRejectedValue(new Error("cursor-agent unavailable")); + const handler = setup(true); + const response = await invoke(handler); + expect(response.models.some((m) => m.provider === "cursor-cli")).toBe(false); + expect(response.models.some((m) => m.provider === "openai" && m.id === "gpt-5")).toBe(true); + }); + + it("surfaces a single discovered model", async () => { + mockedGetCursorPickerModels.mockResolvedValue([ + { provider: "cursor-cli", id: "cursor/only", name: "Only", reasoning: false, contextWindow: 0 }, + ]); + const handler = setup(true); + const response = await invoke(handler); + expect(response.models.filter((m) => m.provider === "cursor-cli")).toHaveLength(1); + }); + + it("final response is deduped by provider/id across all merged sources", async () => { + mockedGetCursorPickerModels.mockResolvedValue([ + { provider: "cursor-cli", id: "cursor/dup", name: "A", reasoning: false, contextWindow: 0 }, + ]); + const registryModels = [ + { provider: "openai", id: "gpt-5", name: "GPT-5", reasoning: true, contextWindow: 128000 }, + { provider: "openai", id: "gpt-5", name: "GPT-5 dup", reasoning: true, contextWindow: 128000 }, + ]; + const handler = setup(true, registryModels); + const response = await invoke(handler); + const keys = response.models.map((m) => `${m.provider}/${m.id}`); + expect(new Set(keys).size).toBe(keys.length); }); }); diff --git a/packages/dashboard/src/cursor-model-cache.ts b/packages/dashboard/src/cursor-model-cache.ts new file mode 100644 index 0000000000..c3ee059675 --- /dev/null +++ b/packages/dashboard/src/cursor-model-cache.ts @@ -0,0 +1,175 @@ +/** + * Cursor CLI discovery → model-picker mapping, behind a short-TTL, + * single-flight cache so `/api/models` never spawns the `cursor-agent` CLI + * per request. + * + * FNXC:ModelCatalog 2026-07-08-00:00: + * FN-7696: With the Cursor Runtime plugin installed and the "Cursor — via + * Cursor CLI" provider toggle enabled (`useCursorCli === true`), Cursor + * models never appeared in Fusion's model picker. `discoverCursorProviderModels` + * (the plugin's `cliProviders[].discoverModels` contribution, which spawns + * `cursor-agent models --json` with text/`model list` fallbacks) was never + * called by the dashboard. This module owns two contracts, mirroring the + * landed Hermes pattern (FN-7636, see hermes-model-cache.ts): + * 1. A deterministic discovery→model-id mapping (id = discovered id; name + * = label ?? id) so picker selections remain stable across requests. + * 2. A per-binaryPath TTL cache (default 60s) with single-flight + * de-duplication of concurrent in-flight fetches, so parallel + * `/api/models` requests spawn `cursor-agent` at most once per TTL + * window. + * A missing/failed/unavailable `cursor-agent` binary (ENOENT, non-zero exit, + * timeout, keychain locked, no Cursor IDE) must degrade to an empty model + * list — never throw — so `/api/models` always returns HTTP 200 with + * existing rows intact. The empty result is cached briefly too, so a + * persistently-unavailable binary does not turn into a spawn-per-request + * storm. Unlike Hermes (whose profile presence IS the enable signal), Cursor + * has its own settings toggle (`useCursorCli`); the toggle gate lives in the + * `/api/models` merge site (register-model-routes.ts), not in this module. + */ + +import { discoverCursorCliModels } from "./runtime-provider-probes.js"; + +/** Stable model-picker row shape emitted for a Cursor-discovered model. */ +export interface CursorPickerModel { + provider: "cursor-cli"; + id: string; + name: string; + reasoning: boolean; + contextWindow: number; +} + +/** The picker provider id used for all Cursor-derived model rows. */ +export const CURSOR_PICKER_PROVIDER_ID = "cursor-cli" as const; + +/** Default cache TTL for Cursor model discovery, in milliseconds. */ +const DEFAULT_TTL_MS = 60_000; + +/** + * Map Cursor CLI discovery output into the stable `/api/models` row shape. + * + * The discovered `id` is used as the stable model id (it is the CLI's own + * unique identifier, e.g. `"cursor/gpt-5"`). `name` falls back to `id` when + * no `label` is provided. `reasoning`/`contextWindow` are unknown at this + * layer (Cursor discovery does not report them), so they default to + * `false`/`0` \u2014 enrichment is a documented follow-up, not part of this + * task's scope. + * + * Discovered entries that map to the same id are de-duplicated, keeping the + * first occurrence. + */ +export function cursorDiscoveryToModels( + models: ReadonlyArray<{ id: string; label?: string }>, +): CursorPickerModel[] { + const seen = new Set(); + const result: CursorPickerModel[] = []; + + for (const model of models) { + const id = model.id?.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + + result.push({ + provider: CURSOR_PICKER_PROVIDER_ID, + id, + name: model.label?.trim() || id, + reasoning: false, + contextWindow: 0, + }); + } + + return result; +} + +interface CacheEntry { + /** Timestamp (ms) at which this entry was populated. */ + fetchedAt: number; + /** The resolved (possibly empty, on failure/unavailability) model list. */ + models: CursorPickerModel[]; +} + +/** Per-binaryPath cache of the most recently resolved Cursor picker models. */ +const cache = new Map(); + +/** Per-binaryPath in-flight fetch promise, for single-flight de-duplication. */ +const inFlight = new Map>(); + +/** + * Reset all cached/in-flight state. Test-only escape hatch — production code + * should never need this since entries expire naturally via TTL. + */ +export function __resetCursorPickerModelsCacheForTests(): void { + cache.clear(); + inFlight.clear(); +} + +export interface GetCursorPickerModelsOptions { + /** Override the Cursor CLI binary path. Defaults to `"cursor-agent"`. */ + binaryPath?: string; + /** Cache TTL in milliseconds. Defaults to 60s. */ + ttlMs?: number; + /** Injectable clock (ms epoch) for deterministic tests. Defaults to `Date.now`. */ + now?: () => number; +} + +/** + * Resolve the Cursor CLI binary path: explicit override, then the bare + * `"cursor-agent"` command (resolved via PATH by the CLI spawn layer). The + * Cursor plugin's own probe layer has no dedicated binary-path env var + * convention (unlike Hermes's `HERMES_BIN`), so none is consulted here. + */ +function resolveBinaryPath(explicit?: string): string { + return explicit ?? "cursor-agent"; +} + +/** + * Fetch Cursor CLI-discovered models for the model picker, behind a + * short-TTL, single-flight cache keyed by binary path. + * + * Never throws: a `discoverCursorCliModels` failure or an unavailable-binary + * result (empty models + `fallbackUsed: true`) resolves to `[]`, which is + * itself cached briefly (same TTL) so a persistently-unavailable binary does + * not spawn the CLI on every call. + */ +export async function getCursorPickerModels( + opts?: GetCursorPickerModelsOptions, +): Promise { + const binaryPath = resolveBinaryPath(opts?.binaryPath); + const ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS; + const now = opts?.now ?? Date.now; + const nowMs = now(); + + const cached = cache.get(binaryPath); + if (cached && nowMs - cached.fetchedAt < ttlMs) { + return cached.models; + } + + const existingInFlight = inFlight.get(binaryPath); + if (existingInFlight) { + return existingInFlight; + } + + const fetchPromise = (async (): Promise => { + try { + const result = await discoverCursorCliModels({ binaryPath }); + if (!result || result.models.length === 0) { + return []; + } + return cursorDiscoveryToModels(result.models); + } catch { + // Degrade to zero Cursor rows on any spawn/parse failure (ENOENT, + // non-zero exit, timeout, keychain locked) — never let a Cursor error + // propagate into /api/models. See FNXC:ModelCatalog comment above. + return []; + } + })(); + + inFlight.set(binaryPath, fetchPromise); + + try { + const models = await fetchPromise; + cache.set(binaryPath, { fetchedAt: now(), models }); + return models; + } finally { + inFlight.delete(binaryPath); + } +} diff --git a/packages/dashboard/src/routes/register-model-routes.ts b/packages/dashboard/src/routes/register-model-routes.ts index 651fd70200..91ade5e9a9 100644 --- a/packages/dashboard/src/routes/register-model-routes.ts +++ b/packages/dashboard/src/routes/register-model-routes.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { customProviderRegistryKey, mergeSupplementalAnthropicModels, resolvePlanningSettingsModel } from "@fusion/core"; import type { CustomProvider } from "@fusion/core"; import { ApiError } from "../api-error.js"; +import { getCursorPickerModels, CURSOR_PICKER_PROVIDER_ID } from "../cursor-model-cache.js"; import { getHermesPickerModels, HERMES_PICKER_PROVIDER_ID } from "../hermes-model-cache.js"; import type { AuthStorageLike } from "../routes.js"; import type { ApiRouteRegistrar } from "./types.js"; @@ -283,6 +284,41 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { models.push(hermesModel); } + /* + FNXC:ModelCatalog 2026-07-08-00:05: + FN-7696: additively surface Cursor CLI-discovered models + (`cursor-agent models --json`, with text/`model list` fallbacks) under + the stable "cursor-cli" provider id, mirroring the FN-7636 Hermes merge + above. Unlike Hermes (whose profile presence IS the enable signal), + Cursor has its own settings toggle (useCursorCli) — the toggle IS the + signal here, so discovery is only attempted when useCursorCli is true. + Fetched through getCursorPickerModels, backed by a short-TTL, + single-flight cache keyed by binary path — this call NEVER spawns + cursor-agent per request, and NEVER throws (a missing/failed/ + unavailable binary degrades to []). Cursor rows are merged respecting + the existing seenModelKeys provider/id dedup so an existing row always + wins over a colliding Cursor row — purely additive, must never + displace, overwrite, or filter out an existing row. + */ + if (useCursorCli) { + // getCursorPickerModels never throws by contract (see + // cursor-model-cache.ts), but this try/catch is a defensive belt so + // a Cursor discovery failure can never reject the /models handler or + // drop existing rows — degrade to zero Cursor rows instead. + try { + const cursorModels = await getCursorPickerModels(); + for (const cursorModel of cursorModels) { + const key = `${cursorModel.provider}/${cursorModel.id}`; + if (seenModelKeys.has(key)) continue; + seenModelKeys.add(key); + models.push(cursorModel); + } + } catch (cursorErr: unknown) { + const message = cursorErr instanceof Error ? cursorErr.message : String(cursorErr); + runtimeLogger.child("models").warn(`Failed to load cursor-cli models: ${message}`); + } + } + // Filter to only providers the user has explicitly configured in Fusion. // getAvailable() checks supplemental credential stores (Codex CLI, // Claude Code, env vars) which surface providers the user may not @@ -305,6 +341,15 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => { if (useClaudeCli) configuredProviders.add("pi-claude-cli"); if (useDroidCli) configuredProviders.add("droid-cli"); if (useLlamaCpp) configuredProviders.add("llama-server"); + // FNXC:ModelCatalog 2026-07-08-00:05 (FN-7696): allow-list "cursor-cli" + // through the final filter whenever the toggle is on — independent of + // any auth.json/models.json cursor-cli entry and independent of + // whether discovery actually contributed rows (mirrors + // useClaudeCli/useDroidCli/useLlamaCpp exactly; unlike hermesRowsAdded, + // Cursor's own toggle IS the signal, not row presence). This closes the + // previously-missing configuredProviders.add("cursor-cli") gap that + // silently dropped Cursor rows even when the plugin surfaced them. + if (useCursorCli) configuredProviders.add(CURSOR_PICKER_PROVIDER_ID); // FNXC:ModelCatalog 2026-07-07-09:05 (FN-7636): only allow-list "hermes" // through the final filter when Hermes rows were actually contributed // above, mirroring the useClaudeCli/useDroidCli toggle pattern (Hermes diff --git a/packages/dashboard/src/runtime-provider-probes.ts b/packages/dashboard/src/runtime-provider-probes.ts index 0163a8ed01..5538822fe7 100644 --- a/packages/dashboard/src/runtime-provider-probes.ts +++ b/packages/dashboard/src/runtime-provider-probes.ts @@ -25,6 +25,7 @@ import { } from "@fusion-plugin-examples/openclaw-runtime"; import { + discoverCursorProviderModels, probeCursorBinary, type CursorBinaryStatus, } from "@fusion-plugin-examples/cursor-runtime"; @@ -65,6 +66,31 @@ export async function probeCursorCliProvider(opts?: { binaryPath?: string }): Pr return probeCursorBinary(opts); } +/** Result shape returned by the Cursor plugin's model-discovery contribution. */ +export interface CursorModelDiscoveryResult { + models: Array<{ id: string; label?: string }>; + source: string; + fallbackUsed: boolean; + reason?: string; +} + +/** + * Discover Cursor CLI models via `cursor-agent models --json` (with text / + * `model list` fallbacks), delegating to the Cursor Runtime plugin's + * `discoverCursorProviderModels` cliProviders contribution. + * + * This is the stable mock/spy boundary for `cursor-model-cache.ts` and its + * tests — never called directly per-request; see `getCursorPickerModels`. + * Never throws by contract of the underlying plugin function (a missing/ + * unavailable binary resolves to `{ models: [], fallbackUsed: true, ... }`). + */ +export async function discoverCursorCliModels(opts?: { + binaryPath?: string; + timeoutMs?: number; +}): Promise { + return discoverCursorProviderModels(opts) as Promise; +} + /** * Probe the local Hermes binary. *