Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/providers/CURSOR-DOCKER.md
Original file line number Diff line number Diff line change
@@ -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
8 changes: 7 additions & 1 deletion docs/providers/meta.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
21 changes: 20 additions & 1 deletion open-sse/executors/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ReadonlySet<string> | 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: {
Expand All @@ -819,7 +834,10 @@ export class CursorExecutor extends BaseExecutor {
}
): Promise<{ body: Uint8Array; blobStore: Map<string, Buffer> }> {
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<string, Buffer>();
const requestBody = buildAgentRequestBody({
Expand All @@ -829,6 +847,7 @@ export class CursorExecutor extends BaseExecutor {
tools,
blobStore,
images,
liveCatalogIds,
});
return { body: requestBody, blobStore };
}
Expand Down
48 changes: 46 additions & 2 deletions open-sse/utils/cursorAgentProtobuf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -342,14 +344,52 @@ 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<string>;
};

export function resolveRequestedModel(
modelId: string,
opts?: ResolveRequestedModelOptions
): {
modelId: string;
parameters: Array<{ id: string; value: string }>;
} {
const normalized = normalizeCursorModelId(modelId);
if (normalized === "auto") {
return { modelId: "default", parameters: [] };
}
// OpenCodex-style router variants: auto-cost / auto-balance / auto-intelligence
// → wire `default` + ModelParameter { id: "optimization", value: <level> }.
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")) {
Expand Down Expand Up @@ -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<string>;
};

export { cursorImageAttachmentPath, encodeSelectedImageBody };
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, (typeof allModels)[0]>();
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);

Expand Down
3 changes: 2 additions & 1 deletion src/app/api/providers/[id]/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down
43 changes: 31 additions & 12 deletions src/app/api/v1/models/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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()) {
Expand Down
23 changes: 23 additions & 0 deletions src/app/api/v1/models/catalogSyncedCoverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading