/** * CLIProxy Variant Service * * Handles CRUD operations for CLIProxy variant profiles. * Supports both unified config (config.yaml) and legacy JSON format. */ import * as os from 'os'; import * as path from 'path'; import { CLIProxyProfileName } from '../../auth/profile-detector'; import { CLIProxyProvider, PLUS_ONLY_PROVIDERS } from '../types'; import { CompositeTierConfig, CompositeVariantConfig } from '../../config/unified-config-types'; import type { TargetType } from '../../targets/target-adapter'; import { isReservedName, isWindowsReservedName } from '../../config/reserved-names'; import { isUnifiedMode } from '../../config/unified-config-loader'; import { deleteConfigForPort } from '../config-generator'; import { hasActiveSessions, deleteSessionLockForPort } from '../session-tracker'; import { warn } from '../../utils/ui'; import { getCcsDir } from '../../utils/config-manager'; import { validateCompositeTiers } from '../composite-validator'; import { canonicalizeModelIdForProvider, getDeniedModelIdReasonForProvider, } from '../model-id-normalizer'; import { createSettingsFile, createSettingsFileUnified, createCompositeSettingsFile, deleteSettingsFile, getRelativeSettingsPath, updateSettingsModel, updateSettingsProviderAndModel, } from './variant-settings'; import { VariantConfig, variantExistsInConfig, listVariantsFromConfig, saveVariantUnified, saveVariantLegacy, saveCompositeVariantUnified, removeVariantFromUnifiedConfig, removeVariantFromLegacyConfig, getNextAvailablePort, } from './variant-config-adapter'; import { getConfiguredBackend, getPlusBackendUnavailableMessage } from '../binary-manager'; // Re-export VariantConfig from adapter export type { VariantConfig } from './variant-config-adapter'; /** Result of variant operations */ export interface VariantOperationResult { success: boolean; error?: string; variant?: VariantConfig; settingsPath?: string; } /** * Validate CLIProxy profile name */ export function validateProfileName(name: string): string | null { if (!name) { return 'Profile name is required'; } if (!/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(name)) { return 'Name must start with letter, contain only letters, numbers, dot, dash, underscore'; } if (name.length > 32) { return 'Name must be 32 characters or less'; } if (isReservedName(name)) { return `'${name}' is a reserved name`; } if (isWindowsReservedName(name)) { return `'${name}' is a Windows reserved device name and cannot be used`; } return null; } /** * Validate provider/backend compatibility * Returns error message if a provider requires Plus while local CLIProxy is * running with the fallbacked original backend. */ export function validateProviderBackend(provider: CLIProxyProfileName): string | null { // Normalize provider to lowercase for case-insensitive comparison const normalizedProvider = provider.toLowerCase() as CLIProxyProvider; const backend = getConfiguredBackend(); if (backend === 'original' && PLUS_ONLY_PROVIDERS.includes(normalizedProvider)) { return getPlusBackendUnavailableMessage(provider); } return null; } /** * Check if CLIProxy variant profile exists */ export function variantExists(name: string): boolean { return variantExistsInConfig(name); } /** * List all CLIProxy variants */ export function listVariants(): Record { return listVariantsFromConfig(); } /** * Create a new CLIProxy variant */ export function createVariant( name: string, provider: CLIProxyProfileName, model: string, account?: string, target: TargetType = 'claude' ): VariantOperationResult { try { const canonicalModel = canonicalizeModelIdForProvider(model, provider); const deniedModelReason = getDeniedModelIdReasonForProvider(canonicalModel, provider); if (deniedModelReason) { return { success: false, error: deniedModelReason }; } // Validate provider/backend compatibility (block kiro/ghcp on original backend) const backendError = validateProviderBackend(provider); if (backendError) { return { success: false, error: backendError }; } // Allocate unique port for this variant const port = getNextAvailablePort(); let settingsPath: string; if (isUnifiedMode()) { settingsPath = createSettingsFileUnified(name, provider, canonicalModel, port); saveVariantUnified( name, provider as CLIProxyProvider, getRelativeSettingsPath(provider, name), account, port, target ); } else { settingsPath = createSettingsFile(name, provider, canonicalModel, port); saveVariantLegacy( name, provider, `~/.ccs/${path.basename(settingsPath)}`, account, port, target ); } return { success: true, settingsPath, variant: { provider, model: canonicalModel, account, port, target }, }; } catch (error) { return { success: false, error: (error as Error).message, }; } } /** * Remove a CLIProxy variant */ export function removeVariant(name: string): VariantOperationResult { try { // First check if variant exists and has active sessions const variants = listVariantsFromConfig(); const existingVariant = variants[name]; if (!existingVariant) { return { success: false, error: `Variant '${name}' not found` }; } // Check for active sessions on this variant's port before deletion if (existingVariant.port && hasActiveSessions(existingVariant.port)) { return { success: false, error: `Cannot delete variant '${name}': CLIProxy is running with active sessions. Stop the session first.`, }; } let variant: VariantConfig | null; if (isUnifiedMode()) { const unifiedVariant = removeVariantFromUnifiedConfig(name); if (unifiedVariant?.settings) { deleteSettingsFile(unifiedVariant.settings); } // Clean up port-specific config and session files if (unifiedVariant?.port) { deleteConfigForPort(unifiedVariant.port); deleteSessionLockForPort(unifiedVariant.port); } variant = unifiedVariant; } else { variant = removeVariantFromLegacyConfig(name); if (variant?.settings) { deleteSettingsFile(variant.settings); } // Clean up port-specific config and session files if (variant?.port) { deleteConfigForPort(variant.port); deleteSessionLockForPort(variant.port); } } return { success: true, variant: variant ?? undefined }; } catch (error) { return { success: false, error: (error as Error).message }; } } /** Update options for variant */ export interface UpdateVariantOptions { provider?: CLIProxyProfileName; account?: string; model?: string; target?: TargetType; } /** * Update an existing CLIProxy variant */ export function updateVariant(name: string, updates: UpdateVariantOptions): VariantOperationResult { try { const variants = listVariantsFromConfig(); const existing = variants[name]; if (!existing) { 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' }; } const providerChanged = updates.provider !== undefined && updates.provider !== existing.provider; const existingTarget = existing.target || 'claude'; const targetChanged = updates.target !== undefined && updates.target !== existingTarget; const hasModelUpdate = updates.model !== undefined && updates.model.trim().length > 0; const providerForModelUpdate = (updates.provider ?? existing.provider) as CLIProxyProfileName; const canonicalModelUpdate = updates.model !== undefined ? canonicalizeModelIdForProvider(updates.model.trim(), providerForModelUpdate) : undefined; if (providerChanged && !hasModelUpdate) { return { success: false, error: 'Changing provider requires model update in the same request', }; } if (hasModelUpdate) { const deniedModelReason = getDeniedModelIdReasonForProvider( canonicalModelUpdate || '', providerForModelUpdate ); if (deniedModelReason) { return { success: false, error: deniedModelReason }; } } if (updates.provider !== undefined) { const backendError = validateProviderBackend(updates.provider); if (backendError) { return { success: false, error: backendError }; } } // Update settings file if (existing.settings) { const settingsPath = existing.settings.replace(/^~/, os.homedir()); if (providerChanged) { updateSettingsProviderAndModel( settingsPath, updates.provider as CLIProxyProfileName, canonicalModelUpdate || '', existing.port ); } else if (updates.model !== undefined) { updateSettingsModel( settingsPath, canonicalModelUpdate || '', existing.provider as CLIProxyProfileName ); } } // Update config entry if provider/account/target changed if (updates.provider !== undefined || updates.account !== undefined || targetChanged) { const newProvider = updates.provider ?? existing.provider; const newAccount = updates.account !== undefined ? updates.account : existing.account; const newTarget = updates.target ?? existingTarget; if (isUnifiedMode()) { saveVariantUnified( name, newProvider as CLIProxyProvider, existing.settings || '', newAccount || undefined, existing.port, newTarget ); } else { saveVariantLegacy( name, newProvider, existing.settings || '', newAccount || undefined, existing.port, newTarget ); } } return { success: true, variant: { provider: updates.provider ?? existing.provider, model: canonicalModelUpdate || existing.model, account: updates.account !== undefined ? updates.account : existing.account, port: existing.port, settings: existing.settings, target: updates.target ?? existingTarget, }, }; } catch (error) { return { success: false, error: (error as Error).message }; } } /** Composite variant creation options */ export interface CreateCompositeVariantOptions { name: string; defaultTier: 'opus' | 'sonnet' | 'haiku'; target?: TargetType; 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, target = 'claude' } = options; const validationError = validateCompositeTiers(tiers, { defaultTier, requireAllTiers: true, }); if (validationError) { return { success: false, error: validationError }; } // 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: settingsPath, port, ...(target !== 'claude' && { target }), }; saveCompositeVariantUnified(name, compositeConfig); return { success: true, settingsPath, variant: { provider: tiers[defaultTier].provider, type: 'composite', default_tier: defaultTier, tiers, port, target, }, }; } catch (error) { return { success: false, error: (error as Error).message }; } } /** Update options for composite variant */ export interface UpdateCompositeVariantOptions { defaultTier?: 'opus' | 'sonnet' | 'haiku'; tiers?: Partial>; target?: TargetType; } /** * Update an existing composite CLIProxy variant. * Merges changes with existing config and regenerates settings file. */ export function updateCompositeVariant( name: string, updates: UpdateCompositeVariantOptions ): VariantOperationResult { if (!isUnifiedMode()) { throw new Error('Composite variants require unified config (config.yaml).'); } try { const variants = listVariantsFromConfig(); const existing = variants[name]; if (!existing) { return { success: false, error: `Variant '${name}' not found` }; } if (existing.type !== 'composite' || !existing.tiers) { return { success: false, error: `Variant '${name}' is not a composite variant` }; } // Deep merge tiers to preserve optional fields (fallback, thinking, account) const mergedTiers = { opus: { ...existing.tiers.opus, ...updates.tiers?.opus }, sonnet: { ...existing.tiers.sonnet, ...updates.tiers?.sonnet }, haiku: { ...existing.tiers.haiku, ...updates.tiers?.haiku }, }; const newDefaultTier = updates.defaultTier ?? existing.default_tier ?? 'sonnet'; const existingTarget = existing.target || 'claude'; const newTarget = updates.target ?? existingTarget; const validationError = validateCompositeTiers(mergedTiers, { defaultTier: newDefaultTier, requireAllTiers: true, }); if (validationError) { return { success: false, error: validationError }; } // Validate all tier providers against backend compatibility const tierNames: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku']; for (const tier of tierNames) { const backendError = validateProviderBackend(mergedTiers[tier].provider); if (backendError) { return { success: false, error: `${tier} tier: ${backendError}` }; } } // Preserve existing settings path when configured; otherwise use default path. const settingsRef = existing.settings || path.join(getCcsDir(), `composite-${name}.settings.json`); // Create new settings file with updated config const settingsPath = createCompositeSettingsFile( name, mergedTiers, newDefaultTier, existing.port, settingsRef ); // Save updated composite config to unified config const compositeConfig: CompositeVariantConfig = { type: 'composite', default_tier: newDefaultTier, tiers: mergedTiers, settings: settingsRef, port: existing.port, ...(newTarget !== 'claude' && { target: newTarget }), }; saveCompositeVariantUnified(name, compositeConfig); return { success: true, settingsPath, variant: { provider: mergedTiers[newDefaultTier].provider, type: 'composite', default_tier: newDefaultTier, tiers: mergedTiers, port: existing.port, settings: settingsRef, target: newTarget, }, }; } catch (error) { return { success: false, error: (error as Error).message }; } }