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
This commit is contained in:
Tam Nhu Tran
2026-02-17 20:53:03 +07:00
parent 9031e5a085
commit fae8716900
4 changed files with 82 additions and 11 deletions
+1
View File
@@ -60,6 +60,7 @@ ccs cursor stop
- `ghost_mode`: enabled - `ghost_mode`: enabled
- `auto_start`: disabled - `auto_start`: disabled
- Model list resolution: authenticated live fetch when available, with cached/default fallback. - 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. These values are managed in unified config and can be updated from CLI or dashboard.
+25 -10
View File
@@ -8,7 +8,7 @@ import * as http from 'http';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { CursorExecutor } from './cursor-executor'; import { CursorExecutor } from './cursor-executor';
import { checkAuthStatus } from './cursor-auth'; 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'; import type { CursorTool } from './cursor-protobuf-schema';
interface DaemonRuntimeOptions { interface DaemonRuntimeOptions {
@@ -229,10 +229,10 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
const parsedBody = (await readJsonBody(req)) as OpenAIChatRequest; const parsedBody = (await readJsonBody(req)) as OpenAIChatRequest;
const messages = normalizeMessages(parsedBody.messages); const messages = normalizeMessages(parsedBody.messages);
const model = const requestedModel =
typeof parsedBody.model === 'string' && parsedBody.model typeof parsedBody.model === 'string' && parsedBody.model.trim().length > 0
? parsedBody.model ? parsedBody.model.trim()
: DEFAULT_CURSOR_MODEL; : undefined;
const stream = parsedBody.stream === true; const stream = parsedBody.stream === true;
const authStatus = checkAuthStatus(); const authStatus = checkAuthStatus();
@@ -256,6 +256,25 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
return; 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 abortController = new AbortController();
const abortOnDisconnect = () => { const abortOnDisconnect = () => {
if (!abortController.signal.aborted && !res.writableEnded) { if (!abortController.signal.aborted && !res.writableEnded) {
@@ -271,11 +290,7 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser
model, model,
stream, stream,
signal: abortController.signal, signal: abortController.signal,
credentials: { credentials: daemonCredentials,
accessToken: authStatus.credentials.accessToken,
machineId: authStatus.credentials.machineId,
ghostMode: options.ghostMode,
},
body: { body: {
messages, messages,
tools: Array.isArray(parsedBody.tools) ? parsedBody.tools : undefined, tools: Array.isArray(parsedBody.tools) ? parsedBody.tools : undefined,
+35 -1
View File
@@ -264,10 +264,44 @@ export async function getAvailableModels(port: number): Promise<CursorModel[]> {
return fetchModelsFromDaemon(port); 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. * Get the default model.
* Uses GPT-5.3 Codex as default. * Uses GPT-5.3 Codex as default.
*/ */
export function getDefaultModel(): string { export function getDefaultModel(): string {
return DEFAULT_CURSOR_MODEL; return getCatalogDefaultModelId(DEFAULT_CURSOR_MODELS);
} }
+21
View File
@@ -15,6 +15,7 @@ import {
fetchModelsFromCursorApi, fetchModelsFromCursorApi,
getModelsForDaemon, getModelsForDaemon,
clearCursorModelsCache, clearCursorModelsCache,
resolveCursorRequestModel,
} from '../../../src/cursor/cursor-models'; } from '../../../src/cursor/cursor-models';
describe('DEFAULT_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', () => { describe('detectProvider', () => {
it('detects anthropic models', () => { it('detects anthropic models', () => {
expect(detectProvider('claude-4.5-sonnet')).toBe('anthropic'); expect(detectProvider('claude-4.5-sonnet')).toBe('anthropic');