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
- `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.
+25 -10
View File
@@ -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,
+35 -1
View File
@@ -264,10 +264,44 @@ export async function getAvailableModels(port: number): Promise<CursorModel[]> {
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);
}
+21
View File
@@ -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');