From fae87169008b704c319f78fcac958dda435985d0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Feb 2026 20:51:57 +0700 Subject: [PATCH] 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 --- docs/cursor-integration.md | 1 + src/cursor/cursor-daemon-entry.ts | 35 +++++++++++++++++------- src/cursor/cursor-models.ts | 36 ++++++++++++++++++++++++- tests/unit/cursor/cursor-models.test.ts | 21 +++++++++++++++ 4 files changed, 82 insertions(+), 11 deletions(-) diff --git a/docs/cursor-integration.md b/docs/cursor-integration.md index fad1eabd..1aa32cda 100644 --- a/docs/cursor-integration.md +++ b/docs/cursor-integration.md @@ -60,6 +60,7 @@ ccs cursor stop - `ghost_mode`: enabled - `auto_start`: disabled - Model list resolution: authenticated live fetch when available, with cached/default fallback. +- Request model validation: if a requested model is not present in the available Cursor model catalog, daemon falls back to the resolved default model. These values are managed in unified config and can be updated from CLI or dashboard. diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts index 355cedfc..f6eb2f7d 100644 --- a/src/cursor/cursor-daemon-entry.ts +++ b/src/cursor/cursor-daemon-entry.ts @@ -8,7 +8,7 @@ import * as http from 'http'; import { Readable } from 'stream'; import { CursorExecutor } from './cursor-executor'; import { checkAuthStatus } from './cursor-auth'; -import { DEFAULT_CURSOR_MODEL, getModelsForDaemon } from './cursor-models'; +import { getModelsForDaemon, resolveCursorRequestModel } from './cursor-models'; import type { CursorTool } from './cursor-protobuf-schema'; interface DaemonRuntimeOptions { @@ -229,10 +229,10 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser const parsedBody = (await readJsonBody(req)) as OpenAIChatRequest; const messages = normalizeMessages(parsedBody.messages); - const model = - typeof parsedBody.model === 'string' && parsedBody.model - ? parsedBody.model - : DEFAULT_CURSOR_MODEL; + const requestedModel = + typeof parsedBody.model === 'string' && parsedBody.model.trim().length > 0 + ? parsedBody.model.trim() + : undefined; const stream = parsedBody.stream === true; const authStatus = checkAuthStatus(); @@ -256,6 +256,25 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser return; } + const daemonCredentials = { + accessToken: authStatus.credentials.accessToken, + machineId: authStatus.credentials.machineId, + ghostMode: options.ghostMode, + }; + const availableModels = await getModelsForDaemon({ + credentials: daemonCredentials, + }); + const model = resolveCursorRequestModel(requestedModel, availableModels); + if ( + requestedModel && + requestedModel !== model && + (process.env.CCS_DEBUG === '1' || process.env.CCS_DEBUG === 'true') + ) { + console.error( + `[cursor] Requested model "${requestedModel}" is unavailable; falling back to "${model}".` + ); + } + const abortController = new AbortController(); const abortOnDisconnect = () => { if (!abortController.signal.aborted && !res.writableEnded) { @@ -271,11 +290,7 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser model, stream, signal: abortController.signal, - credentials: { - accessToken: authStatus.credentials.accessToken, - machineId: authStatus.credentials.machineId, - ghostMode: options.ghostMode, - }, + credentials: daemonCredentials, body: { messages, tools: Array.isArray(parsedBody.tools) ? parsedBody.tools : undefined, diff --git a/src/cursor/cursor-models.ts b/src/cursor/cursor-models.ts index 12e7ad58..208a1a25 100644 --- a/src/cursor/cursor-models.ts +++ b/src/cursor/cursor-models.ts @@ -264,10 +264,44 @@ export async function getAvailableModels(port: number): Promise { 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 DEFAULT_CURSOR_MODEL; + return getCatalogDefaultModelId(DEFAULT_CURSOR_MODELS); } diff --git a/tests/unit/cursor/cursor-models.test.ts b/tests/unit/cursor/cursor-models.test.ts index 7d2dc8d0..0390f5e1 100644 --- a/tests/unit/cursor/cursor-models.test.ts +++ b/tests/unit/cursor/cursor-models.test.ts @@ -15,6 +15,7 @@ import { fetchModelsFromCursorApi, getModelsForDaemon, clearCursorModelsCache, + resolveCursorRequestModel, } from '../../../src/cursor/cursor-models'; describe('DEFAULT_CURSOR_MODELS', () => { @@ -50,6 +51,26 @@ describe('getDefaultModel', () => { }); }); +describe('resolveCursorRequestModel', () => { + it('keeps requested model when present in available models', () => { + const resolved = resolveCursorRequestModel('claude-4.6-opus', DEFAULT_CURSOR_MODELS); + expect(resolved).toBe('claude-4.6-opus'); + }); + + it('falls back to default when requested model is unavailable', () => { + const resolved = resolveCursorRequestModel('non-existent-model', DEFAULT_CURSOR_MODELS); + expect(resolved).toBe(DEFAULT_CURSOR_MODEL); + }); + + it('falls back to first available model when default id is absent from available set', () => { + const resolved = resolveCursorRequestModel('non-existent-model', [ + { id: 'fallback-1', name: 'Fallback 1', provider: 'openai' }, + { id: 'fallback-2', name: 'Fallback 2', provider: 'anthropic' }, + ]); + expect(resolved).toBe('fallback-1'); + }); +}); + describe('detectProvider', () => { it('detects anthropic models', () => { expect(detectProvider('claude-4.5-sonnet')).toBe('anthropic');