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 {
buildOpenAICompatProxyEnv,
getOpenAICompatProxyStatus,
listOpenAICompatProxyStatuses,
resolveOpenAICompatProfileConfig,
startOpenAICompatProxy,
stopOpenAICompatProxy,
@@ -30,9 +31,9 @@ function showHelp(): number {
console.log(
' start <profile> 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 <n> Override the local proxy port (default: 3456)');
@@ -100,35 +101,50 @@ async function handleStart(args: string[]): Promise<number> {
return 0;
}
async function handleStatus(): Promise<number> {
const status = await getOpenAICompatProxyStatus();
if (!status.running) {
async function handleStatus(args: string[] = []): Promise<number> {
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<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) {
console.error(fail('Proxy is not running. Start it with: ccs proxy start <profile>'));
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'));
let profile;
@@ -163,16 +179,17 @@ export async function handleProxyCommand(args: string[]): Promise<number> {
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:
+5
View File
@@ -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>): 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,
+8
View File
@@ -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<string, number>;
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,
},
+1
View File
@@ -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';
+14 -4
View File
@@ -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`
);
}
+39 -11
View File
@@ -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<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[] {
const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js');
const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts');
+88 -37
View File
@@ -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<OpenAICompatProxySession> {
running: boolean;
@@ -101,6 +104,13 @@ async function findOpenAICompatProxyPort(): Promise<number> {
return 0;
}
async function findOpenAICompatProxyPortNear(preferredPort: number): Promise<number> {
if (!(await isPortOccupied(preferredPort))) {
return preferredPort;
}
return findOpenAICompatProxyPort();
}
async function resolveDaemonEntrypoint(): Promise<string | null> {
for (const candidate of resolveOpenAICompatProxyEntrypointCandidates()) {
try {
@@ -144,21 +154,52 @@ export async function isOpenAICompatProxyRunning(port: number): Promise<boolean>
});
}
export async function getOpenAICompatProxyStatus(): Promise<OpenAICompatProxyStatus> {
const session = readOpenAICompatProxySession();
async function getOpenAICompatProxyStatusForProfile(
profileName: string
): Promise<OpenAICompatProxyStatus> {
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<OpenAICompatProxyStatus[]> {
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) {
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<StartOpenAICompatProxyResult> {
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,
+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;
}