diff --git a/docs/providers/CURSOR-DOCKER.md b/docs/providers/CURSOR-DOCKER.md new file mode 100644 index 0000000000..eb5724b0c8 --- /dev/null +++ b/docs/providers/CURSOR-DOCKER.md @@ -0,0 +1,33 @@ +--- +title: "Cursor model listing" +version: 3.8.50 +lastUpdated: 2026-08-09 +--- + +# Cursor model listing + +## Live catalog is exclusive when synced + +After a successful Cursor model sync (`cursor-agent --list-models` → persisted +synced catalog), the **dashboard**, **`/v1/models`**, and **Test All** list: + +1. Models returned by the live sync +2. Injected auto-router ids: `auto`, `auto-cost`, `auto-balance`, `auto-intelligence` +3. Operator **custom** models (Import / manual) — never pruned by sync + +The large static registry under +`open-sse/config/providers/registry/cursor/` is **offline fallback only**. When +synced is empty (or discovery fails), listing falls back to that registry. + +Effort-suffixed ids (for example `claude-4.6-sonnet-high`) may still be +**requested** at runtime: `resolveRequestedModel` strips the suffix into a wire +`ModelParameter`. Exclusive listing intentionally hides those static variants +from Test All so probes match what Cursor actually returns as available. + +## Helpers + +- `providerUsesExclusiveSyncedListing("cursor"|"cu")` — + `src/lib/providers/modelListingCapability.ts` +- `mergeProviderModelListing` — dashboard merge +- `ensureCursorAutoCatalogEntry` — auto* inject on discovery + listing +- `shouldSuppressStaticModelForExclusiveListing` — `/v1/models` static loop diff --git a/docs/providers/meta.json b/docs/providers/meta.json index 97cf893a40..f408531954 100644 --- a/docs/providers/meta.json +++ b/docs/providers/meta.json @@ -1,5 +1,11 @@ { "title": "Providers", "description": "Provider-specific integration guides", - "pages": ["ALIBABA-QWEN-PROVIDER-FAMILIES", "CLAUDE_WEB", "AGENTROUTER", "ZED-DOCKER"] + "pages": [ + "ALIBABA-QWEN-PROVIDER-FAMILIES", + "CLAUDE_WEB", + "AGENTROUTER", + "ZED-DOCKER", + "CURSOR-DOCKER" + ] } diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index 61250dfe4c..42c7bf0055 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -75,6 +75,7 @@ import { visibleComposerContentFromThinking, composerReasoningRemainder, } from "./cursor/composer.ts"; +import { getActiveSyncedCatalog } from "../../src/lib/db/models/activeSyncedCatalog.ts"; // Composer helpers re-exported for external importers (tests). export { isComposerModel, @@ -805,6 +806,20 @@ export class CursorExecutor extends BaseExecutor { return resolveCursorImages(imageUrls); } + /** + * Exact ids from the active Cursor synced catalog. Empty/unavailable → + * undefined so resolveRequestedModel keeps #7289 offline splitting. + */ + private async loadLiveCatalogIds(): Promise | undefined> { + try { + const catalog = await getActiveSyncedCatalog("cursor"); + if (!catalog.models.length) return undefined; + return new Set(catalog.models.map((model) => model.id)); + } catch { + return undefined; + } + } + private async buildRequest( model: string, body: { @@ -819,7 +834,10 @@ export class CursorExecutor extends BaseExecutor { } ): Promise<{ body: Uint8Array; blobStore: Map }> { const { userText, tools } = this.assembleTextAndTools(body); - const images = await this.resolveRequestImages(body); + const [images, liveCatalogIds] = await Promise.all([ + this.resolveRequestImages(body), + this.loadLiveCatalogIds(), + ]); const blobStore = new Map(); const requestBody = buildAgentRequestBody({ @@ -829,6 +847,7 @@ export class CursorExecutor extends BaseExecutor { tools, blobStore, images, + liveCatalogIds, }); return { body: requestBody, blobStore }; } diff --git a/open-sse/utils/cursorAgentProtobuf.ts b/open-sse/utils/cursorAgentProtobuf.ts index a38085ae9e..d86fc7f4ce 100644 --- a/open-sse/utils/cursorAgentProtobuf.ts +++ b/open-sse/utils/cursorAgentProtobuf.ts @@ -332,6 +332,8 @@ function splitCursorEffortSuffix( /** * cursor-agent rewrites model ids before putting them on the wire: * "auto" → RequestedModel { model_id: "default" } + * "auto-cost" → RequestedModel { model_id: "default", + * parameters: [{id: "optimization", value: "cost"}] } * "composer-2-fast" → RequestedModel { model_id: "composer-2", * parameters: [{id: "fast", value: "true"}] } * "claude-opus-4-8-high" → RequestedModel { model_id: "claude-opus-4-8", @@ -342,7 +344,31 @@ function splitCursorEffortSuffix( * Other ids are passed through verbatim after spelling-variant normalization * (see normalizeCursorModelId). */ -export function resolveRequestedModel(modelId: string): { +/** Cursor Router optimization levels (OpenCodex `CURSOR_ROUTING_LEVELS`). */ +export const CURSOR_ROUTING_LEVELS = ["cost", "balance", "intelligence"] as const; +export type CursorRoutingLevel = (typeof CURSOR_ROUTING_LEVELS)[number]; + +/** + * ModelParameter id for Cursor's Cost/Balance/Intelligence control on wire model + * `default` (OpenCodex `CURSOR_ROUTING_LEVEL_PARAMETER_ID`). + */ +export const CURSOR_ROUTING_LEVEL_PARAMETER_ID = "optimization"; + +export type ResolveRequestedModelOptions = { + /** + * When set and containing the normalized client model id, send that id + * verbatim on AgentRun (skip composer-fast / Claude / GPT splits). + * Live AvailableModels returns flattened effort-suffixed ids; stripping them + * to a missing base causes Cursor `AI Model Not Found`. Auto / auto-* still + * map to wire `default` (+ optimization) even when present in this set. + */ + liveCatalogIds?: ReadonlySet; +}; + +export function resolveRequestedModel( + modelId: string, + opts?: ResolveRequestedModelOptions +): { modelId: string; parameters: Array<{ id: string; value: string }>; } { @@ -350,6 +376,20 @@ export function resolveRequestedModel(modelId: string): { if (normalized === "auto") { return { modelId: "default", parameters: [] }; } + // OpenCodex-style router variants: auto-cost / auto-balance / auto-intelligence + // → wire `default` + ModelParameter { id: "optimization", value: }. + for (const level of CURSOR_ROUTING_LEVELS) { + if (normalized === `auto-${level}`) { + return { + modelId: "default", + parameters: [{ id: CURSOR_ROUTING_LEVEL_PARAMETER_ID, value: level }], + }; + } + } + // Live catalog is authoritative for exact ids (flattened effort variants). + if (opts?.liveCatalogIds?.has(normalized)) { + return { modelId: normalized, parameters: [] }; + } // Strip the "-fast" suffix and surface it as a parameter — only the composer // family observably needs this split today, but the protocol field is generic. if (normalized.startsWith("composer-") && normalized.endsWith("-fast")) { @@ -406,6 +446,8 @@ export type AgentRunInput = { // encodeSelectedImageBody). Empty / undefined keeps the request // byte-identical to the text-only path. images?: EncodedImage[]; + /** Exact live AvailableModels ids — see resolveRequestedModel liveCatalogIds. */ + liveCatalogIds?: ReadonlySet; }; export { cursorImageAttachmentPath, encodeSelectedImageBody }; @@ -433,7 +475,9 @@ export function openAIToolsToMcpDefs(tools: OpenAITool[]): McpToolDefinition[] { export function encodeAgentRunRequest(input: AgentRunInput): Buffer { const conversationId = input.conversationId || crypto.randomUUID(); const messageId = input.messageId || crypto.randomUUID(); - const { modelId, parameters } = resolveRequestedModel(input.modelId); + const { modelId, parameters } = resolveRequestedModel(input.modelId, { + liveCatalogIds: input.liveCatalogIds, + }); // UserMessage { text, message_id, selected_context, mode=1 }. // selected_context is normally an empty placeholder (required by the server diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index 24699207d3..38ef80d2b4 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -22,8 +22,11 @@ import { getCompatibleFallbackModels, } from "@/lib/providers/managedAvailableModels"; import { getProviderServiceKinds } from "@/lib/providers/serviceKindIndex"; -import { providerLacksModelListing } from "@/lib/providers/modelListingCapability"; -import { providerUsesCuratedModelsOnly } from "@/lib/providers/modelListingCapability"; +import { + providerLacksModelListing, + providerUsesCuratedModelsOnly, +} from "@/lib/providers/modelListingCapability"; +import { mergeProviderModelListing } from "@/lib/providers/mergeProviderModelListing"; import { normalizeModelCatalogSource } from "@/shared/utils/modelCatalogSearch"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import useEmailPrivacyStore from "@/store/emailPrivacyStore"; @@ -270,39 +273,29 @@ export default function ProviderDetailPageClient() { getProviderById(providerId)?.managedAccount === true; const registryModels = getModelsByProviderId(providerId); // Prefer synced API-discovered models when available, then merge built-ins - // and user-managed custom models without duplicating IDs. + // and user-managed custom models without duplicating IDs. Cursor exclusive + // listing drops the static registry entirely when synced is non-empty. const models = useMemo(() => { - // Synced models keep their full property spread so provider-specific fields - // (e.g. Gemini's `supportedGenerationMethods`) survive into the table. - const builtInModels = registryModels.map((model) => ({ - ...model, - source: "system", - })); - - const registryIds = new Set(builtInModels.map((m) => m.id)); - const syncedExtras = (usesCuratedModelsOnly ? [] : syncedAvailableModels) - .filter((model: any) => model?.id && !registryIds.has(model.id)) - .map((model: any) => ({ - ...model, - id: model.id, - name: model.name || model.id, - source: "imported", - })); - const knownIds = new Set([...registryIds, ...syncedExtras.map((model: any) => model.id)]); - const customExtras = (usesCuratedModelsOnly ? [] : modelMeta.customModels) - .filter((cm: any) => cm.id && !knownIds.has(cm.id)) - .map((cm: any) => ({ - id: cm.id, - name: cm.name || cm.id, - source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom", - })); - const allModels = [...builtInModels, ...syncedExtras, ...customExtras]; - const deduped = new Map(); - for (const m of allModels) { - if (m.id && !deduped.has(m.id)) deduped.set(m.id, m); - } - return Array.from(deduped.values()); - }, [registryModels, syncedAvailableModels, modelMeta.customModels, usesCuratedModelsOnly]); + return mergeProviderModelListing({ + providerId, + registryModels, + syncedModels: syncedAvailableModels, + customModels: (modelMeta.customModels || []).map( + (cm: { id: string; name?: string; source?: string }) => ({ + id: cm.id, + name: cm.name || cm.id, + source: normalizeModelCatalogSource(cm.source) === "imported" ? "imported" : "custom", + }) + ), + usesCuratedModelsOnly, + }); + }, [ + providerId, + registryModels, + syncedAvailableModels, + modelMeta.customModels, + usesCuratedModelsOnly, + ]); const isUpstreamProxyProvider = providerInfo?.category === "upstream-proxy"; const compatibleSupportsModelImport = compatibleProviderSupportsModelImport(providerId); diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index 8532f29412..d2bc1334c9 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -92,6 +92,7 @@ import { } from "@/lib/providerModels/geminiModelsParser"; import { getSyncedAvailableModels, getCustomModels } from "@/lib/db/models"; import { fetchCursorAgentModels } from "@/lib/providerModels/cursorAgent"; +import { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog"; import { fetchRaycastModels } from "@omniroute/open-sse/services/raycast.ts"; import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts"; import { @@ -1408,7 +1409,7 @@ export async function GET( if (autoFetchDisabledResponse) return autoFetchDisabledResponse; try { - const models = await fetchCursorAgentModels(); + const models = ensureCursorAutoCatalogEntry(await fetchCursorAgentModels()); return buildApiDiscoveryResponse(models); } catch (err) { const message = err instanceof Error ? err.message : String(err); diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index e70a617c33..9d4b437404 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -14,7 +14,7 @@ import { createLazyConnectionView } from "@/lib/db/providers/lazyConnectionView" import { extractAliasBackedModels } from "./aliasBackedModels"; import { buildSyncedModelIdsByCanonicalProvider, - shouldSuppressStaticModelBySyncedCoverage, + shouldSuppressStaticModelForExclusiveListing, } from "./catalogSyncedCoverage"; import { buildSyncedCapabilities, mergeSyncedCapabilities } from "./syncedCapabilities"; import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry"; @@ -41,7 +41,11 @@ import type { SyncedAvailableModel } from "@/lib/db/models"; import { getAllActiveSyncedModels } from "@/lib/db/models/activeSyncedCatalog"; import { getModelCatalogCacheVersion } from "@/lib/db/readCache"; import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels"; -import { providerUsesCuratedModelsOnly } from "@/lib/providers/modelListingCapability"; +import { + providerUsesCuratedModelsOnly, + providerUsesExclusiveSyncedListing, +} from "@/lib/providers/modelListingCapability"; +import { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog"; import { getOpenRouterCatalog } from "@/lib/catalog/openrouterCatalog"; import { hasEligibleConnectionForModel } from "@/domain/connectionModelRules"; import { @@ -737,14 +741,22 @@ async function buildUnifiedModelsResponseCore( // `deepseek/deepseek-v4-flash` which its discovery never lists). Before // the fix, a provider with any synced model silently dropped ALL its // static models. + // + // Cursor exclusive listing: when an active synced catalog exists, drop + // ALL static rows (including effort variants) so Test All / clients only + // see live AvailableModels + injected auto*. const syncedForProvider = syncedModelIdsByCanonicalProvider.get(canonicalProviderId); + const exclusiveListing = providerUsesExclusiveSyncedListing(canonicalProviderId); + const providerHasSynced = syncedForProvider !== undefined && syncedForProvider.size > 0; + const coveredBySynced = shouldSuppressStaticModelForExclusiveListing({ + exclusiveListing, + providerHasSynced, + staticModelId: model.id, + syncedModelIds: syncedForProvider ? [...syncedForProvider] : [], + }); if ( - shouldSuppressStaticModelBySyncedCoverage({ - providerHasSynced: syncedForProvider !== undefined && syncedForProvider.size > 0, - staticModelId: model.id, - syncedModelIds: syncedForProvider ? [...syncedForProvider] : [], - }) && - !isRegisteredEffortVariant(providerModels, model.id) + coveredBySynced && + (exclusiveListing || !isRegisteredEffortVariant(providerModels, model.id)) ) continue; if (!providerSupportsModel(canonicalProviderId, model.id)) continue; @@ -838,7 +850,16 @@ async function buildUnifiedModelsResponseCore( continue; } - for (const sm of syncedModels) { + for (const sm of providerUsesExclusiveSyncedListing(providerId) + ? ensureCursorAutoCatalogEntry( + syncedModels.map((row) => ({ + ...row, + id: row.id, + name: row.name || row.id, + owned_by: "cursor", + })) + ) + : syncedModels) { if (!providerSupportsModel(canonicalProviderId, sm.id)) continue; if (canonicalProviderId === "codex" && isCodexDiscoveryModelExcluded(sm)) { continue; @@ -1073,9 +1094,7 @@ async function buildUnifiedModelsResponseCore( // here would discard all but the last segment and miss stored flags for // providers whose model IDs carry a sub-path (e.g. OpenRouter scoped models). const getSpecialtyModelRelativeId = (modelId: string, provider: string): string => - modelId.startsWith(`${provider}/`) - ? modelId.slice(provider.length + 1) - : modelId; + modelId.startsWith(`${provider}/`) ? modelId.slice(provider.length + 1) : modelId; // Add embedding models (filtered by active providers) for (const embModel of getAllEmbeddingModels()) { diff --git a/src/app/api/v1/models/catalogSyncedCoverage.ts b/src/app/api/v1/models/catalogSyncedCoverage.ts index 7a49445d71..bf7751f95d 100644 --- a/src/app/api/v1/models/catalogSyncedCoverage.ts +++ b/src/app/api/v1/models/catalogSyncedCoverage.ts @@ -33,6 +33,29 @@ export function shouldSuppressStaticModelBySyncedCoverage(opts: { return opts.syncedModelIds.includes(opts.staticModelId); } +/** + * Exclusive live-catalog listing (Cursor): when the provider opts in and has a + * non-empty synced catalog, suppress EVERY static registry row — including + * effort-suffixed variants the coverage helper would otherwise preserve. + * + * Non-exclusive providers fall through to exact-id coverage suppression. + */ +export function shouldSuppressStaticModelForExclusiveListing(opts: { + exclusiveListing: boolean; + providerHasSynced: boolean; + staticModelId: string; + syncedModelIds: string[]; +}): boolean { + if (opts.exclusiveListing) { + return opts.providerHasSynced && opts.syncedModelIds.length > 0; + } + return shouldSuppressStaticModelBySyncedCoverage({ + providerHasSynced: opts.providerHasSynced, + staticModelId: opts.staticModelId, + syncedModelIds: opts.syncedModelIds, + }); +} + /** * Build a Map of canonical provider id -> set of synced display-model ids, so the * static loop can decide which static models a provider's synced discovery list diff --git a/src/lib/db/models/activeSyncedCatalog.ts b/src/lib/db/models/activeSyncedCatalog.ts index f5d649d030..981175219f 100644 --- a/src/lib/db/models/activeSyncedCatalog.ts +++ b/src/lib/db/models/activeSyncedCatalog.ts @@ -204,8 +204,18 @@ export async function reconcileProvidersWithActiveSyncedCatalog( for (const { provider, catalog } of states) { const modelIsLive = catalog.models.some((model) => model.id === modelId); + // Cursor auto-router: always allow `auto` / router variants even if a stale live + // catalog omitted them (AvailableModels / agent list often returns wire id + // `default` only; listing injects `auto` + cost/balance/intelligence). + const cursorAutoAllow = + provider === "cursor" && + (modelId === "auto" || + modelId === "default" || + modelId === "auto-cost" || + modelId === "auto-balance" || + modelId === "auto-intelligence"); - if (!catalog.authoritative || modelIsLive) { + if (!catalog.authoritative || modelIsLive || cursorAutoAllow) { providers.push(provider); } else { excludedProviders.push(provider); diff --git a/src/lib/providerModels/cursorAutoCatalog.ts b/src/lib/providerModels/cursorAutoCatalog.ts new file mode 100644 index 0000000000..a9c0f2dca2 --- /dev/null +++ b/src/lib/providerModels/cursorAutoCatalog.ts @@ -0,0 +1,60 @@ +/** + * Ensure Cursor catalog listings always expose OmniRoute's public auto-router + * ids (`auto` + Cost/Balance/Intelligence variants). Live AvailableModels / + * cursor-agent often return wire id `default` only. + */ + +export type CursorAutoCatalogEntry = { + id: string; + name: string; + owned_by?: string; + [key: string]: unknown; +}; + +export const CURSOR_AUTO_ROUTER_VARIANT_IDS = [ + "auto-cost", + "auto-balance", + "auto-intelligence", +] as const; + +const CURSOR_AUTO_ROUTER_VARIANT_NAMES: Record< + (typeof CURSOR_AUTO_ROUTER_VARIANT_IDS)[number], + string +> = { + "auto-cost": "Auto (cost)", + "auto-balance": "Auto (balance)", + "auto-intelligence": "Auto (intelligence)", +}; + +/** Cursor auto-router: catalog id `auto`, wire id `default`. Always keep `auto` visible. */ +export function ensureCursorAutoCatalogEntry(models: T[]): T[] { + const byId = new Map(models.map((m) => [m.id, m])); + const out = [...models]; + + if (!byId.has("auto")) { + const defaultEntry = byId.get("default"); + const autoEntry = { + id: "auto", + name: + typeof defaultEntry?.name === "string" && defaultEntry.name.trim() + ? defaultEntry.name + : "Auto (current, default)", + owned_by: "cursor", + } as T; + out.unshift(autoEntry); + byId.set("auto", autoEntry); + } + + for (const id of CURSOR_AUTO_ROUTER_VARIANT_IDS) { + if (byId.has(id)) continue; + const entry = { + id, + name: CURSOR_AUTO_ROUTER_VARIANT_NAMES[id], + owned_by: "cursor", + } as T; + out.push(entry); + byId.set(id, entry); + } + + return out; +} diff --git a/src/lib/providers/mergeProviderModelListing.ts b/src/lib/providers/mergeProviderModelListing.ts new file mode 100644 index 0000000000..55410d0fd9 --- /dev/null +++ b/src/lib/providers/mergeProviderModelListing.ts @@ -0,0 +1,96 @@ +/** + * Pure merge of registry / synced / custom model rows for the provider detail + * dashboard (and thus Test All targets). Cursor exclusive listing prefers the + * live synced catalog when non-empty. + */ + +import { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog"; +import { + providerUsesCuratedModelsOnly, + providerUsesExclusiveSyncedListing, +} from "@/lib/providers/modelListingCapability"; + +export type ProviderListingModel = { + id: string; + name?: string; + source?: string; + [key: string]: unknown; +}; + +export type MergeProviderModelListingInput = { + providerId: string; + registryModels: Array<{ id: string; name?: string; [key: string]: unknown }>; + syncedModels: Array<{ id: string; name?: string; [key: string]: unknown }>; + customModels: Array<{ id: string; name?: string; source?: string; [key: string]: unknown }>; + usesCuratedModelsOnly?: boolean; +}; + +function normalizeCustomSource(source: unknown): "imported" | "custom" { + return source === "imported" ? "imported" : "custom"; +} + +function dedupeById(models: ProviderListingModel[]): ProviderListingModel[] { + const deduped = new Map(); + for (const m of models) { + if (m.id && !deduped.has(m.id)) deduped.set(m.id, m); + } + return Array.from(deduped.values()); +} + +export function mergeProviderModelListing( + input: MergeProviderModelListingInput +): ProviderListingModel[] { + const curated = + input.usesCuratedModelsOnly === true || providerUsesCuratedModelsOnly(input.providerId); + const synced = curated ? [] : input.syncedModels.filter((m) => m?.id); + const custom = curated ? [] : input.customModels.filter((m) => m?.id); + + const exclusive = providerUsesExclusiveSyncedListing(input.providerId) && synced.length > 0; + + if (exclusive) { + const withAuto = ensureCursorAutoCatalogEntry( + synced.map((model) => ({ + ...model, + id: model.id, + name: model.name || model.id, + owned_by: "cursor", + source: "imported", + })) + ); + const knownIds = new Set(withAuto.map((m) => m.id)); + const customExtras = custom + .filter((cm) => cm.id && !knownIds.has(cm.id)) + .map((cm) => ({ + ...cm, + id: cm.id, + name: cm.name || cm.id, + source: normalizeCustomSource(cm.source), + })); + return dedupeById([...withAuto, ...customExtras]); + } + + const builtInModels = input.registryModels.map((model) => ({ + ...model, + source: "system", + })); + const registryIds = new Set(builtInModels.map((m) => m.id)); + const syncedExtras = synced + .filter((model) => model.id && !registryIds.has(model.id)) + .map((model) => ({ + ...model, + id: model.id, + name: model.name || model.id, + source: "imported", + })); + const knownIds = new Set([...registryIds, ...syncedExtras.map((m) => m.id)]); + const customExtras = custom + .filter((cm) => cm.id && !knownIds.has(cm.id)) + .map((cm) => ({ + ...cm, + id: cm.id, + name: cm.name || cm.id, + source: normalizeCustomSource(cm.source), + })); + + return dedupeById([...builtInModels, ...syncedExtras, ...customExtras]); +} diff --git a/src/lib/providers/modelListingCapability.ts b/src/lib/providers/modelListingCapability.ts index f6c7382e13..8de0a8ecf7 100644 --- a/src/lib/providers/modelListingCapability.ts +++ b/src/lib/providers/modelListingCapability.ts @@ -17,6 +17,19 @@ export function providerUsesCuratedModelsOnly(providerId: string): boolean { return CURATED_MODEL_ONLY_PROVIDERS.has(providerId.trim().toLowerCase()); } +/** + * Providers whose non-empty synced AvailableModels catalog fully replaces the + * static registry for dashboard / `/v1/models` / Test All listing. Static rows + * remain offline fallback only when synced is empty. + * + * Cursor-only for now — other authoritative live-catalog providers keep + * coverage-style static preservation (e.g. command-code uncovered static ids). + */ +export function providerUsesExclusiveSyncedListing(providerId: string): boolean { + const id = providerId.trim().toLowerCase(); + return id === "cursor" || id === "cu"; +} + /** * True when the provider is tool-only and therefore has no model listing: * - its id ends in `-search` (legacy search providers), OR diff --git a/tests/unit/catalog-synced-static-preservation.test.ts b/tests/unit/catalog-synced-static-preservation.test.ts index d6b6748fb2..eaf4aab333 100644 --- a/tests/unit/catalog-synced-static-preservation.test.ts +++ b/tests/unit/catalog-synced-static-preservation.test.ts @@ -4,6 +4,7 @@ import assert from "node:assert/strict"; import { buildSyncedModelIdsByCanonicalProvider, shouldSuppressStaticModelBySyncedCoverage, + shouldSuppressStaticModelForExclusiveListing, } from "../../src/app/api/v1/models/catalogSyncedCoverage.ts"; import type { SyncedAvailableModel } from "../../src/lib/db/models/synced.ts"; @@ -75,3 +76,57 @@ test("buildSyncedModelIdsByCanonicalProvider groups synced ids by canonical prov assert.ok(ds); assert.ok(ds.has("deepseek-v4-flash")); }); + +test("exclusive listing: any static row suppressed when provider has synced catalog", () => { + assert.equal( + shouldSuppressStaticModelForExclusiveListing({ + exclusiveListing: true, + providerHasSynced: true, + staticModelId: "claude-4.6-sonnet-high", + syncedModelIds: ["claude-4.6-sonnet", "composer-2.5"], + }), + true + ); + assert.equal( + shouldSuppressStaticModelForExclusiveListing({ + exclusiveListing: true, + providerHasSynced: true, + staticModelId: "composer-2.5", + syncedModelIds: ["claude-4.6-sonnet", "composer-2.5"], + }), + true + ); +}); + +test("exclusive listing: does not suppress when synced is empty", () => { + assert.equal( + shouldSuppressStaticModelForExclusiveListing({ + exclusiveListing: true, + providerHasSynced: false, + staticModelId: "claude-4.6-sonnet-high", + syncedModelIds: [], + }), + false + ); +}); + +test("exclusive listing: non-exclusive providers keep coverage behavior", () => { + assert.equal( + shouldSuppressStaticModelForExclusiveListing({ + exclusiveListing: false, + providerHasSynced: true, + staticModelId: "deepseek/deepseek-v4-flash", + syncedModelIds: ["gpt-5.6-luna"], + }), + false + ); + assert.equal( + shouldSuppressStaticModelForExclusiveListing({ + exclusiveListing: false, + providerHasSynced: true, + staticModelId: "gpt-5.6-luna", + syncedModelIds: ["gpt-5.6-luna"], + }), + true + ); +}); diff --git a/tests/unit/cursor-agent-protobuf.test.ts b/tests/unit/cursor-agent-protobuf.test.ts index 2ed49ac66d..72ae862f15 100644 --- a/tests/unit/cursor-agent-protobuf.test.ts +++ b/tests/unit/cursor-agent-protobuf.test.ts @@ -31,6 +31,18 @@ import { test("resolveRequestedModel maps cursor-agent's client-side aliases", () => { assert.deepEqual(resolveRequestedModel("auto"), { modelId: "default", parameters: [] }); + assert.deepEqual(resolveRequestedModel("auto-cost"), { + modelId: "default", + parameters: [{ id: "optimization", value: "cost" }], + }); + assert.deepEqual(resolveRequestedModel("auto-balance"), { + modelId: "default", + parameters: [{ id: "optimization", value: "balance" }], + }); + assert.deepEqual(resolveRequestedModel("auto-intelligence"), { + modelId: "default", + parameters: [{ id: "optimization", value: "intelligence" }], + }); assert.deepEqual(resolveRequestedModel("composer-2-fast"), { modelId: "composer-2", parameters: [{ id: "fast", value: "true" }], diff --git a/tests/unit/cursor-auto-catalog-entry.test.ts b/tests/unit/cursor-auto-catalog-entry.test.ts new file mode 100644 index 0000000000..a194885927 --- /dev/null +++ b/tests/unit/cursor-auto-catalog-entry.test.ts @@ -0,0 +1,31 @@ +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; +import { ensureCursorAutoCatalogEntry } from "@/lib/providerModels/cursorAutoCatalog"; + +describe("ensureCursorAutoCatalogEntry", () => { + it("injects auto when only wire id default is present", () => { + const models = ensureCursorAutoCatalogEntry([ + { id: "default", name: "Auto", owned_by: "cursor" }, + { id: "composer-2.5", name: "Composer 2.5", owned_by: "cursor" }, + ]); + assert.ok(models.some((m) => m.id === "auto")); + assert.ok(models.some((m) => m.id === "default")); + }); + + it("injects auto-cost / auto-balance / auto-intelligence", () => { + const models = ensureCursorAutoCatalogEntry([{ id: "auto", name: "Auto", owned_by: "cursor" }]); + const ids = models.map((m) => m.id); + assert.ok(ids.includes("auto-cost")); + assert.ok(ids.includes("auto-balance")); + assert.ok(ids.includes("auto-intelligence")); + }); + + it("does not duplicate existing auto entries", () => { + const models = ensureCursorAutoCatalogEntry([ + { id: "auto", name: "Auto", owned_by: "cursor" }, + { id: "auto-cost", name: "Auto (cost)", owned_by: "cursor" }, + ]); + assert.equal(models.filter((m) => m.id === "auto").length, 1); + assert.equal(models.filter((m) => m.id === "auto-cost").length, 1); + }); +}); diff --git a/tests/unit/cursor-exclusive-listing-merge.test.ts b/tests/unit/cursor-exclusive-listing-merge.test.ts new file mode 100644 index 0000000000..e888745d45 --- /dev/null +++ b/tests/unit/cursor-exclusive-listing-merge.test.ts @@ -0,0 +1,79 @@ +/** + * Cursor exclusive live-catalog listing: when synced AvailableModels exists, + * dashboard / Test All must list synced + injected auto* + custom only — + * never the static registry effort/premium rows. + */ +import { strict as assert } from "node:assert"; +import { describe, it } from "node:test"; +import { mergeProviderModelListing } from "@/lib/providers/mergeProviderModelListing"; + +describe("mergeProviderModelListing (cursor exclusive)", () => { + const registry = [ + { id: "auto", name: "Auto (Server Picks)" }, + { id: "claude-4.6-sonnet-high", name: "Claude Sonnet High" }, + { id: "gpt-5.5-high", name: "GPT 5.5 High" }, + { id: "composer-2.5", name: "Composer 2.5" }, + ]; + + it("exclusive + synced: drops static-only ids and injects auto*", () => { + const models = mergeProviderModelListing({ + providerId: "cursor", + registryModels: registry, + syncedModels: [ + { id: "composer-2.5", name: "Composer 2.5" }, + { id: "claude-4.6-sonnet", name: "Claude 4.6 Sonnet" }, + ], + customModels: [], + usesCuratedModelsOnly: false, + }); + + const ids = models.map((m) => m.id); + assert.ok(ids.includes("composer-2.5")); + assert.ok(ids.includes("claude-4.6-sonnet")); + assert.ok(ids.includes("auto")); + assert.ok(ids.includes("auto-cost")); + assert.ok(ids.includes("auto-balance")); + assert.ok(ids.includes("auto-intelligence")); + assert.equal(ids.includes("claude-4.6-sonnet-high"), false); + assert.equal(ids.includes("gpt-5.5-high"), false); + assert.ok(models.every((m) => m.source === "imported" || m.id.startsWith("auto"))); + }); + + it("exclusive + synced: keeps operator custom models", () => { + const models = mergeProviderModelListing({ + providerId: "cursor", + registryModels: registry, + syncedModels: [{ id: "composer-2.5", name: "Composer 2.5" }], + customModels: [{ id: "my-custom", name: "My Custom", source: "custom" }], + usesCuratedModelsOnly: false, + }); + const custom = models.find((m) => m.id === "my-custom"); + assert.ok(custom); + assert.equal(custom.source, "custom"); + }); + + it("exclusive + empty synced: falls back to registry ∪ custom", () => { + const models = mergeProviderModelListing({ + providerId: "cursor", + registryModels: registry, + syncedModels: [], + customModels: [{ id: "my-custom", name: "My Custom" }], + usesCuratedModelsOnly: false, + }); + const ids = models.map((m) => m.id); + assert.ok(ids.includes("claude-4.6-sonnet-high")); + assert.ok(ids.includes("my-custom")); + }); + + it("non-exclusive providers keep registry-first merge", () => { + const models = mergeProviderModelListing({ + providerId: "openai", + registryModels: [{ id: "gpt-4o", name: "GPT-4o" }], + syncedModels: [{ id: "gpt-4o-mini", name: "GPT-4o mini" }], + customModels: [], + usesCuratedModelsOnly: false, + }); + const ids = models.map((m) => m.id); + assert.deepEqual(ids, ["gpt-4o", "gpt-4o-mini"]); + }); +}); diff --git a/tests/unit/cursor-live-catalog-passthrough.test.ts b/tests/unit/cursor-live-catalog-passthrough.test.ts new file mode 100644 index 0000000000..1d54383a0c --- /dev/null +++ b/tests/unit/cursor-live-catalog-passthrough.test.ts @@ -0,0 +1,90 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + resolveRequestedModel, + encodeAgentRunRequest, +} from "../../open-sse/utils/cursorAgentProtobuf"; +import { CURSOR_REWRITE_FAILURE_IDS } from "./fixtures/cursor-rewrite-failure-ids"; + +test("resolveRequestedModel passes live-catalog Claude effort ids through verbatim", () => { + const live = new Set(["claude-opus-5-low"]); + assert.deepEqual(resolveRequestedModel("claude-opus-5-low", { liveCatalogIds: live }), { + modelId: "claude-opus-5-low", + parameters: [], + }); +}); + +test("resolveRequestedModel passes live-catalog GPT reasoning ids through verbatim", () => { + const live = new Set(["gpt-5.6-sol-medium"]); + assert.deepEqual(resolveRequestedModel("gpt-5.6-sol-medium", { liveCatalogIds: live }), { + modelId: "gpt-5.6-sol-medium", + parameters: [], + }); +}); + +test("resolveRequestedModel still strips effort when id is absent from live catalog", () => { + const live = new Set(["composer-2"]); + assert.deepEqual(resolveRequestedModel("claude-opus-5-low", { liveCatalogIds: live }), { + modelId: "claude-opus-5", + parameters: [{ id: "effort", value: "low" }], + }); + assert.deepEqual(resolveRequestedModel("gpt-5.5-high", { liveCatalogIds: live }), { + modelId: "gpt-5.5", + parameters: [{ id: "reasoning", value: "high" }], + }); +}); + +test("resolveRequestedModel still strips effort when liveCatalogIds is omitted", () => { + assert.deepEqual(resolveRequestedModel("claude-opus-4-8-high"), { + modelId: "claude-opus-4-8", + parameters: [{ id: "effort", value: "high" }], + }); +}); + +test("resolveRequestedModel maps auto to default even when auto is in the live catalog", () => { + const live = new Set(["auto", "auto-cost", "claude-opus-5-low"]); + assert.deepEqual(resolveRequestedModel("auto", { liveCatalogIds: live }), { + modelId: "default", + parameters: [], + }); + assert.deepEqual(resolveRequestedModel("auto-cost", { liveCatalogIds: live }), { + modelId: "default", + parameters: [{ id: "optimization", value: "cost" }], + }); +}); + +test("resolveRequestedModel passes composer-*-fast through when present in live catalog", () => { + const live = new Set(["composer-2.5-fast", "composer-2-fast"]); + assert.deepEqual(resolveRequestedModel("composer-2.5-fast", { liveCatalogIds: live }), { + modelId: "composer-2.5-fast", + parameters: [], + }); + assert.deepEqual(resolveRequestedModel("composer-2-fast", { liveCatalogIds: live }), { + modelId: "composer-2-fast", + parameters: [], + }); +}); + +test("resolveRequestedModel passes every rewrite-failure id through when all are live", () => { + const live = new Set(CURSOR_REWRITE_FAILURE_IDS); + for (const id of CURSOR_REWRITE_FAILURE_IDS) { + assert.deepEqual( + resolveRequestedModel(id, { liveCatalogIds: live }), + { modelId: id, parameters: [] }, + id + ); + } +}); + +test("encodeAgentRunRequest embeds verbatim live catalog model id", () => { + const live = new Set(["claude-opus-5-low"]); + const buf = encodeAgentRunRequest({ + modelId: "claude-opus-5-low", + userText: "hi", + liveCatalogIds: live, + }); + const text = buf.toString("latin1"); + const full = text.split("claude-opus-5-low").length - 1; + assert.ok(full >= 4, `verbatim id must appear in RequestedModel + ModelDetails (got ${full})`); + assert.ok(!text.includes("effort"), "must not emit effort parameter for live verbatim id"); +}); diff --git a/tests/unit/fixtures/cursor-rewrite-failure-ids.ts b/tests/unit/fixtures/cursor-rewrite-failure-ids.ts new file mode 100644 index 0000000000..dd064537c6 --- /dev/null +++ b/tests/unit/fixtures/cursor-rewrite-failure-ids.ts @@ -0,0 +1,92 @@ +/** + * Live-synced Cursor model ids that Test All failed when #7289 + * resolveRequestedModel stripped them to a missing base + parameter. + * Smoke checklist for catalog-aware pass-through. + */ +export const CURSOR_REWRITE_FAILURE_IDS = [ + // Claude (52) + "claude-4.5-opus-high", + "claude-4.6-opus-high", + "claude-4.6-opus-max", + "claude-4.6-sonnet-medium", + "claude-fable-5-low", + "claude-fable-5-medium", + "claude-fable-5-high", + "claude-fable-5-xhigh", + "claude-fable-5-max", + "claude-fable-5-thinking-low", + "claude-fable-5-thinking-medium", + "claude-fable-5-thinking-high", + "claude-fable-5-thinking-xhigh", + "claude-fable-5-thinking-max", + "claude-opus-4-7-low", + "claude-opus-4-7-medium", + "claude-opus-4-7-high", + "claude-opus-4-7-xhigh", + "claude-opus-4-7-max", + "claude-opus-4-7-thinking-low", + "claude-opus-4-7-thinking-medium", + "claude-opus-4-7-thinking-high", + "claude-opus-4-7-thinking-xhigh", + "claude-opus-4-7-thinking-max", + "claude-opus-4-8-low", + "claude-opus-4-8-medium", + "claude-opus-4-8-high", + "claude-opus-4-8-xhigh", + "claude-opus-4-8-max", + "claude-opus-4-8-thinking-low", + "claude-opus-4-8-thinking-medium", + "claude-opus-4-8-thinking-high", + "claude-opus-4-8-thinking-xhigh", + "claude-opus-4-8-thinking-max", + "claude-opus-5-low", + "claude-opus-5-medium", + "claude-opus-5-high", + "claude-opus-5-thinking-low", + "claude-opus-5-thinking-medium", + "claude-opus-5-thinking-high", + "claude-opus-5-thinking-xhigh", + "claude-opus-5-thinking-max", + "claude-sonnet-5-low", + "claude-sonnet-5-medium", + "claude-sonnet-5-high", + "claude-sonnet-5-xhigh", + "claude-sonnet-5-max", + "claude-sonnet-5-thinking-low", + "claude-sonnet-5-thinking-medium", + "claude-sonnet-5-thinking-high", + "claude-sonnet-5-thinking-xhigh", + "claude-sonnet-5-thinking-max", + // GPT (31) + "gpt-5.4-low", + "gpt-5.4-medium", + "gpt-5.4-high", + "gpt-5.4-xhigh", + "gpt-5.4-mini-low", + "gpt-5.4-mini-medium", + "gpt-5.4-mini-high", + "gpt-5.4-mini-xhigh", + "gpt-5.4-nano-low", + "gpt-5.4-nano-medium", + "gpt-5.4-nano-high", + "gpt-5.4-nano-xhigh", + "gpt-5.5-low", + "gpt-5.5-medium", + "gpt-5.5-high", + "gpt-5.5-extra-high", + "gpt-5.6-sol-low", + "gpt-5.6-sol-medium", + "gpt-5.6-sol-high", + "gpt-5.6-sol-xhigh", + "gpt-5.6-sol-max", + "gpt-5.6-terra-low", + "gpt-5.6-terra-medium", + "gpt-5.6-terra-high", + "gpt-5.6-terra-xhigh", + "gpt-5.6-terra-max", + "gpt-5.6-luna-low", + "gpt-5.6-luna-medium", + "gpt-5.6-luna-high", + "gpt-5.6-luna-xhigh", + "gpt-5.6-luna-max", +] as const; diff --git a/tests/unit/model-listing-capability-5420.test.ts b/tests/unit/model-listing-capability-5420.test.ts index 154adc020b..1ac26d7f1d 100644 --- a/tests/unit/model-listing-capability-5420.test.ts +++ b/tests/unit/model-listing-capability-5420.test.ts @@ -6,6 +6,7 @@ import { describe, it } from "node:test"; import { providerLacksModelListing, providerUsesCuratedModelsOnly, + providerUsesExclusiveSyncedListing, } from "@/lib/providers/modelListingCapability"; describe("providerLacksModelListing (#5420)", () => { @@ -36,3 +37,18 @@ describe("providerLacksModelListing (#5420)", () => { assert.equal(providerUsesCuratedModelsOnly("kimi-coding"), false); }); }); + +describe("providerUsesExclusiveSyncedListing", () => { + it("is true only for Cursor (id or alias)", () => { + assert.equal(providerUsesExclusiveSyncedListing("cursor"), true); + assert.equal(providerUsesExclusiveSyncedListing("cu"), true); + assert.equal(providerUsesExclusiveSyncedListing("Cursor"), true); + }); + + it("is false for other providers including authoritative live-catalog ones", () => { + assert.equal(providerUsesExclusiveSyncedListing("github"), false); + assert.equal(providerUsesExclusiveSyncedListing("command-code"), false); + assert.equal(providerUsesExclusiveSyncedListing("openai"), false); + assert.equal(providerUsesExclusiveSyncedListing(""), false); + }); +});