diff --git a/.env.example b/.env.example index 7e1d124c7..1fb5a95e6 100755 --- a/.env.example +++ b/.env.example @@ -42,4 +42,23 @@ HOST=0.0.0.0 VITE_CONTEXT_WINDOW=160000 CONTEXT_WINDOW=160000 +# ============================================================================= +# CC-SWITCH INTEGRATION (optional) +# ============================================================================= +# cc-switch (https://github.com/farion1231/cc-switch) is a proxy that rewrites +# claude CLI model aliases (opus/sonnet/haiku/fable) to real model IDs. When +# enabled here, the claude provider's model list is read dynamically from +# cc-switch's current claude provider (the ANTHROPIC_DEFAULT__MODEL_NAME +# mapping in its settings_config.env) instead of using the static fallback list. +# +# The model "value" stays a CLI alias that cc-switch's proxy rewrites; the label +# shows the real model name (e.g. "Opus · ark/GLM-5.2"). Falls back to the static +# list when cc-switch is disabled, the DB is missing, or no aliases are configured. +# +# Enable by setting to true (or 1). Disabled by default. +# CLAUDE_CC_SWITCH_MODELS_ENABLED=false +# +# Override the cc-switch SQLite DB location (defaults to ~/.cc-switch/cc-switch.db). +# CLAUDE_CC_SWITCH_DB_PATH=/path/to/cc-switch.db + diff --git a/server/modules/providers/list/claude/claude-models.provider.ts b/server/modules/providers/list/claude/claude-models.provider.ts index f6c4c0c60..c83dbe71b 100644 --- a/server/modules/providers/list/claude/claude-models.provider.ts +++ b/server/modules/providers/list/claude/claude-models.provider.ts @@ -1,10 +1,13 @@ import { readFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import { sessionsDb } from '@/modules/database/index.js'; import type { IProviderModels } from '@/shared/interfaces.js'; import type { ProviderChangeActiveModelInput, ProviderCurrentActiveModel, + ProviderModelOption, ProviderModelsDefinition, ProviderSessionActiveModelChange, } from '@/shared/types.js'; @@ -13,6 +16,105 @@ import { writeProviderSessionActiveModelChange, } from '@/shared/utils.js'; +// cc-switch integration is opt-in via env so installs without cc-switch keep the +// static fallback list. CLAUDE_CC_SWITCH_DB_PATH overrides the default DB location. +const isCcSwitchModelsEnabled = (): boolean => { + const value = process.env.CLAUDE_CC_SWITCH_MODELS_ENABLED; + return value === 'true' || value === '1'; +}; + +const getCcSwitchDbPath = (): string => process.env.CLAUDE_CC_SWITCH_DB_PATH?.trim() + || path.join(os.homedir(), '.cc-switch', 'cc-switch.db'); + +// claude CLI accepts tier aliases, not full model IDs. cc-switch's proxy rewrites +// each alias to a real model ID via the ANTHROPIC_DEFAULT__MODEL env vars on +// the current provider. The OPTIONS value must stay a CLI alias so the SDK passes +// it through unchanged; the label surfaces the real model name from cc-switch. +const CLAUDE_MODEL_TIERS = [ + { alias: 'opus', envKey: 'ANTHROPIC_DEFAULT_OPUS_MODEL_NAME', label: 'Opus' }, + { alias: 'sonnet', envKey: 'ANTHROPIC_DEFAULT_SONNET_MODEL_NAME', label: 'Sonnet' }, + { alias: 'haiku', envKey: 'ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME', label: 'Haiku' }, + { alias: 'fable', envKey: 'ANTHROPIC_DEFAULT_FABLE_MODEL_NAME', label: 'Fable' }, +] as const; + +type CcSwitchProviderSettings = { + env?: Record; +}; + +type CcSwitchProviderRow = { + settings_config: string; +}; + +const readClaudeAliasMapFromCcSwitch = async ( + dbPath = getCcSwitchDbPath(), +): Promise | null> => { + const { default: Database } = await import('better-sqlite3'); + const db = new Database(dbPath, { readonly: true, fileMustExist: true }); + + try { + const row = db.prepare( + "SELECT settings_config FROM providers WHERE app_type = 'claude' AND is_current = 1 LIMIT 1", + ).get() as CcSwitchProviderRow | undefined; + + if (!row?.settings_config) { + return null; + } + + const settings = JSON.parse(row.settings_config) as CcSwitchProviderSettings; + return settings.env ?? {}; + } finally { + db.close(); + } +}; + +const buildClaudeModelsFromCcSwitch = ( + env: Record, +): ProviderModelsDefinition | null => { + const options: ProviderModelOption[] = CLAUDE_MODEL_TIERS + .map((tier): ProviderModelOption | null => { + const modelName = env[tier.envKey]?.trim(); + if (!modelName) { + return null; + } + + return { + value: tier.alias, + label: `${tier.label} · ${modelName}`, + }; + }) + .filter((option): option is ProviderModelOption => option !== null); + + if (options.length === 0) { + return null; + } + + // "default" is the CLI's recommended alias — it follows the provider's own + // model preference rather than pinning a tier. Keep it first. + const defaultOption: ProviderModelOption = { + value: 'default', + label: 'Default (recommended)', + }; + + return { + OPTIONS: [defaultOption, ...options], + DEFAULT: 'default', + }; +}; + +const resolveClaudeModelsFromCcSwitch = async (): Promise => { + if (!isCcSwitchModelsEnabled()) { + return null; + } + + try { + const env = await readClaudeAliasMapFromCcSwitch(); + return env ? buildClaudeModelsFromCcSwitch(env) : null; + } catch { + // DB missing, locked, malformed, or better-sqlite3 unavailable — fall back. + return null; + } +}; + export const CLAUDE_FALLBACK_MODELS: ProviderModelsDefinition = { OPTIONS: [ { @@ -170,7 +272,12 @@ export class ClaudeProviderModels implements IProviderModels { // const supportedModels = await queryInstance.supportedModels(); // queryInstance.close(); // return buildClaudeModelsDefinition(supportedModels); - return CLAUDE_FALLBACK_MODELS; + + // Source the model list from cc-switch's current claude provider when available: + // it carries the alias→real-model mapping the proxy actually honors. Fall back to + // the static list when cc-switch is absent or unconfigured. + const ccSwitchModels = await resolveClaudeModelsFromCcSwitch(); + return ccSwitchModels ?? CLAUDE_FALLBACK_MODELS; } async getCurrentActiveModel(sessionId?: string): Promise { diff --git a/server/modules/providers/tests/claude-models.test.ts b/server/modules/providers/tests/claude-models.test.ts new file mode 100644 index 000000000..922a1d512 --- /dev/null +++ b/server/modules/providers/tests/claude-models.test.ts @@ -0,0 +1,160 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; + +import { + ClaudeProviderModels, + CLAUDE_FALLBACK_MODELS, +} from '@/modules/providers/list/claude/claude-models.provider.js'; + +// Cc-switch keeps the alias→real-model mapping on the *current* claude provider's +// settings_config.env. This helper builds a throwaway cc-switch.db at +// /.cc-switch/cc-switch.db and returns tempHome so the reader (which +// resolves the path from os.homedir()) finds it once homedir is monkeypatched. +const createCcSwitchDbUnderHome = async ( + settingsConfig: Record, +): Promise => { + const tempHome = await mkdtemp(path.join(os.tmpdir(), 'ccswitch-home-')); + await mkdir(path.join(tempHome, '.cc-switch'), { recursive: true }); + const dbPath = path.join(tempHome, '.cc-switch', 'cc-switch.db'); + const { default: Database } = await import('better-sqlite3'); + const db = new Database(dbPath); + db.exec(` + CREATE TABLE providers ( + id TEXT NOT NULL, + app_type TEXT NOT NULL, + name TEXT NOT NULL, + settings_config TEXT NOT NULL, + is_current BOOLEAN NOT NULL DEFAULT 0, + PRIMARY KEY (id, app_type) + ); + `); + db.prepare( + 'INSERT INTO providers (id, app_type, name, settings_config, is_current) VALUES (?, ?, ?, ?, ?)', + ).run( + 'test-current', + 'claude', + 'Test Provider', + JSON.stringify(settingsConfig), + 1, + ); + db.close(); + return tempHome; +}; + +const withHomedir = async (tempHome: string, fn: () => Promise): Promise => { + const originalHomedir = os.homedir; + os.homedir = () => tempHome; + try { + return await fn(); + } finally { + os.homedir = originalHomedir; + } +}; + +// cc-switch integration is opt-in via CLAUDE_CC_SWITCH_MODELS_ENABLED. Tests that +// exercise it set the flag; the rest verify the static fallback is untouched. +const withCcSwitchEnabled = async (fn: () => Promise): Promise => { + const previous = process.env.CLAUDE_CC_SWITCH_MODELS_ENABLED; + process.env.CLAUDE_CC_SWITCH_MODELS_ENABLED = 'true'; + try { + return await fn(); + } finally { + if (previous === undefined) { + delete process.env.CLAUDE_CC_SWITCH_MODELS_ENABLED; + } else { + process.env.CLAUDE_CC_SWITCH_MODELS_ENABLED = previous; + } + } +}; + +test('getSupportedModels returns cc-switch aliases with real model names when enabled', async () => { + const tempHome = await createCcSwitchDbUnderHome({ + env: { + ANTHROPIC_DEFAULT_OPUS_MODEL: 'ark/GLM-5.2[1M]', + ANTHROPIC_DEFAULT_OPUS_MODEL_NAME: 'ark/GLM-5.2', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'ds/deepseek-v4-pro[1M]', + ANTHROPIC_DEFAULT_SONNET_MODEL_NAME: 'ds/deepseek-v4-pro', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'ds/deepseek-v4-flash', + ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME: 'ds/deepseek-v4-flash', + ANTHROPIC_DEFAULT_FABLE_MODEL: 'ark/GLM-5.2[1M]', + ANTHROPIC_DEFAULT_FABLE_MODEL_NAME: 'ark/GLM-5.2', + }, + }); + + await withHomedir(tempHome, async () => { + await withCcSwitchEnabled(async () => { + const models = new ClaudeProviderModels(); + const result = await models.getSupportedModels(); + + assert.equal(result.DEFAULT, 'default'); + const values = result.OPTIONS.map((option) => option.value); + assert.deepEqual(values, ['default', 'opus', 'sonnet', 'haiku', 'fable']); + + const opus = result.OPTIONS.find((option) => option.value === 'opus'); + assert.equal(opus?.label, 'Opus · ark/GLM-5.2'); + const sonnet = result.OPTIONS.find((option) => option.value === 'sonnet'); + assert.equal(sonnet?.label, 'Sonnet · ds/deepseek-v4-pro'); + }); + }); +}); + +test('getSupportedModels uses CLAUDE_CC_SWITCH_DB_PATH override', async () => { + const tempHome = await createCcSwitchDbUnderHome({ + env: { ANTHROPIC_DEFAULT_OPUS_MODEL_NAME: 'custom-opus' }, + }); + const customDbPath = path.join(tempHome, '.cc-switch', 'cc-switch.db'); + + // Point homedir elsewhere so the default path would miss; the override must win. + await withHomedir(path.join(os.tmpdir(), `other-home-${process.pid}`), async () => { + process.env.CLAUDE_CC_SWITCH_DB_PATH = customDbPath; + try { + await withCcSwitchEnabled(async () => { + const models = new ClaudeProviderModels(); + const result = await models.getSupportedModels(); + const opus = result.OPTIONS.find((option) => option.value === 'opus'); + assert.equal(opus?.label, 'Opus · custom-opus'); + }); + } finally { + delete process.env.CLAUDE_CC_SWITCH_DB_PATH; + } + }); +}); + +test('getSupportedModels falls back when cc-switch disabled (default)', async () => { + const tempHome = await createCcSwitchDbUnderHome({ + env: { ANTHROPIC_DEFAULT_OPUS_MODEL_NAME: 'ark/GLM-5.2' }, + }); + + await withHomedir(tempHome, async () => { + // Disabled by default — even with a valid DB present, the static list wins. + delete process.env.CLAUDE_CC_SWITCH_MODELS_ENABLED; + const models = new ClaudeProviderModels(); + const result = await models.getSupportedModels(); + assert.equal(result, CLAUDE_FALLBACK_MODELS); + }); +}); + +test('getSupportedModels falls back when no alias env is configured', async () => { + const tempHome = await createCcSwitchDbUnderHome({ env: {} }); + + await withHomedir(tempHome, async () => { + await withCcSwitchEnabled(async () => { + const models = new ClaudeProviderModels(); + const result = await models.getSupportedModels(); + assert.equal(result, CLAUDE_FALLBACK_MODELS); + }); + }); +}); + +test('getSupportedModels falls back when cc-switch db is missing', async () => { + await withHomedir(path.join(os.tmpdir(), `no-ccswitch-${process.pid}`), async () => { + await withCcSwitchEnabled(async () => { + const models = new ClaudeProviderModels(); + const result = await models.getSupportedModels(); + assert.equal(result, CLAUDE_FALLBACK_MODELS); + }); + }); +});