diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index a39fdce9..b7c00ac7 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -91,7 +91,8 @@ function createSettingsFile( baseUrl: string, apiKey: string, models: ModelMapping, - provider?: string + provider?: string, + extraModels?: string[] ): string { const ccsDir = getCcsDir(); const settingsPath = path.join(ccsDir, `${name}.settings.json`); @@ -117,6 +118,9 @@ function createSettingsFile( ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, + ...(extraModels && extraModels.length > 0 + ? { ANTHROPIC_EXTRA_MODELS: extraModels.join(',') } + : {}), CCS_DROID_PROVIDER: droidProvider, }, }; @@ -179,7 +183,8 @@ function createApiProfileUnified( apiKey: string, models: ModelMapping, target: TargetType = 'claude', - provider?: string + provider?: string, + extraModels?: string[] ): void { const ccsDir = getCcsDir(); const settingsFile = `${name}.settings.json`; @@ -204,6 +209,9 @@ function createApiProfileUnified( ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, + ...(extraModels && extraModels.length > 0 + ? { ANTHROPIC_EXTRA_MODELS: extraModels.join(',') } + : {}), CCS_DROID_PROVIDER: droidProvider, }, }; @@ -240,7 +248,8 @@ export function createApiProfile( apiKey: string, models: ModelMapping, target: TargetType = 'claude', - provider?: string + provider?: string, + extraModels?: string[] ): CreateApiProfileResult { try { const deniedReason = getDeniedModelReason(baseUrl, models); @@ -251,9 +260,9 @@ export function createApiProfile( const settingsFile = `~/.ccs/${name}.settings.json`; if (isUnifiedMode()) { - createApiProfileUnified(name, baseUrl, apiKey, models, target, provider); + createApiProfileUnified(name, baseUrl, apiKey, models, target, provider, extraModels); } else { - createSettingsFile(name, baseUrl, apiKey, models, provider); + createSettingsFile(name, baseUrl, apiKey, models, provider, extraModels); updateLegacyConfig(name, target); } diff --git a/src/cliproxy/sync/profile-mapper.ts b/src/cliproxy/sync/profile-mapper.ts index 40583fa4..3a4a120c 100644 --- a/src/cliproxy/sync/profile-mapper.ts +++ b/src/cliproxy/sync/profile-mapper.ts @@ -149,6 +149,28 @@ export function mapProfileToClaudeKey(profile: SyncableProfile): ClaudeKey | nul alias: '', }, ]; + + // Append extra models from ANTHROPIC_EXTRA_MODELS env var (comma-separated). + // Skip duplicates of the primary model and any repeated entries to keep + // the synced CLIProxy `models` array clean. + const extraModelsRaw = env.ANTHROPIC_EXTRA_MODELS; + if (extraModelsRaw && extraModelsRaw.trim()) { + const seen = new Set([modelName]); + const extraModels = extraModelsRaw + .split(',') + .map((m) => m.trim()) + .filter((m) => m.length > 0); + for (const extra of extraModels) { + if (seen.has(extra)) { + continue; + } + seen.add(extra); + claudeKey.models.push({ + name: extra, + alias: '', + }); + } + } } return claudeKey; diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index 1909c2dc..b0d799f8 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -493,7 +493,15 @@ export async function handleApiCreateCommand(args: string[]): Promise { console.log(''); console.log(info('Creating API profile...')); - const result = createApiProfile(name, baseUrl || '', apiKey, finalModels, target); + const result = createApiProfile( + name, + baseUrl || '', + apiKey, + finalModels, + target, + undefined, + parsedArgs.extraModels + ); if (!result.success) { console.log(fail(`Failed to create API profile: ${result.error}`)); process.exit(1); @@ -534,6 +542,10 @@ export async function handleApiCreateCommand(args: string[]): Promise { ` Haiku: ${finalModels.haiku}`; } + if (parsedArgs.extraModels && parsedArgs.extraModels.length > 0) { + details += `\nExtraModels: ${parsedArgs.extraModels.join(', ')}`; + } + console.log(''); console.log(infoBox(details, 'API Profile Created')); if (hasClaudeMappings) { diff --git a/src/commands/api-command/help.ts b/src/commands/api-command/help.ts index 000a1840..2da106d2 100644 --- a/src/commands/api-command/help.ts +++ b/src/commands/api-command/help.ts @@ -55,6 +55,9 @@ export async function showApiCommandHelp(writeLine: HelpWriter = console.log): P writeLine(` ${color('--base-url ', 'command')} API base URL (create)`); writeLine(` ${color('--api-key ', 'command')} API key (create)`); writeLine(` ${color('--model ', 'command')} Default model (create)`); + writeLine( + ` ${color('--extra-models ', 'command')} Comma-separated extra models to expose alongside --model` + ); writeLine( ` ${color('--1m / --no-1m', 'command')} Write or clear [1m] on compatible Claude mappings` ); @@ -96,6 +99,11 @@ export async function showApiCommandHelp(writeLine: HelpWriter = console.log): P ); writeLine(` ${color('ccs api create --preset glm', 'command')}`); writeLine(''); + writeLine(` ${dim('# Expose multiple models from one provider endpoint')}`); + writeLine( + ` ${color('ccs api create dashscope --base-url https://... --api-key sk-xxx --model qwen3-coder-plus --extra-models glm-4.6,kimi-k2', 'command')}` + ); + writeLine(''); writeLine(subheader('Claude Long Context')); writeLine(` ${dim('Plain Claude model IDs stay on standard context by default.')}`); writeLine( diff --git a/src/commands/api-command/shared.ts b/src/commands/api-command/shared.ts index 37dfe35f..657cbb3d 100644 --- a/src/commands/api-command/shared.ts +++ b/src/commands/api-command/shared.ts @@ -17,6 +17,7 @@ export interface ApiCommandArgs { baseUrl?: string; apiKey?: string; model?: string; + extraModels?: string[]; preset?: string; cliproxyProvider?: string; target?: TargetType; @@ -33,6 +34,7 @@ export const API_VALUE_FLAGS = [ '--base-url', '--api-key', '--model', + '--extra-models', '--preset', '--cliproxy-provider', '--target', @@ -222,6 +224,21 @@ export function parseApiCommandArgs( true ); + remaining = applyRepeatedOption( + remaining, + ['--extra-models'], + (value) => { + result.extraModels = value + .split(',') + .map((m) => m.trim()) + .filter((m) => m.length > 0); + }, + () => { + result.errors.push('Missing value for --extra-models'); + }, + false + ); + remaining = applyRepeatedOption( remaining, ['--preset'], diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index 84752b1d..9b812760 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -144,6 +144,7 @@ router.post('/', (req: Request, res: Response): void => { 'baseUrl', 'apiKey', 'model', + 'extraModels', 'opusModel', 'sonnetModel', 'haikuModel', @@ -156,7 +157,8 @@ router.post('/', (req: Request, res: Response): void => { return; } - const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body; + const { name, baseUrl, apiKey, model, extraModels, opusModel, sonnetModel, haikuModel, target } = + req.body; const providerHint = req.body?.droidProvider ?? req.body?.provider; const parsedProvider = normalizeDroidProvider(providerHint); const normalizedBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : ''; @@ -198,6 +200,14 @@ router.post('/', (req: Request, res: Response): void => { } // Create profile using unified-config-aware service + const parsedExtraModels: string[] | undefined = + typeof extraModels === 'string' && extraModels.trim() + ? extraModels + .split(',') + .map((m: string) => m.trim()) + .filter((m: string) => m.length > 0) + : undefined; + const result = createApiProfile( name, normalizedBaseUrl, @@ -209,7 +219,8 @@ router.post('/', (req: Request, res: Response): void => { haiku: haikuModel || model || '', }, parsedTarget || 'claude', - parsedProvider || undefined + parsedProvider || undefined, + parsedExtraModels ); if (!result.success) { @@ -409,6 +420,7 @@ router.put('/:name', (req: Request, res: Response): void => { 'baseUrl', 'apiKey', 'model', + 'extraModels', 'opusModel', 'sonnetModel', 'haikuModel', @@ -422,7 +434,8 @@ router.put('/:name', (req: Request, res: Response): void => { } const { name } = req.params; - const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel, target } = req.body; + const { baseUrl, apiKey, model, extraModels, opusModel, sonnetModel, haikuModel, target } = + req.body; const providerHint = req.body?.droidProvider ?? req.body?.provider; const parsedProvider = normalizeDroidProvider(providerHint); const normalizedBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : baseUrl; @@ -457,6 +470,7 @@ router.put('/:name', (req: Request, res: Response): void => { baseUrl !== undefined || apiKey !== undefined || model !== undefined || + extraModels !== undefined || opusModel !== undefined || sonnetModel !== undefined || haikuModel !== undefined || @@ -473,6 +487,7 @@ router.put('/:name', (req: Request, res: Response): void => { baseUrl: normalizedBaseUrl, apiKey: normalizedApiKey, model, + extraModels, opusModel, sonnetModel, haikuModel, diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index b45f762a..3836711f 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -218,6 +218,7 @@ export function updateSettingsFile( baseUrl?: string; apiKey?: string; model?: string; + extraModels?: string; opusModel?: string; sonnetModel?: string; haikuModel?: string; @@ -336,6 +337,17 @@ export function updateSettingsFile( } } + // Handle extra models (comma-separated string) + if (updates.extraModels !== undefined) { + settings.env = settings.env || {}; + const normalized = typeof updates.extraModels === 'string' ? updates.extraModels.trim() : ''; + if (normalized.length > 0) { + settings.env.ANTHROPIC_EXTRA_MODELS = normalized; + } else { + delete settings.env.ANTHROPIC_EXTRA_MODELS; + } + } + if ( updates.provider !== undefined || updates.baseUrl !== undefined || diff --git a/tests/unit/cliproxy/profile-mapper.test.ts b/tests/unit/cliproxy/profile-mapper.test.ts index c95f2c71..92f688ff 100644 --- a/tests/unit/cliproxy/profile-mapper.test.ts +++ b/tests/unit/cliproxy/profile-mapper.test.ts @@ -74,6 +74,48 @@ describe('Profile Mapper', () => { assert.ok(result); assert.strictEqual(result['base-url'], undefined); }); + + it('appends ANTHROPIC_EXTRA_MODELS as additional models, deduped against primary and each other', () => { + const profile = { + name: 'dashscope', + settingsPath: '/path', + isConfigured: true, + env: { + ANTHROPIC_AUTH_TOKEN: 'sk-test', + ANTHROPIC_BASE_URL: 'https://api.example.com', + ANTHROPIC_MODEL: 'qwen3-coder-plus', + // Includes whitespace, empty entries, a duplicate of the primary + // model, and a repeated extra entry — all should be cleaned up. + ANTHROPIC_EXTRA_MODELS: ' glm-4.6 , kimi-k2 , ,qwen3-coder-plus, kimi-k2 ', + }, + }; + const result = profileMapper.mapProfileToClaudeKey(profile); + + assert.ok(result, 'Should return ClaudeKey'); + assert.deepStrictEqual(result.models, [ + { name: 'qwen3-coder-plus', alias: '' }, + { name: 'glm-4.6', alias: '' }, + { name: 'kimi-k2', alias: '' }, + ]); + }); + + it('ignores empty ANTHROPIC_EXTRA_MODELS', () => { + const profile = { + name: 'test', + settingsPath: '/path', + isConfigured: true, + env: { + ANTHROPIC_AUTH_TOKEN: 'sk-test', + ANTHROPIC_MODEL: 'gpt-4', + ANTHROPIC_EXTRA_MODELS: ' ', + }, + }; + const result = profileMapper.mapProfileToClaudeKey(profile); + + assert.ok(result); + assert.strictEqual(result.models.length, 1); + assert.strictEqual(result.models[0].name, 'gpt-4'); + }); }); describe('loadSyncableProfiles', () => { diff --git a/tests/unit/commands/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts index 1f5dee30..83387831 100644 --- a/tests/unit/commands/api-command-args.test.ts +++ b/tests/unit/commands/api-command-args.test.ts @@ -163,6 +163,24 @@ describe('api-command arg parser', () => { expect(parsed.extendedContext).toBeUndefined(); expect(parsed.errors).toEqual(['Cannot combine --1m and --no-1m']); }); + + test('parses --extra-models as comma-separated list with whitespace and empty entries trimmed', () => { + const parsed = parseApiCommandArgs([ + 'my-api', + '--extra-models', + ' glm-4.6 , kimi-k2 , ,MiniMax-M2 ', + ]); + + expect(parsed.extraModels).toEqual(['glm-4.6', 'kimi-k2', 'MiniMax-M2']); + expect(parsed.errors).toEqual([]); + }); + + test('records missing-value error for --extra-models with no value', () => { + const parsed = parseApiCommandArgs(['my-api', '--extra-models']); + + expect(parsed.extraModels).toBeUndefined(); + expect(parsed.errors).toEqual(['Missing value for --extra-models']); + }); }); describe('collectUnexpectedApiArgs', () => { diff --git a/ui/src/components/profiles/profile-dialog.tsx b/ui/src/components/profiles/profile-dialog.tsx index cdd03d69..401ca8d5 100644 --- a/ui/src/components/profiles/profile-dialog.tsx +++ b/ui/src/components/profiles/profile-dialog.tsx @@ -34,6 +34,7 @@ const schema = z.object({ baseUrl: optionalUrlSchema, apiKey: z.string().min(10, 'API key must be at least 10 characters'), model: z.string().optional(), + extraModels: z.string().optional(), opusModel: z.string().optional(), sonnetModel: z.string().optional(), haikuModel: z.string().optional(), @@ -53,6 +54,13 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { const updateMutation = useUpdateProfile(); const [showModelMapping, setShowModelMapping] = useState(false); + // Tracks whether the user has interacted with the Extra Models field in + // edit mode. RHF's `dirtyFields` compares the current value against + // `defaultValues`, so typing "x" and erasing back to "" reverts dirty to + // false. We need a latch so an explicit-clear (type then delete) still + // forwards the empty-string delete signal to the backend. + const [extraModelsTouched, setExtraModelsTouched] = useState(false); + const { register, handleSubmit, @@ -67,6 +75,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { baseUrl: '', apiKey: '', model: '', + extraModels: '', opusModel: '', sonnetModel: '', haikuModel: '', @@ -89,19 +98,28 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { useEffect(() => { if (!open) { setShowModelMapping(false); + setExtraModelsTouched(false); } }, [open]); const onSubmit = async (data: FormData) => { try { if (profile) { - // Update mode + // Update mode. The dialog cannot pre-populate `extraModels` from the + // existing profile (Profile type carries no env data), so the field + // always starts blank. Forwarding that blank value unconditionally + // would clobber any saved ANTHROPIC_EXTRA_MODELS, since the PUT route + // treats an empty string as a delete signal. Use a latch flipped on + // the first onChange so we can distinguish "user never touched it" + // (skip → preserve existing value) from "user typed then cleared" + // (forward "" → explicit delete). await updateMutation.mutateAsync({ name: profile.name, data: { baseUrl: data.baseUrl, apiKey: data.apiKey, model: data.model, + ...(extraModelsTouched ? { extraModels: data.extraModels } : {}), opusModel: data.opusModel, sonnetModel: data.sonnetModel, haikuModel: data.haikuModel, @@ -165,6 +183,21 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {

+
+ + setExtraModelsTouched(true), + })} + placeholder="model-a,model-b,model-c" + /> +

+ Comma-separated list of additional models available via this provider. These are + synced to CLIProxy alongside the default model. +

+
+ {/* Model Mapping Section */}