Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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_<TIER>_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


109 changes: 108 additions & 1 deletion server/modules/providers/list/claude/claude-models.provider.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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_<TIER>_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<string, string>;
};

type CcSwitchProviderRow = {
settings_config: string;
};

const readClaudeAliasMapFromCcSwitch = async (
dbPath = getCcSwitchDbPath(),
): Promise<Record<string, string> | 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<string, string>,
): 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<ProviderModelsDefinition | null> => {
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: [
{
Expand Down Expand Up @@ -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<ProviderCurrentActiveModel> {
Expand Down
160 changes: 160 additions & 0 deletions server/modules/providers/tests/claude-models.test.ts
Original file line number Diff line number Diff line change
@@ -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
// <tempHome>/.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<string, unknown>,
): Promise<string> => {
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 <T>(tempHome: string, fn: () => Promise<T>): Promise<T> => {
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 <T>(fn: () => Promise<T>): Promise<T> => {
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);
});
});
});