Files
ccs/src/cliproxy/executor/env-resolver.ts
T

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;
}