mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 20:15:40 +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:
1 parent
3d207faac5
commit
c58fdc931e
4 files changed
+224
-8
No files matched your search
@@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user