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', '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) {
+ Comma-separated list of additional models available via this provider. These are + synced to CLIProxy alongside the default model. +
+