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 committed 2026-02-12 04:09:18 +07:00
1 parent 3d207faac5
commit c58fdc931e
4 files changed
+224 -8

No files matched your search

+2
View File
@@ -9,9 +9,11 @@ export {
variantExists, variantExists,
listVariants, listVariants,
createVariant, createVariant,
createCompositeVariant,
removeVariant, removeVariant,
type VariantConfig, type VariantConfig,
type VariantOperationResult, type VariantOperationResult,
type CreateCompositeVariantOptions,
} from './variant-service'; } from './variant-service';
// Proxy lifecycle // Proxy lifecycle
@@ -7,6 +7,12 @@
import * as fs from 'fs'; import * as fs from 'fs';
import { getConfigPath, loadConfigSafe } from '../../utils/config-manager'; import { getConfigPath, loadConfigSafe } from '../../utils/config-manager';
import { CLIProxyProvider } from '../types'; import { CLIProxyProvider } from '../types';
import {
CLIProxyVariantConfig,
CompositeVariantConfig,
CompositeTierConfig,
CLIPROXY_SUPPORTED_PROVIDERS,
} from '../../config/unified-config-types';
import { import {
loadOrCreateUnifiedConfig, loadOrCreateUnifiedConfig,
saveUnifiedConfig, saveUnifiedConfig,
@@ -27,6 +33,14 @@ export interface VariantConfig {
account?: string; account?: string;
model?: string; model?: string;
port?: number; 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> = {}; const result: Record<string, VariantConfig> = {};
for (const name of Object.keys(variants)) { for (const name of Object.keys(variants)) {
const v = variants[name]; const v = variants[name];
result[name] = { if ('type' in v && v.type === 'composite') {
provider: v.provider, const composite = v as CompositeVariantConfig;
settings: v.settings, result[name] = {
account: v.account, provider: composite.tiers[composite.default_tier].provider,
port: v.port, 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; 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 * Save variant to unified config
*/ */
@@ -132,7 +180,7 @@ export function saveVariantUnified(
if (!config.cliproxy) { if (!config.cliproxy) {
config.cliproxy = { config.cliproxy = {
oauth_accounts: {}, oauth_accounts: {},
providers: ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp', 'claude'], providers: [...CLIPROXY_SUPPORTED_PROVIDERS],
variants: {}, variants: {},
}; };
} }
@@ -204,7 +252,23 @@ export function removeVariantFromUnifiedConfig(name: string): VariantConfig | nu
delete config.cliproxy.variants[name]; delete config.cliproxy.variants[name];
saveUnifiedConfig(config); 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 * as path from 'path';
import { CLIProxyProfileName } from '../../auth/profile-detector'; import { CLIProxyProfileName } from '../../auth/profile-detector';
import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS } from '../types'; import { CLIProxyProvider, CLIProxyBackend, PLUS_ONLY_PROVIDERS } from '../types';
import { CompositeTierConfig, CompositeVariantConfig } from '../../config/unified-config-types';
import { isReservedName, isWindowsReservedName } from '../../config/reserved-names'; import { isReservedName, isWindowsReservedName } from '../../config/reserved-names';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { DEFAULT_BACKEND } from '../platform-detector'; import { DEFAULT_BACKEND } from '../platform-detector';
import { isUnifiedMode } from '../../config/unified-config-loader'; import { isUnifiedMode } from '../../config/unified-config-loader';
import { deleteConfigForPort } from '../config-generator'; import { deleteConfigForPort } from '../config-generator';
import { deleteSessionLockForPort } from '../session-tracker'; import { deleteSessionLockForPort } from '../session-tracker';
import { warn } from '../../utils/ui';
import { import {
createSettingsFile, createSettingsFile,
createSettingsFileUnified, createSettingsFileUnified,
createCompositeSettingsFile,
deleteSettingsFile, deleteSettingsFile,
getRelativeSettingsPath, getRelativeSettingsPath,
getCompositeRelativeSettingsPath,
updateSettingsModel, updateSettingsModel,
} from './variant-settings'; } from './variant-settings';
import { import {
@@ -28,6 +32,7 @@ import {
listVariantsFromConfig, listVariantsFromConfig,
saveVariantUnified, saveVariantUnified,
saveVariantLegacy, saveVariantLegacy,
saveCompositeVariantUnified,
removeVariantFromUnifiedConfig, removeVariantFromUnifiedConfig,
removeVariantFromLegacyConfig, removeVariantFromLegacyConfig,
getNextAvailablePort, getNextAvailablePort,
@@ -203,6 +208,15 @@ export function updateVariant(name: string, updates: UpdateVariantOptions): Vari
return { success: false, error: `Variant '${name}' not found` }; 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 // Update model in settings file if provided
if (updates.model !== undefined && existing.settings) { if (updates.model !== undefined && existing.settings) {
const settingsPath = existing.settings.replace(/^~/, os.homedir()); 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 }; 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 { expandPath } from '../../utils/helpers';
import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator'; import { getClaudeEnvVars, CLIPROXY_DEFAULT_PORT } from '../config-generator';
import { CLIProxyProvider } from '../types'; import { CLIProxyProvider } from '../types';
import { CompositeTierConfig } from '../../config/unified-config-types';
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector'; import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
import { ensureProfileHooks as ensureImageAnalyzerHooks } from '../../utils/hooks/image-analyzer-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 */ /** Environment settings structure */
interface SettingsEnv { interface SettingsEnv {
@@ -144,6 +147,63 @@ export function createSettingsFileUnified(
return settingsPath; 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. * Delete settings file if it exists.
* Uses expandPath() for cross-platform path handling. * Uses expandPath() for cross-platform path handling.
@@ -161,6 +221,14 @@ export function deleteSettingsFile(settingsPath: string): boolean {
* Update model in an existing settings file * Update model in an existing settings file
*/ */
export function updateSettingsModel(settingsPath: string, model: string): void { 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()); const resolvedPath = settingsPath.replace(/^~/, os.homedir());
if (!fs.existsSync(resolvedPath)) { if (!fs.existsSync(resolvedPath)) {
return; return;