From a1a8cad2b3e7a0129c5972be70775961f6777f36 Mon Sep 17 00:00:00 2001 From: Alex-wuhu Date: Fri, 13 Mar 2026 16:28:47 +0800 Subject: [PATCH 01/48] feat(novita): add Novita AI provider preset - Add novita preset with OpenAI-compatible endpoint - Default model: deepseek/deepseek-v3.2 - Endpoint: https://api.novita.ai/openai - API key via NOVITA_API_KEY env var --- src/shared/provider-preset-catalog.ts | 15 ++++++++ .../unit/api/provider-presets-novita.test.ts | 35 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 tests/unit/api/provider-presets-novita.test.ts diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index a1704d0d..b64d22d4 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -21,6 +21,7 @@ export const PROVIDER_PRESET_IDS = [ 'deepseek', 'qwen', 'ollama-cloud', + 'novita', ] as const; export type ProviderPresetId = (typeof PROVIDER_PRESET_IDS)[number]; @@ -254,6 +255,20 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ badge: 'Cloud', icon: '/icons/ollama.svg', }, + { + id: 'novita', + name: 'Novita AI', + description: 'OpenAI-compatible API (Llama, Mistral, Qwen, and more)', + baseUrl: 'https://api.novita.ai/openai', + defaultProfileName: 'novita', + defaultModel: 'deepseek/deepseek-v3.2', + apiKeyPlaceholder: 'YOUR_NOVITA_API_KEY', + apiKeyHint: 'Get your API key at novita.ai', + category: 'alternative', + requiresApiKey: true, + badge: 'OpenAI-compatible', + icon: '/icons/novita.svg', + }, ]; function clonePresetDefinition(preset: ProviderPresetDefinition): ProviderPresetDefinition { diff --git a/tests/unit/api/provider-presets-novita.test.ts b/tests/unit/api/provider-presets-novita.test.ts new file mode 100644 index 00000000..976737b5 --- /dev/null +++ b/tests/unit/api/provider-presets-novita.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'bun:test'; +import { getPresetById, isValidPresetId } from '../../../src/api/services/provider-presets'; + +describe('provider-presets-novita', () => { + it('resolves novita preset id', () => { + const preset = getPresetById('novita'); + expect(preset?.id).toBe('novita'); + expect(preset?.baseUrl).toBe('https://api.novita.ai/openai'); + expect(preset?.defaultProfileName).toBe('novita'); + }); + + it('resolves novita preset with expected model IDs', () => { + const preset = getPresetById('novita'); + expect(preset?.defaultModel).toBe('deepseek/deepseek-v3.2'); + }); + + it('validates novita preset requires API key', () => { + const preset = getPresetById('novita'); + expect(preset?.requiresApiKey).toBe(true); + }); + + it('treats novita as a valid preset id', () => { + expect(isValidPresetId('novita')).toBe(true); + }); + + it('handles whitespace in novita preset id', () => { + const preset = getPresetById(' novita '); + expect(preset?.id).toBe('novita'); + }); + + it('handles uppercase novita preset id', () => { + const preset = getPresetById('NOVITA'); + expect(preset?.id).toBe('novita'); + }); +}); From 551591ef18eda0a3cbd5a9ed99c5865d2bb20dfb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 16 Mar 2026 13:43:23 -0400 Subject: [PATCH 02/48] fix(cliproxy): make codex defaults free-plan safe --- config/base-codex.settings.json | 8 +- docs/codebase-summary.md | 3 +- docs/project-roadmap.md | 6 +- src/cliproxy/codex-plan-compatibility.ts | 89 ++++++++++++++ src/cliproxy/executor/index.ts | 9 ++ src/cliproxy/index.ts | 5 + src/cliproxy/model-catalog.ts | 93 ++++++++++++-- src/cliproxy/model-config.ts | 6 +- .../cliproxy/codex-plan-compatibility.test.ts | 114 ++++++++++++++++++ .../cliproxy/variant-update-service.test.ts | 6 +- 10 files changed, 314 insertions(+), 25 deletions(-) create mode 100644 src/cliproxy/codex-plan-compatibility.ts create mode 100644 tests/unit/cliproxy/codex-plan-compatibility.test.ts diff --git a/config/base-codex.settings.json b/config/base-codex.settings.json index 390d568d..5dae2d86 100644 --- a/config/base-codex.settings.json +++ b/config/base-codex.settings.json @@ -2,9 +2,9 @@ "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/codex", "ANTHROPIC_AUTH_TOKEN": "ccs-internal-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.1-codex-mini" + "ANTHROPIC_MODEL": "gpt-5-codex", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5-codex", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5-codex", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5-codex-mini" } } diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index ae78f67a..507a1901 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,6 +1,6 @@ # CCS Codebase Summary -Last Updated: 2026-02-24 +Last Updated: 2026-03-16 Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, and account-context validation hardening. @@ -96,6 +96,7 @@ src/ │ ├── auth-handler.ts # Authentication handling │ ├── model-catalog.ts # Provider model definitions │ ├── model-config.ts # Model configuration +│ ├── codex-plan-compatibility.ts # Codex free/paid model fallback guardrails │ ├── service-manager.ts # Background service │ ├── proxy-detector.ts # Running proxy detection │ ├── startup-lock.ts # Race condition prevention diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 8a721c93..6da3c1b8 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-02-12 +Last Updated: 2026-03-16 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -39,6 +39,10 @@ All major modularization work is complete. The codebase evolved from monolithic ## Current Status +### Recent Fixes + +- **#724**: Codex startup is now free-plan safe. CCS defaults new Codex sessions to a cross-plan model and auto-repairs stale paid-only Codex defaults when the active account is on the free plan. + ### Maintainability Hardening Kickoff - Issue owner: Stream D for **#542** diff --git a/src/cliproxy/codex-plan-compatibility.ts b/src/cliproxy/codex-plan-compatibility.ts new file mode 100644 index 00000000..91a30380 --- /dev/null +++ b/src/cliproxy/codex-plan-compatibility.ts @@ -0,0 +1,89 @@ +import { getDefaultAccount } from './account-manager'; +import { fetchCodexQuota } from './quota-fetcher-codex'; +import { getCachedQuota, setCachedQuota } from './quota-response-cache'; +import type { CodexQuotaResult } from './quota-types'; +import { updateSettingsModel } from './services/variant-settings'; +import { info, warn } from '../utils/ui'; + +export type CodexPlanType = CodexQuotaResult['planType']; + +const FREE_SAFE_DEFAULT_MODEL = 'gpt-5-codex'; +const FREE_SAFE_FAST_MODEL = 'gpt-5-codex-mini'; +const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; +const CODEX_PAREN_SUFFIX_REGEX = /\((xhigh|high|medium)\)$/i; +const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i; + +const FREE_PLAN_FALLBACKS = new Map([ + ['gpt-5.3-codex', FREE_SAFE_DEFAULT_MODEL], + ['gpt-5.3-codex-spark', FREE_SAFE_FAST_MODEL], + ['gpt-5.4', FREE_SAFE_DEFAULT_MODEL], +]); + +function normalizeCodexModelId(model: string): string { + return model + .trim() + .replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '') + .replace(CODEX_PAREN_SUFFIX_REGEX, '') + .replace(CODEX_EFFORT_SUFFIX_REGEX, '') + .trim() + .toLowerCase(); +} + +export function getDefaultCodexModel(): string { + return FREE_SAFE_DEFAULT_MODEL; +} + +export function getFreePlanFallbackCodexModel(model: string): string | null { + return FREE_PLAN_FALLBACKS.get(normalizeCodexModelId(model)) ?? null; +} + +export async function reconcileCodexModelForActivePlan(options: { + settingsPath: string; + currentModel: string | undefined; + verbose: boolean; +}): Promise { + const { settingsPath, currentModel, verbose } = options; + if (!currentModel) return; + + const fallbackModel = getFreePlanFallbackCodexModel(currentModel); + if (!fallbackModel) return; + + const defaultAccount = getDefaultAccount('codex'); + if (!defaultAccount) { + console.error( + warn( + `Configured Codex model "${normalizeCodexModelId(currentModel)}" may require a paid Codex plan. ` + + `If startup fails, switch to "${fallbackModel}" with "ccs codex --config".` + ) + ); + return; + } + + const cachedQuota = getCachedQuota('codex', defaultAccount.id); + const quota = cachedQuota ?? (await fetchCodexQuota(defaultAccount.id, verbose)); + if (!cachedQuota) { + setCachedQuota('codex', defaultAccount.id, quota); + } + + if (quota.planType === 'free') { + updateSettingsModel(settingsPath, fallbackModel, 'codex'); + console.error( + info( + `Codex free plan detected. Switched unsupported model "${normalizeCodexModelId(currentModel)}" ` + + `to "${fallbackModel}".` + ) + ); + return; + } + + if (quota.planType) { + return; + } + + console.error( + warn( + `Could not verify Codex plan for model "${normalizeCodexModelId(currentModel)}". ` + + `If startup fails with model_not_supported, switch to "${fallbackModel}" via "ccs codex --config".` + ) + ); +} diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 0cb45f6d..9945ef4d 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -32,6 +32,7 @@ import { isAuthenticated } from '../auth-handler'; import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS, ExecutorConfig } from '../types'; import { DEFAULT_BACKEND } from '../platform-detector'; import { configureProviderModel, getCurrentModel } from '../model-config'; +import { reconcileCodexModelForActivePlan } from '../codex-plan-compatibility'; import { resolveProxyConfig, PROXY_CLI_FLAGS } from '../proxy-config-resolver'; import { supportsModelConfig, isModelBroken, getModelIssueUrl, findModel } from '../model-catalog'; import { CodexReasoningProxy } from '../codex-reasoning-proxy'; @@ -731,6 +732,14 @@ export async function execClaudeWithCLIProxy( // 6. Ensure user settings file exists ensureProviderSettings(provider); + if (provider === 'codex' && !cfg.isComposite && !skipLocalAuth) { + await reconcileCodexModelForActivePlan({ + settingsPath: cfg.customSettingsPath || getProviderSettingsPath(provider), + currentModel: getCurrentModel(provider, cfg.customSettingsPath), + verbose, + }); + } + // Local proxy mode: generate config, spawn/join proxy, track session let proxy: ChildProcess | null = null; let configPath: string | undefined; diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index a250bb2d..375a2b34 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -100,6 +100,11 @@ export { configureProviderModel, showCurrentConfig, } from './model-config'; +export { + getDefaultCodexModel, + getFreePlanFallbackCodexModel, + reconcileCodexModelForActivePlan, +} from './codex-plan-compatibility'; // Executor export { execClaudeWithCLIProxy, isPortAvailable, findAvailablePort } from './cliproxy-executor'; diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 8194e640..d9644ed5 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -147,15 +147,59 @@ export const MODEL_CATALOG: Partial> = codex: { provider: 'codex', displayName: 'Copilot Codex', - defaultModel: 'gpt-5.3-codex', + defaultModel: 'gpt-5-codex', models: [ { - id: 'gpt-5.3-codex', - name: 'GPT-5.3 Codex', - description: 'Supports up to xhigh effort', + id: 'gpt-5-codex', + name: 'GPT-5 Codex', + description: 'Cross-plan safe Codex default', thinking: { type: 'levels', - levels: ['medium', 'high', 'xhigh'], + levels: ['low', 'medium', 'high'], + maxLevel: 'high', + dynamicAllowed: false, + }, + }, + { + id: 'gpt-5-codex-mini', + name: 'GPT-5 Codex Mini', + description: 'Faster and cheaper Codex option', + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high'], + maxLevel: 'high', + dynamicAllowed: false, + }, + }, + { + id: 'gpt-5-mini', + name: 'GPT-5 Mini', + description: 'Legacy mini model ID kept for backwards compatibility', + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high'], + maxLevel: 'high', + dynamicAllowed: false, + }, + }, + { + id: 'gpt-5.1-codex-mini', + name: 'GPT-5.1 Codex Mini', + description: 'Legacy fast Codex mini model', + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high'], + maxLevel: 'high', + dynamicAllowed: false, + }, + }, + { + id: 'gpt-5.1-codex-max', + name: 'GPT-5.1 Codex Max', + description: 'Higher-effort Codex model with xhigh support', + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh'], maxLevel: 'xhigh', dynamicAllowed: false, }, @@ -163,22 +207,47 @@ export const MODEL_CATALOG: Partial> = { id: 'gpt-5.2-codex', name: 'GPT-5.2 Codex', - description: 'Previous stable Codex model', + description: 'Cross-plan Codex model with xhigh support', thinking: { type: 'levels', - levels: ['medium', 'high', 'xhigh'], + levels: ['low', 'medium', 'high', 'xhigh'], maxLevel: 'xhigh', dynamicAllowed: false, }, }, { - id: 'gpt-5-mini', - name: 'GPT-5 Mini', - description: 'Capped at high effort (no xhigh)', + id: 'gpt-5.3-codex', + name: 'GPT-5.3 Codex', + tier: 'pro', + description: 'Paid Codex plans only', thinking: { type: 'levels', - levels: ['medium', 'high'], - maxLevel: 'high', + levels: ['low', 'medium', 'high', 'xhigh'], + maxLevel: 'xhigh', + dynamicAllowed: false, + }, + }, + { + id: 'gpt-5.3-codex-spark', + name: 'GPT-5.3 Codex Spark', + tier: 'pro', + description: 'Paid Codex plans only, ultra-fast coding model', + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh'], + maxLevel: 'xhigh', + dynamicAllowed: false, + }, + }, + { + id: 'gpt-5.4', + name: 'GPT-5.4', + tier: 'pro', + description: 'Paid Codex plans only, latest GPT-5 family model', + thinking: { + type: 'levels', + levels: ['low', 'medium', 'high', 'xhigh'], + maxLevel: 'xhigh', dynamicAllowed: false, }, }, diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index 0400a54a..21c02ef6 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -146,9 +146,7 @@ export async function configureProviderModel( console.error(header(`Configure ${catalog.displayName} Model`)); console.error(''); console.error(dim(' Select which model to use for this provider.')); - console.error( - dim(' Models marked [Paid Tier] require a paid Google account (not free tier).') - ); + console.error(dim(' Models marked [Pro]/[Ultra] require a paid provider plan.')); console.error(dim(' Models marked [DEPRECATED] are not recommended for use.')); console.error(''); @@ -274,7 +272,7 @@ export async function showCurrentConfig(provider: CLIProxyProvider): Promise { diff --git a/tests/unit/cliproxy/codex-plan-compatibility.test.ts b/tests/unit/cliproxy/codex-plan-compatibility.test.ts new file mode 100644 index 00000000..b3ce52ea --- /dev/null +++ b/tests/unit/cliproxy/codex-plan-compatibility.test.ts @@ -0,0 +1,114 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import { getProviderCatalog, getModelMaxLevel } from '../../../src/cliproxy/model-catalog'; +import { + getDefaultCodexModel, + getFreePlanFallbackCodexModel, +} from '../../../src/cliproxy/codex-plan-compatibility'; + +afterEach(() => { + mock.restore(); +}); + +describe('codex plan compatibility', () => { + it('uses a cross-plan safe Codex default', () => { + expect(getDefaultCodexModel()).toBe('gpt-5-codex'); + expect(getProviderCatalog('codex')?.defaultModel).toBe('gpt-5-codex'); + }); + + it('maps paid-only free-plan models to safe fallbacks', () => { + expect(getFreePlanFallbackCodexModel('gpt-5.3-codex')).toBe('gpt-5-codex'); + expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-xhigh')).toBe('gpt-5-codex'); + expect(getFreePlanFallbackCodexModel('gpt-5.3-codex(high)')).toBe('gpt-5-codex'); + expect(getFreePlanFallbackCodexModel('gpt-5.4')).toBe('gpt-5-codex'); + expect(getFreePlanFallbackCodexModel('gpt-5.3-codex-spark')).toBe('gpt-5-codex-mini'); + }); + + it('does not rewrite cross-plan or already-safe Codex models', () => { + expect(getFreePlanFallbackCodexModel('gpt-5-codex')).toBeNull(); + expect(getFreePlanFallbackCodexModel('gpt-5.2-codex')).toBeNull(); + expect(getFreePlanFallbackCodexModel('gpt-5.1-codex-mini')).toBeNull(); + }); + + it('tracks Codex thinking caps for current safe defaults and paid models', () => { + expect(getModelMaxLevel('codex', 'gpt-5-codex')).toBe('high'); + expect(getModelMaxLevel('codex', 'gpt-5-codex-mini')).toBe('high'); + expect(getModelMaxLevel('codex', 'gpt-5.2-codex')).toBe('xhigh'); + expect(getModelMaxLevel('codex', 'gpt-5.3-codex')).toBe('xhigh'); + }); + + it('repairs stale paid-only Codex settings for free-plan accounts before launch', async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-plan-compat-')); + const settingsPath = path.join(tmpDir, 'codex.settings.json'); + + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-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-codex-mini', + }, + }, + null, + 2 + ), + 'utf-8' + ); + + mock.module('../../../src/cliproxy/account-manager', () => ({ + getDefaultAccount: () => ({ id: 'free@example.com' }), + })); + mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ + fetchCodexQuota: async () => ({ + success: true, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType: 'free', + lastUpdated: Date.now(), + accountId: 'free@example.com', + }), + })); + mock.module('../../../src/cliproxy/quota-response-cache', () => ({ + getCachedQuota: () => null, + setCachedQuota: () => {}, + })); + mock.module('../../../src/utils/ui', () => ({ + info: (message: string) => message, + warn: (message: string) => message, + })); + + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + + try { + const { reconcileCodexModelForActivePlan } = await import( + `../../../src/cliproxy/codex-plan-compatibility?free-plan=${Date.now()}` + ); + + await reconcileCodexModelForActivePlan({ + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }); + + const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(repaired.env.ANTHROPIC_MODEL).toBe('gpt-5-codex'); + expect(repaired.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5-codex'); + expect(repaired.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5-codex'); + expect(repaired.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); + expect(errorSpy).toHaveBeenCalledWith( + 'Codex free plan detected. Switched unsupported model "gpt-5.3-codex" to "gpt-5-codex".' + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/cliproxy/variant-update-service.test.ts b/tests/unit/cliproxy/variant-update-service.test.ts index 28467094..fe63fb7d 100644 --- a/tests/unit/cliproxy/variant-update-service.test.ts +++ b/tests/unit/cliproxy/variant-update-service.test.ts @@ -112,7 +112,7 @@ cliproxy: expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.1-codex-mini'); expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.1-codex-mini'); expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.1-codex-mini'); - expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.1-codex-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); expect(settings.env.CUSTOM_FLAG).toBe('keep-me'); expect(settings.hooks.PreToolUse.length).toBe(1); @@ -134,7 +134,7 @@ cliproxy: expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.1-codex-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); const modelOnly = updateVariant('demo', { model: 'gpt-5.3-codex' }); expect(modelOnly.success).toBe(true); @@ -145,6 +145,6 @@ cliproxy: expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); - expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.1-codex-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); }); }); From ce600a7f229ee06418e1cff37648e74ed74f4a8f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 16 Mar 2026 14:00:38 -0400 Subject: [PATCH 03/48] test(cliproxy): cover codex plan fallback edge cases --- ...codex-plan-compatibility-reconcile.test.ts | 233 ++++++++++++++++++ .../cliproxy/codex-plan-compatibility.test.ts | 82 +----- 2 files changed, 234 insertions(+), 81 deletions(-) create mode 100644 tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts diff --git a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts new file mode 100644 index 00000000..d11d6e6e --- /dev/null +++ b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts @@ -0,0 +1,233 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; + +afterEach(() => { + mock.restore(); +}); + +function createCodexSettingsFixture(): { tmpDir: string; settingsPath: string } { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-plan-compat-')); + const settingsPath = path.join(tmpDir, 'codex.settings.json'); + + fs.writeFileSync( + settingsPath, + JSON.stringify( + { + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-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-codex-mini', + }, + }, + null, + 2 + ), + 'utf-8' + ); + + return { tmpDir, settingsPath }; +} + +async function importCompatibilityModule(cacheTag: string) { + return import(`../../../src/cliproxy/codex-plan-compatibility?${cacheTag}=${Date.now()}`); +} + +describe('codex plan compatibility reconcile', () => { + it('repairs stale paid-only Codex settings for free-plan accounts before launch', async () => { + const { tmpDir, settingsPath } = createCodexSettingsFixture(); + + mock.module('../../../src/cliproxy/account-manager', () => ({ + getDefaultAccount: () => ({ id: 'free@example.com' }), + })); + mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ + fetchCodexQuota: async () => ({ + success: true, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType: 'free', + lastUpdated: Date.now(), + accountId: 'free@example.com', + }), + })); + mock.module('../../../src/cliproxy/quota-response-cache', () => ({ + getCachedQuota: () => null, + setCachedQuota: () => {}, + })); + mock.module('../../../src/utils/ui', () => ({ + info: (message: string) => message, + warn: (message: string) => message, + })); + + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + + try { + const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('free-plan'); + + await reconcileCodexModelForActivePlan({ + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }); + + const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(repaired.env.ANTHROPIC_MODEL).toBe('gpt-5-codex'); + expect(repaired.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5-codex'); + expect(repaired.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5-codex'); + expect(repaired.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); + expect(errorSpy).toHaveBeenCalledWith( + 'Codex free plan detected. Switched unsupported model "gpt-5.3-codex" to "gpt-5-codex".' + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('warns and leaves settings untouched when no default Codex account is available', async () => { + const { tmpDir, settingsPath } = createCodexSettingsFixture(); + + mock.module('../../../src/cliproxy/account-manager', () => ({ + getDefaultAccount: () => null, + })); + mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ + fetchCodexQuota: async () => { + throw new Error('should not fetch quota without a default account'); + }, + })); + mock.module('../../../src/cliproxy/quota-response-cache', () => ({ + getCachedQuota: () => null, + setCachedQuota: () => {}, + })); + mock.module('../../../src/utils/ui', () => ({ + info: (message: string) => message, + warn: (message: string) => message, + })); + + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + + try { + const { reconcileCodexModelForActivePlan } = + await importCompatibilityModule('missing-default-account'); + + await reconcileCodexModelForActivePlan({ + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }); + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(errorSpy).toHaveBeenCalledWith( + 'Configured Codex model "gpt-5.3-codex" may require a paid Codex plan. If startup fails, switch to "gpt-5-codex" with "ccs codex --config".' + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('keeps paid-plan Codex settings unchanged for plus and team accounts', async () => { + for (const planType of ['plus', 'team'] as const) { + const { tmpDir, settingsPath } = createCodexSettingsFixture(); + + mock.module('../../../src/cliproxy/account-manager', () => ({ + getDefaultAccount: () => ({ id: `${planType}@example.com` }), + })); + mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ + fetchCodexQuota: async () => ({ + success: true, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType, + lastUpdated: Date.now(), + accountId: `${planType}@example.com`, + }), + })); + mock.module('../../../src/cliproxy/quota-response-cache', () => ({ + getCachedQuota: () => null, + setCachedQuota: () => {}, + })); + mock.module('../../../src/utils/ui', () => ({ + info: (message: string) => message, + warn: (message: string) => message, + })); + + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + + try { + const { reconcileCodexModelForActivePlan } = await importCompatibilityModule(planType); + + await reconcileCodexModelForActivePlan({ + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }); + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(errorSpy).not.toHaveBeenCalled(); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + mock.restore(); + } + } + }); + + it('warns and keeps settings unchanged when Codex plan verification fails', async () => { + const { tmpDir, settingsPath } = createCodexSettingsFixture(); + + mock.module('../../../src/cliproxy/account-manager', () => ({ + getDefaultAccount: () => ({ id: 'unknown@example.com' }), + })); + mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ + fetchCodexQuota: async () => ({ + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType: null, + lastUpdated: Date.now(), + accountId: 'unknown@example.com', + error: 'network timeout', + }), + })); + mock.module('../../../src/cliproxy/quota-response-cache', () => ({ + getCachedQuota: () => null, + setCachedQuota: () => {}, + })); + mock.module('../../../src/utils/ui', () => ({ + info: (message: string) => message, + warn: (message: string) => message, + })); + + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + + try { + const { reconcileCodexModelForActivePlan } = await importCompatibilityModule('unknown-plan'); + + await reconcileCodexModelForActivePlan({ + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }); + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(errorSpy).toHaveBeenCalledWith( + 'Could not verify Codex plan for model "gpt-5.3-codex". If startup fails with model_not_supported, switch to "gpt-5-codex" via "ccs codex --config".' + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/unit/cliproxy/codex-plan-compatibility.test.ts b/tests/unit/cliproxy/codex-plan-compatibility.test.ts index b3ce52ea..cf84fd28 100644 --- a/tests/unit/cliproxy/codex-plan-compatibility.test.ts +++ b/tests/unit/cliproxy/codex-plan-compatibility.test.ts @@ -1,17 +1,10 @@ -import * as fs from 'fs'; -import * as os from 'os'; -import * as path from 'path'; -import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import { getProviderCatalog, getModelMaxLevel } from '../../../src/cliproxy/model-catalog'; import { getDefaultCodexModel, getFreePlanFallbackCodexModel, } from '../../../src/cliproxy/codex-plan-compatibility'; -afterEach(() => { - mock.restore(); -}); - describe('codex plan compatibility', () => { it('uses a cross-plan safe Codex default', () => { expect(getDefaultCodexModel()).toBe('gpt-5-codex'); @@ -38,77 +31,4 @@ describe('codex plan compatibility', () => { expect(getModelMaxLevel('codex', 'gpt-5.2-codex')).toBe('xhigh'); expect(getModelMaxLevel('codex', 'gpt-5.3-codex')).toBe('xhigh'); }); - - it('repairs stale paid-only Codex settings for free-plan accounts before launch', async () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-plan-compat-')); - const settingsPath = path.join(tmpDir, 'codex.settings.json'); - - fs.writeFileSync( - settingsPath, - JSON.stringify( - { - env: { - ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex', - ANTHROPIC_AUTH_TOKEN: 'ccs-internal-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-codex-mini', - }, - }, - null, - 2 - ), - 'utf-8' - ); - - mock.module('../../../src/cliproxy/account-manager', () => ({ - getDefaultAccount: () => ({ id: 'free@example.com' }), - })); - mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ - fetchCodexQuota: async () => ({ - success: true, - windows: [], - coreUsage: { fiveHour: null, weekly: null }, - planType: 'free', - lastUpdated: Date.now(), - accountId: 'free@example.com', - }), - })); - mock.module('../../../src/cliproxy/quota-response-cache', () => ({ - getCachedQuota: () => null, - setCachedQuota: () => {}, - })); - mock.module('../../../src/utils/ui', () => ({ - info: (message: string) => message, - warn: (message: string) => message, - })); - - const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); - - try { - const { reconcileCodexModelForActivePlan } = await import( - `../../../src/cliproxy/codex-plan-compatibility?free-plan=${Date.now()}` - ); - - await reconcileCodexModelForActivePlan({ - settingsPath, - currentModel: 'gpt-5.3-codex', - verbose: false, - }); - - const repaired = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { - env: Record; - }; - expect(repaired.env.ANTHROPIC_MODEL).toBe('gpt-5-codex'); - expect(repaired.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5-codex'); - expect(repaired.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5-codex'); - expect(repaired.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); - expect(errorSpy).toHaveBeenCalledWith( - 'Codex free plan detected. Switched unsupported model "gpt-5.3-codex" to "gpt-5-codex".' - ); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); }); From e6b363524bc28c5b9d3b1b675cb2cf9f749a936a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 16 Mar 2026 14:31:55 -0400 Subject: [PATCH 04/48] fix(cliproxy): remap codex haiku fallback on free plans --- src/cliproxy/codex-plan-compatibility.ts | 4 +- src/cliproxy/services/variant-settings.ts | 10 +++- ...codex-plan-compatibility-reconcile.test.ts | 59 ++++++++++++++++++- 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/cliproxy/codex-plan-compatibility.ts b/src/cliproxy/codex-plan-compatibility.ts index 91a30380..41177e00 100644 --- a/src/cliproxy/codex-plan-compatibility.ts +++ b/src/cliproxy/codex-plan-compatibility.ts @@ -66,7 +66,9 @@ export async function reconcileCodexModelForActivePlan(options: { } if (quota.planType === 'free') { - updateSettingsModel(settingsPath, fallbackModel, 'codex'); + updateSettingsModel(settingsPath, fallbackModel, 'codex', { + rewriteHaikuModel: (haikuModel) => getFreePlanFallbackCodexModel(haikuModel) ?? haikuModel, + }); console.error( info( `Codex free plan detected. Switched unsupported model "${normalizeCodexModelId(currentModel)}" ` + diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index 4dbca1fc..7c7d2038 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -290,7 +290,10 @@ export function deleteSettingsFile(settingsPath: string): boolean { export function updateSettingsModel( settingsPath: string, model: string, - provider?: CLIProxyProfileName + provider?: CLIProxyProfileName, + options?: { + rewriteHaikuModel?: (model: string) => string; + } ): void { const fileName = path.basename(settingsPath); if (fileName.startsWith('composite-')) { @@ -316,10 +319,13 @@ export function updateSettingsModel( settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = normalizedModel; settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = normalizedModel; if (provider === 'codex' && settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) { - settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = canonicalizeModelForProvider( + const normalizedHaikuModel = canonicalizeModelForProvider( provider, settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL ); + settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = options?.rewriteHaikuModel + ? options.rewriteHaikuModel(normalizedHaikuModel) + : normalizedHaikuModel; } } else { // Clear model settings to use defaults diff --git a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts index d11d6e6e..b22ac1fa 100644 --- a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts +++ b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts @@ -7,7 +7,10 @@ afterEach(() => { mock.restore(); }); -function createCodexSettingsFixture(): { tmpDir: string; settingsPath: string } { +function createCodexSettingsFixture(haikuModel: string = 'gpt-5-codex-mini'): { + tmpDir: string; + settingsPath: string; +} { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-plan-compat-')); const settingsPath = path.join(tmpDir, 'codex.settings.json'); @@ -21,7 +24,7 @@ function createCodexSettingsFixture(): { tmpDir: string; settingsPath: string } 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-codex-mini', + ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel, }, }, null, @@ -39,7 +42,7 @@ async function importCompatibilityModule(cacheTag: string) { describe('codex plan compatibility reconcile', () => { it('repairs stale paid-only Codex settings for free-plan accounts before launch', async () => { - const { tmpDir, settingsPath } = createCodexSettingsFixture(); + const { tmpDir, settingsPath } = createCodexSettingsFixture('gpt-5.3-codex-spark'); mock.module('../../../src/cliproxy/account-manager', () => ({ getDefaultAccount: () => ({ id: 'free@example.com' }), @@ -230,4 +233,54 @@ describe('codex plan compatibility reconcile', () => { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + + it('warns and keeps settings unchanged when quota succeeds without a plan type', async () => { + const { tmpDir, settingsPath } = createCodexSettingsFixture(); + + mock.module('../../../src/cliproxy/account-manager', () => ({ + getDefaultAccount: () => ({ id: 'missing-plan@example.com' }), + })); + mock.module('../../../src/cliproxy/quota-fetcher-codex', () => ({ + fetchCodexQuota: async () => ({ + success: true, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + planType: null, + lastUpdated: Date.now(), + accountId: 'missing-plan@example.com', + }), + })); + mock.module('../../../src/cliproxy/quota-response-cache', () => ({ + getCachedQuota: () => null, + setCachedQuota: () => {}, + })); + mock.module('../../../src/utils/ui', () => ({ + info: (message: string) => message, + warn: (message: string) => message, + })); + + const errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + + try { + const { reconcileCodexModelForActivePlan } = + await importCompatibilityModule('missing-plan-type'); + + await reconcileCodexModelForActivePlan({ + settingsPath, + currentModel: 'gpt-5.3-codex', + verbose: false, + }); + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { + env: Record; + }; + expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-codex-mini'); + expect(errorSpy).toHaveBeenCalledWith( + 'Could not verify Codex plan for model "gpt-5.3-codex". If startup fails with model_not_supported, switch to "gpt-5-codex" via "ccs codex --config".' + ); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); From 1b8627367e7809abcf69f8ce00907483fa7b1ba8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 16 Mar 2026 18:42:09 +0000 Subject: [PATCH 05/48] chore(release): 7.54.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 28673198..b8d5f22c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.54.0", + "version": "7.54.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From daad5d1f50c20ffa9a7bd886c0ddcf25e00e84f3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 16 Mar 2026 13:31:41 -0400 Subject: [PATCH 06/48] feat(cursor): add Anthropic daemon endpoint --- docs/cursor-integration.md | 2 + src/cursor/cursor-anthropic-response.ts | 121 ++++++++++++ src/cursor/cursor-anthropic-translator.ts | 186 ++++++++++++++++++ src/cursor/cursor-anthropic-types.ts | 48 +++++ src/cursor/cursor-daemon-entry.ts | 21 +- .../cursor-anthropic-translator.test.ts | 139 +++++++++++++ tests/unit/cursor/cursor-daemon.test.ts | 70 ++++--- 7 files changed, 561 insertions(+), 26 deletions(-) create mode 100644 src/cursor/cursor-anthropic-response.ts create mode 100644 src/cursor/cursor-anthropic-translator.ts create mode 100644 src/cursor/cursor-anthropic-types.ts create mode 100644 tests/unit/cursor/cursor-anthropic-translator.test.ts diff --git a/docs/cursor-integration.md b/docs/cursor-integration.md index 1aa32cda..3fa9897d 100644 --- a/docs/cursor-integration.md +++ b/docs/cursor-integration.md @@ -5,6 +5,7 @@ This guide covers the local Cursor integration in CCS, including CLI setup, daem ## What It Provides - OpenAI-compatible local endpoint powered by Cursor credentials. +- Anthropic-compatible local endpoint at `/v1/messages` for Claude-native clients. - Cursor model list and chat completions via local daemon. - Dedicated dashboard page: `ccs config` -> `Cursor IDE`. @@ -61,6 +62,7 @@ ccs cursor stop - `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. +- Daemon API surface: `POST /v1/chat/completions`, `POST /v1/messages`, and `GET /v1/models`. These values are managed in unified config and can be updated from CLI or dashboard. diff --git a/src/cursor/cursor-anthropic-response.ts b/src/cursor/cursor-anthropic-response.ts new file mode 100644 index 00000000..ddf31803 --- /dev/null +++ b/src/cursor/cursor-anthropic-response.ts @@ -0,0 +1,121 @@ +import { DeltaAccumulator } from '../glmt/delta-accumulator'; +import { GlmtTransformer } from '../glmt/glmt-transformer'; +import { SSEParser } from '../glmt/sse-parser'; +import type { OpenAIResponse, SSEEvent } from '../glmt/pipeline'; + +function createErrorResponse(message: string): Response { + return new Response( + JSON.stringify({ + error: { + type: 'api_error', + message, + }, + }), + { + status: 502, + headers: { 'Content-Type': 'application/json' }, + } + ); +} + +function formatSseEvent(event: string, data: Record): string { + return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; +} + +async function createAnthropicJsonResponse(response: Response): Promise { + try { + const openAiResponse = (await response.json()) as OpenAIResponse; + const anthropicResponse = new GlmtTransformer().transformResponse(openAiResponse); + return new Response(JSON.stringify(anthropicResponse), { + status: response.status, + headers: { 'Content-Type': 'application/json' }, + }); + } catch (error) { + return createErrorResponse( + `Failed to translate Cursor JSON response: ${(error as Error).message}` + ); + } +} + +function createAnthropicStreamingResponse(response: Response): Response { + const body = response.body; + if (!body) { + return createErrorResponse('Cursor stream ended before a response body was available'); + } + + const parser = new SSEParser(); + const transformer = new GlmtTransformer(); + const accumulator = new DeltaAccumulator({}); + const encoder = new TextEncoder(); + + const readable = new ReadableStream({ + async start(controller) { + const reader = body.getReader(); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + if (!value) { + continue; + } + + const events = parser.parse(Buffer.from(value)); + events.forEach((event) => { + const anthropicEvents = transformer.transformDelta(event as SSEEvent, accumulator); + anthropicEvents.forEach((anthropicEvent) => { + controller.enqueue( + encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data)) + ); + }); + }); + } + + if (!accumulator.isFinalized() && accumulator.isMessageStarted()) { + transformer.finalizeDelta(accumulator).forEach((anthropicEvent) => { + controller.enqueue( + encoder.encode(formatSseEvent(anthropicEvent.event, anthropicEvent.data)) + ); + }); + } + } catch (error) { + controller.enqueue( + encoder.encode( + formatSseEvent('error', { + type: 'error', + error: { + type: 'api_error', + message: `Failed to translate Cursor SSE response: ${(error as Error).message}`, + }, + }) + ) + ); + } finally { + reader.releaseLock(); + controller.close(); + } + }, + }); + + return new Response(readable, { + status: response.status, + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }); +} + +export async function createAnthropicProxyResponse(response: Response): Promise { + if (!response.ok) { + return response; + } + + const contentType = response.headers.get('content-type') || ''; + return contentType.includes('text/event-stream') + ? createAnthropicStreamingResponse(response) + : createAnthropicJsonResponse(response); +} diff --git a/src/cursor/cursor-anthropic-translator.ts b/src/cursor/cursor-anthropic-translator.ts new file mode 100644 index 00000000..4c84cdb5 --- /dev/null +++ b/src/cursor/cursor-anthropic-translator.ts @@ -0,0 +1,186 @@ +import type { CursorTool } from './cursor-protobuf-schema'; +import type { + AnthropicContentBlock, + CursorAnthropicRequest, + CursorOpenAIMessage, +} from './cursor-anthropic-types'; + +export interface TranslatedAnthropicRequest { + model?: string; + stream: boolean; + reasoning_effort?: string; + tools?: CursorTool[]; + messages: CursorOpenAIMessage[]; +} + +function assertObject(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null) { + throw new Error(`${label} must be an object`); + } + return value as Record; +} + +function flattenTextContent(content: unknown, label: string): string { + if (typeof content === 'string') { + return content; + } + if (!Array.isArray(content)) { + throw new Error(`${label} must be a string or content block array`); + } + + return content + .map((block, index) => { + const parsed = assertObject(block, `${label}[${index}]`); + if (parsed.type !== 'text') { + throw new Error(`${label}[${index}].type "${String(parsed.type)}" is not supported`); + } + return typeof parsed.text === 'string' ? parsed.text : ''; + }) + .join('\n'); +} + +function toToolResultContent(content: unknown, label: string): string { + if (content === undefined) { + return ''; + } + if (typeof content === 'string') { + return content; + } + if (Array.isArray(content)) { + return flattenTextContent(content, label); + } + return JSON.stringify(content); +} + +function mapThinkingToReasoningEffort( + thinking: CursorAnthropicRequest['thinking'] +): string | undefined { + if (!thinking) { + return undefined; + } + if (thinking.type === 'disabled') { + return undefined; + } + if (thinking.type !== 'enabled') { + throw new Error('thinking.type must be "enabled" or "disabled"'); + } + return typeof thinking.budget_tokens === 'number' && thinking.budget_tokens >= 8192 + ? 'high' + : 'medium'; +} + +export function translateAnthropicRequest(raw: unknown): TranslatedAnthropicRequest { + const request = assertObject(raw, 'request') as CursorAnthropicRequest; + const translatedMessages: CursorOpenAIMessage[] = []; + + if (request.system !== undefined) { + translatedMessages.push({ + role: 'system', + content: flattenTextContent(request.system, 'system'), + }); + } + + if (!Array.isArray(request.messages)) { + throw new Error('messages must be an array'); + } + + request.messages.forEach((message, messageIndex) => { + const role = message.role; + if (role !== 'user' && role !== 'assistant') { + throw new Error(`messages[${messageIndex}].role must be "user" or "assistant"`); + } + + const content = message.content; + if (typeof content === 'string') { + translatedMessages.push({ role, content }); + return; + } + + if (!Array.isArray(content)) { + throw new Error(`messages[${messageIndex}].content must be a string or array`); + } + + const textParts: string[] = []; + const toolCalls: NonNullable = []; + + content.forEach((block, blockIndex) => { + const parsed = assertObject( + block, + `messages[${messageIndex}].content[${blockIndex}]` + ) as unknown as AnthropicContentBlock; + + if (parsed.type === 'text') { + textParts.push(typeof parsed.text === 'string' ? parsed.text : ''); + return; + } + + if (parsed.type === 'tool_use') { + if (role !== 'assistant') { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}] tool_use requires assistant role` + ); + } + toolCalls.push({ + id: + typeof parsed.id === 'string' && parsed.id.length > 0 + ? parsed.id + : `toolu_${messageIndex}_${blockIndex}`, + type: 'function', + function: { + name: typeof parsed.name === 'string' ? parsed.name : 'tool', + arguments: JSON.stringify(parsed.input ?? {}), + }, + }); + return; + } + + if (parsed.type === 'tool_result') { + if (role !== 'user') { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}] tool_result requires user role` + ); + } + translatedMessages.push({ + role: 'tool', + tool_call_id: typeof parsed.tool_use_id === 'string' ? parsed.tool_use_id : '', + content: toToolResultContent( + parsed.content, + `messages[${messageIndex}].content[${blockIndex}].content` + ), + }); + return; + } + + throw new Error( + `messages[${messageIndex}].content[${blockIndex}].type "${String((parsed as { type?: unknown }).type)}" is not supported` + ); + }); + + if (role === 'assistant') { + translatedMessages.push({ + role, + content: textParts.join('\n'), + tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + }); + return; + } + + if (textParts.length > 0 || toolCalls.length === 0) { + translatedMessages.push({ + role, + content: textParts.join('\n'), + }); + } + }); + + return { + model: + typeof request.model === 'string' && request.model.trim().length > 0 + ? request.model + : undefined, + stream: request.stream === true, + reasoning_effort: mapThinkingToReasoningEffort(request.thinking), + tools: Array.isArray(request.tools) ? request.tools : undefined, + messages: translatedMessages, + }; +} diff --git a/src/cursor/cursor-anthropic-types.ts b/src/cursor/cursor-anthropic-types.ts new file mode 100644 index 00000000..336fb75f --- /dev/null +++ b/src/cursor/cursor-anthropic-types.ts @@ -0,0 +1,48 @@ +import type { CursorTool } from './cursor-protobuf-schema'; + +export interface CursorOpenAIMessage { + role: string; + content: string; + name?: string; + tool_call_id?: string; + tool_calls?: Array<{ + id: string; + type: string; + function: { name: string; arguments: string }; + }>; +} + +export interface AnthropicTextBlock { + type: 'text'; + text?: string; +} + +export interface AnthropicToolUseBlock { + type: 'tool_use'; + id?: string; + name?: string; + input?: Record; +} + +export interface AnthropicToolResultBlock { + type: 'tool_result'; + tool_use_id?: string; + content?: unknown; +} + +export type AnthropicContentBlock = + | AnthropicTextBlock + | AnthropicToolUseBlock + | AnthropicToolResultBlock; + +export interface CursorAnthropicRequest { + model?: string; + messages?: Array<{ role?: string; content?: string | AnthropicContentBlock[] }>; + system?: string | AnthropicTextBlock[]; + stream?: boolean; + tools?: CursorTool[]; + thinking?: { + type?: string; + budget_tokens?: number; + }; +} diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts index f6eb2f7d..a9fd1d2d 100644 --- a/src/cursor/cursor-daemon-entry.ts +++ b/src/cursor/cursor-daemon-entry.ts @@ -7,6 +7,8 @@ import * as http from 'http'; import { Readable } from 'stream'; import { CursorExecutor } from './cursor-executor'; +import { createAnthropicProxyResponse } from './cursor-anthropic-response'; +import { translateAnthropicRequest } from './cursor-anthropic-translator'; import { checkAuthStatus } from './cursor-auth'; import { getModelsForDaemon, resolveCursorRequestModel } from './cursor-models'; import type { CursorTool } from './cursor-protobuf-schema'; @@ -222,13 +224,20 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser return; } - if (method !== 'POST' || requestUrl !== '/v1/chat/completions') { + const isOpenAiRoute = method === 'POST' && requestUrl === '/v1/chat/completions'; + const isAnthropicRoute = method === 'POST' && requestUrl === '/v1/messages'; + + if (!isOpenAiRoute && !isAnthropicRoute) { writeJson(res, 404, { error: 'Not found' }); return; } - const parsedBody = (await readJsonBody(req)) as OpenAIChatRequest; - const messages = normalizeMessages(parsedBody.messages); + const rawBody = await readJsonBody(req); + const anthropicBody = isAnthropicRoute ? translateAnthropicRequest(rawBody) : undefined; + const parsedBody = anthropicBody ?? ((rawBody as OpenAIChatRequest) || {}); + const messages = anthropicBody + ? anthropicBody.messages + : normalizeMessages(parsedBody.messages); const requestedModel = typeof parsedBody.model === 'string' && parsedBody.model.trim().length > 0 ? parsedBody.model.trim() @@ -301,7 +310,11 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser }, }); - await pipeWebResponseToNode(result.response, res); + const outgoingResponse = isAnthropicRoute + ? await createAnthropicProxyResponse(result.response) + : result.response; + + await pipeWebResponseToNode(outgoingResponse, res); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; const isPayloadTooLarge = message.includes('Request body too large'); diff --git a/tests/unit/cursor/cursor-anthropic-translator.test.ts b/tests/unit/cursor/cursor-anthropic-translator.test.ts new file mode 100644 index 00000000..3587b965 --- /dev/null +++ b/tests/unit/cursor/cursor-anthropic-translator.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'bun:test'; +import { createAnthropicProxyResponse } from '../../../src/cursor/cursor-anthropic-response'; +import { translateAnthropicRequest } from '../../../src/cursor/cursor-anthropic-translator'; + +describe('translateAnthropicRequest', () => { + it('maps Anthropic system, tool use, and tool result blocks into Cursor OpenAI messages', () => { + const translated = translateAnthropicRequest({ + model: 'claude-sonnet-4.5', + stream: true, + thinking: { type: 'enabled', budget_tokens: 9000 }, + tools: [{ name: 'search', description: 'Search docs', input_schema: { type: 'object' } }], + system: 'You are helpful.', + messages: [ + { role: 'user', content: [{ type: 'text', text: 'Find release notes' }] }, + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'release' } }], + }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'toolu_1', + content: [{ type: 'text', text: 'v7.53.0' }], + }, + { type: 'text', text: 'Summarize it.' }, + ], + }, + ], + }); + + expect(translated.model).toBe('claude-sonnet-4.5'); + expect(translated.stream).toBe(true); + expect(translated.reasoning_effort).toBe('high'); + expect(translated.messages).toEqual([ + { role: 'system', content: 'You are helpful.' }, + { role: 'user', content: 'Find release notes' }, + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'toolu_1', + type: 'function', + function: { name: 'search', arguments: '{"q":"release"}' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'toolu_1', content: 'v7.53.0' }, + { role: 'user', content: 'Summarize it.' }, + ]); + }); + + it('rejects unsupported content blocks', () => { + expect(() => + translateAnthropicRequest({ + messages: [{ role: 'user', content: [{ type: 'image' }] }], + }) + ).toThrow('is not supported'); + }); +}); + +describe('createAnthropicProxyResponse', () => { + it('converts OpenAI JSON into Anthropic message JSON', async () => { + const response = new Response( + JSON.stringify({ + id: 'chatcmpl_1', + model: 'claude-sonnet-4.5', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'Here is the result.', + reasoning_content: 'Need to call the tool first.', + tool_calls: [ + { + id: 'toolu_2', + type: 'function', + function: { name: 'search', arguments: '{"q":"cursor daemon"}' }, + }, + ], + }, + finish_reason: 'tool_calls', + }, + ], + usage: { prompt_tokens: 12, completion_tokens: 4, total_tokens: 16 }, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ); + + const transformed = await createAnthropicProxyResponse(response); + const body = (await transformed.json()) as { + type: string; + model: string; + stop_reason: string; + content: Array<{ + type: string; + text?: string; + thinking?: string; + name?: string; + input?: Record; + }>; + }; + + expect(body.type).toBe('message'); + expect(body.model).toBe('claude-sonnet-4.5'); + expect(body.stop_reason).toBe('tool_use'); + expect(body.content.map((block) => block.type)).toEqual(['thinking', 'text', 'tool_use']); + expect(body.content[0]?.thinking).toContain('Need to call the tool first'); + expect(body.content[2]?.name).toBe('search'); + expect(body.content[2]?.input).toEqual({ q: 'cursor daemon' }); + }); + + it('converts OpenAI SSE chunks into Anthropic SSE events', async () => { + const openAiSse = [ + 'data: {"id":"chatcmpl_2","object":"chat.completion.chunk","created":1,"model":"claude-sonnet-4.5","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}\n\n', + 'data: {"id":"chatcmpl_2","object":"chat.completion.chunk","created":1,"model":"claude-sonnet-4.5","choices":[{"index":0,"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":1,"total_tokens":6}}\n\n', + 'data: [DONE]\n\n', + ].join(''); + + const transformed = await createAnthropicProxyResponse( + new Response(openAiSse, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + ); + + const body = await transformed.text(); + expect(body).toContain('event: message_start'); + expect(body).toContain('event: content_block_start'); + expect(body).toContain('"type":"text_delta"'); + expect(body).toContain('event: message_stop'); + }); +}); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index 1ba7e553..8bc22d7a 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -151,34 +151,46 @@ describe('startDaemon', () => { const running = await isDaemonRunning(port); expect(running).toBe(true); - // Verify models endpoint exists and is OpenAI-compatible list shape - const modelsResponse = await fetch(`http://127.0.0.1:${port}/v1/models`); - expect(modelsResponse.status).toBe(200); - const modelsJson = (await modelsResponse.json()) as { object?: string; data?: unknown[] }; - expect(modelsJson.object).toBe('list'); - expect(Array.isArray(modelsJson.data)).toBe(true); + // Verify models endpoint exists and is OpenAI-compatible list shape + const modelsResponse = await fetch(`http://127.0.0.1:${port}/v1/models`); + expect(modelsResponse.status).toBe(200); + const modelsJson = (await modelsResponse.json()) as { object?: string; data?: unknown[] }; + expect(modelsJson.object).toBe('list'); + expect(Array.isArray(modelsJson.data)).toBe(true); - // Verify chat endpoint exists (requires auth, should not be 404) - const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: 'gpt-4.1', - messages: [{ role: 'user', content: 'hello' }], - }), - }); - expect(chatResponse.status).toBe(401); + // Verify chat endpoint exists (requires auth, should not be 404) + const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4.1', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + expect(chatResponse.status).toBe(401); + + const anthropicResponse = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-sonnet-4.5', + max_tokens: 256, + messages: [{ role: 'user', content: 'hello' }], + }), + }); + expect(anthropicResponse.status).toBe(401); // Stop const stopResult = await stopDaemon(); expect(stopResult.success).toBe(true); - // Verify stopped - const stillRunning = await isDaemonRunning(port); - expect(stillRunning).toBe(false); - }, - 35000 - ); + // Verify stopped + const stillRunning = await isDaemonRunning(port); + expect(stillRunning).toBe(false); + }, 35000); it('returns 404 for unknown routes', async () => { const port = 10000 + Math.floor(Math.random() * 50000); @@ -248,6 +260,20 @@ describe('startDaemon', () => { }); expect(invalidSchema.status).toBe(400); + const invalidAnthropic = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-sonnet-4.5', + max_tokens: 256, + messages: [{ role: 'user', content: [{ type: 'image' }] }], + }), + }); + expect(invalidAnthropic.status).toBe(400); + const oversized = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, From b068fb26214b60e3e11ae6dba92294bef7d3c05e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 16 Mar 2026 13:39:04 -0400 Subject: [PATCH 07/48] fix(cursor): avoid empty user turns after tool results --- src/cursor/cursor-anthropic-translator.ts | 4 ++- .../cursor-anthropic-translator.test.ts | 30 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/cursor/cursor-anthropic-translator.ts b/src/cursor/cursor-anthropic-translator.ts index 4c84cdb5..30c8db5b 100644 --- a/src/cursor/cursor-anthropic-translator.ts +++ b/src/cursor/cursor-anthropic-translator.ts @@ -102,6 +102,7 @@ export function translateAnthropicRequest(raw: unknown): TranslatedAnthropicRequ const textParts: string[] = []; const toolCalls: NonNullable = []; + let sawToolResult = false; content.forEach((block, blockIndex) => { const parsed = assertObject( @@ -140,6 +141,7 @@ export function translateAnthropicRequest(raw: unknown): TranslatedAnthropicRequ `messages[${messageIndex}].content[${blockIndex}] tool_result requires user role` ); } + sawToolResult = true; translatedMessages.push({ role: 'tool', tool_call_id: typeof parsed.tool_use_id === 'string' ? parsed.tool_use_id : '', @@ -165,7 +167,7 @@ export function translateAnthropicRequest(raw: unknown): TranslatedAnthropicRequ return; } - if (textParts.length > 0 || toolCalls.length === 0) { + if (textParts.length > 0 || !sawToolResult) { translatedMessages.push({ role, content: textParts.join('\n'), diff --git a/tests/unit/cursor/cursor-anthropic-translator.test.ts b/tests/unit/cursor/cursor-anthropic-translator.test.ts index 3587b965..8ae0e619 100644 --- a/tests/unit/cursor/cursor-anthropic-translator.test.ts +++ b/tests/unit/cursor/cursor-anthropic-translator.test.ts @@ -59,6 +59,36 @@ describe('translateAnthropicRequest', () => { }) ).toThrow('is not supported'); }); + + it('does not append an empty user message after tool_result-only turns', () => { + const translated = translateAnthropicRequest({ + messages: [ + { + role: 'assistant', + content: [{ type: 'tool_use', id: 'toolu_1', name: 'search', input: { q: 'x' } }], + }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'done' }], + }, + ], + }); + + expect(translated.messages).toEqual([ + { + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'toolu_1', + type: 'function', + function: { name: 'search', arguments: '{"q":"x"}' }, + }, + ], + }, + { role: 'tool', tool_call_id: 'toolu_1', content: 'done' }, + ]); + }); }); describe('createAnthropicProxyResponse', () => { From 9ff021e42b4739b2721ec4f1bd28a0a526686255 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 16 Mar 2026 14:03:51 -0400 Subject: [PATCH 08/48] fix(cursor): harden anthropic translation edge cases --- src/cursor/cursor-anthropic-translator.ts | 22 +++++- .../cursor-anthropic-translator.test.ts | 78 +++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/cursor/cursor-anthropic-translator.ts b/src/cursor/cursor-anthropic-translator.ts index 30c8db5b..ce245bb7 100644 --- a/src/cursor/cursor-anthropic-translator.ts +++ b/src/cursor/cursor-anthropic-translator.ts @@ -13,6 +13,9 @@ export interface TranslatedAnthropicRequest { messages: CursorOpenAIMessage[]; } +const TOOL_RESULT_SERIALIZATION_FALLBACK = '[unserializable content]'; +const TOOL_USE_ARGUMENTS_FALLBACK = '{}'; + function assertObject(value: unknown, label: string): Record { if (typeof value !== 'object' || value === null) { throw new Error(`${label} must be an object`); @@ -20,6 +23,19 @@ function assertObject(value: unknown, label: string): Record { return value as Record; } +function safeJsonStringify(value: unknown, fallback: string): string { + try { + const serialized = JSON.stringify(value); + return typeof serialized === 'string' ? serialized : fallback; + } catch { + return fallback; + } +} + +function createFallbackToolId(messageIndex: number, blockIndex: number): string { + return `toolu_ccs_fallback_${messageIndex}_${blockIndex}`; +} + function flattenTextContent(content: unknown, label: string): string { if (typeof content === 'string') { return content; @@ -49,7 +65,7 @@ function toToolResultContent(content: unknown, label: string): string { if (Array.isArray(content)) { return flattenTextContent(content, label); } - return JSON.stringify(content); + return safeJsonStringify(content, TOOL_RESULT_SERIALIZATION_FALLBACK); } function mapThinkingToReasoningEffort( @@ -125,11 +141,11 @@ export function translateAnthropicRequest(raw: unknown): TranslatedAnthropicRequ id: typeof parsed.id === 'string' && parsed.id.length > 0 ? parsed.id - : `toolu_${messageIndex}_${blockIndex}`, + : createFallbackToolId(messageIndex, blockIndex), type: 'function', function: { name: typeof parsed.name === 'string' ? parsed.name : 'tool', - arguments: JSON.stringify(parsed.input ?? {}), + arguments: safeJsonStringify(parsed.input ?? {}, TOOL_USE_ARGUMENTS_FALLBACK), }, }); return; diff --git a/tests/unit/cursor/cursor-anthropic-translator.test.ts b/tests/unit/cursor/cursor-anthropic-translator.test.ts index 8ae0e619..63d91201 100644 --- a/tests/unit/cursor/cursor-anthropic-translator.test.ts +++ b/tests/unit/cursor/cursor-anthropic-translator.test.ts @@ -89,6 +89,67 @@ describe('translateAnthropicRequest', () => { { role: 'tool', tool_call_id: 'toolu_1', content: 'done' }, ]); }); + + it('uses a distinct fallback prefix for missing tool_use ids', () => { + const translated = translateAnthropicRequest({ + messages: [ + { + role: 'assistant', + content: [{ type: 'tool_use', name: 'search', input: { q: 'x' } }], + }, + ], + }); + + expect(translated.messages[0]?.tool_calls?.[0]?.id).toBe('toolu_ccs_fallback_0_0'); + }); + + it('falls back when tool_result content cannot be serialized', () => { + const circular: Record = {}; + circular.self = circular; + + const translated = translateAnthropicRequest({ + messages: [ + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: circular }], + }, + ], + }); + + expect(translated.messages).toEqual([ + { + role: 'tool', + tool_call_id: 'toolu_1', + content: '[unserializable content]', + }, + ]); + }); + + it('falls back when tool_use input cannot be serialized', () => { + const circular: Record = {}; + circular.self = circular; + + const translated = translateAnthropicRequest({ + messages: [ + { + role: 'assistant', + content: [{ type: 'tool_use', name: 'search', input: circular }], + }, + ], + }); + + expect(translated.messages[0]).toEqual({ + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'toolu_ccs_fallback_0_0', + type: 'function', + function: { name: 'search', arguments: '{}' }, + }, + ], + }); + }); }); describe('createAnthropicProxyResponse', () => { @@ -166,4 +227,21 @@ describe('createAnthropicProxyResponse', () => { expect(body).toContain('"type":"text_delta"'); expect(body).toContain('event: message_stop'); }); + + it('emits Anthropic-style error events when SSE translation fails', async () => { + const oversizedChunk = `data: ${'x'.repeat(1024 * 1024 + 32)}`; + + const transformed = await createAnthropicProxyResponse( + new Response(oversizedChunk, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + ); + + const body = await transformed.text(); + expect(body).toContain('event: error'); + expect(body).toContain('"type":"error"'); + expect(body).toContain('"error":{"type":"api_error"'); + expect(body).toContain('Failed to translate Cursor SSE response'); + }); }); From eaee4a92cad9776ac67b2aecf0a01d13880155a7 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 16 Mar 2026 14:41:44 -0400 Subject: [PATCH 09/48] fix(cursor): harden anthropic response fallback paths --- src/cursor/cursor-anthropic-response.ts | 28 +++++-- .../cursor-anthropic-translator.test.ts | 80 +++++++++++++++++++ 2 files changed, 101 insertions(+), 7 deletions(-) diff --git a/src/cursor/cursor-anthropic-response.ts b/src/cursor/cursor-anthropic-response.ts index ddf31803..6102de27 100644 --- a/src/cursor/cursor-anthropic-response.ts +++ b/src/cursor/cursor-anthropic-response.ts @@ -3,6 +3,9 @@ import { GlmtTransformer } from '../glmt/glmt-transformer'; import { SSEParser } from '../glmt/sse-parser'; import type { OpenAIResponse, SSEEvent } from '../glmt/pipeline'; +const JSON_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor JSON response'; +const STREAM_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor SSE response'; + function createErrorResponse(message: string): Response { return new Response( JSON.stringify({ @@ -22,18 +25,29 @@ function formatSseEvent(event: string, data: Record): string { return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; } +function hasTranslatableChoices(value: unknown): value is OpenAIResponse { + if (typeof value !== 'object' || value === null) { + return false; + } + + const { choices } = value as OpenAIResponse; + return Array.isArray(choices) && choices.length > 0; +} + async function createAnthropicJsonResponse(response: Response): Promise { try { - const openAiResponse = (await response.json()) as OpenAIResponse; + const openAiResponse = await response.json(); + if (!hasTranslatableChoices(openAiResponse)) { + return createErrorResponse(JSON_TRANSLATION_ERROR_MESSAGE); + } + const anthropicResponse = new GlmtTransformer().transformResponse(openAiResponse); return new Response(JSON.stringify(anthropicResponse), { status: response.status, headers: { 'Content-Type': 'application/json' }, }); - } catch (error) { - return createErrorResponse( - `Failed to translate Cursor JSON response: ${(error as Error).message}` - ); + } catch { + return createErrorResponse(JSON_TRANSLATION_ERROR_MESSAGE); } } @@ -80,14 +94,14 @@ function createAnthropicStreamingResponse(response: Response): Response { ); }); } - } catch (error) { + } catch { controller.enqueue( encoder.encode( formatSseEvent('error', { type: 'error', error: { type: 'api_error', - message: `Failed to translate Cursor SSE response: ${(error as Error).message}`, + message: STREAM_TRANSLATION_ERROR_MESSAGE, }, }) ) diff --git a/tests/unit/cursor/cursor-anthropic-translator.test.ts b/tests/unit/cursor/cursor-anthropic-translator.test.ts index 63d91201..e0dac78f 100644 --- a/tests/unit/cursor/cursor-anthropic-translator.test.ts +++ b/tests/unit/cursor/cursor-anthropic-translator.test.ts @@ -90,6 +90,12 @@ describe('translateAnthropicRequest', () => { ]); }); + it('handles empty messages arrays', () => { + const translated = translateAnthropicRequest({ messages: [] }); + + expect(translated.messages).toEqual([]); + }); + it('uses a distinct fallback prefix for missing tool_use ids', () => { const translated = translateAnthropicRequest({ messages: [ @@ -125,6 +131,25 @@ describe('translateAnthropicRequest', () => { ]); }); + it('returns empty string for tool_result blocks without content', () => { + const translated = translateAnthropicRequest({ + messages: [ + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1' }], + }, + ], + }); + + expect(translated.messages).toEqual([ + { + role: 'tool', + tool_call_id: 'toolu_1', + content: '', + }, + ]); + }); + it('falls back when tool_use input cannot be serialized', () => { const circular: Record = {}; circular.self = circular; @@ -207,6 +232,61 @@ describe('createAnthropicProxyResponse', () => { expect(body.content[2]?.input).toEqual({ q: 'cursor daemon' }); }); + it('returns 502 when Cursor returns invalid JSON', async () => { + const transformed = await createAnthropicProxyResponse( + new Response('not json', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + expect(transformed.status).toBe(502); + const body = (await transformed.json()) as { error?: { type?: string; message?: string } }; + expect(body.error?.type).toBe('api_error'); + expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); + }); + + it('returns 502 when Cursor response is missing choices', async () => { + const transformed = await createAnthropicProxyResponse( + new Response( + JSON.stringify({ + id: 'chatcmpl_missing_choices', + model: 'claude-sonnet-4.5', + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + + expect(transformed.status).toBe(502); + const body = (await transformed.json()) as { error?: { type?: string; message?: string } }; + expect(body.error?.type).toBe('api_error'); + expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); + }); + + it('returns 502 when Cursor response has empty choices', async () => { + const transformed = await createAnthropicProxyResponse( + new Response( + JSON.stringify({ + id: 'chatcmpl_empty_choices', + model: 'claude-sonnet-4.5', + choices: [], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + + expect(transformed.status).toBe(502); + const body = (await transformed.json()) as { error?: { type?: string; message?: string } }; + expect(body.error?.type).toBe('api_error'); + expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); + }); + it('converts OpenAI SSE chunks into Anthropic SSE events', async () => { const openAiSse = [ 'data: {"id":"chatcmpl_2","object":"chat.completion.chunk","created":1,"model":"claude-sonnet-4.5","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"},"finish_reason":null}]}\n\n', From 67290e85c0c513f31f8f648a33817d1f71dcd835 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 16 Mar 2026 14:55:53 -0400 Subject: [PATCH 10/48] test(cursor): reclassify daemon lifecycle smoke coverage --- tests/README.md | 24 +-- .../cursor-daemon-lifecycle.test.ts | 165 ++++++++++++++++++ tests/unit/cursor/cursor-daemon.test.ts | 154 +--------------- 3 files changed, 179 insertions(+), 164 deletions(-) create mode 100644 tests/integration/cursor-daemon-lifecycle.test.ts diff --git a/tests/README.md b/tests/README.md index 3f0091ed..3abf42e6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -11,7 +11,7 @@ tests/ ├── native/ # Native installation tests (bash/PowerShell) │ ├── unix/ # Unix/Linux/macOS tests │ └── windows/ # Windows PowerShell tests -├── integration/ # Integration tests (manual execution) +├── integration/ # Integration + smoke tests └── shared/ # Shared utilities ├── fixtures/ # Test configuration and environment ├── unit/ # Helper function tests @@ -22,9 +22,9 @@ tests/ ## Running Tests ```bash -bun run test # All automated tests (unit + npm) -bun run test:unit # Unit tests only (Mocha) -bun run test:npm # npm package tests (Mocha) +bun run test # All automated tests (unit + integration + npm) +bun run test:unit # Unit tests only +bun run test:npm # npm package tests bun run test:native # Native Unix tests (bash) ``` @@ -48,16 +48,18 @@ Installation tests for curl|bash (Unix) and irm|iex (Windows): - `native/windows/edge-cases.ps1` - Windows edge case tests ### Integration Tests (`integration/`) -Manual execution tests for specific scenarios: -- `token-counting-test.js` - Token counting validation -- `z-ai-streaming-test.js` - Z.AI streaming -- `glmt-integration-test.sh` - GLMT integration +Integration and smoke coverage for scenarios that exercise multiple layers: +- Automated `*.test.ts` files run as part of `bun run test:all` and CI +- Shell and standalone probe scripts remain on-demand for targeted debugging +- `cursor-daemon-lifecycle.test.ts` - local daemon process + HTTP smoke coverage +- `image-analyzer-hook.test.ts` - hook integration coverage +- `glmt-integration-test.sh` - GLMT integration probe - `symlink-chain-test.sh` - Symlink chain handling - `ux-integration-test.sh` - CLI UX integration ## Adding New Tests -- **Unit tests**: Add to `unit//` using Mocha + Node.js assert -- **npm tests**: Add to `npm/` using Mocha +- **Unit tests**: Add to `unit//` for isolated module behavior +- **npm tests**: Add to `npm/` for package behavior - **Native tests**: Add to `native/unix/` or `native/windows/` -- **Integration tests**: Add to `integration/` +- **Integration tests**: Add automated cross-layer smoke coverage to `integration/*.test.ts` diff --git a/tests/integration/cursor-daemon-lifecycle.test.ts b/tests/integration/cursor-daemon-lifecycle.test.ts new file mode 100644 index 00000000..db76fcbd --- /dev/null +++ b/tests/integration/cursor-daemon-lifecycle.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { isDaemonRunning, startDaemon, stopDaemon } from '../../src/cursor/cursor-daemon'; +import { saveCredentials } from '../../src/cursor/cursor-auth'; + +let originalCcsHome: string | undefined; +let tempDir: string; + +beforeEach(() => { + originalCcsHome = process.env.CCS_HOME; + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-daemon-integration-')); + process.env.CCS_HOME = tempDir; +}); + +afterEach(async () => { + await stopDaemon(); + + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } +}); + +describe('cursor daemon lifecycle smoke', () => { + it('starts, serves expected routes, and stops cleanly', async () => { + const port = 10000 + Math.floor(Math.random() * 50000); + const result = await startDaemon({ port, ghost_mode: true }); + expect(result.success).toBe(true); + expect(result.pid).toBeDefined(); + + expect(await isDaemonRunning(port)).toBe(true); + + const modelsResponse = await fetch(`http://127.0.0.1:${port}/v1/models`); + expect(modelsResponse.status).toBe(200); + const modelsJson = (await modelsResponse.json()) as { object?: string; data?: unknown[] }; + expect(modelsJson.object).toBe('list'); + expect(Array.isArray(modelsJson.data)).toBe(true); + + const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4.1', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + expect(chatResponse.status).toBe(401); + + const anthropicResponse = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-sonnet-4.5', + max_tokens: 256, + messages: [{ role: 'user', content: 'hello' }], + }), + }); + expect(anthropicResponse.status).toBe(401); + + const stopResult = await stopDaemon(); + expect(stopResult.success).toBe(true); + expect(await isDaemonRunning(port)).toBe(false); + }, 35000); + + it('returns 404 for unknown routes', async () => { + const port = 10000 + Math.floor(Math.random() * 50000); + const result = await startDaemon({ port, ghost_mode: true }); + expect(result.success).toBe(true); + + const response = await fetch(`http://127.0.0.1:${port}/unknown`); + expect(response.status).toBe(404); + }); + + it('returns 401 when credentials are expired', async () => { + const port = 10000 + Math.floor(Math.random() * 50000); + const expiredAt = new Date(Date.now() - 26 * 60 * 60 * 1000).toISOString(); + + saveCredentials({ + accessToken: 'a'.repeat(60), + machineId: '1234567890abcdef1234567890abcdef', + authMethod: 'manual', + importedAt: expiredAt, + }); + + const result = await startDaemon({ port, ghost_mode: true }); + expect(result.success).toBe(true); + + const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4.1', + messages: [{ role: 'user', content: 'hello' }], + }), + }); + + expect(response.status).toBe(401); + const body = (await response.json()) as { error?: { message?: string } }; + expect(body.error?.message).toContain('expired'); + }); + + it('validates invalid JSON, invalid message schema, and oversized body', async () => { + const port = 10000 + Math.floor(Math.random() * 50000); + const result = await startDaemon({ port, ghost_mode: true }); + expect(result.success).toBe(true); + + const invalidJson = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{invalid-json', + }); + expect(invalidJson.status).toBe(400); + + const invalidSchema = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4.1', + messages: { role: 'user', content: 'hello' }, + }), + }); + expect(invalidSchema.status).toBe(400); + + const invalidAnthropic = await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-sonnet-4.5', + max_tokens: 256, + messages: [{ role: 'user', content: [{ type: 'image' }] }], + }), + }); + expect(invalidAnthropic.status).toBe(400); + + const oversized = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-4.1', + messages: [ + { + role: 'user', + content: 'x'.repeat(10 * 1024 * 1024 + 1024), + }, + ], + }), + }); + expect(oversized.status).toBe(413); + }); +}); diff --git a/tests/unit/cursor/cursor-daemon.test.ts b/tests/unit/cursor/cursor-daemon.test.ts index 8bc22d7a..3b5fc576 100644 --- a/tests/unit/cursor/cursor-daemon.test.ts +++ b/tests/unit/cursor/cursor-daemon.test.ts @@ -19,7 +19,7 @@ import { } from '../../../src/cursor/cursor-daemon'; import { getCcsDir } from '../../../src/utils/config-manager'; import { handleCursorCommand } from '../../../src/commands/cursor-command'; -import { loadCredentials, saveCredentials } from '../../../src/cursor/cursor-auth'; +import { loadCredentials } from '../../../src/cursor/cursor-auth'; // Test isolation let originalCcsHome: string | undefined; @@ -140,158 +140,6 @@ describe('startDaemon', () => { expect(result.success).toBe(false); expect(result.error).toContain('Invalid port'); }); - - it('starts and stops daemon successfully', async () => { - const port = 10000 + Math.floor(Math.random() * 50000); - const result = await startDaemon({ port, ghost_mode: true }); - expect(result.success).toBe(true); - expect(result.pid).toBeDefined(); - - // Verify health - const running = await isDaemonRunning(port); - expect(running).toBe(true); - - // Verify models endpoint exists and is OpenAI-compatible list shape - const modelsResponse = await fetch(`http://127.0.0.1:${port}/v1/models`); - expect(modelsResponse.status).toBe(200); - const modelsJson = (await modelsResponse.json()) as { object?: string; data?: unknown[] }; - expect(modelsJson.object).toBe('list'); - expect(Array.isArray(modelsJson.data)).toBe(true); - - // Verify chat endpoint exists (requires auth, should not be 404) - const chatResponse = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: 'gpt-4.1', - messages: [{ role: 'user', content: 'hello' }], - }), - }); - expect(chatResponse.status).toBe(401); - - const anthropicResponse = await fetch(`http://127.0.0.1:${port}/v1/messages`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify({ - model: 'claude-sonnet-4.5', - max_tokens: 256, - messages: [{ role: 'user', content: 'hello' }], - }), - }); - expect(anthropicResponse.status).toBe(401); - - // Stop - const stopResult = await stopDaemon(); - expect(stopResult.success).toBe(true); - - // Verify stopped - const stillRunning = await isDaemonRunning(port); - expect(stillRunning).toBe(false); - }, 35000); - - it('returns 404 for unknown routes', async () => { - const port = 10000 + Math.floor(Math.random() * 50000); - const result = await startDaemon({ port, ghost_mode: true }); - expect(result.success).toBe(true); - - try { - const response = await fetch(`http://127.0.0.1:${port}/unknown`); - expect(response.status).toBe(404); - } finally { - await stopDaemon(); - } - }); - - it('returns 401 when credentials are expired', async () => { - const port = 10000 + Math.floor(Math.random() * 50000); - const expiredAt = new Date(Date.now() - 26 * 60 * 60 * 1000).toISOString(); - - saveCredentials({ - accessToken: 'a'.repeat(60), - machineId: '1234567890abcdef1234567890abcdef', - authMethod: 'manual', - importedAt: expiredAt, - }); - - const result = await startDaemon({ port, ghost_mode: true }); - expect(result.success).toBe(true); - - try { - const response = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: 'gpt-4.1', - messages: [{ role: 'user', content: 'hello' }], - }), - }); - - expect(response.status).toBe(401); - const body = (await response.json()) as { error?: { message?: string } }; - expect(body.error?.message).toContain('expired'); - } finally { - await stopDaemon(); - } - }); - - it('validates invalid JSON, invalid message schema, and oversized body', async () => { - const port = 10000 + Math.floor(Math.random() * 50000); - const result = await startDaemon({ port, ghost_mode: true }); - expect(result.success).toBe(true); - - try { - const invalidJson = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: '{invalid-json', - }); - expect(invalidJson.status).toBe(400); - - const invalidSchema = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: 'gpt-4.1', - messages: { role: 'user', content: 'hello' }, - }), - }); - expect(invalidSchema.status).toBe(400); - - const invalidAnthropic = await fetch(`http://127.0.0.1:${port}/v1/messages`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify({ - model: 'claude-sonnet-4.5', - max_tokens: 256, - messages: [{ role: 'user', content: [{ type: 'image' }] }], - }), - }); - expect(invalidAnthropic.status).toBe(400); - - const oversized = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: 'gpt-4.1', - messages: [ - { - role: 'user', - content: 'x'.repeat(10 * 1024 * 1024 + 1024), - }, - ], - }), - }); - expect(oversized.status).toBe(413); - } finally { - await stopDaemon(); - } - }); }); describe('isDaemonRunning', () => { From 94fa96aa81f83a29aedde2abaa369ebe33d58f8f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Mar 2026 07:02:47 -0400 Subject: [PATCH 11/48] fix: correct novita preset endpoint and asset handling --- README.md | 1 + src/shared/provider-preset-catalog.ts | 7 +++---- tests/unit/api/provider-presets-novita.test.ts | 2 +- tests/unit/api/provider-presets.test.ts | 17 ++++++++++++++++- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 366d4df2..954e51da 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ The dashboard provides visual management for all account types: | **Azure Foundry** | API Key | `ccs foundry` | Claude via Microsoft Azure | | **Minimax** | API Key | `ccs mm` | M2 series, 1M context | | **DeepSeek** | API Key | `ccs deepseek` | V3.2 and R1 reasoning | +| **Novita AI** | API Key | `ccs api create --preset novita` | Anthropic-compatible Novita endpoint for Claude Code | | **Qwen (OAuth)** | OAuth | `ccs qwen` | Qwen Code via CLIProxy | | **Qwen API** | API Key | `ccs api create --preset qwen` | DashScope Anthropic-compatible API | | **Alibaba Coding Plan** | API Key | `ccs api create --preset alibaba-coding-plan` | Model Studio Coding Plan endpoint | diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index b64d22d4..0ca546a0 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -258,16 +258,15 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ { id: 'novita', name: 'Novita AI', - description: 'OpenAI-compatible API (Llama, Mistral, Qwen, and more)', - baseUrl: 'https://api.novita.ai/openai', + description: 'Anthropic-compatible API for Claude Code and CCS profiles', + baseUrl: 'https://api.novita.ai/anthropic', defaultProfileName: 'novita', defaultModel: 'deepseek/deepseek-v3.2', apiKeyPlaceholder: 'YOUR_NOVITA_API_KEY', apiKeyHint: 'Get your API key at novita.ai', category: 'alternative', requiresApiKey: true, - badge: 'OpenAI-compatible', - icon: '/icons/novita.svg', + badge: 'Anthropic-compatible', }, ]; diff --git a/tests/unit/api/provider-presets-novita.test.ts b/tests/unit/api/provider-presets-novita.test.ts index 976737b5..33557503 100644 --- a/tests/unit/api/provider-presets-novita.test.ts +++ b/tests/unit/api/provider-presets-novita.test.ts @@ -5,7 +5,7 @@ describe('provider-presets-novita', () => { it('resolves novita preset id', () => { const preset = getPresetById('novita'); expect(preset?.id).toBe('novita'); - expect(preset?.baseUrl).toBe('https://api.novita.ai/openai'); + expect(preset?.baseUrl).toBe('https://api.novita.ai/anthropic'); expect(preset?.defaultProfileName).toBe('novita'); }); diff --git a/tests/unit/api/provider-presets.test.ts b/tests/unit/api/provider-presets.test.ts index 2246ed6e..9f5d03e2 100644 --- a/tests/unit/api/provider-presets.test.ts +++ b/tests/unit/api/provider-presets.test.ts @@ -1,5 +1,11 @@ +import { existsSync } from 'fs'; +import { resolve } from 'path'; import { describe, expect, it } from 'bun:test'; -import { getPresetById, isValidPresetId } from '../../../src/api/services/provider-presets'; +import { + PROVIDER_PRESETS, + getPresetById, + isValidPresetId, +} from '../../../src/api/services/provider-presets'; describe('provider-presets', () => { it('resolves Alibaba Coding Plan preset id', () => { @@ -54,4 +60,13 @@ describe('provider-presets', () => { const preset = getPresetById('qwen'); expect(preset?.defaultProfileName).toBe('qwen-api'); }); + + it('only references provider preset icons that exist in ui/public', () => { + for (const preset of PROVIDER_PRESETS) { + if (!preset.icon) continue; + + const iconPath = resolve(import.meta.dir, '../../../ui/public', preset.icon.replace(/^\/+/, '')); + expect(existsSync(iconPath)).toBe(true); + } + }); }); From a35859aba57de1212298fb9524dd912ac2be5c3a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Mar 2026 07:17:35 -0400 Subject: [PATCH 12/48] fix(cursor): harden anthropic daemon contract handling --- src/cursor/cursor-anthropic-response.ts | 176 +++++++++++++++--- src/cursor/cursor-anthropic-translator.ts | 14 +- src/cursor/cursor-daemon-entry.ts | 76 +++++--- src/cursor/cursor-models.ts | 50 ++++- src/glmt/sse-parser.ts | 6 + .../cursor-daemon-lifecycle.test.ts | 14 ++ .../cursor-anthropic-translator.test.ts | 119 +++++++++++- tests/unit/cursor/cursor-models.test.ts | 13 ++ 8 files changed, 406 insertions(+), 62 deletions(-) diff --git a/src/cursor/cursor-anthropic-response.ts b/src/cursor/cursor-anthropic-response.ts index 6102de27..6575fd14 100644 --- a/src/cursor/cursor-anthropic-response.ts +++ b/src/cursor/cursor-anthropic-response.ts @@ -5,23 +5,59 @@ import type { OpenAIResponse, SSEEvent } from '../glmt/pipeline'; const JSON_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor JSON response'; const STREAM_TRANSLATION_ERROR_MESSAGE = 'Failed to translate Cursor SSE response'; +type ResponseHeaders = Headers | Record | Array<[string, string]>; -function createErrorResponse(message: string): Response { - return new Response( - JSON.stringify({ - error: { - type: 'api_error', - message, - }, - }), - { - status: 502, - headers: { 'Content-Type': 'application/json' }, - } - ); +interface AnthropicErrorPayload { + type: 'error'; + error: { + type: string; + message: string; + }; } -function formatSseEvent(event: string, data: Record): string { +function createAnthropicErrorPayload(type: string, message: string): AnthropicErrorPayload { + return { + type: 'error', + error: { + type, + message, + }, + }; +} + +function formatErrorForLog(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + +function logTranslationError(context: string, error: unknown): void { + console.error(`[cursor-anthropic-response] ${context}: ${formatErrorForLog(error)}`); +} + +export function createAnthropicErrorResponse( + status: number, + type: string, + message: string, + headers?: ResponseHeaders +): Response { + const responseHeaders = new Headers(headers); + responseHeaders.set('Content-Type', 'application/json'); + responseHeaders.delete('Content-Length'); + + return new Response(JSON.stringify(createAnthropicErrorPayload(type, message)), { + status, + headers: responseHeaders, + }); +} + +function formatSseEvent(event: string, data: unknown): string { return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; } @@ -31,33 +67,110 @@ function hasTranslatableChoices(value: unknown): value is OpenAIResponse { } const { choices } = value as OpenAIResponse; - return Array.isArray(choices) && choices.length > 0; + if (!Array.isArray(choices) || choices.length === 0) { + return false; + } + + const firstChoice = choices[0]; + if (typeof firstChoice !== 'object' || firstChoice === null) { + return false; + } + + const message = (firstChoice as { message?: unknown }).message; + return typeof message === 'object' && message !== null; +} + +function isSyntheticTransformationFallback(value: unknown): boolean { + return ( + typeof value === 'object' && + value !== null && + typeof (value as { id?: unknown }).id === 'string' && + (value as { id: string }).id.startsWith('msg_error_') + ); +} + +async function createAnthropicErrorProxyResponse(response: Response): Promise { + const headers = new Headers(response.headers); + headers.delete('Content-Type'); + headers.delete('Content-Length'); + + let type = + response.status === 401 + ? 'authentication_error' + : response.status === 429 + ? 'rate_limit_error' + : response.status >= 400 && response.status < 500 + ? 'invalid_request_error' + : 'api_error'; + let message = `Cursor request failed with status ${response.status}`; + + try { + const contentType = (response.headers.get('content-type') || '').toLowerCase(); + if (contentType.includes('application/json')) { + const payload = (await response.json()) as { + error?: { type?: string; message?: string }; + message?: string; + }; + + if (typeof payload?.error?.type === 'string' && payload.error.type.trim().length > 0) { + type = payload.error.type; + } + + if (typeof payload?.error?.message === 'string' && payload.error.message.trim().length > 0) { + message = payload.error.message; + } else if (typeof payload?.message === 'string' && payload.message.trim().length > 0) { + message = payload.message; + } + } else { + const text = (await response.text()).trim(); + if (text.length > 0) { + message = text; + } + } + } catch (error) { + logTranslationError('Failed to parse Cursor error response', error); + } + + return createAnthropicErrorResponse(response.status, type, message, headers); } async function createAnthropicJsonResponse(response: Response): Promise { try { const openAiResponse = await response.json(); if (!hasTranslatableChoices(openAiResponse)) { - return createErrorResponse(JSON_TRANSLATION_ERROR_MESSAGE); + return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); } const anthropicResponse = new GlmtTransformer().transformResponse(openAiResponse); + if (isSyntheticTransformationFallback(anthropicResponse)) { + logTranslationError( + 'Cursor JSON translation produced synthetic fallback response', + anthropicResponse + ); + return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); + } + return new Response(JSON.stringify(anthropicResponse), { status: response.status, headers: { 'Content-Type': 'application/json' }, }); - } catch { - return createErrorResponse(JSON_TRANSLATION_ERROR_MESSAGE); + } catch (error) { + logTranslationError('Cursor JSON translation failed', error); + return createAnthropicErrorResponse(502, 'api_error', JSON_TRANSLATION_ERROR_MESSAGE); } } function createAnthropicStreamingResponse(response: Response): Response { const body = response.body; if (!body) { - return createErrorResponse('Cursor stream ended before a response body was available'); + return createAnthropicErrorResponse( + 502, + 'api_error', + 'Cursor stream ended before a response body was available' + ); } - const parser = new SSEParser(); + const parser = new SSEParser({ throwOnMalformedJson: true }); const transformer = new GlmtTransformer(); const accumulator = new DeltaAccumulator({}); const encoder = new TextEncoder(); @@ -94,16 +207,14 @@ function createAnthropicStreamingResponse(response: Response): Response { ); }); } - } catch { + } catch (error) { + logTranslationError('Cursor SSE translation failed', error); controller.enqueue( encoder.encode( - formatSseEvent('error', { - type: 'error', - error: { - type: 'api_error', - message: STREAM_TRANSLATION_ERROR_MESSAGE, - }, - }) + formatSseEvent( + 'error', + createAnthropicErrorPayload('api_error', STREAM_TRANSLATION_ERROR_MESSAGE) + ) ) ); } finally { @@ -125,11 +236,14 @@ function createAnthropicStreamingResponse(response: Response): Response { export async function createAnthropicProxyResponse(response: Response): Promise { if (!response.ok) { - return response; + return createAnthropicErrorProxyResponse(response); } - const contentType = response.headers.get('content-type') || ''; - return contentType.includes('text/event-stream') + const contentType = (response.headers.get('content-type') || '').toLowerCase(); + const isEventStream = + contentType === 'text/event-stream' || contentType.startsWith('text/event-stream;'); + + return isEventStream ? createAnthropicStreamingResponse(response) : createAnthropicJsonResponse(response); } diff --git a/src/cursor/cursor-anthropic-translator.ts b/src/cursor/cursor-anthropic-translator.ts index ce245bb7..6dbe7d92 100644 --- a/src/cursor/cursor-anthropic-translator.ts +++ b/src/cursor/cursor-anthropic-translator.ts @@ -157,10 +157,22 @@ export function translateAnthropicRequest(raw: unknown): TranslatedAnthropicRequ `messages[${messageIndex}].content[${blockIndex}] tool_result requires user role` ); } + if (typeof parsed.tool_use_id !== 'string' || parsed.tool_use_id.trim().length === 0) { + throw new Error( + `messages[${messageIndex}].content[${blockIndex}].tool_use_id must be a non-empty string` + ); + } sawToolResult = true; + if (textParts.length > 0) { + translatedMessages.push({ + role, + content: textParts.join('\n'), + }); + textParts.length = 0; + } translatedMessages.push({ role: 'tool', - tool_call_id: typeof parsed.tool_use_id === 'string' ? parsed.tool_use_id : '', + tool_call_id: parsed.tool_use_id, content: toToolResultContent( parsed.content, `messages[${messageIndex}].content[${blockIndex}].content` diff --git a/src/cursor/cursor-daemon-entry.ts b/src/cursor/cursor-daemon-entry.ts index a9fd1d2d..bfcea61c 100644 --- a/src/cursor/cursor-daemon-entry.ts +++ b/src/cursor/cursor-daemon-entry.ts @@ -7,7 +7,10 @@ import * as http from 'http'; import { Readable } from 'stream'; import { CursorExecutor } from './cursor-executor'; -import { createAnthropicProxyResponse } from './cursor-anthropic-response'; +import { + createAnthropicErrorResponse, + createAnthropicProxyResponse, +} from './cursor-anthropic-response'; import { translateAnthropicRequest } from './cursor-anthropic-translator'; import { checkAuthStatus } from './cursor-auth'; import { getModelsForDaemon, resolveCursorRequestModel } from './cursor-models'; @@ -192,10 +195,12 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser const executor = new CursorExecutor(); const server = http.createServer(async (req, res) => { - try { - const method = req.method || 'GET'; - const requestUrl = req.url || '/'; + const method = req.method || 'GET'; + const requestUrl = req.url || '/'; + const isOpenAiRoute = method === 'POST' && requestUrl === '/v1/chat/completions'; + const isAnthropicRoute = method === 'POST' && requestUrl === '/v1/messages'; + try { if (method === 'GET' && requestUrl === '/health') { writeJson(res, 200, { ok: true, service: 'cursor-daemon' }); return; @@ -224,9 +229,6 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser return; } - const isOpenAiRoute = method === 'POST' && requestUrl === '/v1/chat/completions'; - const isAnthropicRoute = method === 'POST' && requestUrl === '/v1/messages'; - if (!isOpenAiRoute && !isAnthropicRoute) { writeJson(res, 404, { error: 'Not found' }); return; @@ -246,22 +248,38 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser const authStatus = checkAuthStatus(); if (!authStatus.authenticated || !authStatus.credentials) { - writeJson(res, 401, { - error: { - type: 'authentication_error', - message: 'Cursor credentials not found. Run `ccs cursor auth` first.', - }, - }); + const message = 'Cursor credentials not found. Run `ccs cursor auth` first.'; + if (isAnthropicRoute) { + await pipeWebResponseToNode( + createAnthropicErrorResponse(401, 'authentication_error', message), + res + ); + } else { + writeJson(res, 401, { + error: { + type: 'authentication_error', + message, + }, + }); + } return; } if (authStatus.expired) { - writeJson(res, 401, { - error: { - type: 'authentication_error', - message: 'Cursor credentials expired. Run `ccs cursor auth` again.', - }, - }); + const message = 'Cursor credentials expired. Run `ccs cursor auth` again.'; + if (isAnthropicRoute) { + await pipeWebResponseToNode( + createAnthropicErrorResponse(401, 'authentication_error', message), + res + ); + } else { + writeJson(res, 401, { + error: { + type: 'authentication_error', + message, + }, + }); + } return; } @@ -318,12 +336,20 @@ export function startCursorDaemonServer(options: DaemonRuntimeOptions): http.Ser } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; const isPayloadTooLarge = message.includes('Request body too large'); - writeJson(res, isPayloadTooLarge ? 413 : 400, { - error: { - type: 'invalid_request_error', - message, - }, - }); + const status = isPayloadTooLarge ? 413 : 400; + if (isAnthropicRoute) { + await pipeWebResponseToNode( + createAnthropicErrorResponse(status, 'invalid_request_error', message), + res + ); + } else { + writeJson(res, status, { + error: { + type: 'invalid_request_error', + message, + }, + }); + } } }); diff --git a/src/cursor/cursor-models.ts b/src/cursor/cursor-models.ts index 208a1a25..1bd9a31f 100644 --- a/src/cursor/cursor-models.ts +++ b/src/cursor/cursor-models.ts @@ -281,6 +281,48 @@ function getCatalogDefaultModelId(availableModels: CursorModel[]): string { return firstAvailable || DEFAULT_CURSOR_MODEL; } +function addLookupCandidate(candidates: Set, value: string): void { + const normalized = value.trim().toLowerCase(); + if (normalized) { + candidates.add(normalized); + } +} + +function buildCursorAnthropicModelLookupCandidates(requestedModel: string): string[] { + const candidates = new Set(); + const raw = requestedModel.trim().toLowerCase(); + addLookupCandidate(candidates, raw); + + let normalized = raw.replace(/^[a-z0-9_-]+\//, ''); + addLookupCandidate(candidates, normalized); + + while (true) { + const stripped = normalized + .replace(/\(\d+\)$/i, '') + .replace(/\[1m\]$/i, '') + .replace(/-thinking$/i, '') + .replace(/-\d{8}$/i, ''); + + if (stripped === normalized) { + break; + } + + normalized = stripped; + addLookupCandidate(candidates, normalized); + } + + const anthropicAliasMatch = normalized.match( + /^claude-(opus|sonnet|haiku)-(\d+)(?:[.-](\d+))?(?:-(1m|fast-mode))?$/i + ); + if (anthropicAliasMatch) { + const [, family, major, minor, variant] = anthropicAliasMatch; + const cursorModelId = `claude-${major}${minor ? `.${minor}` : ''}-${family.toLowerCase()}${variant ? `-${variant.toLowerCase()}` : ''}`; + addLookupCandidate(candidates, cursorModelId); + } + + return [...candidates]; +} + export function resolveCursorRequestModel( requestedModel: string | null | undefined, availableModels: CursorModel[] @@ -291,8 +333,12 @@ export function resolveCursorRequestModel( return fallbackModel; } - if (availableModels.some((model) => model.id === normalizedRequested)) { - return normalizedRequested; + const lookupCandidates = new Set(buildCursorAnthropicModelLookupCandidates(normalizedRequested)); + const matchedModel = availableModels.find((model) => + lookupCandidates.has(model.id.toLowerCase()) + ); + if (matchedModel) { + return matchedModel.id; } return fallbackModel; diff --git a/src/glmt/sse-parser.ts b/src/glmt/sse-parser.ts index eaeb00dd..9a731654 100644 --- a/src/glmt/sse-parser.ts +++ b/src/glmt/sse-parser.ts @@ -19,6 +19,7 @@ interface SSEParserOptions { maxBufferSize?: number; + throwOnMalformedJson?: boolean; } interface SSEEvent { @@ -33,11 +34,13 @@ export class SSEParser { private buffer: string; private eventCount: number; private maxBufferSize: number; + private throwOnMalformedJson: boolean; constructor(options: SSEParserOptions = {}) { this.buffer = ''; this.eventCount = 0; this.maxBufferSize = options.maxBufferSize || 1024 * 1024; // 1MB default + this.throwOnMalformedJson = options.throwOnMalformedJson === true; } /** @@ -92,6 +95,9 @@ export class SSEParser { data.substring(0, 100) ); } + if (this.throwOnMalformedJson) { + throw new Error(`Malformed SSE JSON event: ${(e as Error).message}`); + } } } } else if (line.startsWith('id: ')) { diff --git a/tests/integration/cursor-daemon-lifecycle.test.ts b/tests/integration/cursor-daemon-lifecycle.test.ts index db76fcbd..7a4e28ed 100644 --- a/tests/integration/cursor-daemon-lifecycle.test.ts +++ b/tests/integration/cursor-daemon-lifecycle.test.ts @@ -68,6 +68,13 @@ describe('cursor daemon lifecycle smoke', () => { }), }); expect(anthropicResponse.status).toBe(401); + const anthropicBody = (await anthropicResponse.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(anthropicBody.type).toBe('error'); + expect(anthropicBody.error?.type).toBe('authentication_error'); + expect(anthropicBody.error?.message).toContain('Run `ccs cursor auth` first'); const stopResult = await stopDaemon(); expect(stopResult.success).toBe(true); @@ -146,6 +153,13 @@ describe('cursor daemon lifecycle smoke', () => { }), }); expect(invalidAnthropic.status).toBe(400); + const invalidAnthropicBody = (await invalidAnthropic.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(invalidAnthropicBody.type).toBe('error'); + expect(invalidAnthropicBody.error?.type).toBe('invalid_request_error'); + expect(invalidAnthropicBody.error?.message).toContain('is not supported'); const oversized = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, { method: 'POST', diff --git a/tests/unit/cursor/cursor-anthropic-translator.test.ts b/tests/unit/cursor/cursor-anthropic-translator.test.ts index e0dac78f..0abaec2b 100644 --- a/tests/unit/cursor/cursor-anthropic-translator.test.ts +++ b/tests/unit/cursor/cursor-anthropic-translator.test.ts @@ -90,6 +90,27 @@ describe('translateAnthropicRequest', () => { ]); }); + it('preserves mixed user text around tool_result blocks in order', () => { + const translated = translateAnthropicRequest({ + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'before' }, + { type: 'tool_result', tool_use_id: 'toolu_1', content: 'done' }, + { type: 'text', text: 'after' }, + ], + }, + ], + }); + + expect(translated.messages).toEqual([ + { role: 'user', content: 'before' }, + { role: 'tool', tool_call_id: 'toolu_1', content: 'done' }, + { role: 'user', content: 'after' }, + ]); + }); + it('handles empty messages arrays', () => { const translated = translateAnthropicRequest({ messages: [] }); @@ -150,6 +171,19 @@ describe('translateAnthropicRequest', () => { ]); }); + it('rejects tool_result blocks without a non-empty tool_use_id', () => { + expect(() => + translateAnthropicRequest({ + messages: [ + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: ' ', content: 'done' }], + }, + ], + }) + ).toThrow('tool_use_id must be a non-empty string'); + }); + it('falls back when tool_use input cannot be serialized', () => { const circular: Record = {}; circular.self = circular; @@ -241,7 +275,11 @@ describe('createAnthropicProxyResponse', () => { ); expect(transformed.status).toBe(502); - const body = (await transformed.json()) as { error?: { type?: string; message?: string } }; + const body = (await transformed.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(body.type).toBe('error'); expect(body.error?.type).toBe('api_error'); expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); }); @@ -261,7 +299,11 @@ describe('createAnthropicProxyResponse', () => { ); expect(transformed.status).toBe(502); - const body = (await transformed.json()) as { error?: { type?: string; message?: string } }; + const body = (await transformed.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(body.type).toBe('error'); expect(body.error?.type).toBe('api_error'); expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); }); @@ -282,7 +324,63 @@ describe('createAnthropicProxyResponse', () => { ); expect(transformed.status).toBe(502); - const body = (await transformed.json()) as { error?: { type?: string; message?: string } }; + const body = (await transformed.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(body.type).toBe('error'); + expect(body.error?.type).toBe('api_error'); + expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); + }); + + it('returns Anthropic error envelopes for non-OK upstream JSON errors', async () => { + const transformed = await createAnthropicProxyResponse( + new Response( + JSON.stringify({ + error: { + type: 'invalid_request_error', + message: '[400]: upstream rejected request', + }, + }), + { + status: 400, + headers: { 'Content-Type': 'application/json', 'Retry-After': '7' }, + } + ) + ); + + expect(transformed.status).toBe(400); + expect(transformed.headers.get('retry-after')).toBe('7'); + const body = (await transformed.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(body.type).toBe('error'); + expect(body.error?.type).toBe('invalid_request_error'); + expect(body.error?.message).toBe('[400]: upstream rejected request'); + }); + + it('returns 502 when Cursor response choices are malformed', async () => { + const transformed = await createAnthropicProxyResponse( + new Response( + JSON.stringify({ + id: 'chatcmpl_missing_message', + model: 'claude-sonnet-4.5', + choices: [{ index: 0 }], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + + expect(transformed.status).toBe(502); + const body = (await transformed.json()) as { + type?: string; + error?: { type?: string; message?: string }; + }; + expect(body.type).toBe('error'); expect(body.error?.type).toBe('api_error'); expect(body.error?.message).toBe('Failed to translate Cursor JSON response'); }); @@ -324,4 +422,19 @@ describe('createAnthropicProxyResponse', () => { expect(body).toContain('"error":{"type":"api_error"'); expect(body).toContain('Failed to translate Cursor SSE response'); }); + + it('emits Anthropic-style error events when SSE JSON is malformed', async () => { + const transformed = await createAnthropicProxyResponse( + new Response('data: {not-json}\n\n', { + status: 200, + headers: { 'Content-Type': 'text/event-stream; charset=utf-8' }, + }) + ); + + const body = await transformed.text(); + expect(body).toContain('event: error'); + expect(body).toContain('"type":"error"'); + expect(body).toContain('"error":{"type":"api_error"'); + expect(body).toContain('Failed to translate Cursor SSE response'); + }); }); diff --git a/tests/unit/cursor/cursor-models.test.ts b/tests/unit/cursor/cursor-models.test.ts index 0390f5e1..a5095b5e 100644 --- a/tests/unit/cursor/cursor-models.test.ts +++ b/tests/unit/cursor/cursor-models.test.ts @@ -57,6 +57,19 @@ describe('resolveCursorRequestModel', () => { expect(resolved).toBe('claude-4.6-opus'); }); + it('maps Anthropic family-first aliases to the matching Cursor model id', () => { + const resolved = resolveCursorRequestModel('claude-sonnet-4.5', DEFAULT_CURSOR_MODELS); + expect(resolved).toBe('claude-4.5-sonnet'); + }); + + it('strips provider, dated, and thinking suffixes before resolving Anthropic aliases', () => { + const resolved = resolveCursorRequestModel( + 'anthropic/claude-sonnet-4.5-20250929-thinking', + DEFAULT_CURSOR_MODELS + ); + expect(resolved).toBe('claude-4.5-sonnet'); + }); + 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); From eeadff3e52fe5f154ad647a990fef8603be2bc51 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Mar 2026 07:20:46 -0400 Subject: [PATCH 13/48] fix: add novita preset icon asset --- src/shared/provider-preset-catalog.ts | 1 + ui/public/icons/novita.svg | 9 +++++++++ 2 files changed, 10 insertions(+) create mode 100644 ui/public/icons/novita.svg diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts index 0ca546a0..bdb16441 100644 --- a/src/shared/provider-preset-catalog.ts +++ b/src/shared/provider-preset-catalog.ts @@ -267,6 +267,7 @@ const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [ category: 'alternative', requiresApiKey: true, badge: 'Anthropic-compatible', + icon: '/icons/novita.svg', }, ]; diff --git a/ui/public/icons/novita.svg b/ui/public/icons/novita.svg new file mode 100644 index 00000000..356b6d69 --- /dev/null +++ b/ui/public/icons/novita.svg @@ -0,0 +1,9 @@ + + Novita AI + + From 334f3697f939f81032965f31007ece2cedb33018 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Mar 2026 08:13:50 -0400 Subject: [PATCH 14/48] test: cover invalid novita preset ids --- tests/unit/api/provider-presets-novita.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/api/provider-presets-novita.test.ts b/tests/unit/api/provider-presets-novita.test.ts index 33557503..b820e536 100644 --- a/tests/unit/api/provider-presets-novita.test.ts +++ b/tests/unit/api/provider-presets-novita.test.ts @@ -32,4 +32,9 @@ describe('provider-presets-novita', () => { const preset = getPresetById('NOVITA'); expect(preset?.id).toBe('novita'); }); + + it('does not resolve partial or invalid novita ids', () => { + expect(getPresetById('novita-invalid')).toBeUndefined(); + expect(isValidPresetId('novita-invalid')).toBe(false); + }); }); From e829235070a5221a85c8fcea902aa712621e2835 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 17 Mar 2026 12:22:20 +0000 Subject: [PATCH 15/48] chore(release): 7.54.0-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b8d5f22c..fad33218 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.54.0-dev.1", + "version": "7.54.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From a5f457e1f31bece42782639e9acb8c95707adee4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 17 Mar 2026 12:25:18 +0000 Subject: [PATCH 16/48] chore(release): 7.54.0-dev.3 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index fad33218..eba3ebf1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.54.0-dev.2", + "version": "7.54.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From d05687853990dd5c4f3474195bcad8cd46481d82 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Mar 2026 09:01:21 -0400 Subject: [PATCH 17/48] feat(ui): add searchable model comboboxes --- docs/codebase-summary.md | 3 +- docs/cursor-integration.md | 2 +- docs/project-roadmap.md | 3 +- .../cliproxy/categorized-model-selector.tsx | 71 ++- .../cliproxy/provider-model-selector.tsx | 403 +++++++++--------- .../copilot/config-form/model-selector.tsx | 122 +++--- ui/src/components/ui/searchable-select.tsx | 194 +++++++++ ui/src/lib/i18n.ts | 16 + ui/src/pages/cursor.tsx | 110 ++--- ui/tests/setup/vitest-setup.ts | 13 +- .../components/ui/searchable-select.test.tsx | 95 +++++ 11 files changed, 671 insertions(+), 361 deletions(-) create mode 100644 ui/src/components/ui/searchable-select.tsx create mode 100644 ui/tests/unit/components/ui/searchable-select.test.tsx diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 507a1901..4d7ec3ac 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -1,6 +1,6 @@ # CCS Codebase Summary -Last Updated: 2026-03-16 +Last Updated: 2026-03-17 Comprehensive overview of the modularized CCS codebase structure following the Phase 9 modularization effort (Settings, Analytics, Auth Monitor splits + Test Infrastructure), v7.1 Remote CLIProxy feature, v7.2 Kiro + GitHub Copilot (ghcp) OAuth providers, v7.14 Hybrid Quota Management, v7.34 Image Analysis Hook, and account-context validation hardening. @@ -365,6 +365,7 @@ ui/src/ │ ├── button.tsx │ ├── card.tsx │ ├── dialog.tsx +│ ├── searchable-select.tsx # Shared searchable combobox for model pickers │ ├── sidebar.tsx # Custom sidebar (674 lines) │ └── [UI primitives...] │ diff --git a/docs/cursor-integration.md b/docs/cursor-integration.md index 3fa9897d..8278544e 100644 --- a/docs/cursor-integration.md +++ b/docs/cursor-integration.md @@ -82,7 +82,7 @@ Available controls: - Auth actions (auto-detect, manual import) - Daemon actions (start/stop) - Runtime config (port, auto-start, ghost mode) -- Models list +- Models list with searchable combobox filtering for large catalogs - Raw editor for `~/.ccs/cursor.settings.json` ## Raw Settings and Unified Config Sync diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index 6da3c1b8..5d47170a 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-03-16 +Last Updated: 2026-03-17 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -42,6 +42,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes - **#724**: Codex startup is now free-plan safe. CCS defaults new Codex sessions to a cross-plan model and auto-repairs stale paid-only Codex defaults when the active account is on the free plan. +- **#737**: Dashboard model pickers in Cursor, Copilot, and CLIProxy now use a searchable combobox with autofocus and explicit no-results states for large model catalogs. ### Maintainability Hardening Kickoff diff --git a/ui/src/components/cliproxy/categorized-model-selector.tsx b/ui/src/components/cliproxy/categorized-model-selector.tsx index c52f8794..e80d9d1d 100644 --- a/ui/src/components/cliproxy/categorized-model-selector.tsx +++ b/ui/src/components/cliproxy/categorized-model-selector.tsx @@ -5,15 +5,7 @@ import { useMemo } from 'react'; import { Badge } from '@/components/ui/badge'; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; +import { SearchableSelect } from '@/components/ui/searchable-select'; import { Skeleton } from '@/components/ui/skeleton'; import { Cpu } from 'lucide-react'; import type { CliproxyModelsResponse } from '@/lib/api-client'; @@ -94,36 +86,37 @@ export function CategorizedModelSelector({ } return ( - + ({ + key: category, + label: ( +
+ + {t(`cliproxyModelCategory.${display.key}`)} + + + {models.length} + +
+ ), + }))} + options={sortedCategories.flatMap(({ category, models }) => + models.map((model) => ({ + value: model.id, + groupKey: category, + searchText: model.id, + keywords: [category], + itemContent: {model.id}, + })) + )} + /> ); } diff --git a/ui/src/components/cliproxy/provider-model-selector.tsx b/ui/src/components/cliproxy/provider-model-selector.tsx index 5b8d5ec6..af2a1afb 100644 --- a/ui/src/components/cliproxy/provider-model-selector.tsx +++ b/ui/src/components/cliproxy/provider-model-selector.tsx @@ -5,22 +5,15 @@ */ import { useMemo } from 'react'; -import { Badge } from '@/components/ui/badge'; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { Skeleton } from '@/components/ui/skeleton'; -import { AlertTriangle, AlertCircle, Check } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { getCodexEffortDisplay } from '@/lib/codex-effort'; +import { AlertCircle, AlertTriangle } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { Badge } from '@/components/ui/badge'; +import { SearchableSelect } from '@/components/ui/searchable-select'; +import { Skeleton } from '@/components/ui/skeleton'; +import { getCodexEffortDisplay } from '@/lib/codex-effort'; +import { cn } from '@/lib/utils'; + /** Model entry from catalog */ export interface ModelEntry { id: string; @@ -67,6 +60,53 @@ interface ProviderModelSelectorProps { className?: string; } +function PaidBadge({ label }: { label: string }) { + return ( + + {label} + + ); +} + +function StatusBadges({ + model, + brokenLabel, + deprecatedLabel, +}: { + model: Pick; + brokenLabel: string; + deprecatedLabel: string; +}) { + return ( + <> + {model.broken && ( + + {brokenLabel} + + )} + {model.deprecated && ( + + {deprecatedLabel} + + )} + + ); +} + +function CodexEffortBadge({ modelId }: { modelId: string | undefined }) { + const codexEffort = getCodexEffortDisplay(modelId); + if (!codexEffort) return null; + + return ( + + {codexEffort.label} + + ); +} + export function ProviderModelSelector({ catalog, isLoading, @@ -79,18 +119,18 @@ export function ProviderModelSelector({ const { t } = useTranslation(); const resolvedPlaceholder = placeholder ?? t('providerModelSelector.selectModel'); - // Group models by tier const groupedModels = useMemo(() => { if (!catalog?.models) return { free: [], paid: [] }; return { - free: catalog.models.filter((m) => !m.tier || m.tier === 'free'), - paid: catalog.models.filter((m) => m.tier === 'paid'), + free: catalog.models.filter((model) => !model.tier || model.tier === 'free'), + paid: catalog.models.filter((model) => model.tier === 'paid'), }; }, [catalog]); - const selectedModel = useMemo(() => { - return catalog?.models.find((m) => m.id === value); - }, [catalog, value]); + const selectedModel = useMemo( + () => catalog?.models.find((model) => model.id === value), + [catalog, value] + ); if (isLoading) { return ; @@ -98,76 +138,69 @@ export function ProviderModelSelector({ if (!catalog || catalog.models.length === 0) { return ( -
+
{t('providerModelSelector.noModelsForProvider')}
); } - const renderModelItem = (model: ModelEntry) => ( - -
- {model.name} - {model.broken && ( - - {t('providerModelSelector.broken')} - - )} - {model.deprecated && ( - - {t('providerModelSelector.deprecated')} - - )} - {value === model.id && } -
-
- ); - return (
- + + ), + }, + { + key: 'paid', + label: ( + {t('providerModelSelector.paidTier')} + ), + }, + ]} + options={[...groupedModels.free, ...groupedModels.paid].map((model) => ({ + value: model.id, + groupKey: model.tier === 'paid' ? 'paid' : 'free', + searchText: `${model.name} ${model.id}`, + keywords: [model.tier ?? 'free'], + triggerContent: ( +
+ {model.name} + {model.tier === 'paid' && } +
+ ), + itemContent: ( +
+ {model.name} + +
+ ), + }))} + /> - {/* Warning for broken/deprecated models */} {selectedModel?.broken && ( -
- +
+

{t('providerModelSelector.modelKnownIssues')}

{selectedModel.issueUrl && ( @@ -185,8 +218,8 @@ export function ProviderModelSelector({ )} {selectedModel?.deprecated && ( -
- +
+

{t('providerModelSelector.modelDeprecated')}

{selectedModel.deprecationReason && ( @@ -196,7 +229,6 @@ export function ProviderModelSelector({
)} - {/* Model description */} {selectedModel?.description && !selectedModel.broken && !selectedModel.deprecated && (

{selectedModel.description}

)} @@ -226,27 +258,26 @@ export function ModelMappingSelector({ return (
- + ({ + value: model.id, + searchText: `${model.name} ${model.id}`, + triggerContent: {model.id}, + itemContent: ( +
+ {model.name} + {model.tier === 'paid' && } +
+ ), + }))} + />
); } @@ -272,10 +303,50 @@ export function FlexibleModelSelector({ disabled, }: FlexibleModelSelectorProps) { const { t } = useTranslation(); - // Combine catalog models (recommended) with all available models - const catalogModelIds = new Set(catalog?.models.map((m) => m.id) || []); + const catalogModelIds = new Set(catalog?.models.map((model) => model.id) || []); const isCodexProvider = catalog?.provider === 'codex'; - const selectedCodexEffort = isCodexProvider ? getCodexEffortDisplay(value) : null; + + const recommendedOptions = (catalog?.models ?? []).map((model) => ({ + value: model.id, + groupKey: 'recommended', + searchText: `${model.id} ${model.name}`, + keywords: [model.tier ?? '', catalog?.provider ?? ''], + triggerContent: ( +
+ {model.id} + {isCodexProvider && } +
+ ), + itemContent: ( +
+ {model.id} + {model.tier === 'paid' && } + {isCodexProvider && } +
+ ), + })); + + const allModelOptions = allModels + .filter((model) => !catalogModelIds.has(model.id)) + .map((model) => ({ + value: model.id, + groupKey: 'all', + searchText: model.id, + keywords: [model.owned_by], + triggerContent: ( +
+ {model.id} + {isCodexProvider && } +
+ ), + itemContent: ( +
+ {model.id} + {isCodexProvider && } +
+ ), + })); + const hasAvailableModels = recommendedOptions.length + allModelOptions.length > 0; return (
@@ -283,96 +354,36 @@ export function FlexibleModelSelector({ {description &&

{description}

}
- + + ), + }, + ]} + options={[...recommendedOptions, ...allModelOptions]} + />
); } diff --git a/ui/src/components/copilot/config-form/model-selector.tsx b/ui/src/components/copilot/config-form/model-selector.tsx index 1f3a91f0..6d5448a9 100644 --- a/ui/src/components/copilot/config-form/model-selector.tsx +++ b/ui/src/components/copilot/config-form/model-selector.tsx @@ -4,16 +4,7 @@ */ import { Badge } from '@/components/ui/badge'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - SelectGroup, - SelectLabel, -} from '@/components/ui/select'; -import { Check } from 'lucide-react'; +import { SearchableSelect } from '@/components/ui/searchable-select'; import type { FlexibleModelSelectorProps } from './types'; import { getPlanBadgeStyle, getMultiplierDisplay } from './utils'; import { useTranslation } from 'react-i18next'; @@ -27,8 +18,6 @@ export function FlexibleModelSelector({ disabled, }: FlexibleModelSelectorProps) { const { t } = useTranslation(); - // Find current model for display - const currentModel = models.find((m) => m.id === value); return (
@@ -36,58 +25,63 @@ export function FlexibleModelSelector({ {description &&

{description}

}
- + ({ + value: model.id, + groupKey: 'models', + searchText: `${model.name || model.id} ${model.id}`, + keywords: [model.minPlan ?? '', model.preview ? 'preview' : ''], + triggerContent: ( +
+ {model.id} + {model.minPlan && ( + + {model.minPlan} + + )} +
+ ), + itemContent: ( +
+ {model.name || model.id} + {model.minPlan && ( + + {model.minPlan} + + )} + {model.multiplier !== undefined && ( + + {getMultiplierDisplay(model.multiplier)} + + )} + {model.preview && ( + + {t('componentModelSelector.preview')} + + )} +
+ ), + }))} + />
); } diff --git a/ui/src/components/ui/searchable-select.tsx b/ui/src/components/ui/searchable-select.tsx new file mode 100644 index 00000000..a110441f --- /dev/null +++ b/ui/src/components/ui/searchable-select.tsx @@ -0,0 +1,194 @@ +import * as React from 'react'; +import { Check, ChevronsUpDown, Search } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { ScrollArea } from '@/components/ui/scroll-area'; + +export interface SearchableSelectGroup { + key: string; + label?: React.ReactNode; +} + +export interface SearchableSelectOption { + value: string; + searchText: string; + itemContent: React.ReactNode; + triggerContent?: React.ReactNode; + keywords?: string[]; + groupKey?: string; + disabled?: boolean; +} + +interface SearchableSelectProps { + value?: string; + onChange: (value: string) => void; + options: SearchableSelectOption[]; + groups?: SearchableSelectGroup[]; + placeholder: string; + searchPlaceholder: string; + emptyText: string; + disabled?: boolean; + className?: string; + triggerClassName?: string; + contentClassName?: string; +} + +function normalizeSearch(value: string): string { + return value.trim().toLowerCase(); +} + +export function SearchableSelect({ + value, + onChange, + options, + groups, + placeholder, + searchPlaceholder, + emptyText, + disabled, + className, + triggerClassName, + contentClassName, +}: SearchableSelectProps) { + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(''); + const searchInputRef = React.useRef(null); + + const selectedOption = React.useMemo( + () => options.find((option) => option.value === value), + [options, value] + ); + + const filteredOptions = React.useMemo(() => { + const normalizedQuery = normalizeSearch(query); + if (!normalizedQuery) return options; + + return options.filter((option) => + [option.searchText, ...(option.keywords ?? [])].some((candidate) => + normalizeSearch(candidate).includes(normalizedQuery) + ) + ); + }, [options, query]); + + const groupedOptions = React.useMemo(() => { + const knownGroups = new Map((groups ?? []).map((group) => [group.key, group])); + const ungrouped = filteredOptions.filter( + (option) => !option.groupKey || !knownGroups.has(option.groupKey) + ); + const grouped = (groups ?? []) + .map((group) => ({ + ...group, + options: filteredOptions.filter((option) => option.groupKey === group.key), + })) + .filter((group) => group.options.length > 0); + + if (ungrouped.length === 0) return grouped; + + return [{ key: '__default', options: ungrouped }, ...grouped]; + }, [filteredOptions, groups]); + + const selectedContent = selectedOption?.triggerContent ?? selectedOption?.itemContent; + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (!nextOpen) setQuery(''); + }; + + return ( + + + + + { + event.preventDefault(); + const focusInput = () => searchInputRef.current?.focus(); + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(focusInput); + return; + } + setTimeout(focusInput, 0); + }} + > +
+
+ + setQuery(event.target.value)} + placeholder={searchPlaceholder} + className="pl-8" + /> +
+
+ + + {filteredOptions.length === 0 ? ( +
{emptyText}
+ ) : ( +
+ {groupedOptions.map((group) => ( +
+ {group.label && ( +
+ {group.label} +
+ )} + {group.options.map((option) => { + const isSelected = option.value === value; + return ( + + ); + })} +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 31f6d592..7736850b 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -148,6 +148,10 @@ const resources = { availableModelsCount: 'Available Models ({{count}})', preview: 'Preview', }, + searchableSelect: { + searchModels: 'Search models...', + noResults: 'No results found.', + }, copilotSettings: { enableCopilot: 'Enable Copilot', enableCopilotDesc: 'Allow using GitHub Copilot subscription', @@ -1318,6 +1322,10 @@ const resources = { availableModelsCount: '可用模型({{count}})', preview: '预览', }, + searchableSelect: { + searchModels: '搜索模型...', + noResults: '未找到匹配结果。', + }, copilotSettings: { enableCopilot: '启用 Copilot', enableCopilotDesc: '允许使用 GitHub Copilot 订阅', @@ -2451,6 +2459,10 @@ const resources = { availableModelsCount: 'Mô hình khả dụng ({{count}})', preview: 'Xem trước', }, + searchableSelect: { + searchModels: 'Tìm mô hình...', + noResults: 'Không tìm thấy kết quả phù hợp.', + }, copilotSettings: { enableCopilot: 'Bật Copilot', enableCopilotDesc: 'Cho phép sử dụng đăng ký GitHub Copilot', @@ -3641,6 +3653,10 @@ const resources = { availableModelsCount: '利用可能なモデル ({{count}})', preview: 'プレビュー', }, + searchableSelect: { + searchModels: 'モデルを検索...', + noResults: '一致する結果がありません。', + }, copilotSettings: { enableCopilot: 'Copilot を有効化', enableCopilotDesc: 'GitHub Copilot サブスクリプションを利用できるようにします', diff --git a/ui/src/pages/cursor.tsx b/ui/src/pages/cursor.tsx index 2268fb3e..2c4b7679 100644 --- a/ui/src/pages/cursor.tsx +++ b/ui/src/pages/cursor.tsx @@ -35,15 +35,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Separator } from '@/components/ui/separator'; import { RawEditorSection } from '@/components/copilot/config-form/raw-editor-section'; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; +import { SearchableSelect } from '@/components/ui/searchable-select'; import { Dialog, DialogContent, @@ -159,7 +151,47 @@ function CursorModelSelector({ }) { const { t } = useTranslation(); const selectorValue = value || (allowDefaultFallback ? '__default' : ''); - const selected = models.find((model) => model.id === value); + const options = useMemo(() => { + const mappedModels = models.map((model) => ({ + value: model.id, + groupKey: 'models', + searchText: `${model.name || model.id} ${model.id}`, + keywords: [model.provider], + triggerContent: ( +
+ {model.name || model.id} + {model.provider && ( + + {model.provider} + + )} +
+ ), + itemContent: ( +
+ {model.name || model.id} + + {model.provider} + +
+ ), + })); + + if (!allowDefaultFallback) return mappedModels; + + return [ + { + value: '__default', + groupKey: 'models', + searchText: t('cursorPage.useDefaultModel'), + triggerContent: ( + {t('cursorPage.useDefaultModel')} + ), + itemContent: {t('cursorPage.useDefaultModel')}, + }, + ...mappedModels, + ]; + }, [allowDefaultFallback, models, t]); return (
@@ -167,9 +199,9 @@ function CursorModelSelector({

{description}

- + placeholder={t('cursorPage.selectModel')} + searchPlaceholder={t('searchableSelect.searchModels')} + emptyText={t('searchableSelect.noResults')} + triggerClassName="h-9" + groups={[ + { + key: 'models', + label: t('cursorPage.availableModelCount', { count: models.length }), + }, + ]} + options={options} + />
); } diff --git a/ui/tests/setup/vitest-setup.ts b/ui/tests/setup/vitest-setup.ts index e50b7023..3633d6ac 100644 --- a/ui/tests/setup/vitest-setup.ts +++ b/ui/tests/setup/vitest-setup.ts @@ -27,12 +27,13 @@ Object.defineProperty(window, 'matchMedia', { })), }); -// Mock ResizeObserver -global.ResizeObserver = vi.fn().mockImplementation(() => ({ - observe: vi.fn(), - unobserve: vi.fn(), - disconnect: vi.fn(), -})); +// Mock ResizeObserver with a constructible class for Radix/Floating UI usage +class ResizeObserverMock { + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); +} +global.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; // Mock localStorage const localStorageMock = { diff --git a/ui/tests/unit/components/ui/searchable-select.test.tsx b/ui/tests/unit/components/ui/searchable-select.test.tsx new file mode 100644 index 00000000..39226419 --- /dev/null +++ b/ui/tests/unit/components/ui/searchable-select.test.tsx @@ -0,0 +1,95 @@ +import { useState } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { SearchableSelect } from '@/components/ui/searchable-select'; +import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils'; + +function SearchableSelectHarness() { + const [value, setValue] = useState(); + + return ( + Claude Sonnet 4, + }, + { + value: 'gpt-5.3-codex', + groupKey: 'core', + searchText: 'GPT-5.3 Codex gpt-5.3-codex', + itemContent: GPT-5.3 Codex, + }, + { + value: 'gemini-2.5-pro', + groupKey: 'other', + searchText: 'Gemini 2.5 Pro gemini-2.5-pro', + itemContent: Gemini 2.5 Pro, + }, + ]} + /> + ); +} + +describe('SearchableSelect', () => { + beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', { + configurable: true, + value: vi.fn(() => false), + }); + Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', { + configurable: true, + value: vi.fn(), + }); + Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', { + configurable: true, + value: vi.fn(), + }); + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }); + }); + + it('autofocuses the search input, filters options, and updates the selection', async () => { + render(); + + await userEvent.click(screen.getByRole('combobox')); + + const searchInput = await screen.findByPlaceholderText('Search models...'); + await waitFor(() => { + expect(searchInput).toHaveFocus(); + }); + + await userEvent.type(searchInput, 'gpt'); + + expect(screen.getByText('GPT-5.3 Codex')).toBeInTheDocument(); + expect(screen.queryByText('Claude Sonnet 4')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('option', { name: 'GPT-5.3 Codex' })); + + await waitFor(() => { + expect(screen.getByRole('combobox')).toHaveTextContent('GPT-5.3 Codex'); + }); + }); + + it('shows the empty state when the search query has no matches', async () => { + render(); + + await userEvent.click(screen.getByRole('combobox')); + await userEvent.type(await screen.findByPlaceholderText('Search models...'), 'no-match'); + + expect(screen.getByText('No results found.')).toBeInTheDocument(); + }); +}); From 5fe96b74b9ab7fdd9e658fc967139f5d182d9305 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 17 Mar 2026 09:42:38 -0400 Subject: [PATCH 18/48] fix(ui): restore searchable combobox keyboard navigation --- ui/src/components/ui/searchable-select.tsx | 164 ++++++++++++++++-- .../components/ui/searchable-select.test.tsx | 50 +++++- 2 files changed, 201 insertions(+), 13 deletions(-) diff --git a/ui/src/components/ui/searchable-select.tsx b/ui/src/components/ui/searchable-select.tsx index a110441f..9da3602e 100644 --- a/ui/src/components/ui/searchable-select.tsx +++ b/ui/src/components/ui/searchable-select.tsx @@ -40,6 +40,10 @@ function normalizeSearch(value: string): string { return value.trim().toLowerCase(); } +function getOptionId(listboxId: string, value: string): string { + return `${listboxId}-option-${value.replace(/[^a-z0-9_-]+/gi, '-')}`; +} + export function SearchableSelect({ value, onChange, @@ -55,7 +59,10 @@ export function SearchableSelect({ }: SearchableSelectProps) { const [open, setOpen] = React.useState(false); const [query, setQuery] = React.useState(''); + const [activeOptionValue, setActiveOptionValue] = React.useState(); + const listboxId = React.useId(); const searchInputRef = React.useRef(null); + const optionRefs = React.useRef>({}); const selectedOption = React.useMemo( () => options.find((option) => option.value === value), @@ -90,11 +97,83 @@ export function SearchableSelect({ return [{ key: '__default', options: ungrouped }, ...grouped]; }, [filteredOptions, groups]); + const enabledFilteredOptions = React.useMemo( + () => filteredOptions.filter((option) => !option.disabled), + [filteredOptions] + ); + const selectedContent = selectedOption?.triggerContent ?? selectedOption?.itemContent; const handleOpenChange = (nextOpen: boolean) => { setOpen(nextOpen); - if (!nextOpen) setQuery(''); + if (!nextOpen) { + setQuery(''); + setActiveOptionValue(undefined); + } + }; + + const focusSearchInput = () => searchInputRef.current?.focus(); + + React.useEffect(() => { + if (!open) return; + if (enabledFilteredOptions.length === 0) { + setActiveOptionValue(undefined); + return; + } + + setActiveOptionValue((currentValue) => { + if (currentValue && enabledFilteredOptions.some((option) => option.value === currentValue)) { + return currentValue; + } + + return ( + enabledFilteredOptions.find((option) => option.value === value)?.value ?? + enabledFilteredOptions[0]?.value + ); + }); + }, [enabledFilteredOptions, open, value]); + + React.useEffect(() => { + if (!open || !activeOptionValue) return; + optionRefs.current[activeOptionValue]?.scrollIntoView({ block: 'nearest' }); + }, [activeOptionValue, open]); + + const moveActiveOption = (direction: 'next' | 'previous' | 'first' | 'last') => { + if (enabledFilteredOptions.length === 0) return; + if (direction === 'first') { + setActiveOptionValue(enabledFilteredOptions[0]?.value); + return; + } + if (direction === 'last') { + setActiveOptionValue(enabledFilteredOptions.at(-1)?.value); + return; + } + + const currentIndex = enabledFilteredOptions.findIndex( + (option) => option.value === activeOptionValue + ); + const fallbackIndex = direction === 'next' ? -1 : enabledFilteredOptions.length; + const startIndex = currentIndex >= 0 ? currentIndex : fallbackIndex; + const nextIndex = + direction === 'next' + ? Math.min(startIndex + 1, enabledFilteredOptions.length - 1) + : Math.max(startIndex - 1, 0); + + setActiveOptionValue(enabledFilteredOptions[nextIndex]?.value); + }; + + const selectOption = (nextValue: string) => { + onChange(nextValue); + handleOpenChange(false); + }; + + const selectActiveOption = () => { + if (!activeOptionValue) return; + const activeOption = enabledFilteredOptions.find( + (option) => option.value === activeOptionValue + ); + if (!activeOption) return; + selectOption(activeOption.value); }; return ( @@ -103,10 +182,27 @@ export function SearchableSelect({ -
-
- - {/* Show quick templates when custom mode or non-recommended preset is selected */} - {(selectedPreset === CUSTOM_PRESET_ID || isQuickTemplateSelected) && ( -
-