feat(cliproxy): add composite variant runtime execution

- Add getCompositeEnvVars() for root URL + per-tier model env vars
- Extend ProxyChainConfig with composite fields
- Route composite variants through root URL in buildClaudeEnvironment()
- Multi-provider auth check for composite (all tiers must pass)
- Multi-provider token refresh for composite
- Per-tier broken model check
- Block --config for composite variants
- Pass composite config through to env resolver

Refs #506
This commit is contained in:
Tam Nhu Tran
2026-02-12 04:09:19 +07:00
parent c58fdc931e
commit 44b30b8753
3 changed files with 182 additions and 23 deletions
+62
View File
@@ -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<string, string> {
const globalEnv = getGlobalEnvVars();
// Load user settings if provided (may contain additional env vars like hooks)
let additionalEnvVars: Record<string, string> = {};
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<string, string>;
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,
};
}
+30 -3
View File
@@ -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<string,
extendedContextOverride,
codexReasoningPort,
toolSanitizationPort,
isComposite,
compositeTiers,
compositeDefaultTier,
} = config;
// Build base env vars - remote or local
// Build base env vars - composite, remote, or local
let envVars: NodeJS.ProcessEnv;
if (useRemoteProxy && remoteConfig) {
if (isComposite && compositeTiers && compositeDefaultTier) {
// Composite variant: root URL, per-tier models from different providers
envVars = getCompositeEnvVars(
compositeTiers,
compositeDefaultTier,
localPort,
customSettingsPath
);
} else if (useRemoteProxy && remoteConfig) {
if (httpsTunnel && tunnelPort) {
// HTTPS remote via local tunnel - use HTTP to tunnel
envVars = getRemoteEnvVars(
+90 -20
View File
@@ -113,6 +113,12 @@ export async function execClaudeWithCLIProxy(
}
};
// Helper: Extract unique providers from composite tiers
const compositeProviders =
cfg.isComposite && cfg.compositeTiers
? [...new Set(Object.values(cfg.compositeTiers).map((t) => 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();