diff --git a/src/cliproxy/services/index.ts b/src/cliproxy/services/index.ts index c4ece9c5..0672fe0f 100644 --- a/src/cliproxy/services/index.ts +++ b/src/cliproxy/services/index.ts @@ -9,9 +9,11 @@ export { variantExists, listVariants, createVariant, + createCompositeVariant, removeVariant, type VariantConfig, type VariantOperationResult, + type CreateCompositeVariantOptions, } from './variant-service'; // Proxy lifecycle diff --git a/src/cliproxy/services/variant-config-adapter.ts b/src/cliproxy/services/variant-config-adapter.ts index 1fb0ec65..40cfbfa7 100644 --- a/src/cliproxy/services/variant-config-adapter.ts +++ b/src/cliproxy/services/variant-config-adapter.ts @@ -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 { const result: Record = {}; 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 { } } +/** + * 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, + }; } /** diff --git a/src/cliproxy/services/variant-service.ts b/src/cliproxy/services/variant-service.ts index 31ad54ce..05d974f4 100644 --- a/src/cliproxy/services/variant-service.ts +++ b/src/cliproxy/services/variant-service.ts @@ -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 }; + } +} diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index d50c0cf2..01fc3e49 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -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;