feat(proxy): support profile-scoped local proxy ports

This commit is contained in:
Wooseong Kim
2026-04-20 13:14:19 +09:00
parent fc9dd23a2e
commit 94bf1fbfe9
8 changed files with 205 additions and 74 deletions
+39 -22
View File
@@ -5,6 +5,7 @@ import { fail, info, ok } from '../utils/ui';
import { import {
buildOpenAICompatProxyEnv, buildOpenAICompatProxyEnv,
getOpenAICompatProxyStatus, getOpenAICompatProxyStatus,
listOpenAICompatProxyStatuses,
resolveOpenAICompatProfileConfig, resolveOpenAICompatProfileConfig,
startOpenAICompatProxy, startOpenAICompatProxy,
stopOpenAICompatProxy, stopOpenAICompatProxy,
@@ -30,9 +31,9 @@ function showHelp(): number {
console.log( console.log(
' start <profile> Start the local proxy for an OpenAI-compatible settings profile' ' start <profile> Start the local proxy for an OpenAI-compatible settings profile'
); );
console.log(' stop Stop the running proxy'); console.log(' stop [profile] Stop the running proxy (or all proxies when omitted)');
console.log(' status Show daemon status and active profile'); console.log(' status [profile] Show daemon status');
console.log(' activate Print shell exports for the running proxy'); console.log(' activate [profile] Print shell exports for the running proxy');
console.log(''); console.log('');
console.log('Options:'); console.log('Options:');
console.log(' --port <n> Override the local proxy port (default: 3456)'); console.log(' --port <n> Override the local proxy port (default: 3456)');
@@ -100,35 +101,50 @@ async function handleStart(args: string[]): Promise<number> {
return 0; return 0;
} }
async function handleStatus(): Promise<number> { async function handleStatus(args: string[] = []): Promise<number> {
const status = await getOpenAICompatProxyStatus(); const profileName = args.find((arg) => !arg.startsWith('-'));
if (!status.running) { 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')); console.log(info('Proxy is not running'));
return 0; return 0;
} }
console.log(ok(`Proxy running on port ${status.port}`)); for (const status of running) {
if (status.host) { console.log(ok(`Proxy running on port ${status.port}`));
console.log(` Host: ${status.host}`); if (status.host) {
console.log(` Local URL: http://${status.host}:${status.port}`); 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}`); console.log(` Profile: ${status.profileName}`);
if (status.model) { console.log(` Base URL: ${status.baseUrl}`);
console.log(` Model: ${status.model}`); if (status.model) {
} console.log(` Model: ${status.model}`);
if (status.pid) { }
console.log(` PID: ${status.pid}`); if (status.pid) {
console.log(` PID: ${status.pid}`);
}
} }
return 0; return 0;
} }
async function handleActivate(args: string[]): Promise<number> { async function handleActivate(args: string[]): Promise<number> {
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) { if (!status.running || !status.profileName || !status.port || !status.authToken) {
console.error(fail('Proxy is not running. Start it with: ccs proxy start <profile>')); console.error(fail('Proxy is not running. Start it with: ccs proxy start <profile>'));
return 1; 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 <profile>')
);
return 1;
}
}
const shell = detectShell(args.includes('--fish') ? 'fish' : parseOptionValue(args, '--shell')); const shell = detectShell(args.includes('--fish') ? 'fish' : parseOptionValue(args, '--shell'));
let profile; let profile;
@@ -163,16 +179,17 @@ export async function handleProxyCommand(args: string[]): Promise<number> {
case 'start': case 'start':
return handleStart(args.slice(1)); return handleStart(args.slice(1));
case 'stop': { case 'stop': {
const result = await stopOpenAICompatProxy(); const profileName = args[1] && !args[1]?.startsWith('-') ? args[1] : undefined;
const result = await stopOpenAICompatProxy(profileName);
if (!result.success) { if (!result.success) {
console.error(fail(result.error || 'Failed to stop proxy')); console.error(fail(result.error || 'Failed to stop proxy'));
return 1; return 1;
} }
console.log(ok('Proxy stopped')); console.log(ok(profileName ? `Proxy stopped for profile ${profileName}` : 'Proxy stopped'));
return 0; return 0;
} }
case 'status': case 'status':
return handleStatus(); return handleStatus(args.slice(1));
case 'activate': case 'activate':
return handleActivate(args.slice(1)); return handleActivate(args.slice(1));
default: default:
+5
View File
@@ -19,6 +19,7 @@ import {
DEFAULT_GLOBAL_ENV, DEFAULT_GLOBAL_ENV,
DEFAULT_CLIPROXY_SERVER_CONFIG, DEFAULT_CLIPROXY_SERVER_CONFIG,
DEFAULT_CLIPROXY_SAFETY_CONFIG, DEFAULT_CLIPROXY_SAFETY_CONFIG,
DEFAULT_OPENAI_COMPAT_PROXY_CONFIG,
DEFAULT_QUOTA_MANAGEMENT_CONFIG, DEFAULT_QUOTA_MANAGEMENT_CONFIG,
DEFAULT_THINKING_CONFIG, DEFAULT_THINKING_CONFIG,
DEFAULT_OFFICIAL_CHANNELS_CONFIG, DEFAULT_OFFICIAL_CHANNELS_CONFIG,
@@ -414,6 +415,10 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
}, },
}, },
proxy: { 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: { routing: {
default: partial.proxy?.routing?.default ?? defaults.proxy?.routing?.default, default: partial.proxy?.routing?.default ?? defaults.proxy?.routing?.default,
background: partial.proxy?.routing?.background ?? defaults.proxy?.routing?.background, background: partial.proxy?.routing?.background ?? defaults.proxy?.routing?.background,
+8
View File
@@ -508,6 +508,10 @@ export interface OpenAICompatProxyRoutingConfig {
} }
export interface OpenAICompatProxyConfig { export interface OpenAICompatProxyConfig {
/** Default local port for OpenAI-compatible proxy instances */
port?: number;
/** Optional profile-scoped local port overrides */
profile_ports?: Record<string, number>;
routing?: OpenAICompatProxyRoutingConfig; routing?: OpenAICompatProxyRoutingConfig;
} }
@@ -986,6 +990,8 @@ export const DEFAULT_CLIPROXY_SERVER_CONFIG: CliproxyServerConfig = {
}; };
export const DEFAULT_OPENAI_COMPAT_PROXY_CONFIG: OpenAICompatProxyConfig = { export const DEFAULT_OPENAI_COMPAT_PROXY_CONFIG: OpenAICompatProxyConfig = {
port: 3456,
profile_ports: {},
routing: { routing: {
longContextThreshold: 60_000, longContextThreshold: 60_000,
}, },
@@ -1016,6 +1022,8 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
}, },
}, },
proxy: { proxy: {
port: DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.port,
profile_ports: { ...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.profile_ports },
routing: { routing: {
...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.routing, ...DEFAULT_OPENAI_COMPAT_PROXY_CONFIG.routing,
}, },
+1
View File
@@ -2,6 +2,7 @@ export * from './profile-router';
export * from './proxy-daemon'; export * from './proxy-daemon';
export * from './proxy-daemon-paths'; export * from './proxy-daemon-paths';
export * from './proxy-env'; export * from './proxy-env';
export * from './proxy-port-resolver';
export * from './upstream-url'; export * from './upstream-url';
export * from './transformers/request-transformer'; export * from './transformers/request-transformer';
export * from './transformers/sse-stream-transformer'; export * from './transformers/sse-stream-transformer';
+14 -4
View File
@@ -8,10 +8,20 @@ export function getOpenAICompatProxyDir(): string {
return path.join(getCcsDir(), 'proxy'); return path.join(getCcsDir(), 'proxy');
} }
export function getOpenAICompatProxyPidPath(): string { export function getOpenAICompatProxyProfileKey(profileName: string): string {
return path.join(getOpenAICompatProxyDir(), 'daemon.pid'); return encodeURIComponent(profileName.trim());
} }
export function getOpenAICompatProxySessionPath(): string { export function getOpenAICompatProxyPidPath(profileName: string): string {
return path.join(getOpenAICompatProxyDir(), 'session.json'); return path.join(
getOpenAICompatProxyDir(),
`${getOpenAICompatProxyProfileKey(profileName)}.daemon.pid`
);
}
export function getOpenAICompatProxySessionPath(profileName: string): string {
return path.join(
getOpenAICompatProxyDir(),
`${getOpenAICompatProxyProfileKey(profileName)}.session.json`
);
} }
+39 -11
View File
@@ -21,9 +21,9 @@ function ensureProxyDir(): void {
fs.mkdirSync(getOpenAICompatProxyDir(), { recursive: true }); fs.mkdirSync(getOpenAICompatProxyDir(), { recursive: true });
} }
export function getOpenAICompatProxyPid(): number | null { export function getOpenAICompatProxyPid(profileName: string): number | null {
try { try {
const raw = fs.readFileSync(getOpenAICompatProxyPidPath(), 'utf8').trim(); const raw = fs.readFileSync(getOpenAICompatProxyPidPath(profileName), 'utf8').trim();
const pid = Number.parseInt(raw, 10); const pid = Number.parseInt(raw, 10);
return Number.isInteger(pid) ? pid : null; return Number.isInteger(pid) ? pid : null;
} catch { } 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(); 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 { try {
fs.unlinkSync(getOpenAICompatProxyPidPath()); fs.unlinkSync(getOpenAICompatProxyPidPath(profileName));
} catch { } catch {
// Best-effort cleanup. // Best-effort cleanup.
} }
} }
export function readOpenAICompatProxySession(): OpenAICompatProxySession | null { export function readOpenAICompatProxySession(profileName: string): OpenAICompatProxySession | null {
try { try {
return JSON.parse( return JSON.parse(
fs.readFileSync(getOpenAICompatProxySessionPath(), 'utf8') fs.readFileSync(getOpenAICompatProxySessionPath(profileName), 'utf8')
) as OpenAICompatProxySession; ) as OpenAICompatProxySession;
} catch { } catch {
return null; return null;
@@ -57,20 +57,48 @@ export function readOpenAICompatProxySession(): OpenAICompatProxySession | null
export function writeOpenAICompatProxySession(session: OpenAICompatProxySession): void { export function writeOpenAICompatProxySession(session: OpenAICompatProxySession): void {
ensureProxyDir(); ensureProxyDir();
fs.writeFileSync( fs.writeFileSync(
getOpenAICompatProxySessionPath(), getOpenAICompatProxySessionPath(session.profileName),
JSON.stringify(session, null, 2) + '\n', JSON.stringify(session, null, 2) + '\n',
'utf8' 'utf8'
); );
} }
export function removeOpenAICompatProxySession(): void { export function removeOpenAICompatProxySession(profileName: string): void {
try { try {
fs.unlinkSync(getOpenAICompatProxySessionPath()); fs.unlinkSync(getOpenAICompatProxySessionPath(profileName));
} catch { } catch {
// Best-effort cleanup. // Best-effort cleanup.
} }
} }
export function listOpenAICompatProxyProfileNames(): string[] {
try {
const entries = fs.readdirSync(getOpenAICompatProxyDir(), { withFileTypes: true });
const profileKeys = new Set<string>();
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[] { export function resolveOpenAICompatProxyEntrypointCandidates(): string[] {
const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js'); const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js');
const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts'); const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts');
+88 -37
View File
@@ -13,7 +13,9 @@ import {
} from './proxy-daemon-paths'; } from './proxy-daemon-paths';
import { import {
getOpenAICompatProxyPid, getOpenAICompatProxyPid,
listOpenAICompatProxyProfileNames,
readOpenAICompatProxySession, readOpenAICompatProxySession,
cleanupLegacyOpenAICompatProxyState,
removeOpenAICompatProxyPid, removeOpenAICompatProxyPid,
removeOpenAICompatProxySession, removeOpenAICompatProxySession,
resolveOpenAICompatProxyEntrypointCandidates, resolveOpenAICompatProxyEntrypointCandidates,
@@ -21,6 +23,7 @@ import {
writeOpenAICompatProxyPid, writeOpenAICompatProxyPid,
writeOpenAICompatProxySession, writeOpenAICompatProxySession,
} from './proxy-daemon-state'; } from './proxy-daemon-state';
import { resolveOpenAICompatProxyPreferredPort } from './proxy-port-resolver';
export interface OpenAICompatProxyStatus extends Partial<OpenAICompatProxySession> { export interface OpenAICompatProxyStatus extends Partial<OpenAICompatProxySession> {
running: boolean; running: boolean;
@@ -101,6 +104,13 @@ async function findOpenAICompatProxyPort(): Promise<number> {
return 0; return 0;
} }
async function findOpenAICompatProxyPortNear(preferredPort: number): Promise<number> {
if (!(await isPortOccupied(preferredPort))) {
return preferredPort;
}
return findOpenAICompatProxyPort();
}
async function resolveDaemonEntrypoint(): Promise<string | null> { async function resolveDaemonEntrypoint(): Promise<string | null> {
for (const candidate of resolveOpenAICompatProxyEntrypointCandidates()) { for (const candidate of resolveOpenAICompatProxyEntrypointCandidates()) {
try { try {
@@ -144,21 +154,52 @@ export async function isOpenAICompatProxyRunning(port: number): Promise<boolean>
}); });
} }
export async function getOpenAICompatProxyStatus(): Promise<OpenAICompatProxyStatus> { async function getOpenAICompatProxyStatusForProfile(
const session = readOpenAICompatProxySession(); profileName: string
): Promise<OpenAICompatProxyStatus> {
const session = readOpenAICompatProxySession(profileName);
const port = session?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT; const port = session?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT;
const running = await isOpenAICompatProxyRunning(port); const running = await isOpenAICompatProxyRunning(port);
return { return {
running, running,
pid: running ? getOpenAICompatProxyPid() || undefined : undefined, pid: running ? getOpenAICompatProxyPid(profileName) || undefined : undefined,
...session, ...session,
}; };
} }
async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; error?: string }> { export async function listOpenAICompatProxyStatuses(): Promise<OpenAICompatProxyStatus[]> {
const pid = getOpenAICompatProxyPid(); 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<OpenAICompatProxyStatus> {
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) { if (!pid) {
removeOpenAICompatProxySession(); removeOpenAICompatProxySession(profileName);
return { success: true }; return { success: true };
} }
@@ -170,7 +211,8 @@ async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; erro
); );
if (ownership === 'not-owned') { if (ownership === 'not-owned') {
removeOpenAICompatProxyPid(); removeOpenAICompatProxyPid(profileName);
removeOpenAICompatProxySession(profileName);
return { success: true }; return { success: true };
} }
@@ -182,8 +224,8 @@ async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; erro
} }
if (ownership === 'not-running') { if (ownership === 'not-running') {
removeOpenAICompatProxyPid(); removeOpenAICompatProxyPid(profileName);
removeOpenAICompatProxySession(); removeOpenAICompatProxySession(profileName);
return { success: true }; return { success: true };
} }
@@ -214,13 +256,32 @@ async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; erro
} }
} }
removeOpenAICompatProxyPid(); removeOpenAICompatProxyPid(profileName);
removeOpenAICompatProxySession(); removeOpenAICompatProxySession(profileName);
return { success: true }; return { success: true };
} }
export async function stopOpenAICompatProxy(): Promise<{ success: boolean; error?: string }> { export async function stopOpenAICompatProxy(
return withOpenAICompatProxyLock(() => stopOpenAICompatProxyUnlocked()); 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( export async function startOpenAICompatProxy(
@@ -228,30 +289,28 @@ export async function startOpenAICompatProxy(
options: { port?: number; host?: string; insecure?: boolean } = {} options: { port?: number; host?: string; insecure?: boolean } = {}
): Promise<StartOpenAICompatProxyResult> { ): Promise<StartOpenAICompatProxyResult> {
return withOpenAICompatProxyLock(async () => { 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 host = options.host?.trim() || status.host || '127.0.0.1';
const port = const preferredPort =
typeof options.port === 'number' typeof options.port === 'number'
? options.port ? options.port
: status.running && status.profileName === profile.profileName && status.port : status.port || resolveOpenAICompatProxyPreferredPort(profile.profileName);
? status.port const port =
: await findOpenAICompatProxyPort(); status.running && status.port && (status.host || '127.0.0.1') === host
? status.port
: await findOpenAICompatProxyPortNear(preferredPort);
if (port === 0) { if (port === 0) {
return { return {
success: false, 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}`, 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) { if (!Number.isInteger(port) || port < 1 || port > 65535) {
return { success: false, port, error: `Invalid port: ${port}` }; return { success: false, port, error: `Invalid port: ${port}` };
} }
if ( if (status.running && status.port === port && (status.host || '127.0.0.1') === host) {
status.running &&
status.profileName === profile.profileName &&
status.port === port &&
(status.host || '127.0.0.1') === host
) {
return { return {
success: true, success: true,
alreadyRunning: true, alreadyRunning: true,
@@ -261,15 +320,7 @@ export async function startOpenAICompatProxy(
}; };
} }
if (status.running) { if (status.running) {
if (status.profileName !== profile.profileName) { const stopped = await stopOpenAICompatProxyUnlocked(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();
if (!stopped.success) { if (!stopped.success) {
return { return {
success: false, success: false,
@@ -298,8 +349,8 @@ export async function startOpenAICompatProxy(
resolved = true; resolved = true;
if (timeout) clearTimeout(timeout); if (timeout) clearTimeout(timeout);
if (!result.success) { if (!result.success) {
removeOpenAICompatProxyPid(); removeOpenAICompatProxyPid(profile.profileName);
removeOpenAICompatProxySession(); removeOpenAICompatProxySession(profile.profileName);
} }
resolve(result); resolve(result);
}; };
@@ -326,7 +377,7 @@ export async function startOpenAICompatProxy(
proc.unref(); proc.unref();
if (proc.pid) { if (proc.pid) {
writeOpenAICompatProxyPid(proc.pid); writeOpenAICompatProxyPid(profile.profileName, proc.pid);
} }
writeOpenAICompatProxySession({ writeOpenAICompatProxySession({
profileName: profile.profileName, profileName: profile.profileName,
+11
View File
@@ -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;
}