mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user