From 07bef9f50eeb3a8fe4beace38de04784f6bdda1e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 12 Feb 2026 03:27:51 +0700 Subject: [PATCH] feat(cliproxy): wire composite variant into main execution flow - Detect composite variants in ProfileDetector with tier metadata - Pass isComposite/compositeTiers/compositeDefaultTier through ccs.ts - Handle composite in loadConfigSafe() legacy adapter - Add validateCompositeVariants() for provider string validation - Display 'composite' label in API profile listing Refs #506 --- src/api/services/profile-reader.ts | 6 +++- src/auth/profile-detector.ts | 47 ++++++++++++++++++++++++++--- src/ccs.ts | 3 ++ src/config/unified-config-loader.ts | 28 ++++++++++++++++- src/utils/config-manager.ts | 31 ++++++++++++++----- 5 files changed, 101 insertions(+), 14 deletions(-) diff --git a/src/api/services/profile-reader.ts b/src/api/services/profile-reader.ts index 2773e0ad..f31d4986 100644 --- a/src/api/services/profile-reader.ts +++ b/src/api/services/profile-reader.ts @@ -72,9 +72,13 @@ export function listApiProfiles(): ApiListResult { } // CLIProxy variants for (const [name, variant] of Object.entries(unifiedConfig.cliproxy?.variants || {})) { + const provider = + variant && 'type' in variant && variant.type === 'composite' + ? 'composite' + : (variant as { provider?: string })?.provider || 'unknown'; variants.push({ name, - provider: variant?.provider || 'unknown', + provider, settings: variant?.settings || '-', }); } diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index d5db7ae1..d2c63087 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -13,7 +13,13 @@ import * as fs from 'fs'; import * as path from 'path'; import { findSimilarStrings, expandPath } from '../utils/helpers'; import { Config, Settings, ProfileMetadata } from '../types'; -import { UnifiedConfig, CopilotConfig } from '../config/unified-config-types'; +import { + UnifiedConfig, + CopilotConfig, + CLIProxyVariantConfig, + CompositeVariantConfig, + CompositeTierConfig, +} from '../config/unified-config-types'; import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader'; import { getCcsDir } from '../utils/config-manager'; @@ -46,6 +52,16 @@ export interface ProfileDetectionResult { env?: Record; /** For copilot profile: the copilot config */ copilotConfig?: CopilotConfig; + /** For composite variants: true when variant mixes providers per tier */ + isComposite?: boolean; + /** For composite variants: which tier is the default */ + compositeDefaultTier?: 'opus' | 'sonnet' | 'haiku'; + /** For composite variants: per-tier provider+model mappings */ + compositeTiers?: { + opus: CompositeTierConfig; + sonnet: CompositeTierConfig; + haiku: CompositeTierConfig; + }; } export interface AllProfiles { @@ -110,12 +126,29 @@ class ProfileDetector { // Check CLIProxy variants first if (config.cliproxy?.variants?.[profileName]) { const variant = config.cliproxy.variants[profileName]; + + // Handle composite variants + if ('type' in variant && variant.type === 'composite') { + const composite = variant as CompositeVariantConfig; + return { + type: 'cliproxy', + name: profileName, + provider: composite.tiers[composite.default_tier].provider as CLIProxyProfileName, + settingsPath: composite.settings, + port: composite.port, + isComposite: true, + compositeDefaultTier: composite.default_tier, + compositeTiers: composite.tiers, + }; + } + + const singleVariant = variant as CLIProxyVariantConfig; return { type: 'cliproxy', name: profileName, - provider: variant.provider as CLIProxyProfileName, - settingsPath: variant.settings, - port: variant.port, + provider: singleVariant.provider as CLIProxyProfileName, + settingsPath: singleVariant.settings, + port: singleVariant.port, }; } @@ -376,7 +409,11 @@ class ProfileDetector { lines.push('CLIProxy variants (unified config):'); variants.forEach((name) => { const variant = unifiedConfig.cliproxy?.variants[name]; - lines.push(` - ${name} (${variant?.provider || 'unknown'})`); + const label = + variant && 'type' in variant && variant.type === 'composite' + ? 'composite' + : (variant as { provider?: string })?.provider || 'unknown'; + lines.push(` - ${name} (${label})`); }); } diff --git a/src/ccs.ts b/src/ccs.ts index 43d7eba2..1d5a7414 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -608,6 +608,9 @@ async function main(): Promise { await execClaudeWithCLIProxy(claudeCli, provider, remainingArgs, { customSettingsPath, port: variantPort, + isComposite: profileInfo.isComposite, + compositeTiers: profileInfo.compositeTiers, + compositeDefaultTier: profileInfo.compositeDefaultTier, }); } else if (profileInfo.type === 'copilot') { // COPILOT FLOW: GitHub Copilot subscription via copilot-api proxy diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 2fb49f77..b3993318 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -25,6 +25,7 @@ import { ThinkingConfig, DashboardAuthConfig, ImageAnalysisConfig, + CLIPROXY_SUPPORTED_PROVIDERS, } from './unified-config-types'; import { isUnifiedConfigEnabled } from './feature-flags'; @@ -204,6 +205,28 @@ export function loadUnifiedConfig(): UnifiedConfig | null { } } +/** + * Validate composite variant provider strings. + * Warns about invalid providers in composite variant configurations. + */ +function validateCompositeVariants(config: UnifiedConfig): void { + const validProviders = new Set(CLIPROXY_SUPPORTED_PROVIDERS); + const variants = config.cliproxy?.variants; + if (!variants) return; + + for (const [name, variant] of Object.entries(variants)) { + if ('type' in variant && variant.type === 'composite') { + for (const [tier, tierConfig] of Object.entries(variant.tiers)) { + if (!validProviders.has(tierConfig.provider)) { + console.warn( + `[!] Variant '${name}': invalid provider '${tierConfig.provider}' in ${tier} tier` + ); + } + } + } + } +} + /** * Merge partial config with defaults. * Preserves existing data while filling in missing sections. @@ -404,7 +427,10 @@ export function loadOrCreateUnifiedConfig(): UnifiedConfig { const existing = loadUnifiedConfig(); if (existing) { // Merge with defaults to fill any missing sections - return mergeWithDefaults(existing); + const merged = mergeWithDefaults(existing); + // Validate composite variant provider strings + validateCompositeVariants(merged); + return merged; } // Create empty config diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index 2c5fecfd..28b09105 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -1,7 +1,14 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { Config, isConfig, Settings, isSettings, CLIProxyVariantsConfig } from '../types'; +import { + Config, + isConfig, + Settings, + isSettings, + CLIProxyVariantsConfig, + CLIProxyVariantConfig, +} from '../types'; import { expandPath, error } from './helpers'; import { info } from './ui'; import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; @@ -191,12 +198,22 @@ export function loadConfigSafe(): Config { if (unifiedConfig.cliproxy?.variants) { cliproxy = {}; for (const [name, variant] of Object.entries(unifiedConfig.cliproxy.variants)) { - cliproxy[name] = { - provider: variant.provider, - settings: variant.settings, - account: variant.account, - port: variant.port, - }; + if ('type' in variant && variant.type === 'composite') { + // Composite variants: use default tier's provider + cliproxy[name] = { + provider: variant.tiers[variant.default_tier].provider, + settings: variant.settings, + port: variant.port, + }; + } else { + const single = variant as CLIProxyVariantConfig; + cliproxy[name] = { + provider: single.provider, + settings: single.settings, + account: single.account, + port: single.port, + }; + } } }