feat(cursor): sync model mapping across config and raw settings

- add cursor model fields to unified config defaults and YAML comments

- expand Cursor model catalog/defaults and return configured current model

- keep cursor.settings.json env model fields in sync with config updates

- align unit tests for new model defaults and route merge behavior

Refs #555
This commit is contained in:
Tam Nhu Tran
2026-02-14 16:57:04 +07:00
parent 9a76f866b0
commit 7b73658f87
7 changed files with 322 additions and 54 deletions
+6
View File
@@ -306,6 +306,10 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
port: partial.cursor?.port ?? DEFAULT_CURSOR_CONFIG.port,
auto_start: partial.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start,
ghost_mode: partial.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode,
model: partial.cursor?.model ?? DEFAULT_CURSOR_CONFIG.model,
opus_model: partial.cursor?.opus_model,
sonnet_model: partial.cursor?.sonnet_model,
haiku_model: partial.cursor?.haiku_model,
},
// Global env - injected into all non-Claude subscription profiles
global_env: {
@@ -603,6 +607,8 @@ function generateYamlWithComments(config: UnifiedConfig): string {
lines.push('# port: Port for cursor proxy daemon (default: 20129)');
lines.push('# auto_start: Auto-start daemon when CCS starts (default: false)');
lines.push('# ghost_mode: Disable telemetry for privacy (default: true)');
lines.push('# model: Default model ID (used for ANTHROPIC_MODEL)');
lines.push('# opus_model/sonnet_model/haiku_model: Optional tier model mapping');
lines.push('# ----------------------------------------------------------------------------');
lines.push(
yaml.dump({ cursor: config.cursor }, { indent: 2, lineWidth: -1, quotingType: '"' }).trim()
+9
View File
@@ -304,6 +304,14 @@ export interface CursorConfig {
auto_start: boolean;
/** Enable ghost mode to disable telemetry (default: true) */
ghost_mode: boolean;
/** Default model ID used by Cursor integration */
model: string;
/** Optional tier mapping for Claude-compatible model routing */
opus_model?: string;
/** Optional tier mapping for Claude-compatible model routing */
sonnet_model?: string;
/** Optional tier mapping for Claude-compatible model routing */
haiku_model?: string;
}
/**
@@ -723,6 +731,7 @@ export const DEFAULT_CURSOR_CONFIG: CursorConfig = {
port: 20129,
auto_start: false,
ghost_mode: true,
model: 'gpt-5.3-codex',
};
/**
+106 -23
View File
@@ -13,61 +13,143 @@ import { isDaemonRunning } from './cursor-daemon';
export const DEFAULT_CURSOR_PORT = 20129;
/** Default model ID */
export const DEFAULT_CURSOR_MODEL = 'gpt-4.1';
export const DEFAULT_CURSOR_MODEL = 'gpt-5.3-codex';
/**
* Default models available through Cursor IDE.
* Used as fallback when daemon is not reachable.
* Source: Cursor IDE supported models (Feb 2025)
* Source: Cursor docs model catalog (Feb 2026)
*/
export const DEFAULT_CURSOR_MODELS: CursorModel[] = [
// Anthropic Models
{
id: 'claude-sonnet-4',
name: 'Claude Sonnet 4',
id: 'claude-4.6-opus',
name: 'Claude 4.6 Opus',
provider: 'anthropic',
},
{
id: 'claude-sonnet-4.5',
name: 'Claude Sonnet 4.5',
id: 'claude-4.6-opus-fast-mode',
name: 'Claude 4.6 Opus (Fast mode)',
provider: 'anthropic',
},
{
id: 'claude-opus-4',
name: 'Claude Opus 4',
id: 'claude-4.5-sonnet',
name: 'Claude 4.5 Sonnet',
provider: 'anthropic',
},
{
id: 'claude-4.5-opus',
name: 'Claude 4.5 Opus',
provider: 'anthropic',
},
{
id: 'claude-4.5-haiku',
name: 'Claude 4.5 Haiku',
provider: 'anthropic',
},
{
id: 'claude-4-sonnet',
name: 'Claude 4 Sonnet',
provider: 'anthropic',
},
{
id: 'claude-4-sonnet-1m',
name: 'Claude 4 Sonnet 1M',
provider: 'anthropic',
},
// Cursor Models
{
id: 'composer-1.5',
name: 'Composer 1.5',
provider: 'cursor',
},
{
id: 'composer-1',
name: 'Composer 1',
provider: 'cursor',
},
// OpenAI Models
{
id: 'gpt-4.1',
name: 'GPT-4.1',
id: 'gpt-5.3-codex',
name: 'GPT-5.3 Codex',
provider: 'openai',
isDefault: true,
},
{
id: 'gpt-5.2-codex',
name: 'GPT-5.2 Codex',
provider: 'openai',
},
{
id: 'gpt-5.2',
name: 'GPT-5.2',
provider: 'openai',
},
{
id: 'gpt-5.1-codex',
name: 'GPT-5.1 Codex',
provider: 'openai',
},
{
id: 'gpt-5.1-codex-max',
name: 'GPT-5.1 Codex Max',
provider: 'openai',
},
{
id: 'gpt-5.1-codex-mini',
name: 'GPT-5.1 Codex Mini',
provider: 'openai',
},
{
id: 'gpt-5-codex',
name: 'GPT-5-Codex',
provider: 'openai',
},
{
id: 'gpt-5',
name: 'GPT-5',
provider: 'openai',
},
{
id: 'gpt-5-fast',
name: 'GPT-5 Fast',
provider: 'openai',
},
{
id: 'gpt-5-mini',
name: 'GPT-5 Mini',
provider: 'openai',
},
{
id: 'o3-mini',
name: 'O3 Mini',
provider: 'openai',
},
// Google Models
{
id: 'gemini-2.5-pro',
name: 'Gemini 2.5 Pro',
id: 'gemini-3-pro',
name: 'Gemini 3 Pro',
provider: 'google',
},
{
id: 'gemini-3-pro-image-preview',
name: 'Gemini 3 Pro Image Preview',
provider: 'google',
},
{
id: 'gemini-3-flash',
name: 'Gemini 3 Flash',
provider: 'google',
},
{
id: 'gemini-2.5-flash',
name: 'Gemini 2.5 Flash',
provider: 'google',
},
// Cursor Custom Models
// xAI Models
{
id: 'cursor-small',
name: 'Cursor Small',
provider: 'cursor',
id: 'grok-code',
name: 'Grok Code',
provider: 'xai',
},
];
@@ -154,7 +236,7 @@ export async function getAvailableModels(port: number): Promise<CursorModel[]> {
/**
* Get the default model.
* Uses gpt-4.1 as it's commonly available.
* Uses GPT-5.3 Codex as default.
*/
export function getDefaultModel(): string {
return DEFAULT_CURSOR_MODEL;
@@ -167,7 +249,8 @@ export function detectProvider(modelId: string): string {
if (modelId.includes('claude')) return 'anthropic';
if (modelId.includes('gpt') || /^o[1-9]\d*(-|$)/.test(modelId)) return 'openai';
if (modelId.includes('gemini')) return 'google';
if (modelId.includes('cursor')) return 'cursor';
if (modelId.includes('cursor') || modelId.includes('composer')) return 'cursor';
if (modelId.includes('grok')) return 'xai';
return 'unknown';
}
+1 -2
View File
@@ -7,7 +7,6 @@ import { Router } from 'express';
import {
getDaemonStatus,
getAvailableModels,
getDefaultModel,
startDaemon,
stopDaemon,
checkAuthStatus,
@@ -150,7 +149,7 @@ router.get('/models', async (_req: Request, res: Response): Promise<void> => {
try {
const cursorConfig = getCursorConfig();
const models = await getAvailableModels(cursorConfig.port);
res.json({ models, current: getDefaultModel() });
res.json({ models, current: cursorConfig.model });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
+132 -17
View File
@@ -13,6 +13,7 @@ import {
saveUnifiedConfig,
getCursorConfig,
} from '../../config/unified-config-loader';
import type { CursorConfig } from '../../config/unified-config-types';
const router = Router();
@@ -34,8 +35,78 @@ function parseLocalCursorPort(settings: unknown): number | null {
}
}
function parseRequiredModel(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function parseOptionalModel(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function getDefaultCursorSettings(cursorConfig: CursorConfig): { env: Record<string, string> } {
const model = cursorConfig.model || DEFAULT_CURSOR_CONFIG.model;
return {
env: {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${cursorConfig.port}`,
ANTHROPIC_AUTH_TOKEN: 'cursor-managed',
ANTHROPIC_MODEL: model,
ANTHROPIC_DEFAULT_OPUS_MODEL: cursorConfig.opus_model || model,
ANTHROPIC_DEFAULT_SONNET_MODEL: cursorConfig.sonnet_model || model,
ANTHROPIC_DEFAULT_HAIKU_MODEL: cursorConfig.haiku_model || model,
},
};
}
function syncRawSettingsFromCursorConfig(cursorConfig: CursorConfig): void {
const settingsPath = path.join(getCcsDir(), 'cursor.settings.json');
let settings: Record<string, unknown> = {};
if (fs.existsSync(settingsPath)) {
try {
const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
settings = parsed as Record<string, unknown>;
}
} catch {
settings = {};
}
}
const envSource = settings.env;
const env =
typeof envSource === 'object' && envSource !== null && !Array.isArray(envSource)
? { ...(envSource as Record<string, string>) }
: {};
const model = cursorConfig.model || DEFAULT_CURSOR_CONFIG.model;
const localPort = parseLocalCursorPort({ env });
if (!env.ANTHROPIC_BASE_URL || localPort !== null) {
env.ANTHROPIC_BASE_URL = `http://127.0.0.1:${cursorConfig.port}`;
}
if (!env.ANTHROPIC_AUTH_TOKEN) {
env.ANTHROPIC_AUTH_TOKEN = 'cursor-managed';
}
env.ANTHROPIC_MODEL = model;
env.ANTHROPIC_DEFAULT_OPUS_MODEL = cursorConfig.opus_model || model;
env.ANTHROPIC_DEFAULT_SONNET_MODEL = cursorConfig.sonnet_model || model;
env.ANTHROPIC_DEFAULT_HAIKU_MODEL = cursorConfig.haiku_model || model;
const nextSettings = {
...settings,
env,
};
const tempPath = settingsPath + '.tmp';
fs.writeFileSync(tempPath, JSON.stringify(nextSettings, null, 2) + '\n');
fs.renameSync(tempPath, settingsPath);
}
/**
* GET /api/cursor/settings - Get cursor config (port, auto_start, ghost_mode)
* GET /api/cursor/settings - Get cursor config
*/
router.get('/', (_req: Request, res: Response): void => {
try {
@@ -82,8 +153,40 @@ router.put('/', (req: Request, res: Response): void => {
res.status(400).json({ error: 'ghost_mode must be a boolean' });
return;
}
if ('model' in updates && !parseRequiredModel(updates.model)) {
res.status(400).json({ error: 'model must be a non-empty string' });
return;
}
if (
'opus_model' in updates &&
updates.opus_model !== undefined &&
updates.opus_model !== null &&
typeof updates.opus_model !== 'string'
) {
res.status(400).json({ error: 'opus_model must be a string' });
return;
}
if (
'sonnet_model' in updates &&
updates.sonnet_model !== undefined &&
updates.sonnet_model !== null &&
typeof updates.sonnet_model !== 'string'
) {
res.status(400).json({ error: 'sonnet_model must be a string' });
return;
}
if (
'haiku_model' in updates &&
updates.haiku_model !== undefined &&
updates.haiku_model !== null &&
typeof updates.haiku_model !== 'string'
) {
res.status(400).json({ error: 'haiku_model must be a string' });
return;
}
const config = loadOrCreateUnifiedConfig();
const normalizedModel = parseRequiredModel(updates.model);
// Merge updates with existing config
// Only known fields are merged — unknown properties are ignored
@@ -94,9 +197,23 @@ router.put('/', (req: Request, res: Response): void => {
updates.auto_start ?? config.cursor?.auto_start ?? DEFAULT_CURSOR_CONFIG.auto_start,
ghost_mode:
updates.ghost_mode ?? config.cursor?.ghost_mode ?? DEFAULT_CURSOR_CONFIG.ghost_mode,
model: normalizedModel ?? config.cursor?.model ?? DEFAULT_CURSOR_CONFIG.model,
opus_model:
'opus_model' in updates
? parseOptionalModel(updates.opus_model)
: config.cursor?.opus_model,
sonnet_model:
'sonnet_model' in updates
? parseOptionalModel(updates.sonnet_model)
: config.cursor?.sonnet_model,
haiku_model:
'haiku_model' in updates
? parseOptionalModel(updates.haiku_model)
: config.cursor?.haiku_model,
};
saveUnifiedConfig(config);
syncRawSettingsFromCursorConfig(config.cursor);
res.json({ success: true, cursor: config.cursor });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
@@ -114,14 +231,7 @@ router.get('/raw', (_req: Request, res: Response): void => {
// If file doesn't exist, return default structure
if (!fs.existsSync(settingsPath)) {
// Create settings structure matching Cursor pattern
// Use 127.0.0.1 instead of localhost for more reliable local connections
const defaultSettings = {
env: {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${cursorConfig.port}`,
ANTHROPIC_AUTH_TOKEN: 'cursor-managed',
},
};
const defaultSettings = getDefaultCursorSettings(cursorConfig);
res.json({
settings: defaultSettings,
@@ -186,14 +296,19 @@ router.put('/raw', (req: Request, res: Response): void => {
// Keep unified config aligned with raw settings edits (parity with Copilot raw editor).
const parsedPort = parseLocalCursorPort(settings);
if (parsedPort) {
const config = loadOrCreateUnifiedConfig();
config.cursor = {
...(config.cursor ?? DEFAULT_CURSOR_CONFIG),
port: parsedPort,
};
saveUnifiedConfig(config);
}
const config = loadOrCreateUnifiedConfig();
const env = (settings as { env?: Record<string, unknown> }).env ?? {};
const model = parseRequiredModel(env.ANTHROPIC_MODEL) ?? config.cursor?.model;
config.cursor = {
...(config.cursor ?? DEFAULT_CURSOR_CONFIG),
...(parsedPort !== null ? { port: parsedPort } : {}),
...(model ? { model } : {}),
opus_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_OPUS_MODEL),
sonnet_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_SONNET_MODEL),
haiku_model: parseOptionalModel(env.ANTHROPIC_DEFAULT_HAIKU_MODEL),
};
saveUnifiedConfig(config);
const stat = fs.statSync(settingsPath);
res.json({ success: true, mtime: stat.mtimeMs });
+14 -9
View File
@@ -35,8 +35,8 @@ describe('DEFAULT_CURSOR_PORT', () => {
});
describe('DEFAULT_CURSOR_MODEL', () => {
it('is gpt-4.1', () => {
expect(DEFAULT_CURSOR_MODEL).toBe('gpt-4.1');
it('is gpt-5.3-codex', () => {
expect(DEFAULT_CURSOR_MODEL).toBe('gpt-5.3-codex');
});
});
@@ -48,12 +48,12 @@ describe('getDefaultModel', () => {
describe('detectProvider', () => {
it('detects anthropic models', () => {
expect(detectProvider('claude-sonnet-4')).toBe('anthropic');
expect(detectProvider('claude-opus-4')).toBe('anthropic');
expect(detectProvider('claude-4.5-sonnet')).toBe('anthropic');
expect(detectProvider('claude-4.6-opus')).toBe('anthropic');
});
it('detects openai models', () => {
expect(detectProvider('gpt-4.1')).toBe('openai');
expect(detectProvider('gpt-5.3-codex')).toBe('openai');
expect(detectProvider('gpt-5-mini')).toBe('openai');
expect(detectProvider('o3-mini')).toBe('openai');
});
@@ -65,11 +65,16 @@ describe('detectProvider', () => {
});
it('detects google models', () => {
expect(detectProvider('gemini-2.5-pro')).toBe('google');
expect(detectProvider('gemini-3-pro')).toBe('google');
});
it('detects cursor models', () => {
expect(detectProvider('cursor-small')).toBe('cursor');
expect(detectProvider('composer-1.5')).toBe('cursor');
expect(detectProvider('cursor-model')).toBe('cursor');
});
it('detects xai models', () => {
expect(detectProvider('grok-code')).toBe('xai');
});
it('defaults to unknown for unrecognized models', () => {
@@ -79,8 +84,8 @@ describe('detectProvider', () => {
describe('formatModelName', () => {
it('returns catalog name for known models', () => {
expect(formatModelName('claude-sonnet-4')).toBe('Claude Sonnet 4');
expect(formatModelName('gpt-4.1')).toBe('GPT-4.1');
expect(formatModelName('claude-4.6-opus')).toBe('Claude 4.6 Opus');
expect(formatModelName('gpt-5.3-codex')).toBe('GPT-5.3 Codex');
});
it('converts kebab-case to title case for unknown models', () => {
@@ -128,6 +128,13 @@ describe('Cursor Settings Routes Logic', () => {
port: updates.port ?? config.cursor?.port ?? 3000,
auto_start: updates.auto_start ?? config.cursor?.auto_start ?? false,
ghost_mode: updates.ghost_mode ?? config.cursor?.ghost_mode ?? false,
model: updates.model ?? config.cursor?.model ?? 'gpt-5.3-codex',
opus_model:
updates.opus_model !== undefined ? updates.opus_model : config.cursor?.opus_model,
sonnet_model:
updates.sonnet_model !== undefined ? updates.sonnet_model : config.cursor?.sonnet_model,
haiku_model:
updates.haiku_model !== undefined ? updates.haiku_model : config.cursor?.haiku_model,
};
expect(cursorConfig.port).toBe(5000);
@@ -137,7 +144,13 @@ describe('Cursor Settings Routes Logic', () => {
it('updates port only', () => {
const config = loadOrCreateUnifiedConfig();
config.cursor = { enabled: false, port: 3000, auto_start: false, ghost_mode: false };
config.cursor = {
enabled: false,
port: 3000,
auto_start: false,
ghost_mode: false,
model: 'gpt-5.3-codex',
};
saveUnifiedConfig(config);
const updates = { port: 4000 };
@@ -146,6 +159,13 @@ describe('Cursor Settings Routes Logic', () => {
port: updates.port ?? config.cursor?.port ?? 3000,
auto_start: updates.auto_start ?? config.cursor?.auto_start ?? false,
ghost_mode: updates.ghost_mode ?? config.cursor?.ghost_mode ?? false,
model: updates.model ?? config.cursor?.model ?? 'gpt-5.3-codex',
opus_model:
updates.opus_model !== undefined ? updates.opus_model : config.cursor?.opus_model,
sonnet_model:
updates.sonnet_model !== undefined ? updates.sonnet_model : config.cursor?.sonnet_model,
haiku_model:
updates.haiku_model !== undefined ? updates.haiku_model : config.cursor?.haiku_model,
};
expect(cursorConfig.port).toBe(4000);
@@ -155,7 +175,13 @@ describe('Cursor Settings Routes Logic', () => {
it('updates auto_start only', () => {
const config = loadOrCreateUnifiedConfig();
config.cursor = { enabled: false, port: 3000, auto_start: false, ghost_mode: false };
config.cursor = {
enabled: false,
port: 3000,
auto_start: false,
ghost_mode: false,
model: 'gpt-5.3-codex',
};
saveUnifiedConfig(config);
const updates = { auto_start: true };
@@ -164,6 +190,13 @@ describe('Cursor Settings Routes Logic', () => {
port: updates.port ?? config.cursor?.port ?? 3000,
auto_start: updates.auto_start ?? config.cursor?.auto_start ?? false,
ghost_mode: updates.ghost_mode ?? config.cursor?.ghost_mode ?? false,
model: updates.model ?? config.cursor?.model ?? 'gpt-5.3-codex',
opus_model:
updates.opus_model !== undefined ? updates.opus_model : config.cursor?.opus_model,
sonnet_model:
updates.sonnet_model !== undefined ? updates.sonnet_model : config.cursor?.sonnet_model,
haiku_model:
updates.haiku_model !== undefined ? updates.haiku_model : config.cursor?.haiku_model,
};
expect(cursorConfig.port).toBe(3000);
@@ -173,7 +206,13 @@ describe('Cursor Settings Routes Logic', () => {
it('updates ghost_mode only', () => {
const config = loadOrCreateUnifiedConfig();
config.cursor = { enabled: false, port: 3000, auto_start: false, ghost_mode: false };
config.cursor = {
enabled: false,
port: 3000,
auto_start: false,
ghost_mode: false,
model: 'gpt-5.3-codex',
};
saveUnifiedConfig(config);
const updates = { ghost_mode: true };
@@ -182,6 +221,13 @@ describe('Cursor Settings Routes Logic', () => {
port: updates.port ?? config.cursor?.port ?? 3000,
auto_start: updates.auto_start ?? config.cursor?.auto_start ?? false,
ghost_mode: updates.ghost_mode ?? config.cursor?.ghost_mode ?? false,
model: updates.model ?? config.cursor?.model ?? 'gpt-5.3-codex',
opus_model:
updates.opus_model !== undefined ? updates.opus_model : config.cursor?.opus_model,
sonnet_model:
updates.sonnet_model !== undefined ? updates.sonnet_model : config.cursor?.sonnet_model,
haiku_model:
updates.haiku_model !== undefined ? updates.haiku_model : config.cursor?.haiku_model,
};
expect(cursorConfig.port).toBe(3000);
@@ -203,11 +249,16 @@ describe('Cursor Settings Routes Logic', () => {
env: {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${cursorPort}`,
ANTHROPIC_AUTH_TOKEN: 'cursor-managed',
ANTHROPIC_MODEL: 'gpt-5.3-codex',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5.3-codex',
},
};
expect(defaultSettings.env.ANTHROPIC_BASE_URL).toContain('http://127.0.0.1:');
expect(defaultSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('cursor-managed');
expect(defaultSettings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
});
it('reads existing file', () => {