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');