mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-12 02:02:13 +03:00
feat(cursor): exclusive live listing + verbatim AgentRun model ids (#9911)
* feat(cursor): prefer live synced catalog for listing and Test All When an active synced Cursor catalog exists, list only live models plus injected auto routers (and customs). Keep the static registry as offline fallback. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(cursor): send live-catalog model ids verbatim on AgentRun Skip #7289 effort/reasoning splits when the exact id is in the active synced Cursor catalog so AgentRun does not rewrite flattened live ids into missing bases that return AI Model Not Found. Also wires auto-cost/balance/intelligence to default + optimization for the injected routers. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: yansigit <yansigit@users.noreply.github.com>
This commit is contained in:
33
docs/providers/CURSOR-DOCKER.md
Normal file
33
docs/providers/CURSOR-DOCKER.md
Normal file
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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<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: {
|
||||
@@ -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({
|
||||
@@ -829,6 +847,7 @@ export class CursorExecutor extends BaseExecutor {
|
||||
tools,
|
||||
blobStore,
|
||||
images,
|
||||
liveCatalogIds,
|
||||
});
|
||||
return { body: requestBody, blobStore };
|
||||
}
|
||||
|
||||
@@ -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<string>;
|
||||
};
|
||||
|
||||
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: <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")) {
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
|
||||
@@ -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<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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
60
src/lib/providerModels/cursorAutoCatalog.ts
Normal file
60
src/lib/providerModels/cursorAutoCatalog.ts
Normal file
@@ -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<T extends CursorAutoCatalogEntry>(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;
|
||||
}
|
||||
96
src/lib/providers/mergeProviderModelListing.ts
Normal file
96
src/lib/providers/mergeProviderModelListing.ts
Normal file
@@ -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<string, ProviderListingModel>();
|
||||
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]);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
31
tests/unit/cursor-auto-catalog-entry.test.ts
Normal file
31
tests/unit/cursor-auto-catalog-entry.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
79
tests/unit/cursor-exclusive-listing-merge.test.ts
Normal file
79
tests/unit/cursor-exclusive-listing-merge.test.ts
Normal file
@@ -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"]);
|
||||
});
|
||||
});
|
||||
90
tests/unit/cursor-live-catalog-passthrough.test.ts
Normal file
90
tests/unit/cursor-live-catalog-passthrough.test.ts
Normal file
@@ -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<string>(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");
|
||||
});
|
||||
92
tests/unit/fixtures/cursor-rewrite-failure-ids.ts
Normal file
92
tests/unit/fixtures/cursor-rewrite-failure-ids.ts
Normal file
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user