feat: add extra models support for API profiles

Add ANTHROPIC_EXTRA_MODELS env var to allow each API profile to
configure additional models alongside the primary ANTHROPIC_MODEL.
These are synced to CLIProxy config.yaml during ccs cliproxy sync.

- profile-mapper.ts: parse ANTHROPIC_EXTRA_MODELS on sync
- profile-writer.ts: write extra models to settings.json
- shared.ts + create-command.ts: --extra-models CLI flag
- profile-routes.ts + route-helpers.ts: REST API support
- profile-dialog.tsx + api-client.ts: frontend input
This commit is contained in:
B1GGersnow committed 2026-04-25 03:20:29 +08:00
1 parent 8ccf193fd4
commit 2915ab0c40
8 files changed
+97 -9

No files matched your search

+14 -5
View File
@@ -91,7 +91,8 @@ function createSettingsFile(
baseUrl: string, baseUrl: string,
apiKey: string, apiKey: string,
models: ModelMapping, models: ModelMapping,
provider?: string provider?: string,
extraModels?: string[]
): string { ): string {
const ccsDir = getCcsDir(); const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${name}.settings.json`); const settingsPath = path.join(ccsDir, `${name}.settings.json`);
@@ -117,6 +118,9 @@ function createSettingsFile(
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
...(extraModels && extraModels.length > 0
? { ANTHROPIC_EXTRA_MODELS: extraModels.join(',') }
: {}),
CCS_DROID_PROVIDER: droidProvider, CCS_DROID_PROVIDER: droidProvider,
}, },
}; };
@@ -179,7 +183,8 @@ function createApiProfileUnified(
apiKey: string, apiKey: string,
models: ModelMapping, models: ModelMapping,
target: TargetType = 'claude', target: TargetType = 'claude',
provider?: string provider?: string,
extraModels?: string[]
): void { ): void {
const ccsDir = getCcsDir(); const ccsDir = getCcsDir();
const settingsFile = `${name}.settings.json`; const settingsFile = `${name}.settings.json`;
@@ -204,6 +209,9 @@ function createApiProfileUnified(
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus, ANTHROPIC_DEFAULT_OPUS_MODEL: models.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet, ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnet,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku, ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haiku,
...(extraModels && extraModels.length > 0
? { ANTHROPIC_EXTRA_MODELS: extraModels.join(',') }
: {}),
CCS_DROID_PROVIDER: droidProvider, CCS_DROID_PROVIDER: droidProvider,
}, },
}; };
@@ -240,7 +248,8 @@ export function createApiProfile(
apiKey: string, apiKey: string,
models: ModelMapping, models: ModelMapping,
target: TargetType = 'claude', target: TargetType = 'claude',
provider?: string provider?: string,
extraModels?: string[]
): CreateApiProfileResult { ): CreateApiProfileResult {
try { try {
const deniedReason = getDeniedModelReason(baseUrl, models); const deniedReason = getDeniedModelReason(baseUrl, models);
@@ -251,9 +260,9 @@ export function createApiProfile(
const settingsFile = `~/.ccs/${name}.settings.json`; const settingsFile = `~/.ccs/${name}.settings.json`;
if (isUnifiedMode()) { if (isUnifiedMode()) {
createApiProfileUnified(name, baseUrl, apiKey, models, target, provider); createApiProfileUnified(name, baseUrl, apiKey, models, target, provider, extraModels);
} else { } else {
createSettingsFile(name, baseUrl, apiKey, models, provider); createSettingsFile(name, baseUrl, apiKey, models, provider, extraModels);
updateLegacyConfig(name, target); updateLegacyConfig(name, target);
} }
+15
View File
@@ -149,6 +149,21 @@ export function mapProfileToClaudeKey(profile: SyncableProfile): ClaudeKey | nul
alias: '', alias: '',
}, },
]; ];
// Append extra models from ANTHROPIC_EXTRA_MODELS env var (comma-separated)
const extraModelsRaw = env.ANTHROPIC_EXTRA_MODELS;
if (extraModelsRaw && extraModelsRaw.trim()) {
const extraModels = extraModelsRaw
.split(',')
.map((m) => m.trim())
.filter((m) => m.length > 0);
for (const extra of extraModels) {
claudeKey.models.push({
name: extra,
alias: '',
});
}
}
} }
return claudeKey; return claudeKey;
+5 -1
View File
@@ -493,7 +493,7 @@ export async function handleApiCreateCommand(args: string[]): Promise<void> {
console.log(''); console.log('');
console.log(info('Creating API profile...')); 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) { if (!result.success) {
console.log(fail(`Failed to create API profile: ${result.error}`)); console.log(fail(`Failed to create API profile: ${result.error}`));
process.exit(1); process.exit(1);
@@ -534,6 +534,10 @@ export async function handleApiCreateCommand(args: string[]): Promise<void> {
` Haiku: ${finalModels.haiku}`; ` Haiku: ${finalModels.haiku}`;
} }
if (parsedArgs.extraModels && parsedArgs.extraModels.length > 0) {
details += `\nExtraModels: ${parsedArgs.extraModels.join(', ')}`;
}
console.log(''); console.log('');
console.log(infoBox(details, 'API Profile Created')); console.log(infoBox(details, 'API Profile Created'));
if (hasClaudeMappings) { if (hasClaudeMappings) {
+17
View File
@@ -17,6 +17,7 @@ export interface ApiCommandArgs {
baseUrl?: string; baseUrl?: string;
apiKey?: string; apiKey?: string;
model?: string; model?: string;
extraModels?: string[];
preset?: string; preset?: string;
cliproxyProvider?: string; cliproxyProvider?: string;
target?: TargetType; target?: TargetType;
@@ -33,6 +34,7 @@ export const API_VALUE_FLAGS = [
'--base-url', '--base-url',
'--api-key', '--api-key',
'--model', '--model',
'--extra-models',
'--preset', '--preset',
'--cliproxy-provider', '--cliproxy-provider',
'--target', '--target',
@@ -222,6 +224,21 @@ export function parseApiCommandArgs(
true 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 = applyRepeatedOption(
remaining, remaining,
['--preset'], ['--preset'],
+16 -3
View File
@@ -144,6 +144,7 @@ router.post('/', (req: Request, res: Response): void => {
'baseUrl', 'baseUrl',
'apiKey', 'apiKey',
'model', 'model',
'extraModels',
'opusModel', 'opusModel',
'sonnetModel', 'sonnetModel',
'haikuModel', 'haikuModel',
@@ -156,7 +157,7 @@ router.post('/', (req: Request, res: Response): void => {
return; 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 providerHint = req.body?.droidProvider ?? req.body?.provider;
const parsedProvider = normalizeDroidProvider(providerHint); const parsedProvider = normalizeDroidProvider(providerHint);
const normalizedBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : ''; const normalizedBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : '';
@@ -198,6 +199,14 @@ router.post('/', (req: Request, res: Response): void => {
} }
// Create profile using unified-config-aware service // 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( const result = createApiProfile(
name, name,
normalizedBaseUrl, normalizedBaseUrl,
@@ -209,7 +218,8 @@ router.post('/', (req: Request, res: Response): void => {
haiku: haikuModel || model || '', haiku: haikuModel || model || '',
}, },
parsedTarget || 'claude', parsedTarget || 'claude',
parsedProvider || undefined parsedProvider || undefined,
parsedExtraModels
); );
if (!result.success) { if (!result.success) {
@@ -409,6 +419,7 @@ router.put('/:name', (req: Request, res: Response): void => {
'baseUrl', 'baseUrl',
'apiKey', 'apiKey',
'model', 'model',
'extraModels',
'opusModel', 'opusModel',
'sonnetModel', 'sonnetModel',
'haikuModel', 'haikuModel',
@@ -422,7 +433,7 @@ router.put('/:name', (req: Request, res: Response): void => {
} }
const { name } = req.params; 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 providerHint = req.body?.droidProvider ?? req.body?.provider;
const parsedProvider = normalizeDroidProvider(providerHint); const parsedProvider = normalizeDroidProvider(providerHint);
const normalizedBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : baseUrl; const normalizedBaseUrl = typeof baseUrl === 'string' ? baseUrl.trim() : baseUrl;
@@ -457,6 +468,7 @@ router.put('/:name', (req: Request, res: Response): void => {
baseUrl !== undefined || baseUrl !== undefined ||
apiKey !== undefined || apiKey !== undefined ||
model !== undefined || model !== undefined ||
extraModels !== undefined ||
opusModel !== undefined || opusModel !== undefined ||
sonnetModel !== undefined || sonnetModel !== undefined ||
haikuModel !== undefined || haikuModel !== undefined ||
@@ -473,6 +485,7 @@ router.put('/:name', (req: Request, res: Response): void => {
baseUrl: normalizedBaseUrl, baseUrl: normalizedBaseUrl,
apiKey: normalizedApiKey, apiKey: normalizedApiKey,
model, model,
extraModels,
opusModel, opusModel,
sonnetModel, sonnetModel,
haikuModel, haikuModel,
+12
View File
@@ -218,6 +218,7 @@ export function updateSettingsFile(
baseUrl?: string; baseUrl?: string;
apiKey?: string; apiKey?: string;
model?: string; model?: string;
extraModels?: string;
opusModel?: string; opusModel?: string;
sonnetModel?: string; sonnetModel?: string;
haikuModel?: 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 ( if (
updates.provider !== undefined || updates.provider !== undefined ||
updates.baseUrl !== undefined || updates.baseUrl !== undefined ||
@@ -34,6 +34,7 @@ const schema = z.object({
baseUrl: optionalUrlSchema, baseUrl: optionalUrlSchema,
apiKey: z.string().min(10, 'API key must be at least 10 characters'), apiKey: z.string().min(10, 'API key must be at least 10 characters'),
model: z.string().optional(), model: z.string().optional(),
extraModels: z.string().optional(),
opusModel: z.string().optional(), opusModel: z.string().optional(),
sonnetModel: z.string().optional(), sonnetModel: z.string().optional(),
haikuModel: z.string().optional(), haikuModel: z.string().optional(),
@@ -67,6 +68,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
baseUrl: '', baseUrl: '',
apiKey: '', apiKey: '',
model: '', model: '',
extraModels: '',
opusModel: '', opusModel: '',
sonnetModel: '', sonnetModel: '',
haikuModel: '', haikuModel: '',
@@ -102,6 +104,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
baseUrl: data.baseUrl, baseUrl: data.baseUrl,
apiKey: data.apiKey, apiKey: data.apiKey,
model: data.model, model: data.model,
extraModels: data.extraModels,
opusModel: data.opusModel, opusModel: data.opusModel,
sonnetModel: data.sonnetModel, sonnetModel: data.sonnetModel,
haikuModel: data.haikuModel, haikuModel: data.haikuModel,
@@ -165,6 +168,19 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
</p> </p>
</div> </div>
<div>
<Label htmlFor="extraModels">Extra Models</Label>
<Input
id="extraModels"
{...register('extraModels')}
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 */} {/* Model Mapping Section */}
<div className="border rounded-md"> <div className="border rounded-md">
<button <button
+2
View File
@@ -336,6 +336,7 @@ export interface CreateProfile {
baseUrl: string; baseUrl: string;
apiKey: string; apiKey: string;
model?: string; model?: string;
extraModels?: string;
opusModel?: string; opusModel?: string;
sonnetModel?: string; sonnetModel?: string;
haikuModel?: string; haikuModel?: string;
@@ -346,6 +347,7 @@ export interface UpdateProfile {
baseUrl?: string; baseUrl?: string;
apiKey?: string; apiKey?: string;
model?: string; model?: string;
extraModels?: string;
opusModel?: string; opusModel?: string;
sonnetModel?: string; sonnetModel?: string;
haikuModel?: string; haikuModel?: string;