mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user