/** * 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 { 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 { 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 { 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 { 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); }