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:
7
.changeset/fn-7696-cursor-cli-models-in-picker.md
Normal file
7
.changeset/fn-7696-cursor-cli-models-in-picker.md
Normal 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).
|
||||
@@ -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`
|
||||
|
||||
158
packages/dashboard/src/__tests__/cursor-model-cache.test.ts
Normal file
158
packages/dashboard/src/__tests__/cursor-model-cache.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
175
packages/dashboard/src/cursor-model-cache.ts
Normal file
175
packages/dashboard/src/cursor-model-cache.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user