mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 20:14:34 +00:00
Merge pull request #1086 from BlackJulySnow/main
feat: add extra models support for API profiles
This commit is contained in:
11 files changed
+200
-10
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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'],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user