mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-11 14:22:06 +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 { spawn, ChildProcess } from 'child_process';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
|
||||||
import { fail, info, warn } from '../../utils/ui';
|
import { fail, info, warn } from '../../utils/ui';
|
||||||
import { getCcsDir } from '../../utils/config-manager';
|
|
||||||
import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor';
|
import { escapeShellArg, getWindowsEscapedCommandShell } from '../../utils/shell-executor';
|
||||||
import {
|
import {
|
||||||
generateConfig,
|
generateConfig,
|
||||||
@@ -79,11 +77,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildThinkingStartupStatus,
|
buildThinkingStartupStatus,
|
||||||
resolveRuntimeThinkingOverride,
|
resolveRuntimeThinkingOverride,
|
||||||
shouldDisableCodexReasoning,
|
|
||||||
} from './thinking-override-resolver';
|
} from './thinking-override-resolver';
|
||||||
import { shouldStartHttpsTunnel } from './https-tunnel-policy';
|
import { shouldStartHttpsTunnel } from './https-tunnel-policy';
|
||||||
import { filterCcsFlags, parseExecutorFlags, validateFlagCombinations } from './arg-parser';
|
import { filterCcsFlags, parseExecutorFlags, validateFlagCombinations } from './arg-parser';
|
||||||
import { resolveExecutorProxy } from './proxy-resolver';
|
import { resolveExecutorProxy } from './proxy-resolver';
|
||||||
|
import { buildProxyChain } from './proxy-chain-builder';
|
||||||
|
|
||||||
/** Local alias so internal call sites need no change */
|
/** Local alias so internal call sites need no change */
|
||||||
const resolveRuntimeQuotaMonitorProviders = _resolveRuntimeQuotaMonitorProviders;
|
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 httpsTunnel: HttpsTunnelProxy | null = null;
|
||||||
let tunnelPort: number | 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.'
|
? 'ImageAnalysis MCP provisioning failed. This session will use compatibility fallback when available.'
|
||||||
: imageAnalysisResolution.warning;
|
: imageAnalysisResolution.warning;
|
||||||
|
|
||||||
// 9. Setup tool sanitization proxy
|
// 9. Resolve config dir + browser runtime (needed before proxy chain)
|
||||||
let toolSanitizationProxy: ToolSanitizationProxy | null = null;
|
let toolSanitizationProxy: ToolSanitizationProxy | null = null;
|
||||||
let toolSanitizationPort: number | null = null;
|
let toolSanitizationPort: number | null = null;
|
||||||
|
let codexReasoningProxy: CodexReasoningProxy | null = null;
|
||||||
|
let codexReasoningPort: number | null = null;
|
||||||
let inheritedClaudeConfigDir = cfg.claudeConfigDir;
|
let inheritedClaudeConfigDir = cfg.claudeConfigDir;
|
||||||
|
|
||||||
if (!inheritedClaudeConfigDir && cfg.profileName) {
|
if (!inheritedClaudeConfigDir && cfg.profileName) {
|
||||||
@@ -488,74 +488,19 @@ export async function execClaudeWithCLIProxy(
|
|||||||
imageAnalysisEnv,
|
imageAnalysisEnv,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (initialEnvVars.ANTHROPIC_BASE_URL) {
|
// 9b. Build env-dependent proxy chain (tool-sanitization + codex-reasoning)
|
||||||
try {
|
({ toolSanitizationProxy, toolSanitizationPort, codexReasoningProxy, codexReasoningPort } =
|
||||||
toolSanitizationProxy = new ToolSanitizationProxy({
|
await buildProxyChain({
|
||||||
upstreamBaseUrl: initialEnvVars.ANTHROPIC_BASE_URL,
|
provider,
|
||||||
verbose,
|
useRemoteProxy,
|
||||||
warnOnSanitize: true,
|
proxyConfig,
|
||||||
allowSelfSigned: useRemoteProxy ? (proxyConfig.allowSelfSigned ?? false) : false,
|
cfg,
|
||||||
});
|
initialEnvVars,
|
||||||
toolSanitizationPort = await toolSanitizationProxy.start();
|
thinkingOverride,
|
||||||
log(`Tool sanitization proxy active on port ${toolSanitizationPort}`);
|
thinkingCfg,
|
||||||
} catch (error) {
|
verbose,
|
||||||
const err = error as Error;
|
log,
|
||||||
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}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 11. Build final environment with all proxy chains
|
// 11. Build final environment with all proxy chains
|
||||||
const env = buildClaudeEnvironment({
|
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