Files
ccs/src/cursor/cursor-models.ts
T
Tam Nhu Tran fae8716900 fix(cursor): fallback when requested model is unavailable
- resolve request model against current available catalog

- choose safe default when configured default id is not present

- add tests and docs for daemon request-model fallback behavior
2026-02-17 20:53:03 +07:00

308 lines
8.8 KiB
TypeScript

/**
* Cursor Model Catalog
*
* Manages available models from Cursor IDE.
*/
import * as http from 'http';
import type { CursorModel } from './types';
import type { CursorApiCredentials } from './cursor-protobuf-schema';
import { isDaemonRunning } from './cursor-daemon';
import { buildCursorModelsHeaders } from './cursor-client-policy';
import {
DEFAULT_CURSOR_MODEL,
DEFAULT_CURSOR_MODELS,
detectProvider,
formatModelName,
} from './cursor-default-models';
/** Default daemon port */
export const DEFAULT_CURSOR_PORT = 20129;
export { DEFAULT_CURSOR_MODEL, DEFAULT_CURSOR_MODELS, detectProvider, formatModelName };
const CURSOR_MODELS_API_ENDPOINT = 'https://api2.cursor.sh/v1/models';
const CURSOR_MODELS_CACHE_TTL_MS = 5 * 60 * 1000;
let liveModelsCache: {
models: CursorModel[];
expiresAtMs: number;
} | null = null;
interface CursorModelsApiResponse {
data?: Array<{ id?: unknown; name?: unknown; provider?: unknown }>;
models?: Array<{ id?: unknown; name?: unknown; provider?: unknown }>;
}
function debugLog(message: string, error?: unknown): void {
if (!process.env.CCS_DEBUG) return;
if (error) {
console.error(`[cursor] ${message}`, error);
return;
}
console.error(`[cursor] ${message}`);
}
function normalizeModelRecords(
records: Array<{ id?: unknown; name?: unknown; provider?: unknown }>
): CursorModel[] {
const models: CursorModel[] = [];
for (const record of records) {
if (!record || typeof record !== 'object') continue;
if (typeof record.id !== 'string' || !record.id) continue;
const modelId = record.id;
const modelName = typeof record.name === 'string' && record.name ? record.name : modelId;
const provider =
typeof record.provider === 'string' && record.provider
? record.provider
: detectProvider(modelId);
models.push({
id: modelId,
name: modelName,
provider,
isDefault: modelId === DEFAULT_CURSOR_MODEL,
});
}
return models;
}
function parseApiModelsResponse(payload: unknown): CursorModel[] | null {
if (!payload || typeof payload !== 'object') return null;
const response = payload as CursorModelsApiResponse;
const records = Array.isArray(response.data)
? response.data
: Array.isArray(response.models)
? response.models
: null;
if (!records) return null;
const models = normalizeModelRecords(records);
return models.length > 0 ? models : null;
}
function getCachedLiveModels(nowMs: number = Date.now()): CursorModel[] | null {
if (!liveModelsCache) return null;
if (liveModelsCache.expiresAtMs <= nowMs) {
liveModelsCache = null;
return null;
}
return liveModelsCache.models;
}
function setCachedLiveModels(models: CursorModel[], nowMs: number = Date.now()): void {
liveModelsCache = {
models,
expiresAtMs: nowMs + CURSOR_MODELS_CACHE_TTL_MS,
};
}
export function clearCursorModelsCache(): void {
liveModelsCache = null;
}
export async function fetchModelsFromCursorApi(
credentials: CursorApiCredentials,
options: {
endpoint?: string;
timeoutMs?: number;
} = {}
): Promise<CursorModel[] | null> {
if (!credentials.accessToken || !credentials.machineId) {
return null;
}
const endpoint = options.endpoint || CURSOR_MODELS_API_ENDPOINT;
const timeoutMs = options.timeoutMs ?? 5000;
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), timeoutMs);
try {
const response = await fetch(endpoint, {
method: 'GET',
headers: buildCursorModelsHeaders(credentials),
signal: abortController.signal,
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
clearCursorModelsCache();
}
debugLog(`Cursor models API returned ${response.status} (${endpoint})`);
return null;
}
const payload = (await response.json()) as unknown;
const parsed = parseApiModelsResponse(payload);
if (!parsed) {
debugLog(`Cursor models API payload shape invalid (${endpoint})`);
}
return parsed;
} catch (error) {
debugLog(`Cursor models API fetch failed (${endpoint})`, error);
return null;
} finally {
clearTimeout(timeout);
}
}
export async function getModelsForDaemon(
options: {
credentials?: CursorApiCredentials | null;
endpoint?: string;
timeoutMs?: number;
} = {}
): Promise<CursorModel[]> {
const cached = getCachedLiveModels();
if (cached) {
return cached;
}
const credentials = options.credentials;
if (credentials?.accessToken && credentials.machineId) {
const liveModels = await fetchModelsFromCursorApi(credentials, {
endpoint: options.endpoint,
timeoutMs: options.timeoutMs,
});
if (liveModels && liveModels.length > 0) {
setCachedLiveModels(liveModels);
return liveModels;
}
}
return DEFAULT_CURSOR_MODELS;
}
/**
* Fetch available models from running cursor daemon.
*
* @param port The port cursor daemon is running on
* @returns List of available models
*/
export async function fetchModelsFromDaemon(port: number): Promise<CursorModel[]> {
return new Promise((resolve) => {
let resolved = false;
const safeResolve = (models: CursorModel[]) => {
if (resolved) return;
resolved = true;
resolve(models);
};
const req = http.request(
{
// Use 127.0.0.1 instead of localhost for more reliable local connections
hostname: '127.0.0.1',
port,
path: '/v1/models',
method: 'GET',
timeout: 5000,
},
(res) => {
const MAX_BODY_SIZE = 1024 * 1024; // 1MB limit
let data = '';
res.on('data', (chunk) => {
data += chunk;
if (data.length > MAX_BODY_SIZE) {
debugLog('Cursor daemon /v1/models body exceeded 1MB; falling back to defaults');
req.destroy();
safeResolve(DEFAULT_CURSOR_MODELS);
}
});
res.on('end', () => {
try {
const response = JSON.parse(data) as { data?: Array<{ id?: unknown }> };
if (Array.isArray(response.data)) {
const models: CursorModel[] = response.data
.filter((m) => m && typeof m.id === 'string' && m.id.length > 0)
.map((m) => ({
id: m.id as string,
name: formatModelName(m.id as string),
provider: detectProvider(m.id as string),
isDefault: m.id === DEFAULT_CURSOR_MODEL,
}));
safeResolve(models.length > 0 ? models : DEFAULT_CURSOR_MODELS);
} else {
debugLog('Cursor daemon /v1/models payload missing data[]; falling back to defaults');
safeResolve(DEFAULT_CURSOR_MODELS);
}
} catch (error) {
debugLog(
'Cursor daemon /v1/models returned invalid JSON; falling back to defaults',
error
);
safeResolve(DEFAULT_CURSOR_MODELS);
}
});
}
);
req.on('error', (error) => {
debugLog('Cursor daemon /v1/models request failed; falling back to defaults', error);
safeResolve(DEFAULT_CURSOR_MODELS);
});
req.on('timeout', () => {
debugLog('Cursor daemon /v1/models request timed out; falling back to defaults');
req.destroy();
safeResolve(DEFAULT_CURSOR_MODELS);
});
req.end();
});
}
/**
* Get available models (from daemon or defaults).
* Checks daemon health first to avoid 5s timeout when daemon is not running.
*/
export async function getAvailableModels(port: number): Promise<CursorModel[]> {
if (!(await isDaemonRunning(port))) {
return DEFAULT_CURSOR_MODELS;
}
return fetchModelsFromDaemon(port);
}
function getCatalogDefaultModelId(availableModels: CursorModel[]): string {
if (availableModels.some((model) => model.id === DEFAULT_CURSOR_MODEL)) {
return DEFAULT_CURSOR_MODEL;
}
const explicitDefault = availableModels.find((model) => model.isDefault)?.id;
if (explicitDefault) {
return explicitDefault;
}
const firstAvailable = availableModels.find(
(model) => typeof model.id === 'string' && model.id.trim().length > 0
)?.id;
return firstAvailable || DEFAULT_CURSOR_MODEL;
}
export function resolveCursorRequestModel(
requestedModel: string | null | undefined,
availableModels: CursorModel[]
): string {
const fallbackModel = getCatalogDefaultModelId(availableModels);
const normalizedRequested = typeof requestedModel === 'string' ? requestedModel.trim() : '';
if (!normalizedRequested) {
return fallbackModel;
}
if (availableModels.some((model) => model.id === normalizedRequested)) {
return normalizedRequested;
}
return fallbackModel;
}
/**
* Get the default model.
* Uses GPT-5.3 Codex as default.
*/
export function getDefaultModel(): string {
return getCatalogDefaultModelId(DEFAULT_CURSOR_MODELS);
}