feat(cliproxy): implement composite variant CRUD and settings

- Add saveCompositeVariantUnified() for persisting composite configs
- Add createCompositeVariant() with per-tier backend validation
- Add createCompositeSettingsFile() with root URL + per-tier model env vars
- Add getCompositeRelativeSettingsPath() helper
- Handle composite in listVariantsFromConfig() and removeVariantFromUnifiedConfig()
- Block model update for composite variants (edit config.yaml instead)
- Export new functions from services barrel

Refs #506
This commit is contained in:
Tam Nhu Tran
2026-02-12 04:09:18 +07:00
parent 3d207faac5
commit c58fdc931e
4 changed files with 224 additions and 8 deletions
+2
View File
@@ -9,9 +9,11 @@ export {
variantExists,
listVariants,
createVariant,
createCompositeVariant,
removeVariant,
type VariantConfig,
type VariantOperationResult,
type CreateCompositeVariantOptions,
} from './variant-service';
// Proxy lifecycle
@@ -7,6 +7,12 @@
import * as fs from 'fs';
import { getConfigPath, loadConfigSafe } from '../../utils/config-manager';
import { CLIProxyProvider } from '../types';
import {
CLIProxyVariantConfig,
CompositeVariantConfig,
CompositeTierConfig,
CLIPROXY_SUPPORTED_PROVIDERS,
} from '../../config/unified-config-types';
import {
loadOrCreateUnifiedConfig,
saveUnifiedConfig,
@@ -27,6 +33,14 @@ export interface VariantConfig {
account?: string;
model?: string;
port?: number;
/** Composite variant fields */
type?: 'composite';
default_tier?: 'opus' | 'sonnet' | 'haiku';
tiers?: {
opus: CompositeTierConfig;
sonnet: CompositeTierConfig;
haiku: CompositeTierConfig;
};
}
/**
@@ -84,12 +98,25 @@ export function listVariantsFromConfig(): Record<string, VariantConfig> {
const result: Record<string, VariantConfig> = {};
for (const name of Object.keys(variants)) {
const v = variants[name];
result[name] = {
provider: v.provider,
settings: v.settings,
account: v.account,
port: v.port,
};
if ('type' in v && v.type === 'composite') {
const composite = v as CompositeVariantConfig;
result[name] = {
provider: composite.tiers[composite.default_tier].provider,
settings: composite.settings,
port: composite.port,
type: 'composite',
default_tier: composite.default_tier,
tiers: composite.tiers,
};
} else {
const single = v as CLIProxyVariantConfig;
result[name] = {
provider: single.provider,
settings: single.settings,
account: single.account,
port: single.port,
};
}
}
return result;
}
@@ -117,6 +144,27 @@ export function listVariantsFromConfig(): Record<string, VariantConfig> {
}
}
/**
* Save composite variant to unified config
*/
export function saveCompositeVariantUnified(name: string, config: CompositeVariantConfig): void {
const unifiedConfig = loadOrCreateUnifiedConfig();
if (!unifiedConfig.cliproxy) {
unifiedConfig.cliproxy = {
oauth_accounts: {},
providers: [...CLIPROXY_SUPPORTED_PROVIDERS],
variants: {},
};
}
if (!unifiedConfig.cliproxy.variants) {
unifiedConfig.cliproxy.variants = {};
}
unifiedConfig.cliproxy.variants[name] = config;
saveUnifiedConfig(unifiedConfig);
}
/**
* Save variant to unified config
*/
@@ -132,7 +180,7 @@ export function saveVariantUnified(
if (!config.cliproxy) {
config.cliproxy = {
oauth_accounts: {},
providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp', 'claude'],
providers: [...CLIPROXY_SUPPORTED_PROVIDERS],
variants: {},
};
}
@@ -204,7 +252,23 @@ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | nu
delete config.cliproxy.variants[name];
saveUnifiedConfig(config);
return { provider: variant.provider, settings: variant.settings, port: variant.port };
if ('type' in variant && variant.type === 'composite') {
const composite = variant as CompositeVariantConfig;
return {
provider: composite.tiers[composite.default_tier].provider,
settings: composite.settings,
port: composite.port,
type: 'composite',
default_tier: composite.default_tier,
tiers: composite.tiers,
};
}
const singleVariant = variant as CLIProxyVariantConfig;
return {
provider: singleVariant.provider,
settings: singleVariant.settings,
port: singleVariant.port,
};
}
/**
+82
View File
@@ -9,17 +9,21 @@ import * as os from 'os';
import * as path from 'path';
import { CLIProxyProfileName } from '../../auth/profile-detector';
import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS } from '../types';
import { CompositeTierConfig, CompositeVariantConfig } from '../../config/unified-config-types';
import { isReservedName, isWindowsReservedName } from '../../config/reserved-names';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { DEFAULT_BACKEND } from '../platform-detector';
import { isUnifiedMode } from '../../config/unified-config-loader';
import { deleteConfigForPort } from '../config-generator';
import { deleteSessionLockForPort } from '../session-tracker';
import { warn } from '../../utils/ui';
import {
createSettingsFile,
createSettingsFileUnified,
createCompositeSettingsFile,
deleteSettingsFile,
getRelativeSettingsPath,
getCompositeRelativeSettingsPath,
updateSettingsModel,
} from './variant-settings';
import {
@@ -28,6 +32,7 @@ import {
listVariantsFromConfig,
saveVariantUnified,
saveVariantLegacy,
saveCompositeVariantUnified,
removeVariantFromUnifiedConfig,
removeVariantFromLegacyConfig,
getNextAvailablePort,
@@ -203,6 +208,15 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
return { success: false, error: `Variant '${name}' not found` };
}
if (existing.type === 'composite') {
console.log(
warn(
'Cannot update composite variant properties directly. Remove and recreate, or edit config.yaml.'
)
);
return { success: false, error: 'Composite variant update not supported' };
}
// Update model in settings file if provided
if (updates.model !== undefined && existing.settings) {
const settingsPath = existing.settings.replace(/^~/, os.homedir());
@@ -255,3 +269,71 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
return { success: false, error: (error as Error).message };
}
}
/** Composite variant creation options */
export interface CreateCompositeVariantOptions {
name: string;
defaultTier: 'opus' | 'sonnet' | 'haiku';
tiers: {
opus: CompositeTierConfig;
sonnet: CompositeTierConfig;
haiku: CompositeTierConfig;
};
}
/**
* Create a new composite CLIProxy variant.
* Mixes different providers per tier using CLIProxyAPI root endpoints.
*/
export function createCompositeVariant(
options: CreateCompositeVariantOptions
): VariantOperationResult {
if (!isUnifiedMode()) {
throw new Error(
'Composite variants require unified config (config.yaml). Run "ccs migrate" first.'
);
}
try {
const { name, defaultTier, tiers } = options;
// Validate all tier providers against backend compatibility
const tierNames: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku'];
for (const tier of tierNames) {
const backendError = validateProviderBackend(tiers[tier].provider);
if (backendError) {
return { success: false, error: `${tier} tier: ${backendError}` };
}
}
// Allocate unique port for this composite variant
const port = getNextAvailablePort();
// Create settings file with root URL + per-tier models
const settingsPath = createCompositeSettingsFile(name, tiers, defaultTier, port);
// Save composite config to unified config
const compositeConfig: CompositeVariantConfig = {
type: 'composite',
default_tier: defaultTier,
tiers,
settings: getCompositeRelativeSettingsPath(name),
port,
};
saveCompositeVariantUnified(name, compositeConfig);
return {
success: true,
settingsPath,
variant: {
provider: tiers[defaultTier].provider,
type: 'composite',
default_tier: defaultTier,
tiers,
port,
},
};
} catch (error) {
return { success: false, error: (error as Error).message };
}
}
+68
View File
@@ -13,8 +13,11 @@ import { getCcsDir } from '../../utils/config-manager';
import { expandPath } from '../../utils/helpers';
import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator';
import { CLIProxyProvider } from '../types';
import { CompositeTierConfig } from '../../config/unified-config-types';
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-profile-hook-injector';
import { getEffectiveApiKey } from '../auth-token-manager';
import { warn } from '../../utils/ui';
/** Environment settings structure */
interface SettingsEnv {
@@ -144,6 +147,63 @@ export function createSettingsFileUnified(
return settingsPath;
}
/**
* Build settings env object for a composite variant.
* Uses root URL (no /api/provider/ path) for model-based routing.
*/
function buildCompositeSettingsEnv(
tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; haiku: CompositeTierConfig },
defaultTier: 'opus' | 'sonnet' | 'haiku',
port: number = CLIPROXY_DEFAULT_PORT
): SettingsEnv {
const defaultModel = tiers[defaultTier].model;
return {
// Root URL — CLIProxyAPI routes based on model name, no provider prefix
ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}`,
ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(),
ANTHROPIC_MODEL: defaultModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: tiers.opus.model,
ANTHROPIC_DEFAULT_SONNET_MODEL: tiers.sonnet.model,
ANTHROPIC_DEFAULT_HAIKU_MODEL: tiers.haiku.model,
};
}
/**
* Create settings.json file for a composite variant.
*/
export function createCompositeSettingsFile(
name: string,
tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; haiku: CompositeTierConfig },
defaultTier: 'opus' | 'sonnet' | 'haiku',
port: number = CLIPROXY_DEFAULT_PORT
): string {
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `composite-${name}.settings.json`);
const settings: SettingsFile = {
env: buildCompositeSettingsEnv(tiers, defaultTier, port),
};
ensureDir(ccsDir);
writeSettings(settingsPath, settings);
// Inject WebSearch hooks into variant settings
ensureProfileHooks(`composite-${name}`);
// Inject Image Analyzer hooks into variant settings
ensureImageAnalyzerHooks(`composite-${name}`);
return settingsPath;
}
/**
* Get relative settings path for a composite variant
*/
export function getCompositeRelativeSettingsPath(name: string): string {
return `~/.ccs/composite-${name}.settings.json`;
}
/**
* Delete settings file if it exists.
* Uses expandPath() for cross-platform path handling.
@@ -161,6 +221,14 @@ export function deleteSettingsFile(settingsPath: string): boolean {
* Update model in an existing settings file
*/
export function updateSettingsModel(settingsPath: string, model: string): void {
const fileName = path.basename(settingsPath);
if (fileName.startsWith('composite-')) {
console.log(
warn('Cannot update model for composite variant. Edit config.yaml tiers directly.')
);
return;
}
const resolvedPath = settingsPath.replace(/^~/, os.homedir());
if (!fs.existsSync(resolvedPath)) {
return;