diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index 4674ecd3..38b7cc28 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -10,6 +10,7 @@ import { getGlobalEnvConfig } from '../../config/unified-config-loader'; import { getEffectiveApiKey } from '../auth-token-manager'; import { expandPath } from '../../utils/helpers'; import { warn } from '../../utils/ui'; +import { CompositeTierConfig } from '../../config/unified-config-types'; import { validatePort, validateRemotePort, @@ -413,3 +414,64 @@ export function getRemoteEnvVars( return env; } + +/** + * Get environment variables for composite variant. + * Uses root URL (no /api/provider/ path) for model-based routing. + * Each tier maps to a different provider's model, routed by CLIProxyAPI. + * + * @param tiers Per-tier provider+model mappings + * @param defaultTier Which tier ANTHROPIC_MODEL equals + * @param port Local CLIProxy port + * @param customSettingsPath Optional path to user's custom settings file + */ +export function getCompositeEnvVars( + tiers: { opus: CompositeTierConfig; sonnet: CompositeTierConfig; haiku: CompositeTierConfig }, + defaultTier: 'opus' | 'sonnet' | 'haiku', + port: number = CLIPROXY_DEFAULT_PORT, + customSettingsPath?: string +): Record { + const globalEnv = getGlobalEnvVars(); + + // Load user settings if provided (may contain additional env vars like hooks) + let additionalEnvVars: Record = {}; + if (customSettingsPath) { + const expandedPath = expandPath(customSettingsPath); + if (fs.existsSync(expandedPath)) { + try { + const content = fs.readFileSync(expandedPath, 'utf-8'); + const settings: ProviderSettings = JSON.parse(content); + if (settings.env && typeof settings.env === 'object') { + // Extract non-core env vars (hooks, etc.) + const { + ANTHROPIC_BASE_URL: _baseUrl, + ANTHROPIC_AUTH_TOKEN: _authToken, + ANTHROPIC_MODEL: _model, + ANTHROPIC_DEFAULT_OPUS_MODEL: _opus, + ANTHROPIC_DEFAULT_SONNET_MODEL: _sonnet, + ANTHROPIC_DEFAULT_HAIKU_MODEL: _haiku, + ...extra + } = settings.env as Record; + additionalEnvVars = extra; + } + } catch { + // Invalid JSON — ignore + } + } + } + + const validPort = validatePort(port); + const defaultModel = tiers[defaultTier].model; + + return { + ...globalEnv, + ...additionalEnvVars, + // Root URL — CLIProxyAPI routes based on model name in request body + ANTHROPIC_BASE_URL: `http://127.0.0.1:${validPort}`, + 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, + }; +} diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index 2747a70a..bb452cd2 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -9,9 +9,15 @@ * - WebSearch and ImageAnalysis hook integration */ -import { getEffectiveEnvVars, getRemoteEnvVars, applyThinkingConfig } from '../config-generator'; +import { + getEffectiveEnvVars, + getRemoteEnvVars, + getCompositeEnvVars, + applyThinkingConfig, +} from '../config-generator'; import { applyExtendedContextConfig } from '../config/extended-context-config'; import { CLIProxyProvider } from '../types'; +import { CompositeTierConfig } from '../../config/unified-config-types'; import { getWebSearchHookEnv } from '../../utils/websearch-manager'; import { getImageAnalysisHookEnv } from '../../utils/hooks/get-image-analysis-hook-env'; import { CodexReasoningProxy } from '../codex-reasoning-proxy'; @@ -41,6 +47,16 @@ export interface ProxyChainConfig { /** Extended context override: true = force on, false = force off, undefined = auto */ extendedContextOverride?: boolean; verbose: boolean; + /** Composite variant: true when mixing providers per tier */ + isComposite?: boolean; + /** Composite variant: per-tier provider+model mappings */ + compositeTiers?: { + opus: CompositeTierConfig; + sonnet: CompositeTierConfig; + haiku: CompositeTierConfig; + }; + /** Composite variant: which tier is the default */ + compositeDefaultTier?: 'opus' | 'sonnet' | 'haiku'; } /** @@ -60,12 +76,23 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record t.provider))] + : []; + // 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults) const unifiedConfig = loadOrCreateUnifiedConfig(); @@ -421,7 +427,14 @@ export async function execClaudeWithCLIProxy( // Handle --config if (forceConfig && supportsModelConfig(provider)) { - await configureProviderModel(provider, true, cfg.customSettingsPath); + // Block --config for composite variants (per-tier models in config.yaml) + if (cfg.isComposite) { + console.log( + warn('Composite variants use per-tier config. Edit config.yaml to change tier models.') + ); + } else { + await configureProviderModel(provider, true, cfg.customSettingsPath); + } process.exit(0); } @@ -477,7 +490,22 @@ export async function execClaudeWithCLIProxy( if (providerConfig.requiresOAuth && !skipLocalAuth) { log(`Checking authentication for ${provider}`); - if (forceAuth || !isAuthenticated(provider)) { + // Multi-provider auth check for composite variants + if (compositeProviders.length > 0) { + const unauthenticatedProviders: string[] = []; + for (const p of compositeProviders) { + if (!isAuthenticated(p)) { + unauthenticatedProviders.push(p); + } + } + if (unauthenticatedProviders.length > 0) { + console.error(fail('Composite variant requires authentication for multiple providers:')); + for (const p of unauthenticatedProviders) { + console.error(` - ${p} (run "ccs ${p} --auth")`); + } + process.exit(1); + } + } else if (forceAuth || !isAuthenticated(provider)) { const { triggerOAuth } = await import('../auth-handler'); const authSuccess = await triggerOAuth(provider, { verbose, @@ -498,8 +526,14 @@ export async function execClaudeWithCLIProxy( log(`${provider} already authenticated`); } - // 3a. Proactive token refresh - await handleTokenExpiration(provider, verbose); + // 3a. Proactive token refresh (multi-provider for composite) + if (compositeProviders.length > 0) { + for (const p of compositeProviders) { + await handleTokenExpiration(p, verbose); + } + } else { + await handleTokenExpiration(provider, verbose); + } // 3a-1. Update lastUsedAt const usedAccount = getDefaultAccount(provider); @@ -510,7 +544,14 @@ export async function execClaudeWithCLIProxy( // 3b. Preflight quota check (Antigravity only) if (!skipLocalAuth) { - await handleQuotaCheck(provider); + // Multi-tier quota check for composite variants (check if ANY tier uses 'agy') + if (compositeProviders.length > 0) { + if (compositeProviders.includes('agy')) { + await handleQuotaCheck('agy'); + } + } else { + await handleQuotaCheck(provider); + } } // 3c. Account safety: enforce cross-provider isolation @@ -533,23 +574,46 @@ export async function execClaudeWithCLIProxy( await configureProviderModel(provider, false, cfg.customSettingsPath); } - // 5. Check for broken models - const currentModel = getCurrentModel(provider, cfg.customSettingsPath); - if (currentModel && isModelBroken(provider, currentModel)) { - const modelEntry = findModel(provider, currentModel); - const issueUrl = getModelIssueUrl(provider, currentModel); - console.error(''); - console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`)); - console.error(' Tool calls will fail. Use "gemini-3-pro-preview" instead.'); - if (issueUrl) { - console.error(` Tracking: ${issueUrl}`); + // 5. Check for broken models (multi-tier for composite) + if (compositeProviders.length > 0 && cfg.compositeTiers) { + // Check all tier models in composite variant + const tiers: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku']; + for (const tier of tiers) { + const tierConfig = cfg.compositeTiers[tier]; + if (tierConfig && isModelBroken(tierConfig.provider, tierConfig.model)) { + const modelEntry = findModel(tierConfig.provider, tierConfig.model); + const issueUrl = getModelIssueUrl(tierConfig.provider, tierConfig.model); + console.error(''); + console.error( + warn( + `${tier} tier: ${modelEntry?.name || tierConfig.model} has known issues with Claude Code` + ) + ); + console.error(' Tool calls will fail. Consider changing the model in config.yaml.'); + if (issueUrl) { + console.error(` Tracking: ${issueUrl}`); + } + console.error(''); + } } - if (skipLocalAuth) { - console.error(' Note: Model may be overridden by remote proxy configuration.'); - } else { - console.error(` Run "ccs ${provider} --config" to change model.`); + } else { + const currentModel = getCurrentModel(provider, cfg.customSettingsPath); + if (currentModel && isModelBroken(provider, currentModel)) { + const modelEntry = findModel(provider, currentModel); + const issueUrl = getModelIssueUrl(provider, currentModel); + console.error(''); + console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`)); + console.error(' Tool calls will fail. Use "gemini-3-pro-preview" instead.'); + if (issueUrl) { + console.error(` Tracking: ${issueUrl}`); + } + if (skipLocalAuth) { + console.error(' Note: Model may be overridden by remote proxy configuration.'); + } else { + console.error(` Run "ccs ${provider} --config" to change model.`); + } + console.error(''); } - console.error(''); } // 6. Ensure user settings file exists @@ -640,6 +704,9 @@ export async function execClaudeWithCLIProxy( thinkingOverride, extendedContextOverride, verbose, + isComposite: cfg.isComposite, + compositeTiers: cfg.compositeTiers, + compositeDefaultTier: cfg.compositeDefaultTier, }); if (initialEnvVars.ANTHROPIC_BASE_URL) { @@ -729,6 +796,9 @@ export async function execClaudeWithCLIProxy( thinkingOverride, extendedContextOverride, verbose, + isComposite: cfg.isComposite, + compositeTiers: cfg.compositeTiers, + compositeDefaultTier: cfg.compositeDefaultTier, }); const webSearchEnv = getWebSearchHookEnv();