mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 00:22:34 +00:00
refactor(cliproxy/executor): extract proxy-chain-builder from index.ts
Phase 07 of #1162. Splits tool-sanitization + codex-reasoning proxy spawn out of the orchestrator: - src/cliproxy/executor/proxy-chain-builder.ts (185 LOC): buildProxyChain + ProxyChainContext / ProxyChainResult types. DI escape hatch (_ToolSanitizationProxy / _CodexReasoningProxy) for testability without refactoring the proxy classes (Bun module cache blocks mock.module of already-loaded modules). - 9 unit tests covering codex-only, tool-san only, both-together, spawn failure swallowing, env propagation. index.ts: 716 -> 665 LOC (-51). HTTPS tunnel kept inline because tunnelPort is needed by image-analysis resolution before first-pass buildClaudeEnvironment — folding it in would also require moving image analysis. Two-pass buildClaudeEnvironment dance preserved exactly. Behavior unchanged; full suite passes 1824/1824. Refs #1162
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Unit tests for proxy-chain-builder.ts (Phase 07)
|
||||
*
|
||||
* Mocking strategy: proxy constructors are injected via the _ToolSanitizationProxy
|
||||
* and _CodexReasoningProxy fields on ProxyChainContext (dependency-injection escape
|
||||
* hatch added for testability). This avoids Bun module-cache limitations with
|
||||
* mock.module / jest.mock and never spins up real HTTP servers.
|
||||
*
|
||||
* Tests cover:
|
||||
* - Tool sanitization proxy started when ANTHROPIC_BASE_URL is set
|
||||
* - Tool sanitization proxy skipped when ANTHROPIC_BASE_URL is absent
|
||||
* - Tool sanitization proxy start failure swallowed (null returned, verbose warn)
|
||||
* - Codex reasoning proxy started for single-provider codex
|
||||
* - Codex reasoning proxy skipped for composite codex (cfg.isComposite true)
|
||||
* - Codex reasoning proxy skipped for non-codex provider
|
||||
* - Codex reasoning proxy uses post-sanitization URL when tool-san is active
|
||||
* - Codex reasoning proxy start failure swallowed (null returned)
|
||||
* - All results null when ANTHROPIC_BASE_URL absent
|
||||
*/
|
||||
|
||||
import { describe, expect, it, jest, beforeEach } from 'bun:test';
|
||||
import { buildProxyChain } from '../proxy-chain-builder';
|
||||
import type { ProxyChainContext } from '../proxy-chain-builder';
|
||||
import type { ToolSanitizationProxy } from '../../proxy/tool-sanitization-proxy';
|
||||
import type { CodexReasoningProxy } from '../../ai-providers/codex-reasoning-proxy';
|
||||
|
||||
// ── Stub factory ──────────────────────────────────────────────────────────────
|
||||
|
||||
type ProxyStub = { start: jest.Mock; stop: jest.Mock; _port?: number };
|
||||
|
||||
function makeStubCtor(resolvePort: number | (() => Promise<number> | number)): {
|
||||
ctor: jest.Mock;
|
||||
instance: ProxyStub;
|
||||
} {
|
||||
const instance: ProxyStub = {
|
||||
start: jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
typeof resolvePort === 'function' ? resolvePort : () => Promise.resolve(resolvePort)
|
||||
),
|
||||
stop: jest.fn(),
|
||||
};
|
||||
const ctor = jest.fn().mockReturnValue(instance);
|
||||
return { ctor, instance };
|
||||
}
|
||||
|
||||
function makeFailCtor(message: string): { ctor: jest.Mock; instance: ProxyStub } {
|
||||
const instance: ProxyStub = {
|
||||
start: jest.fn().mockRejectedValue(new Error(message)),
|
||||
stop: jest.fn(),
|
||||
};
|
||||
const ctor = jest.fn().mockReturnValue(instance);
|
||||
return { ctor, instance };
|
||||
}
|
||||
|
||||
// ── Context helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function makeThinkingCfg() {
|
||||
return { mode: 'auto' as const, override: undefined };
|
||||
}
|
||||
|
||||
function makeProxyConfig() {
|
||||
return {
|
||||
mode: 'local' as const,
|
||||
port: 8317,
|
||||
protocol: 'http' as const,
|
||||
fallbackEnabled: false,
|
||||
autoStartLocal: true,
|
||||
remoteOnly: false,
|
||||
forceLocal: false,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCfg(overrides: Partial<{ isComposite: boolean }> = {}) {
|
||||
return { port: 8317, timeout: 5000, verbose: false, pollInterval: 100, ...overrides };
|
||||
}
|
||||
|
||||
// Stub ctors shared across tests, reset in beforeEach
|
||||
let defaultToolSan: ReturnType<typeof makeStubCtor>;
|
||||
let defaultCodex: ReturnType<typeof makeStubCtor>;
|
||||
|
||||
beforeEach(() => {
|
||||
defaultToolSan = makeStubCtor(19001);
|
||||
defaultCodex = makeStubCtor(19002);
|
||||
});
|
||||
|
||||
function baseCtx(overrides: Partial<ProxyChainContext> = {}): ProxyChainContext {
|
||||
return {
|
||||
provider: 'gemini',
|
||||
useRemoteProxy: false,
|
||||
proxyConfig: makeProxyConfig(),
|
||||
cfg: makeCfg(),
|
||||
initialEnvVars: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317' },
|
||||
thinkingOverride: undefined,
|
||||
thinkingCfg: makeThinkingCfg(),
|
||||
verbose: false,
|
||||
log: () => {},
|
||||
_ToolSanitizationProxy: defaultToolSan.ctor as unknown as new (
|
||||
cfg: object
|
||||
) => ToolSanitizationProxy,
|
||||
_CodexReasoningProxy: defaultCodex.ctor as unknown as new (cfg: object) => CodexReasoningProxy,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Tool sanitization: started when ANTHROPIC_BASE_URL set ───────────────────
|
||||
|
||||
describe('buildProxyChain — tool sanitization: started when ANTHROPIC_BASE_URL set', () => {
|
||||
it('starts proxy and returns instance + port', async () => {
|
||||
const { ctor, instance } = makeStubCtor(13001);
|
||||
|
||||
const result = await buildProxyChain(baseCtx({ _ToolSanitizationProxy: ctor as never }));
|
||||
|
||||
expect(ctor).toHaveBeenCalledTimes(1);
|
||||
expect(result.toolSanitizationProxy).toBe(instance);
|
||||
expect(result.toolSanitizationPort).toBe(13001);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tool sanitization: skipped when ANTHROPIC_BASE_URL absent ────────────────
|
||||
|
||||
describe('buildProxyChain — tool sanitization: skipped when no ANTHROPIC_BASE_URL', () => {
|
||||
it('returns null proxy and port without constructing', async () => {
|
||||
const { ctor } = makeStubCtor(13001);
|
||||
|
||||
const result = await buildProxyChain(
|
||||
baseCtx({ initialEnvVars: {}, _ToolSanitizationProxy: ctor as never })
|
||||
);
|
||||
|
||||
expect(ctor).not.toHaveBeenCalled();
|
||||
expect(result.toolSanitizationProxy).toBeNull();
|
||||
expect(result.toolSanitizationPort).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tool sanitization: start failure swallowed ────────────────────────────────
|
||||
|
||||
describe('buildProxyChain — tool sanitization: start failure swallowed', () => {
|
||||
it('returns null and emits verbose warn without throwing', async () => {
|
||||
const { ctor } = makeFailCtor('port in use');
|
||||
const warnSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
const result = await buildProxyChain(
|
||||
baseCtx({ verbose: true, _ToolSanitizationProxy: ctor as never })
|
||||
);
|
||||
|
||||
expect(result.toolSanitizationProxy).toBeNull();
|
||||
expect(result.toolSanitizationPort).toBeNull();
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('port in use'));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Codex reasoning: started for single-provider codex ───────────────────────
|
||||
|
||||
describe('buildProxyChain — codex reasoning: started for single-provider codex', () => {
|
||||
it('starts codex proxy and returns instance + port', async () => {
|
||||
const sanCtorPair = makeStubCtor(13010);
|
||||
const codexCtorPair = makeStubCtor(13011);
|
||||
|
||||
const result = await buildProxyChain(
|
||||
baseCtx({
|
||||
provider: 'codex',
|
||||
cfg: makeCfg({ isComposite: false }),
|
||||
_ToolSanitizationProxy: sanCtorPair.ctor as never,
|
||||
_CodexReasoningProxy: codexCtorPair.ctor as never,
|
||||
})
|
||||
);
|
||||
|
||||
expect(codexCtorPair.ctor).toHaveBeenCalledTimes(1);
|
||||
expect(result.codexReasoningProxy).toBe(codexCtorPair.instance);
|
||||
expect(result.codexReasoningPort).toBe(13011);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Codex reasoning: skipped for composite codex ─────────────────────────────
|
||||
|
||||
describe('buildProxyChain — codex reasoning: skipped for composite codex', () => {
|
||||
it('does not construct CodexReasoningProxy when cfg.isComposite is true', async () => {
|
||||
const { ctor } = makeStubCtor(13020);
|
||||
|
||||
const result = await buildProxyChain(
|
||||
baseCtx({
|
||||
provider: 'codex',
|
||||
cfg: makeCfg({ isComposite: true }),
|
||||
_CodexReasoningProxy: ctor as never,
|
||||
})
|
||||
);
|
||||
|
||||
expect(ctor).not.toHaveBeenCalled();
|
||||
expect(result.codexReasoningProxy).toBeNull();
|
||||
expect(result.codexReasoningPort).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Codex reasoning: skipped for non-codex provider ──────────────────────────
|
||||
|
||||
describe('buildProxyChain — codex reasoning: skipped for non-codex provider', () => {
|
||||
it('does not construct CodexReasoningProxy for gemini', async () => {
|
||||
const { ctor } = makeStubCtor(13030);
|
||||
|
||||
await buildProxyChain(baseCtx({ provider: 'gemini', _CodexReasoningProxy: ctor as never }));
|
||||
|
||||
expect(ctor).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Codex reasoning: uses post-sanitization URL ───────────────────────────────
|
||||
|
||||
describe('buildProxyChain — codex reasoning: uses post-sanitization base URL', () => {
|
||||
it('passes http://127.0.0.1:<sanPort> as upstreamBaseUrl', async () => {
|
||||
const sanCtorPair = makeStubCtor(13040);
|
||||
const codexCtorPair = makeStubCtor(13041);
|
||||
|
||||
await buildProxyChain(
|
||||
baseCtx({
|
||||
provider: 'codex',
|
||||
cfg: makeCfg({ isComposite: false }),
|
||||
_ToolSanitizationProxy: sanCtorPair.ctor as never,
|
||||
_CodexReasoningProxy: codexCtorPair.ctor as never,
|
||||
})
|
||||
);
|
||||
|
||||
expect(codexCtorPair.ctor).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ upstreamBaseUrl: 'http://127.0.0.1:13040' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Codex reasoning: start failure swallowed ─────────────────────────────────
|
||||
|
||||
describe('buildProxyChain — codex reasoning: start failure swallowed', () => {
|
||||
it('returns null codex proxy/port without throwing', async () => {
|
||||
const sanCtorPair = makeStubCtor(13050);
|
||||
const { ctor: codexFailCtor } = makeFailCtor('bind failed');
|
||||
|
||||
const result = await buildProxyChain(
|
||||
baseCtx({
|
||||
provider: 'codex',
|
||||
cfg: makeCfg({ isComposite: false }),
|
||||
_ToolSanitizationProxy: sanCtorPair.ctor as never,
|
||||
_CodexReasoningProxy: codexFailCtor as never,
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.codexReasoningProxy).toBeNull();
|
||||
expect(result.codexReasoningPort).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── All results null when no ANTHROPIC_BASE_URL ───────────────────────────────
|
||||
|
||||
describe('buildProxyChain — all results null when no ANTHROPIC_BASE_URL', () => {
|
||||
it('returns four nulls for gemini without base URL', async () => {
|
||||
const sanCtor = jest.fn();
|
||||
const codexCtor = jest.fn();
|
||||
|
||||
const result = await buildProxyChain(
|
||||
baseCtx({
|
||||
initialEnvVars: {},
|
||||
_ToolSanitizationProxy: sanCtor as never,
|
||||
_CodexReasoningProxy: codexCtor as never,
|
||||
})
|
||||
);
|
||||
|
||||
expect(sanCtor).not.toHaveBeenCalled();
|
||||
expect(codexCtor).not.toHaveBeenCalled();
|
||||
expect(result.toolSanitizationProxy).toBeNull();
|
||||
expect(result.toolSanitizationPort).toBeNull();
|
||||
expect(result.codexReasoningProxy).toBeNull();
|
||||
expect(result.codexReasoningPort).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -13,9 +13,7 @@
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { fail, info, warn } from '../../utils/ui';
|
||||
import { getCcsDir } from '../../utils/config-manager';
|
||||
import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor';
|
||||
import {
|
||||
generateConfig,
|
||||
@@ -79,11 +77,11 @@ import {
|
||||
import {
|
||||
buildThinkingStartupStatus,
|
||||
resolveRuntimeThinkingOverride,
|
||||
shouldDisableCodexReasoning,
|
||||
} from './thinking-override-resolver';
|
||||
import { shouldStartHttpsTunnel } from './https-tunnel-policy';
|
||||
import { filterCcsFlags, parseExecutorFlags, validateFlagCombinations } from './arg-parser';
|
||||
import { resolveExecutorProxy } from './proxy-resolver';
|
||||
import { buildProxyChain } from './proxy-chain-builder';
|
||||
|
||||
/** Local alias so internal call sites need no change */
|
||||
const resolveRuntimeQuotaMonitorProviders = _resolveRuntimeQuotaMonitorProviders;
|
||||
@@ -365,7 +363,7 @@ export async function execClaudeWithCLIProxy(
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Setup HTTPS tunnel if needed
|
||||
// 8. Setup HTTPS tunnel if needed (tunnelPort used by imageAnalysisProxyTarget below)
|
||||
let httpsTunnel: HttpsTunnelProxy | null = null;
|
||||
let tunnelPort: number | null = null;
|
||||
|
||||
@@ -435,9 +433,11 @@ export async function execClaudeWithCLIProxy(
|
||||
? 'ImageAnalysis MCP provisioning failed. This session will use compatibility fallback when available.'
|
||||
: imageAnalysisResolution.warning;
|
||||
|
||||
// 9. Setup tool sanitization proxy
|
||||
// 9. Resolve config dir + browser runtime (needed before proxy chain)
|
||||
let toolSanitizationProxy: ToolSanitizationProxy | null = null;
|
||||
let toolSanitizationPort: number | null = null;
|
||||
let codexReasoningProxy: CodexReasoningProxy | null = null;
|
||||
let codexReasoningPort: number | null = null;
|
||||
let inheritedClaudeConfigDir = cfg.claudeConfigDir;
|
||||
|
||||
if (!inheritedClaudeConfigDir && cfg.profileName) {
|
||||
@@ -488,74 +488,19 @@ export async function execClaudeWithCLIProxy(
|
||||
imageAnalysisEnv,
|
||||
});
|
||||
|
||||
if (initialEnvVars.ANTHROPIC_BASE_URL) {
|
||||
try {
|
||||
toolSanitizationProxy = new ToolSanitizationProxy({
|
||||
upstreamBaseUrl: initialEnvVars.ANTHROPIC_BASE_URL,
|
||||
verbose,
|
||||
warnOnSanitize: true,
|
||||
allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false,
|
||||
});
|
||||
toolSanitizationPort = await toolSanitizationProxy.start();
|
||||
log(`Tool sanitization proxy active on port ${toolSanitizationPort}`);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
toolSanitizationProxy = null;
|
||||
toolSanitizationPort = null;
|
||||
if (verbose) {
|
||||
console.error(warn(`Tool sanitization proxy disabled: ${err.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const postSanitizationBaseUrl = toolSanitizationPort
|
||||
? `http://127.0.0.1:${toolSanitizationPort}`
|
||||
: initialEnvVars.ANTHROPIC_BASE_URL;
|
||||
|
||||
// 10. Setup Codex reasoning proxy (single-provider Codex only)
|
||||
let codexReasoningProxy: CodexReasoningProxy | null = null;
|
||||
let codexReasoningPort: number | null = null;
|
||||
|
||||
// Composite variants require root model-routed endpoints, never provider-pinned codex endpoints.
|
||||
if (provider === 'codex' && !cfg.isComposite) {
|
||||
if (!postSanitizationBaseUrl) {
|
||||
log('ANTHROPIC_BASE_URL not set for Codex, reasoning proxy disabled');
|
||||
} else {
|
||||
try {
|
||||
const traceEnabled =
|
||||
process.env.CCS_CODEX_REASONING_TRACE === '1' ||
|
||||
process.env.CCS_CODEX_REASONING_TRACE === 'true';
|
||||
const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined;
|
||||
const codexThinkingOff = shouldDisableCodexReasoning(thinkingCfg, thinkingOverride);
|
||||
codexReasoningProxy = new CodexReasoningProxy({
|
||||
upstreamBaseUrl: postSanitizationBaseUrl,
|
||||
verbose,
|
||||
defaultEffort: 'medium',
|
||||
disableEffort: codexThinkingOff,
|
||||
traceFilePath: traceEnabled ? path.join(getCcsDir(), 'codex-reasoning-proxy.log') : '',
|
||||
allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false,
|
||||
modelMap: {
|
||||
defaultModel: initialEnvVars.ANTHROPIC_MODEL,
|
||||
opusModel: initialEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL,
|
||||
sonnetModel: initialEnvVars.ANTHROPIC_DEFAULT_SONNET_MODEL,
|
||||
haikuModel: initialEnvVars.ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
||||
},
|
||||
stripPathPrefix,
|
||||
});
|
||||
codexReasoningPort = await codexReasoningProxy.start();
|
||||
log(
|
||||
`Codex reasoning proxy active: http://127.0.0.1:${codexReasoningPort}/api/provider/codex`
|
||||
);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
codexReasoningProxy = null;
|
||||
codexReasoningPort = null;
|
||||
if (verbose) {
|
||||
console.error(warn(`Codex reasoning proxy disabled: ${err.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 9b. Build env-dependent proxy chain (tool-sanitization + codex-reasoning)
|
||||
({ toolSanitizationProxy, toolSanitizationPort, codexReasoningProxy, codexReasoningPort } =
|
||||
await buildProxyChain({
|
||||
provider,
|
||||
useRemoteProxy,
|
||||
proxyConfig,
|
||||
cfg,
|
||||
initialEnvVars,
|
||||
thinkingOverride,
|
||||
thinkingCfg,
|
||||
verbose,
|
||||
log,
|
||||
}));
|
||||
|
||||
// 11. Build final environment with all proxy chains
|
||||
const env = buildClaudeEnvironment({
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Proxy Chain Builder (Concern F — tool-sanitization + codex-reasoning layers)
|
||||
*
|
||||
* Orchestrates the two env-dependent proxy layers that sit between the
|
||||
* Claude CLI process and the upstream CLIProxy / remote endpoint:
|
||||
*
|
||||
* 1. ToolSanitizationProxy — wraps ANTHROPIC_BASE_URL to sanitize tool names/schemas
|
||||
* 2. CodexReasoningProxy — only for single-provider codex, wraps tool-san URL
|
||||
*
|
||||
* Placement in the execution sequence
|
||||
* ─────────────────────────────────────
|
||||
* The HTTPS tunnel (HttpsTunnelProxy) is started BEFORE this function is called,
|
||||
* because its tunnelPort is needed by both imageAnalysisResolution and the
|
||||
* first-pass buildClaudeEnvironment. This function receives the result of that
|
||||
* first-pass env as `initialEnvVars`, from which it extracts ANTHROPIC_BASE_URL
|
||||
* to wire up the tool-sanitization proxy.
|
||||
*
|
||||
* Lifecycle: proxies are started (spawned) but NOT stopped here. The caller
|
||||
* is responsible for registering cleanup handlers (setupCleanupHandlers).
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import { warn } from '../../utils/ui';
|
||||
import { getCcsDir } from '../../utils/config-manager';
|
||||
import {
|
||||
ToolSanitizationProxy,
|
||||
type ToolSanitizationProxyConfig,
|
||||
} from '../proxy/tool-sanitization-proxy';
|
||||
import {
|
||||
CodexReasoningProxy,
|
||||
type CodexReasoningProxyConfig,
|
||||
} from '../ai-providers/codex-reasoning-proxy';
|
||||
import { shouldDisableCodexReasoning } from './thinking-override-resolver';
|
||||
import type { CLIProxyProvider, ExecutorConfig, ResolvedProxyConfig } from '../types';
|
||||
import type { ThinkingConfig } from '../../config/unified-config-types';
|
||||
|
||||
// ── Proxy constructor types (for dependency injection in tests) ───────────────
|
||||
|
||||
type ToolSanitizationProxyCtor = new (config: ToolSanitizationProxyConfig) => ToolSanitizationProxy;
|
||||
type CodexReasoningProxyCtor = new (config: CodexReasoningProxyConfig) => CodexReasoningProxy;
|
||||
|
||||
// ── Public types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ProxyChainContext {
|
||||
/** Provider being executed */
|
||||
provider: CLIProxyProvider;
|
||||
/** True when routing to a remote proxy host */
|
||||
useRemoteProxy: boolean;
|
||||
/** Resolved proxy configuration (host, port, protocol, auth, …) */
|
||||
proxyConfig: ResolvedProxyConfig;
|
||||
/** Executor configuration */
|
||||
cfg: ExecutorConfig;
|
||||
/**
|
||||
* Initial environment from first-pass buildClaudeEnvironment.
|
||||
* ANTHROPIC_BASE_URL and ANTHROPIC_MODEL* keys are consumed here.
|
||||
*/
|
||||
initialEnvVars: Partial<Record<string, string>>;
|
||||
/** Thinking mode override (value or undefined for default) */
|
||||
thinkingOverride: string | number | undefined;
|
||||
/** Thinking configuration from unified config */
|
||||
thinkingCfg: ThinkingConfig;
|
||||
verbose: boolean;
|
||||
log: (msg: string) => void;
|
||||
/**
|
||||
* Optional constructor overrides for unit testing.
|
||||
* Production code omits these; tests inject stubs to avoid real HTTP servers.
|
||||
*/
|
||||
_ToolSanitizationProxy?: ToolSanitizationProxyCtor;
|
||||
_CodexReasoningProxy?: CodexReasoningProxyCtor;
|
||||
}
|
||||
|
||||
export interface ProxyChainResult {
|
||||
toolSanitizationProxy: ToolSanitizationProxy | null;
|
||||
toolSanitizationPort: number | null;
|
||||
codexReasoningProxy: CodexReasoningProxy | null;
|
||||
codexReasoningPort: number | null;
|
||||
}
|
||||
|
||||
// ── Implementation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build and start the two env-dependent proxy layers (tool-sanitization and
|
||||
* codex-reasoning). Each layer is started independently; a failed start is
|
||||
* swallowed with a verbose warning so the session can continue degraded.
|
||||
*
|
||||
* Note: HttpsTunnelProxy is started inline in the orchestrator (index.ts)
|
||||
* before this function is called, because tunnelPort is required for
|
||||
* imageAnalysisResolution and the first-pass buildClaudeEnvironment.
|
||||
*/
|
||||
export async function buildProxyChain(context: ProxyChainContext): Promise<ProxyChainResult> {
|
||||
const {
|
||||
provider,
|
||||
useRemoteProxy,
|
||||
proxyConfig,
|
||||
cfg,
|
||||
initialEnvVars,
|
||||
thinkingOverride,
|
||||
thinkingCfg,
|
||||
verbose,
|
||||
log,
|
||||
// Allow test injection of proxy constructors so tests never spin up real servers
|
||||
_ToolSanitizationProxy: ToolSanitizationProxyImpl = ToolSanitizationProxy,
|
||||
_CodexReasoningProxy: CodexReasoningProxyImpl = CodexReasoningProxy,
|
||||
} = context;
|
||||
|
||||
// ── Step 1: Tool sanitization proxy ────────────────────────────────────────
|
||||
let toolSanitizationProxy: ToolSanitizationProxy | null = null;
|
||||
let toolSanitizationPort: number | null = null;
|
||||
|
||||
if (initialEnvVars.ANTHROPIC_BASE_URL) {
|
||||
try {
|
||||
toolSanitizationProxy = new ToolSanitizationProxyImpl({
|
||||
upstreamBaseUrl: initialEnvVars.ANTHROPIC_BASE_URL,
|
||||
verbose,
|
||||
warnOnSanitize: true,
|
||||
allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false,
|
||||
});
|
||||
toolSanitizationPort = await toolSanitizationProxy.start();
|
||||
log(`Tool sanitization proxy active on port ${toolSanitizationPort}`);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
toolSanitizationProxy = null;
|
||||
toolSanitizationPort = null;
|
||||
if (verbose) {
|
||||
console.error(warn(`Tool sanitization proxy disabled: ${err.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 2: Codex reasoning proxy ──────────────────────────────────────────
|
||||
let codexReasoningProxy: CodexReasoningProxy | null = null;
|
||||
let codexReasoningPort: number | null = null;
|
||||
|
||||
// Composite variants require root model-routed endpoints, never provider-pinned codex endpoints.
|
||||
if (provider === 'codex' && !cfg.isComposite) {
|
||||
const postSanitizationBaseUrl = toolSanitizationPort
|
||||
? `http://127.0.0.1:${toolSanitizationPort}`
|
||||
: initialEnvVars.ANTHROPIC_BASE_URL;
|
||||
|
||||
if (!postSanitizationBaseUrl) {
|
||||
log('ANTHROPIC_BASE_URL not set for Codex, reasoning proxy disabled');
|
||||
} else {
|
||||
try {
|
||||
const traceEnabled =
|
||||
process.env.CCS_CODEX_REASONING_TRACE === '1' ||
|
||||
process.env.CCS_CODEX_REASONING_TRACE === 'true';
|
||||
const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined;
|
||||
const codexThinkingOff = shouldDisableCodexReasoning(thinkingCfg, thinkingOverride);
|
||||
codexReasoningProxy = new CodexReasoningProxyImpl({
|
||||
upstreamBaseUrl: postSanitizationBaseUrl,
|
||||
verbose,
|
||||
defaultEffort: 'medium',
|
||||
disableEffort: codexThinkingOff,
|
||||
traceFilePath: traceEnabled ? path.join(getCcsDir(), 'codex-reasoning-proxy.log') : '',
|
||||
allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false,
|
||||
modelMap: {
|
||||
defaultModel: initialEnvVars.ANTHROPIC_MODEL,
|
||||
opusModel: initialEnvVars.ANTHROPIC_DEFAULT_OPUS_MODEL,
|
||||
sonnetModel: initialEnvVars.ANTHROPIC_DEFAULT_SONNET_MODEL,
|
||||
haikuModel: initialEnvVars.ANTHROPIC_DEFAULT_HAIKU_MODEL,
|
||||
},
|
||||
stripPathPrefix,
|
||||
});
|
||||
codexReasoningPort = await codexReasoningProxy.start();
|
||||
log(
|
||||
`Codex reasoning proxy active: http://127.0.0.1:${codexReasoningPort}/api/provider/codex`
|
||||
);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
codexReasoningProxy = null;
|
||||
codexReasoningPort = null;
|
||||
if (verbose) {
|
||||
console.error(warn(`Codex reasoning proxy disabled: ${err.message}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
toolSanitizationProxy,
|
||||
toolSanitizationPort,
|
||||
codexReasoningProxy,
|
||||
codexReasoningPort,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user