From 94bf1fbfe9b970eb7ce86881e958ddc4880e0d79 Mon Sep 17 00:00:00 2001 From: Wooseong Kim Date: Mon, 20 Apr 2026 13:12:40 +0900 Subject: [PATCH] feat(proxy): support profile-scoped local proxy ports --- src/commands/proxy-command.ts | 61 +++++++++----- src/config/unified-config-loader.ts | 5 ++ src/config/unified-config-types.ts | 8 ++ src/proxy/index.ts | 1 + src/proxy/proxy-daemon-paths.ts | 18 +++- src/proxy/proxy-daemon-state.ts | 50 ++++++++--- src/proxy/proxy-daemon.ts | 125 ++++++++++++++++++++-------- src/proxy/proxy-port-resolver.ts | 11 +++ 8 files changed, 205 insertions(+), 74 deletions(-) create mode 100644 src/proxy/proxy-port-resolver.ts diff --git a/src/commands/proxy-command.ts b/src/commands/proxy-command.ts index 51c406f9..336472f0 100644 --- a/src/commands/proxy-command.ts +++ b/src/commands/proxy-command.ts @@ -5,6 +5,7 @@ import { fail, info, ok } from '../utils/ui'; import { buildOpenAICompatProxyEnv, getOpenAICompatProxyStatus, + listOpenAICompatProxyStatuses, resolveOpenAICompatProfileConfig, startOpenAICompatProxy, stopOpenAICompatProxy, @@ -30,9 +31,9 @@ function showHelp(): number { console.log( ' start Start the local proxy for an OpenAI-compatible settings profile' ); - console.log(' stop Stop the running proxy'); - console.log(' status Show daemon status and active profile'); - console.log(' activate Print shell exports for the running proxy'); + console.log(' stop [profile] Stop the running proxy (or all proxies when omitted)'); + console.log(' status [profile] Show daemon status'); + console.log(' activate [profile] Print shell exports for the running proxy'); console.log(''); console.log('Options:'); console.log(' --port Override the local proxy port (default: 3456)'); @@ -100,35 +101,50 @@ async function handleStart(args: string[]): Promise { return 0; } -async function handleStatus(): Promise { - const status = await getOpenAICompatProxyStatus(); - if (!status.running) { +async function handleStatus(args: string[] = []): Promise { + const profileName = args.find((arg) => !arg.startsWith('-')); + const running = profileName + ? [await getOpenAICompatProxyStatus(profileName)].filter((status) => status.running) + : (await listOpenAICompatProxyStatuses()).filter((status) => status.running); + if (running.length === 0) { console.log(info('Proxy is not running')); return 0; } - console.log(ok(`Proxy running on port ${status.port}`)); - if (status.host) { - console.log(` Host: ${status.host}`); - console.log(` Local URL: http://${status.host}:${status.port}`); - } - console.log(` Profile: ${status.profileName}`); - console.log(` Base URL: ${status.baseUrl}`); - if (status.model) { - console.log(` Model: ${status.model}`); - } - if (status.pid) { - console.log(` PID: ${status.pid}`); + for (const status of running) { + console.log(ok(`Proxy running on port ${status.port}`)); + if (status.host) { + console.log(` Host: ${status.host}`); + console.log(` Local URL: http://${status.host}:${status.port}`); + } + console.log(` Profile: ${status.profileName}`); + console.log(` Base URL: ${status.baseUrl}`); + if (status.model) { + console.log(` Model: ${status.model}`); + } + if (status.pid) { + console.log(` PID: ${status.pid}`); + } } return 0; } async function handleActivate(args: string[]): Promise { - const status = await getOpenAICompatProxyStatus(); + const profileName = args.find((arg) => !arg.startsWith('-')); + const status = await getOpenAICompatProxyStatus(profileName); if (!status.running || !status.profileName || !status.port || !status.authToken) { console.error(fail('Proxy is not running. Start it with: ccs proxy start ')); return 1; } + if (!profileName) { + const running = (await listOpenAICompatProxyStatuses()).filter((entry) => entry.running); + if (running.length > 1) { + console.error( + fail('Multiple proxies are running. Specify a profile: ccs proxy activate ') + ); + return 1; + } + } const shell = detectShell(args.includes('--fish') ? 'fish' : parseOptionValue(args, '--shell')); let profile; @@ -163,16 +179,17 @@ export async function handleProxyCommand(args: string[]): Promise { case 'start': return handleStart(args.slice(1)); case 'stop': { - const result = await stopOpenAICompatProxy(); + const profileName = args[1] && !args[1]?.startsWith('-') ? args[1] : undefined; + const result = await stopOpenAICompatProxy(profileName); if (!result.success) { console.error(fail(result.error || 'Failed to stop proxy')); return 1; } - console.log(ok('Proxy stopped')); + console.log(ok(profileName ? `Proxy stopped for profile ${profileName}` : 'Proxy stopped')); return 0; } case 'status': - return handleStatus(); + return handleStatus(args.slice(1)); case 'activate': return handleActivate(args.slice(1)); default: diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 50835435..a5a53672 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -19,6 +19,7 @@ import { DEFAULT_GLOBAL_ENV, DEFAULT_CLIPROXY_SERVER_CONFIG, DEFAULT_CLIPROXY_SAFETY_CONFIG, + DEFAULT_OPENAI_COMPAT_PROXY_CONFIG, DEFAULT_QUOTA_MANAGEMENT_CONFIG, DEFAULT_THINKING_CONFIG, DEFAULT_OFFICIAL_CHANNELS_CONFIG, @@ -414,6 +415,10 @@ function mergeWithDefaults(partial: Partial): UnifiedConfig { }, }, proxy: { + port: partial.proxy?.port ?? DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.port, + profile_ports: partial.proxy?.profile_ports ?? { + ...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.profile_ports, + }, routing: { default: partial.proxy?.routing?.default ?? defaults.proxy?.routing?.default, background: partial.proxy?.routing?.background ?? defaults.proxy?.routing?.background, diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 1b8b67f4..41887804 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -508,6 +508,10 @@ export interface OpenAICompatProxyRoutingConfig { } export interface OpenAICompatProxyConfig { + /** Default local port for OpenAI-compatible proxy instances */ + port?: number; + /** Optional profile-scoped local port overrides */ + profile_ports?: Record; routing?: OpenAICompatProxyRoutingConfig; } @@ -986,6 +990,8 @@ export const DEFAULT_CLIPROXY_SERVER_CONFIG: CliproxyServerConfig = { }; export const DEFAULT_OPENAI_COMPAT_PROXY_CONFIG: OpenAICompatProxyConfig = { + port: 3456, + profile_ports: {}, routing: { longContextThreshold: 60_000, }, @@ -1016,6 +1022,8 @@ export function createEmptyUnifiedConfig(): UnifiedConfig { }, }, proxy: { + port: DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.port, + profile_ports: { ...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.profile_ports }, routing: { ...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.routing, }, diff --git a/src/proxy/index.ts b/src/proxy/index.ts index 24a0a516..2f389857 100644 --- a/src/proxy/index.ts +++ b/src/proxy/index.ts @@ -2,6 +2,7 @@ export * from './profile-router'; export * from './proxy-daemon'; export * from './proxy-daemon-paths'; export * from './proxy-env'; +export * from './proxy-port-resolver'; export * from './upstream-url'; export * from './transformers/request-transformer'; export * from './transformers/sse-stream-transformer'; diff --git a/src/proxy/proxy-daemon-paths.ts b/src/proxy/proxy-daemon-paths.ts index 92b6b95b..011a9f59 100644 --- a/src/proxy/proxy-daemon-paths.ts +++ b/src/proxy/proxy-daemon-paths.ts @@ -8,10 +8,20 @@ export function getOpenAICompatProxyDir(): string { return path.join(getCcsDir(), 'proxy'); } -export function getOpenAICompatProxyPidPath(): string { - return path.join(getOpenAICompatProxyDir(), 'daemon.pid'); +export function getOpenAICompatProxyProfileKey(profileName: string): string { + return encodeURIComponent(profileName.trim()); } -export function getOpenAICompatProxySessionPath(): string { - return path.join(getOpenAICompatProxyDir(), 'session.json'); +export function getOpenAICompatProxyPidPath(profileName: string): string { + return path.join( + getOpenAICompatProxyDir(), + `${getOpenAICompatProxyProfileKey(profileName)}.daemon.pid` + ); +} + +export function getOpenAICompatProxySessionPath(profileName: string): string { + return path.join( + getOpenAICompatProxyDir(), + `${getOpenAICompatProxyProfileKey(profileName)}.session.json` + ); } diff --git a/src/proxy/proxy-daemon-state.ts b/src/proxy/proxy-daemon-state.ts index 525c5a7e..82d11130 100644 --- a/src/proxy/proxy-daemon-state.ts +++ b/src/proxy/proxy-daemon-state.ts @@ -21,9 +21,9 @@ function ensureProxyDir(): void { fs.mkdirSync(getOpenAICompatProxyDir(), { recursive: true }); } -export function getOpenAICompatProxyPid(): number | null { +export function getOpenAICompatProxyPid(profileName: string): number | null { try { - const raw = fs.readFileSync(getOpenAICompatProxyPidPath(), 'utf8').trim(); + const raw = fs.readFileSync(getOpenAICompatProxyPidPath(profileName), 'utf8').trim(); const pid = Number.parseInt(raw, 10); return Number.isInteger(pid) ? pid : null; } catch { @@ -31,23 +31,23 @@ export function getOpenAICompatProxyPid(): number | null { } } -export function writeOpenAICompatProxyPid(pid: number): void { +export function writeOpenAICompatProxyPid(profileName: string, pid: number): void { ensureProxyDir(); - fs.writeFileSync(getOpenAICompatProxyPidPath(), String(pid), 'utf8'); + fs.writeFileSync(getOpenAICompatProxyPidPath(profileName), String(pid), 'utf8'); } -export function removeOpenAICompatProxyPid(): void { +export function removeOpenAICompatProxyPid(profileName: string): void { try { - fs.unlinkSync(getOpenAICompatProxyPidPath()); + fs.unlinkSync(getOpenAICompatProxyPidPath(profileName)); } catch { // Best-effort cleanup. } } -export function readOpenAICompatProxySession(): OpenAICompatProxySession | null { +export function readOpenAICompatProxySession(profileName: string): OpenAICompatProxySession | null { try { return JSON.parse( - fs.readFileSync(getOpenAICompatProxySessionPath(), 'utf8') + fs.readFileSync(getOpenAICompatProxySessionPath(profileName), 'utf8') ) as OpenAICompatProxySession; } catch { return null; @@ -57,20 +57,48 @@ export function readOpenAICompatProxySession(): OpenAICompatProxySession | null export function writeOpenAICompatProxySession(session: OpenAICompatProxySession): void { ensureProxyDir(); fs.writeFileSync( - getOpenAICompatProxySessionPath(), + getOpenAICompatProxySessionPath(session.profileName), JSON.stringify(session, null, 2) + '\n', 'utf8' ); } -export function removeOpenAICompatProxySession(): void { +export function removeOpenAICompatProxySession(profileName: string): void { try { - fs.unlinkSync(getOpenAICompatProxySessionPath()); + fs.unlinkSync(getOpenAICompatProxySessionPath(profileName)); } catch { // Best-effort cleanup. } } +export function listOpenAICompatProxyProfileNames(): string[] { + try { + const entries = fs.readdirSync(getOpenAICompatProxyDir(), { withFileTypes: true }); + const profileKeys = new Set(); + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.session.json')) { + continue; + } + profileKeys.add(entry.name.slice(0, -'.session.json'.length)); + } + return [...profileKeys].map((profileKey) => decodeURIComponent(profileKey)); + } catch { + return []; + } +} + +export function cleanupLegacyOpenAICompatProxyState(): void { + ensureProxyDir(); + const legacyNames = ['daemon.pid', 'session.json']; + for (const legacyName of legacyNames) { + try { + fs.unlinkSync(path.join(getOpenAICompatProxyDir(), legacyName)); + } catch { + // Best-effort cleanup. + } + } +} + export function resolveOpenAICompatProxyEntrypointCandidates(): string[] { const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js'); const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts'); diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index 8a28382e..b2cf668c 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -13,7 +13,9 @@ import { } from './proxy-daemon-paths'; import { getOpenAICompatProxyPid, + listOpenAICompatProxyProfileNames, readOpenAICompatProxySession, + cleanupLegacyOpenAICompatProxyState, removeOpenAICompatProxyPid, removeOpenAICompatProxySession, resolveOpenAICompatProxyEntrypointCandidates, @@ -21,6 +23,7 @@ import { writeOpenAICompatProxyPid, writeOpenAICompatProxySession, } from './proxy-daemon-state'; +import { resolveOpenAICompatProxyPreferredPort } from './proxy-port-resolver'; export interface OpenAICompatProxyStatus extends Partial { running: boolean; @@ -101,6 +104,13 @@ async function findOpenAICompatProxyPort(): Promise { return 0; } +async function findOpenAICompatProxyPortNear(preferredPort: number): Promise { + if (!(await isPortOccupied(preferredPort))) { + return preferredPort; + } + return findOpenAICompatProxyPort(); +} + async function resolveDaemonEntrypoint(): Promise { for (const candidate of resolveOpenAICompatProxyEntrypointCandidates()) { try { @@ -144,21 +154,52 @@ export async function isOpenAICompatProxyRunning(port: number): Promise }); } -export async function getOpenAICompatProxyStatus(): Promise { - const session = readOpenAICompatProxySession(); +async function getOpenAICompatProxyStatusForProfile( + profileName: string +): Promise { + const session = readOpenAICompatProxySession(profileName); const port = session?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT; const running = await isOpenAICompatProxyRunning(port); return { running, - pid: running ? getOpenAICompatProxyPid() || undefined : undefined, + pid: running ? getOpenAICompatProxyPid(profileName) || undefined : undefined, ...session, }; } -async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; error?: string }> { - const pid = getOpenAICompatProxyPid(); +export async function listOpenAICompatProxyStatuses(): Promise { + const profileNames = listOpenAICompatProxyProfileNames(); + const statuses = await Promise.all( + profileNames.map((profileName) => getOpenAICompatProxyStatusForProfile(profileName)) + ); + return statuses.filter((status) => status.profileName); +} + +export async function getOpenAICompatProxyStatus( + profileName?: string +): Promise { + if (profileName) { + return getOpenAICompatProxyStatusForProfile(profileName); + } + + const statuses = await listOpenAICompatProxyStatuses(); + const running = statuses.filter((status) => status.running); + if (running.length === 1) { + return running[0] || { running: false }; + } + if (running.length > 1) { + return { running: true }; + } + const latestKnown = statuses[0]; + return latestKnown ?? { running: false }; +} + +async function stopOpenAICompatProxyUnlocked( + profileName: string +): Promise<{ success: boolean; error?: string }> { + const pid = getOpenAICompatProxyPid(profileName); if (!pid) { - removeOpenAICompatProxySession(); + removeOpenAICompatProxySession(profileName); return { success: true }; } @@ -170,7 +211,8 @@ async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; erro ); if (ownership === 'not-owned') { - removeOpenAICompatProxyPid(); + removeOpenAICompatProxyPid(profileName); + removeOpenAICompatProxySession(profileName); return { success: true }; } @@ -182,8 +224,8 @@ async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; erro } if (ownership === 'not-running') { - removeOpenAICompatProxyPid(); - removeOpenAICompatProxySession(); + removeOpenAICompatProxyPid(profileName); + removeOpenAICompatProxySession(profileName); return { success: true }; } @@ -214,13 +256,32 @@ async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; erro } } - removeOpenAICompatProxyPid(); - removeOpenAICompatProxySession(); + removeOpenAICompatProxyPid(profileName); + removeOpenAICompatProxySession(profileName); return { success: true }; } -export async function stopOpenAICompatProxy(): Promise<{ success: boolean; error?: string }> { - return withOpenAICompatProxyLock(() => stopOpenAICompatProxyUnlocked()); +export async function stopOpenAICompatProxy( + profileName?: string +): Promise<{ success: boolean; error?: string }> { + return withOpenAICompatProxyLock(async () => { + cleanupLegacyOpenAICompatProxyState(); + if (profileName) { + return stopOpenAICompatProxyUnlocked(profileName); + } + + const statuses = await listOpenAICompatProxyStatuses(); + for (const status of statuses) { + if (!status.profileName) { + continue; + } + const stopped = await stopOpenAICompatProxyUnlocked(status.profileName); + if (!stopped.success) { + return stopped; + } + } + return { success: true }; + }); } export async function startOpenAICompatProxy( @@ -228,30 +289,28 @@ export async function startOpenAICompatProxy( options: { port?: number; host?: string; insecure?: boolean } = {} ): Promise { return withOpenAICompatProxyLock(async () => { - const status = await getOpenAICompatProxyStatus(); + cleanupLegacyOpenAICompatProxyState(); + const status = await getOpenAICompatProxyStatus(profile.profileName); const host = options.host?.trim() || status.host || '127.0.0.1'; - const port = + const preferredPort = typeof options.port === 'number' ? options.port - : status.running && status.profileName === profile.profileName && status.port - ? status.port - : await findOpenAICompatProxyPort(); + : status.port || resolveOpenAICompatProxyPreferredPort(profile.profileName); + const port = + status.running && status.port && (status.host || '127.0.0.1') === host + ? status.port + : await findOpenAICompatProxyPortNear(preferredPort); if (port === 0) { return { success: false, - port: OPENAI_COMPAT_PROXY_DEFAULT_PORT, + port: preferredPort, error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`, }; } if (!Number.isInteger(port) || port < 1 || port > 65535) { return { success: false, port, error: `Invalid port: ${port}` }; } - if ( - status.running && - status.profileName === profile.profileName && - status.port === port && - (status.host || '127.0.0.1') === host - ) { + if (status.running && status.port === port && (status.host || '127.0.0.1') === host) { return { success: true, alreadyRunning: true, @@ -261,15 +320,7 @@ export async function startOpenAICompatProxy( }; } if (status.running) { - if (status.profileName !== profile.profileName) { - return { - success: false, - port, - error: `Proxy already running for profile "${status.profileName}" on port ${status.port}. Stop it before starting a different profile.`, - }; - } - - const stopped = await stopOpenAICompatProxyUnlocked(); + const stopped = await stopOpenAICompatProxyUnlocked(profile.profileName); if (!stopped.success) { return { success: false, @@ -298,8 +349,8 @@ export async function startOpenAICompatProxy( resolved = true; if (timeout) clearTimeout(timeout); if (!result.success) { - removeOpenAICompatProxyPid(); - removeOpenAICompatProxySession(); + removeOpenAICompatProxyPid(profile.profileName); + removeOpenAICompatProxySession(profile.profileName); } resolve(result); }; @@ -326,7 +377,7 @@ export async function startOpenAICompatProxy( proc.unref(); if (proc.pid) { - writeOpenAICompatProxyPid(proc.pid); + writeOpenAICompatProxyPid(profile.profileName, proc.pid); } writeOpenAICompatProxySession({ profileName: profile.profileName, diff --git a/src/proxy/proxy-port-resolver.ts b/src/proxy/proxy-port-resolver.ts new file mode 100644 index 00000000..919e5755 --- /dev/null +++ b/src/proxy/proxy-port-resolver.ts @@ -0,0 +1,11 @@ +import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; +import { OPENAI_COMPAT_PROXY_DEFAULT_PORT } from './proxy-daemon-paths'; + +export function resolveOpenAICompatProxyPreferredPort(profileName: string): number { + const config = loadOrCreateUnifiedConfig(); + const profilePort = config.proxy?.profile_ports?.[profileName]; + if (typeof profilePort === 'number') { + return profilePort; + } + return config.proxy?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT; +}