Merge pull request #1086 from BlackJulySnow/main

feat: add extra models support for API profiles
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-04-25 12:07:01 -04:00
commit 5dd5878bb4
11 files changed
+200 -10

No files matched your search

+14 -5
View File
@@ -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);
}
+22
View File
@@ -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<string>([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;
+13 -1
View File
@@ -493,7 +493,15 @@ export async function handleApiCreateCommand(args: string[]): Promise<void> {
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<void> {
` 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) {
+8
View File
@@ -55,6 +55,9 @@ export async function showApiCommandHelp(writeLine: HelpWriter = console.log): P
writeLine(` ${color('--base-url <url>', 'command')} API base URL (create)`);
writeLine(` ${color('--api-key <key>', 'command')} API key (create)`);
writeLine(` ${color('--model <model>', 'command')} Default model (create)`);
writeLine(
` ${color('--extra-models <list>', '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(
+17
View File
@@ -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'],
+18 -3
View File
@@ -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,
+12
View File
@@ -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 ||
@@ -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', () => {
@@ -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', () => {
+34 -1
View File
@@ -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) {
</p>
</div>
<div>
<Label htmlFor="extraModels">Extra Models</Label>
<Input
id="extraModels"
{...register('extraModels', {
onChange: () => setExtraModelsTouched(true),
})}
placeholder="model-a,model-b,model-c"
/>
<p className="text-xs text-muted-foreground mt-1">
Comma-separated list of additional models available via this provider. These are
synced to CLIProxy alongside the default model.
</p>
</div>
{/* Model Mapping Section */}
<div className="border rounded-md">
<button
+2
View File
@@ -336,6 +336,7 @@ export interface CreateProfile {
baseUrl: string;
apiKey: string;
model?: string;
extraModels?: string;
opusModel?: string;
sonnetModel?: string;
haikuModel?: string;
@@ -346,6 +347,7 @@ export interface UpdateProfile {
baseUrl?: string;
apiKey?: string;
model?: string;
extraModels?: string;
opusModel?: string;
sonnetModel?: string;
haikuModel?: string;