mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
327 lines
11 KiB
TypeScript
327 lines
11 KiB
TypeScript
/**
|
|
* Environment Resolver - Build environment variables for Claude CLI
|
|
*
|
|
* Handles:
|
|
* - Remote proxy environment configuration
|
|
* - Local proxy environment configuration
|
|
* - HTTPS tunnel integration
|
|
* - Proxy chain configuration (CodexReasoning, ToolSanitization)
|
|
* - WebSearch and ImageAnalysis hook integration
|
|
*/
|
|
|
|
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 { stripClaudeCodeEnv } from '../../utils/shell-executor';
|
|
import { CodexReasoningProxy } from '../codex-reasoning-proxy';
|
|
import { ToolSanitizationProxy } from '../tool-sanitization-proxy';
|
|
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
|
import { MODEL_ENV_VAR_KEYS, normalizeModelIdForProvider } from '../model-id-normalizer';
|
|
|
|
export interface RemoteProxyConfig {
|
|
host: string;
|
|
port: number;
|
|
protocol: 'http' | 'https';
|
|
authToken?: string;
|
|
}
|
|
|
|
export interface ProxyChainConfig {
|
|
provider: CLIProxyProvider;
|
|
useRemoteProxy: boolean;
|
|
remoteConfig?: RemoteProxyConfig;
|
|
httpsTunnel?: HttpsTunnelProxy;
|
|
tunnelPort?: number;
|
|
codexReasoningProxy?: CodexReasoningProxy;
|
|
codexReasoningPort?: number;
|
|
toolSanitizationProxy?: ToolSanitizationProxy;
|
|
toolSanitizationPort?: number;
|
|
localPort: number;
|
|
customSettingsPath?: string;
|
|
thinkingOverride?: string | number;
|
|
/** 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';
|
|
/** Optional inherited continuity directory from mapped account profile */
|
|
claudeConfigDir?: string;
|
|
}
|
|
|
|
const CODEX_EFFORT_SUFFIX_REGEX = /^(.*)-(xhigh|high|medium)$/i;
|
|
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
|
|
|
|
function normalizeCodexModelForDirectUpstream(model: string): string {
|
|
const withoutExtendedContext = model.trim().replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '').trim();
|
|
if (!withoutExtendedContext) return withoutExtendedContext;
|
|
|
|
const effortMatch = withoutExtendedContext.match(CODEX_EFFORT_SUFFIX_REGEX);
|
|
if (!effortMatch?.[1] || !effortMatch[2]) {
|
|
return withoutExtendedContext;
|
|
}
|
|
|
|
return `${effortMatch[1].trim()}(${effortMatch[2].toLowerCase()})`;
|
|
}
|
|
|
|
function normalizeCodexEnvForDirectUpstream(envVars: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
let nextEnv: NodeJS.ProcessEnv | null = null;
|
|
|
|
for (const key of MODEL_ENV_VAR_KEYS) {
|
|
const value = envVars[key];
|
|
if (typeof value !== 'string' || value.trim().length === 0) continue;
|
|
|
|
const normalizedValue = normalizeCodexModelForDirectUpstream(value);
|
|
if (normalizedValue === value) continue;
|
|
|
|
if (!nextEnv) nextEnv = { ...envVars };
|
|
nextEnv[key] = normalizedValue;
|
|
}
|
|
|
|
return nextEnv ?? envVars;
|
|
}
|
|
|
|
/**
|
|
* Build final environment variables for Claude CLI execution
|
|
* Handles proxy chain ordering and integration with hooks
|
|
*/
|
|
export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string, string> {
|
|
const {
|
|
provider,
|
|
useRemoteProxy,
|
|
remoteConfig,
|
|
httpsTunnel,
|
|
tunnelPort,
|
|
localPort,
|
|
customSettingsPath,
|
|
thinkingOverride,
|
|
extendedContextOverride,
|
|
codexReasoningPort,
|
|
toolSanitizationPort,
|
|
isComposite,
|
|
compositeTiers,
|
|
compositeDefaultTier,
|
|
claudeConfigDir,
|
|
} = config;
|
|
|
|
// Build base env vars - check remote mode first
|
|
let envVars: NodeJS.ProcessEnv;
|
|
|
|
if (useRemoteProxy && remoteConfig) {
|
|
if (httpsTunnel && tunnelPort) {
|
|
// HTTPS remote via local tunnel
|
|
if (isComposite && compositeTiers && compositeDefaultTier) {
|
|
envVars = getCompositeEnvVars(
|
|
compositeTiers,
|
|
compositeDefaultTier,
|
|
tunnelPort,
|
|
customSettingsPath,
|
|
{
|
|
host: '127.0.0.1',
|
|
port: tunnelPort,
|
|
protocol: 'http',
|
|
authToken: remoteConfig.authToken,
|
|
}
|
|
);
|
|
} else {
|
|
envVars = getRemoteEnvVars(
|
|
provider,
|
|
{
|
|
host: '127.0.0.1',
|
|
port: tunnelPort,
|
|
protocol: 'http', // Tunnel speaks HTTP locally
|
|
authToken: remoteConfig.authToken,
|
|
},
|
|
customSettingsPath
|
|
);
|
|
}
|
|
} else {
|
|
// HTTP remote - direct connection
|
|
if (isComposite && compositeTiers && compositeDefaultTier) {
|
|
envVars = getCompositeEnvVars(
|
|
compositeTiers,
|
|
compositeDefaultTier,
|
|
remoteConfig.port,
|
|
customSettingsPath,
|
|
{
|
|
host: remoteConfig.host,
|
|
port: remoteConfig.port,
|
|
protocol: remoteConfig.protocol,
|
|
authToken: remoteConfig.authToken,
|
|
}
|
|
);
|
|
} else {
|
|
envVars = getRemoteEnvVars(
|
|
provider,
|
|
{
|
|
host: remoteConfig.host,
|
|
port: remoteConfig.port,
|
|
protocol: remoteConfig.protocol,
|
|
authToken: remoteConfig.authToken,
|
|
},
|
|
customSettingsPath
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
// Local proxy mode
|
|
if (isComposite && compositeTiers && compositeDefaultTier) {
|
|
envVars = getCompositeEnvVars(
|
|
compositeTiers,
|
|
compositeDefaultTier,
|
|
localPort,
|
|
customSettingsPath
|
|
);
|
|
} else {
|
|
const remoteRewriteConfig = remoteConfig
|
|
? {
|
|
host: remoteConfig.host,
|
|
port: remoteConfig.port,
|
|
protocol: remoteConfig.protocol,
|
|
authToken: remoteConfig.authToken,
|
|
}
|
|
: undefined;
|
|
|
|
envVars = getEffectiveEnvVars(provider, localPort, customSettingsPath, remoteRewriteConfig);
|
|
}
|
|
}
|
|
|
|
// Extract per-tier thinking from composite config
|
|
let compositeTierThinking: { opus?: string; sonnet?: string; haiku?: string } | undefined;
|
|
if (isComposite && compositeTiers) {
|
|
const tierThinking: { opus?: string; sonnet?: string; haiku?: string } = {};
|
|
if (compositeTiers.opus?.thinking) tierThinking.opus = compositeTiers.opus.thinking;
|
|
if (compositeTiers.sonnet?.thinking) tierThinking.sonnet = compositeTiers.sonnet.thinking;
|
|
if (compositeTiers.haiku?.thinking) tierThinking.haiku = compositeTiers.haiku.thinking;
|
|
if (Object.keys(tierThinking).length > 0) {
|
|
compositeTierThinking = tierThinking;
|
|
}
|
|
}
|
|
|
|
// Apply thinking configuration to model (auto tier-based or manual override)
|
|
envVars = applyThinkingConfig(
|
|
envVars,
|
|
provider,
|
|
thinkingOverride,
|
|
compositeTierThinking,
|
|
compositeTiers
|
|
);
|
|
|
|
// Apply extended context suffix for 1M token context window
|
|
// Auto-enabled for Gemini, opt-in for Claude (--1m flag)
|
|
applyExtendedContextConfig(envVars, provider, extendedContextOverride);
|
|
|
|
// Fallback compatibility for Codex:
|
|
// CLIProxyAPI provider lookup recognizes model(level) suffix form, while CCS stores codex
|
|
// effort aliases as model-level (e.g., gpt-5.3-codex-high). If codex reasoning proxy is
|
|
// unavailable, normalize to model(level) to avoid "unknown provider for model ..." failures.
|
|
if (provider === 'codex' && !isComposite && !codexReasoningPort) {
|
|
envVars = normalizeCodexEnvForDirectUpstream(envVars);
|
|
}
|
|
|
|
// Determine the final ANTHROPIC_BASE_URL based on active proxies
|
|
// Chain order: Claude CLI → [CodexReasoningProxy] → [ToolSanitizationProxy] → CLIProxy
|
|
let finalBaseUrl = envVars.ANTHROPIC_BASE_URL;
|
|
|
|
if (toolSanitizationPort) {
|
|
finalBaseUrl = `http://127.0.0.1:${toolSanitizationPort}`;
|
|
}
|
|
|
|
if (codexReasoningPort) {
|
|
// Codex reasoning proxy is the outermost layer for codex provider
|
|
finalBaseUrl = `http://127.0.0.1:${codexReasoningPort}/api/provider/codex`;
|
|
}
|
|
|
|
const effectiveEnvVars = {
|
|
...envVars,
|
|
ANTHROPIC_BASE_URL: finalBaseUrl,
|
|
};
|
|
|
|
// Add hook environment variables
|
|
const webSearchEnv = getWebSearchHookEnv();
|
|
const imageAnalysisEnv = getImageAnalysisHookEnv(provider);
|
|
|
|
// Merge all environment variables (filter undefined values)
|
|
const baseEnv = Object.fromEntries(
|
|
Object.entries(process.env).filter(([, v]) => v !== undefined)
|
|
) as Record<string, string>;
|
|
|
|
const effectiveEnvVarsFiltered = Object.fromEntries(
|
|
Object.entries(effectiveEnvVars).filter(([, v]) => v !== undefined)
|
|
) as Record<string, string>;
|
|
|
|
const mergedEnv = {
|
|
...baseEnv,
|
|
...effectiveEnvVarsFiltered,
|
|
...(claudeConfigDir ? { CLAUDE_CONFIG_DIR: claudeConfigDir } : {}),
|
|
...webSearchEnv,
|
|
...imageAnalysisEnv,
|
|
CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider
|
|
};
|
|
|
|
return Object.fromEntries(
|
|
Object.entries(stripClaudeCodeEnv(mergedEnv)).filter(([, v]) => v !== undefined)
|
|
) as Record<string, string>;
|
|
}
|
|
|
|
/**
|
|
* Log environment configuration for debugging
|
|
*/
|
|
export function logEnvironment(
|
|
env: Record<string, string>,
|
|
webSearchEnv: Record<string, string>,
|
|
verbose: boolean
|
|
): void {
|
|
if (!verbose) return;
|
|
|
|
const log = (msg: string) => console.error(`[cliproxy] ${msg}`);
|
|
|
|
log(`Claude env: ANTHROPIC_BASE_URL=${env.ANTHROPIC_BASE_URL}`);
|
|
log(`Claude env: ANTHROPIC_MODEL=${env.ANTHROPIC_MODEL}`);
|
|
|
|
if (Object.keys(webSearchEnv).length > 0) {
|
|
log(`Claude env: WebSearch config=${JSON.stringify(webSearchEnv)}`);
|
|
}
|
|
|
|
// Log global env vars for visibility
|
|
if (env.DISABLE_TELEMETRY || env.DISABLE_ERROR_REPORTING || env.DISABLE_BUG_COMMAND) {
|
|
log(`Claude env: Global env applied (telemetry/reporting disabled)`);
|
|
}
|
|
}
|
|
|
|
/** Apply fallback provider config to env vars for a failed tier */
|
|
export function applyFallback(
|
|
env: Record<string, string>,
|
|
failedTier: 'opus' | 'sonnet' | 'haiku',
|
|
fallback: { provider: string; model: string }
|
|
): Record<string, string> {
|
|
const tierEnvMap = {
|
|
opus: 'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
|
sonnet: 'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
|
haiku: 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
|
} as const;
|
|
const result = { ...env };
|
|
const originalModel = result[tierEnvMap[failedTier]];
|
|
const normalizedFallbackModel = normalizeModelIdForProvider(fallback.model, fallback.provider);
|
|
result[tierEnvMap[failedTier]] = normalizedFallbackModel;
|
|
// If failed tier is default tier, also update ANTHROPIC_MODEL
|
|
if (result.ANTHROPIC_MODEL === originalModel) {
|
|
result.ANTHROPIC_MODEL = normalizedFallbackModel;
|
|
}
|
|
return result;
|
|
}
|