FN-7696: surface Cursor CLI models in the model picker

Adds Cursor CLI model discovery to the dashboard's /api/models endpoint so cursor-agent-backed models appear in the picker when the Cursor CLI provider is enabled.

- Add cursor-model-cache.ts: short-TTL, single-flight cache for cursor-agent model discovery (no per-request CLI spawn)
- register-model-routes.ts additively merges cursor-cli models, deduped by provider/id, without displacing existing entries
- runtime-provider-probes.ts adds cursor-cli to configuredProviders when useCursorCli is on so rows survive the final provider filter
- Add unit tests for the cache and for register-model-routes cursor-cli integration
- Update docs/settings-reference.md
- Add changeset (patch/minor: @runfusion/fusion) documenting the fix

Files changed:
 .changeset/fn-7696-cursor-cli-models-in-picker.md  |   7 +
 docs/settings-reference.md                         |   2 +
 .../src/__tests__/cursor-model-cache.test.ts       | 158 +++++++++++++++++++
 .../register-model-routes-cursor-cli.test.ts       | 131 +++++++++++++--
 packages/dashboard/src/cursor-model-cache.ts       | 175 +++++++++++++++++++++
 .../dashboard/src/routes/register-model-routes.ts  |  45 ++++++
 packages/dashboard/src/runtime-provider-probes.ts  |  26 +++
 7 files changed, 527 insertions(+), 17 deletions(-)

Fusion-Task-Id: FN-7696

Fusion-Task-Lineage: 2f2baf1e-5e4d-4c47-a112-b282a8ee45b6

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-08 18:10:06 -07:00
parent bccb552211
commit 7fe18dfc9b
7 changed files with 527 additions and 17 deletions

View File

@@ -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).

View File

@@ -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`

View File

@@ -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<CursorModelDiscoveryResult>((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);
});
});

View File

@@ -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<typeof import("node:fs/promises")>();
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<string, (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>>();
const router = {
get: vi.fn((path: string, handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>) => {
@@ -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<void>) {
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);
});
});

View File

@@ -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<string>();
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<string, CacheEntry>();
/** Per-binaryPath in-flight fetch promise, for single-flight de-duplication. */
const inFlight = new Map<string, Promise<CursorPickerModel[]>>();
/**
* 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<CursorPickerModel[]> {
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<CursorPickerModel[]> => {
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);
}
}

View File

@@ -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

View File

@@ -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<CursorModelDiscoveryResult> {
return discoverCursorProviderModels(opts) as Promise<CursorModelDiscoveryResult>;
}
/**
* Probe the local Hermes binary.
*