mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 20:15:40 +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:
1 parent
c58fdc931e
commit
44b30b8753
3 files changed
+182
-23
No files matched your search
@@ -10,6 +10,7 @@ import { getGlobalEnvConfig } from '../../config/unified-config-loader';
|
|||||||
import { getEffectiveApiKey } from '../auth-token-manager';
|
import { getEffectiveApiKey } from '../auth-token-manager';
|
||||||
import { expandPath } from '../../utils/helpers';
|
import { expandPath } from '../../utils/helpers';
|
||||||
import { warn } from '../../utils/ui';
|
import { warn } from '../../utils/ui';
|
||||||
|
import { CompositeTierConfig } from '../../config/unified-config-types';
|
||||||
import {
|
import {
|
||||||
validatePort,
|
validatePort,
|
||||||
validateRemotePort,
|
validateRemotePort,
|
||||||
@@ -413,3 +414,64 @@ export function getRemoteEnvVars(
|
|||||||
|
|
||||||
return env;
|
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
|
* - 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 { applyExtendedContextConfig } from '../config/extended-context-config';
|
||||||
import { CLIProxyProvider } from '../types';
|
import { CLIProxyProvider } from '../types';
|
||||||
|
import { CompositeTierConfig } from '../../config/unified-config-types';
|
||||||
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
|
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
|
||||||
import { getImageAnalysisHookEnv } from '../../utils/hooks/get-image-analysis-hook-env';
|
import { getImageAnalysisHookEnv } from '../../utils/hooks/get-image-analysis-hook-env';
|
||||||
import { CodexReasoningProxy } from '../codex-reasoning-proxy';
|
import { CodexReasoningProxy } from '../codex-reasoning-proxy';
|
||||||
@@ -41,6 +47,16 @@ export interface ProxyChainConfig {
|
|||||||
/** Extended context override: true = force on, false = force off, undefined = auto */
|
/** Extended context override: true = force on, false = force off, undefined = auto */
|
||||||
extendedContextOverride?: boolean;
|
extendedContextOverride?: boolean;
|
||||||
verbose: 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,
|
extendedContextOverride,
|
||||||
codexReasoningPort,
|
codexReasoningPort,
|
||||||
toolSanitizationPort,
|
toolSanitizationPort,
|
||||||
|
isComposite,
|
||||||
|
compositeTiers,
|
||||||
|
compositeDefaultTier,
|
||||||
} = config;
|
} = config;
|
||||||
|
|
||||||
// Build base env vars - remote or local
|
// Build base env vars - composite, remote, or local
|
||||||
let envVars: NodeJS.ProcessEnv;
|
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) {
|
if (httpsTunnel && tunnelPort) {
|
||||||
// HTTPS remote via local tunnel - use HTTP to tunnel
|
// HTTPS remote via local tunnel - use HTTP to tunnel
|
||||||
envVars = getRemoteEnvVars(
|
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)
|
// 0. Resolve proxy configuration (CLI > ENV > config.yaml > defaults)
|
||||||
const unifiedConfig = loadOrCreateUnifiedConfig();
|
const unifiedConfig = loadOrCreateUnifiedConfig();
|
||||||
|
|
||||||
@@ -421,7 +427,14 @@ export async function execClaudeWithCLIProxy(
|
|||||||
|
|
||||||
// Handle --config
|
// Handle --config
|
||||||
if (forceConfig && supportsModelConfig(provider)) {
|
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);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,7 +490,22 @@ export async function execClaudeWithCLIProxy(
|
|||||||
if (providerConfig.requiresOAuth && !skipLocalAuth) {
|
if (providerConfig.requiresOAuth && !skipLocalAuth) {
|
||||||
log(`Checking authentication for ${provider}`);
|
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 { triggerOAuth } = await import('../auth-handler');
|
||||||
const authSuccess = await triggerOAuth(provider, {
|
const authSuccess = await triggerOAuth(provider, {
|
||||||
verbose,
|
verbose,
|
||||||
@@ -498,8 +526,14 @@ export async function execClaudeWithCLIProxy(
|
|||||||
log(`${provider} already authenticated`);
|
log(`${provider} already authenticated`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3a. Proactive token refresh
|
// 3a. Proactive token refresh (multi-provider for composite)
|
||||||
await handleTokenExpiration(provider, verbose);
|
if (compositeProviders.length > 0) {
|
||||||
|
for (const p of compositeProviders) {
|
||||||
|
await handleTokenExpiration(p, verbose);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await handleTokenExpiration(provider, verbose);
|
||||||
|
}
|
||||||
|
|
||||||
// 3a-1. Update lastUsedAt
|
// 3a-1. Update lastUsedAt
|
||||||
const usedAccount = getDefaultAccount(provider);
|
const usedAccount = getDefaultAccount(provider);
|
||||||
@@ -510,7 +544,14 @@ export async function execClaudeWithCLIProxy(
|
|||||||
|
|
||||||
// 3b. Preflight quota check (Antigravity only)
|
// 3b. Preflight quota check (Antigravity only)
|
||||||
if (!skipLocalAuth) {
|
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
|
// 3c. Account safety: enforce cross-provider isolation
|
||||||
@@ -533,23 +574,46 @@ export async function execClaudeWithCLIProxy(
|
|||||||
await configureProviderModel(provider, false, cfg.customSettingsPath);
|
await configureProviderModel(provider, false, cfg.customSettingsPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Check for broken models
|
// 5. Check for broken models (multi-tier for composite)
|
||||||
const currentModel = getCurrentModel(provider, cfg.customSettingsPath);
|
if (compositeProviders.length > 0 && cfg.compositeTiers) {
|
||||||
if (currentModel && isModelBroken(provider, currentModel)) {
|
// Check all tier models in composite variant
|
||||||
const modelEntry = findModel(provider, currentModel);
|
const tiers: Array<'opus' | 'sonnet' | 'haiku'> = ['opus', 'sonnet', 'haiku'];
|
||||||
const issueUrl = getModelIssueUrl(provider, currentModel);
|
for (const tier of tiers) {
|
||||||
console.error('');
|
const tierConfig = cfg.compositeTiers[tier];
|
||||||
console.error(warn(`${modelEntry?.name || currentModel} has known issues with Claude Code`));
|
if (tierConfig && isModelBroken(tierConfig.provider, tierConfig.model)) {
|
||||||
console.error(' Tool calls will fail. Use "gemini-3-pro-preview" instead.');
|
const modelEntry = findModel(tierConfig.provider, tierConfig.model);
|
||||||
if (issueUrl) {
|
const issueUrl = getModelIssueUrl(tierConfig.provider, tierConfig.model);
|
||||||
console.error(` Tracking: ${issueUrl}`);
|
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) {
|
} else {
|
||||||
console.error(' Note: Model may be overridden by remote proxy configuration.');
|
const currentModel = getCurrentModel(provider, cfg.customSettingsPath);
|
||||||
} else {
|
if (currentModel && isModelBroken(provider, currentModel)) {
|
||||||
console.error(` Run "ccs ${provider} --config" to change model.`);
|
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
|
// 6. Ensure user settings file exists
|
||||||
@@ -640,6 +704,9 @@ export async function execClaudeWithCLIProxy(
|
|||||||
thinkingOverride,
|
thinkingOverride,
|
||||||
extendedContextOverride,
|
extendedContextOverride,
|
||||||
verbose,
|
verbose,
|
||||||
|
isComposite: cfg.isComposite,
|
||||||
|
compositeTiers: cfg.compositeTiers,
|
||||||
|
compositeDefaultTier: cfg.compositeDefaultTier,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (initialEnvVars.ANTHROPIC_BASE_URL) {
|
if (initialEnvVars.ANTHROPIC_BASE_URL) {
|
||||||
@@ -729,6 +796,9 @@ export async function execClaudeWithCLIProxy(
|
|||||||
thinkingOverride,
|
thinkingOverride,
|
||||||
extendedContextOverride,
|
extendedContextOverride,
|
||||||
verbose,
|
verbose,
|
||||||
|
isComposite: cfg.isComposite,
|
||||||
|
compositeTiers: cfg.compositeTiers,
|
||||||
|
compositeDefaultTier: cfg.compositeDefaultTier,
|
||||||
});
|
});
|
||||||
|
|
||||||
const webSearchEnv = getWebSearchHookEnv();
|
const webSearchEnv = getWebSearchHookEnv();
|
||||||
|
|||||||
Reference in new issue
Block a user