mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 06:16:37 +00:00
Merge pull request #1053 from innocarpe/feat/profile-scoped-proxy-ports-pr
fix(proxy): allow openai-compatible profiles to run on separate local ports
This commit is contained in:
@@ -52,9 +52,7 @@ jobs:
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Ensure dependencies
|
||||
run: |
|
||||
[ -d node_modules ] || bun install --frozen-lockfile
|
||||
[ -d ui/node_modules ] || (cd ui && bun install --frozen-lockfile)
|
||||
run: bash scripts/ensure-deps.sh
|
||||
|
||||
- name: Run ${{ matrix.check.name }}
|
||||
run: ${{ matrix.check.cmd }}
|
||||
@@ -88,9 +86,7 @@ jobs:
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Ensure dependencies
|
||||
run: |
|
||||
[ -d node_modules ] || bun install --frozen-lockfile
|
||||
[ -d ui/node_modules ] || (cd ui && bun install --frozen-lockfile)
|
||||
run: bash scripts/ensure-deps.sh
|
||||
|
||||
- name: Build
|
||||
run: bun run build:all
|
||||
@@ -133,9 +129,7 @@ jobs:
|
||||
${{ runner.os }}-bun-
|
||||
|
||||
- name: Ensure dependencies
|
||||
run: |
|
||||
[ -d node_modules ] || bun install --frozen-lockfile
|
||||
[ -d ui/node_modules ] || (cd ui && bun install --frozen-lockfile)
|
||||
run: bash scripts/ensure-deps.sh
|
||||
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -145,3 +139,8 @@ jobs:
|
||||
|
||||
- name: Test
|
||||
run: bun run test:all
|
||||
|
||||
- name: Test CLI e2e
|
||||
env:
|
||||
CCS_E2E_SKIP_BUILD: '1'
|
||||
run: bun run test:e2e
|
||||
|
||||
@@ -41,9 +41,7 @@ jobs:
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
cd ui && bun install --frozen-lockfile
|
||||
run: bash scripts/ensure-deps.sh
|
||||
|
||||
- name: Build
|
||||
run: bun run build:all
|
||||
@@ -51,6 +49,11 @@ jobs:
|
||||
- name: Validate (typecheck + lint + format + tests)
|
||||
run: bun run validate
|
||||
|
||||
- name: Test CLI e2e
|
||||
env:
|
||||
CCS_E2E_SKIP_BUILD: '1'
|
||||
run: bun run test:e2e
|
||||
|
||||
- name: Release
|
||||
id: release
|
||||
env:
|
||||
|
||||
@@ -37,9 +37,7 @@ jobs:
|
||||
bun-version: '1.3.9'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
cd ui && bun install --frozen-lockfile
|
||||
run: bash scripts/ensure-deps.sh
|
||||
|
||||
- name: Build package
|
||||
run: bun run build:all
|
||||
@@ -47,6 +45,11 @@ jobs:
|
||||
- name: Validate (typecheck + lint + format + tests)
|
||||
run: bun run validate
|
||||
|
||||
- name: Test CLI e2e
|
||||
env:
|
||||
CCS_E2E_SKIP_BUILD: '1'
|
||||
run: bun run test:e2e
|
||||
|
||||
- name: Release
|
||||
id: release
|
||||
env:
|
||||
|
||||
+3
-1
@@ -35,6 +35,8 @@ package-lock.json
|
||||
.claude/active-plan
|
||||
.claude/agent-memory/
|
||||
.claude/plans-registry.json*
|
||||
CLAUDE.local.md
|
||||
AGENTS.override.md
|
||||
|
||||
# Logs directory
|
||||
logs/
|
||||
@@ -43,4 +45,4 @@ logs/
|
||||
.dev-release-info.json
|
||||
|
||||
# Test coverage
|
||||
ui/coverage/
|
||||
ui/coverage/
|
||||
|
||||
@@ -72,7 +72,10 @@ Useful variants:
|
||||
|
||||
```bash
|
||||
ccs proxy start hf --host 127.0.0.1
|
||||
ccs proxy activate hf
|
||||
ccs proxy activate --fish
|
||||
ccs proxy status hf
|
||||
ccs proxy stop hf
|
||||
```
|
||||
|
||||
`ccs proxy activate` now prints the full local runtime contract:
|
||||
@@ -85,16 +88,26 @@ ccs proxy activate --fish
|
||||
- `API_TIMEOUT_MS`
|
||||
- `NO_PROXY`
|
||||
|
||||
## One Active Proxy Profile
|
||||
## Multiple Active Proxy Profiles
|
||||
|
||||
The current runtime is a single local proxy daemon.
|
||||
CCS now stores OpenAI-compatible proxy state per profile instead of treating the
|
||||
runtime as a singleton.
|
||||
|
||||
- Reusing the same OpenAI-compatible profile is supported
|
||||
- Starting a different OpenAI-compatible profile while one proxy is already
|
||||
running is rejected instead of silently replacing the active upstream
|
||||
- Different compatible profiles can run at the same time on separate local ports
|
||||
- `ccs proxy activate` without a profile stays convenient when only one proxy is
|
||||
running
|
||||
- When multiple proxies are running, pass the profile explicitly to
|
||||
`activate`, `status`, or `stop`
|
||||
|
||||
This is intentional to avoid breaking an in-flight Claude session by swapping
|
||||
its upstream provider out from under it.
|
||||
If you want deterministic ports, configure them in `~/.ccs/config.yaml`:
|
||||
|
||||
```yaml
|
||||
proxy:
|
||||
port: 3456
|
||||
profile_ports:
|
||||
hf: 3456
|
||||
openai: 3461
|
||||
```
|
||||
|
||||
## Request-Time Routing
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
UI_DIR="$ROOT_DIR/ui"
|
||||
|
||||
ensure_root_deps() {
|
||||
echo "[deps] Syncing root dependencies"
|
||||
(cd "$ROOT_DIR" && bun install --frozen-lockfile)
|
||||
}
|
||||
|
||||
ensure_ui_deps() {
|
||||
echo "[deps] Syncing UI dependencies"
|
||||
(cd "$UI_DIR" && bun install --frozen-lockfile)
|
||||
}
|
||||
|
||||
ensure_root_deps
|
||||
ensure_ui_deps
|
||||
+105
-22
@@ -5,6 +5,7 @@ import { fail, info, ok } from '../utils/ui';
|
||||
import {
|
||||
buildOpenAICompatProxyEnv,
|
||||
getOpenAICompatProxyStatus,
|
||||
listOpenAICompatProxyStatuses,
|
||||
resolveOpenAICompatProfileConfig,
|
||||
startOpenAICompatProxy,
|
||||
stopOpenAICompatProxy,
|
||||
@@ -21,6 +22,34 @@ function parseOptionValue(args: string[], key: string): string | undefined {
|
||||
return withEquals ? withEquals.slice(prefix.length) : undefined;
|
||||
}
|
||||
|
||||
function hasHelpFlag(args: string[]): boolean {
|
||||
return args.includes('--help') || args.includes('-h');
|
||||
}
|
||||
|
||||
export function findPositionalArg(
|
||||
args: string[],
|
||||
optionsWithValues: string[] = [],
|
||||
flagOptions: string[] = []
|
||||
): string | undefined {
|
||||
for (let i = 0; i < args.length; i += 1) {
|
||||
const arg = args[i];
|
||||
if (arg === '--') {
|
||||
return i + 1 < args.length ? args[i + 1] : undefined;
|
||||
}
|
||||
if (arg.startsWith('-')) {
|
||||
if (flagOptions.includes(arg)) {
|
||||
continue;
|
||||
}
|
||||
if (optionsWithValues.includes(arg)) {
|
||||
i += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return arg;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function showHelp(): number {
|
||||
console.log('OpenAI-Compatible Proxy');
|
||||
console.log('');
|
||||
@@ -30,9 +59,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)');
|
||||
@@ -62,7 +91,10 @@ function resolveProfile(profileName: string) {
|
||||
}
|
||||
|
||||
async function handleStart(args: string[]): Promise<number> {
|
||||
const profileName = args.find((arg) => !arg.startsWith('-'));
|
||||
if (hasHelpFlag(args)) {
|
||||
return showHelp();
|
||||
}
|
||||
const profileName = findPositionalArg(args, ['--port', '--host'], ['--insecure']);
|
||||
if (!profileName) {
|
||||
console.error(
|
||||
fail('Usage: ccs proxy start <profile> [--port <n>] [--host <addr>] [--insecure]')
|
||||
@@ -100,31 +132,78 @@ async function handleStart(args: string[]): Promise<number> {
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function handleStatus(): Promise<number> {
|
||||
async function handleStatus(args: string[] = []): Promise<number> {
|
||||
if (hasHelpFlag(args)) {
|
||||
return showHelp();
|
||||
}
|
||||
const profileName = findPositionalArg(args);
|
||||
const printStatus = (status: Awaited<ReturnType<typeof getOpenAICompatProxyStatus>>) => {
|
||||
console.log(
|
||||
status.running
|
||||
? ok(`Proxy running on port ${status.port ?? 'unknown'}`)
|
||||
: info(`Proxy is not running${status.port ? ` (last known port ${status.port})` : ''}`)
|
||||
);
|
||||
if (status.host && status.port) {
|
||||
console.log(` Host: ${status.host}`);
|
||||
console.log(` Local URL: http://${status.host}:${status.port}`);
|
||||
}
|
||||
if (status.profileName) {
|
||||
console.log(` Profile: ${status.profileName}`);
|
||||
}
|
||||
if (status.baseUrl) {
|
||||
console.log(` Base URL: ${status.baseUrl}`);
|
||||
}
|
||||
if (status.model) {
|
||||
console.log(` Model: ${status.model}`);
|
||||
}
|
||||
if (status.pid) {
|
||||
console.log(` PID: ${status.pid}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (profileName) {
|
||||
const status = await getOpenAICompatProxyStatus(profileName);
|
||||
if (!status.running && !status.profileName) {
|
||||
console.log(info('Proxy is not running'));
|
||||
return 0;
|
||||
}
|
||||
printStatus(status);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const status = await getOpenAICompatProxyStatus();
|
||||
if (!status.running) {
|
||||
if (!status.running && !status.profileName) {
|
||||
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}`);
|
||||
if (status.running && !status.profileName) {
|
||||
const running = (await listOpenAICompatProxyStatuses()).filter((entry) => entry.running);
|
||||
for (const entry of running) {
|
||||
printStatus(entry);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
printStatus(status);
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function handleActivate(args: string[]): Promise<number> {
|
||||
const status = await getOpenAICompatProxyStatus();
|
||||
if (hasHelpFlag(args)) {
|
||||
return showHelp();
|
||||
}
|
||||
const profileName = findPositionalArg(args, ['--shell']);
|
||||
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 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;
|
||||
@@ -163,16 +242,20 @@ export async function handleProxyCommand(args: string[]): Promise<number> {
|
||||
case 'start':
|
||||
return handleStart(args.slice(1));
|
||||
case 'stop': {
|
||||
const result = await stopOpenAICompatProxy();
|
||||
if (hasHelpFlag(args.slice(1))) {
|
||||
return showHelp();
|
||||
}
|
||||
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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -8,10 +8,28 @@ export function getOpenAICompatProxyDir(): string {
|
||||
return path.join(getCcsDir(), 'proxy');
|
||||
}
|
||||
|
||||
export function getOpenAICompatProxyPidPath(): string {
|
||||
export function getOpenAICompatProxyProfileKey(profileName: string): string {
|
||||
return encodeURIComponent(profileName.trim());
|
||||
}
|
||||
|
||||
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`
|
||||
);
|
||||
}
|
||||
|
||||
export function getLegacyOpenAICompatProxyPidPath(): string {
|
||||
return path.join(getOpenAICompatProxyDir(), 'daemon.pid');
|
||||
}
|
||||
|
||||
export function getOpenAICompatProxySessionPath(): string {
|
||||
export function getLegacyOpenAICompatProxySessionPath(): string {
|
||||
return path.join(getOpenAICompatProxyDir(), 'session.json');
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
getOpenAICompatProxyDir,
|
||||
getLegacyOpenAICompatProxyPidPath,
|
||||
getLegacyOpenAICompatProxySessionPath,
|
||||
getOpenAICompatProxyPidPath,
|
||||
getOpenAICompatProxySessionPath,
|
||||
} from './proxy-daemon-paths';
|
||||
@@ -21,9 +23,9 @@ function ensureProxyDir(): void {
|
||||
fs.mkdirSync(getOpenAICompatProxyDir(), { recursive: true });
|
||||
}
|
||||
|
||||
export function getOpenAICompatProxyPid(): number | null {
|
||||
function readPid(pidPath: string): number | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(getOpenAICompatProxyPidPath(), 'utf8').trim();
|
||||
const raw = fs.readFileSync(pidPath, 'utf8').trim();
|
||||
const pid = Number.parseInt(raw, 10);
|
||||
return Number.isInteger(pid) ? pid : null;
|
||||
} catch {
|
||||
@@ -31,46 +33,113 @@ export function getOpenAICompatProxyPid(): number | null {
|
||||
}
|
||||
}
|
||||
|
||||
export function writeOpenAICompatProxyPid(pid: number): void {
|
||||
ensureProxyDir();
|
||||
fs.writeFileSync(getOpenAICompatProxyPidPath(), String(pid), 'utf8');
|
||||
export function getOpenAICompatProxyPid(profileName: string): number | null {
|
||||
return readPid(getOpenAICompatProxyPidPath(profileName));
|
||||
}
|
||||
|
||||
export function removeOpenAICompatProxyPid(): void {
|
||||
export function getLegacyOpenAICompatProxyPid(): number | null {
|
||||
return readPid(getLegacyOpenAICompatProxyPidPath());
|
||||
}
|
||||
|
||||
export function writeOpenAICompatProxyPid(profileName: string, pid: number): void {
|
||||
ensureProxyDir();
|
||||
fs.writeFileSync(getOpenAICompatProxyPidPath(profileName), String(pid), 'utf8');
|
||||
}
|
||||
|
||||
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 removeLegacyOpenAICompatProxyPid(): void {
|
||||
try {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(getOpenAICompatProxySessionPath(), 'utf8')
|
||||
) as OpenAICompatProxySession;
|
||||
fs.unlinkSync(getLegacyOpenAICompatProxyPidPath());
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
function readSession(sessionPath: string): OpenAICompatProxySession | null {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(sessionPath, 'utf8')) as OpenAICompatProxySession;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function readOpenAICompatProxySession(profileName: string): OpenAICompatProxySession | null {
|
||||
return readSession(getOpenAICompatProxySessionPath(profileName));
|
||||
}
|
||||
|
||||
export function readLegacyOpenAICompatProxySession(): OpenAICompatProxySession | null {
|
||||
return readSession(getLegacyOpenAICompatProxySessionPath());
|
||||
}
|
||||
|
||||
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 removeLegacyOpenAICompatProxySession(): void {
|
||||
try {
|
||||
fs.unlinkSync(getLegacyOpenAICompatProxySessionPath());
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
function decodeProfileKey(profileKey: string): string[] {
|
||||
try {
|
||||
return [decodeURIComponent(profileKey)];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function listProfileNamesForSuffix(suffix: string, legacyName?: string): string[] {
|
||||
try {
|
||||
const entries = fs.readdirSync(getOpenAICompatProxyDir(), { withFileTypes: true });
|
||||
const profileKeys = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || entry.name === legacyName || !entry.name.endsWith(suffix)) {
|
||||
continue;
|
||||
}
|
||||
const profileKey = entry.name.slice(0, -suffix.length);
|
||||
if (!profileKey) {
|
||||
continue;
|
||||
}
|
||||
profileKeys.add(profileKey);
|
||||
}
|
||||
return [...profileKeys].flatMap(decodeProfileKey);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function listOpenAICompatProxyProfileNames(): string[] {
|
||||
return [
|
||||
...new Set([
|
||||
...listProfileNamesForSuffix('.session.json', 'session.json'),
|
||||
...listProfileNamesForSuffix('.daemon.pid', 'daemon.pid'),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
export function resolveOpenAICompatProxyEntrypointCandidates(): string[] {
|
||||
const jsEntry = path.join(__dirname, 'proxy-daemon-entry.js');
|
||||
const tsEntry = path.join(__dirname, 'proxy-daemon-entry.ts');
|
||||
|
||||
+512
-164
@@ -2,7 +2,6 @@ import { spawn, type ChildProcess } from 'child_process';
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as http from 'http';
|
||||
import * as net from 'net';
|
||||
import * as lockfile from 'proper-lockfile';
|
||||
import { verifyProcessOwnership } from '../cursor/daemon-process-ownership';
|
||||
import type { OpenAICompatProfileConfig } from './profile-router';
|
||||
@@ -12,8 +11,13 @@ import {
|
||||
getOpenAICompatProxyDir,
|
||||
} from './proxy-daemon-paths';
|
||||
import {
|
||||
getLegacyOpenAICompatProxyPid,
|
||||
getOpenAICompatProxyPid,
|
||||
listOpenAICompatProxyProfileNames,
|
||||
readLegacyOpenAICompatProxySession,
|
||||
readOpenAICompatProxySession,
|
||||
removeLegacyOpenAICompatProxyPid,
|
||||
removeLegacyOpenAICompatProxySession,
|
||||
removeOpenAICompatProxyPid,
|
||||
removeOpenAICompatProxySession,
|
||||
resolveOpenAICompatProxyEntrypointCandidates,
|
||||
@@ -21,6 +25,7 @@ import {
|
||||
writeOpenAICompatProxyPid,
|
||||
writeOpenAICompatProxySession,
|
||||
} from './proxy-daemon-state';
|
||||
import { resolveOpenAICompatProxyPortPreference } from './proxy-port-resolver';
|
||||
|
||||
export interface OpenAICompatProxyStatus extends Partial<OpenAICompatProxySession> {
|
||||
running: boolean;
|
||||
@@ -36,6 +41,37 @@ export interface StartOpenAICompatProxyResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface OpenAICompatProxyLaunchResult extends StartOpenAICompatProxyResult {
|
||||
commitState?: () => void;
|
||||
stop?: () => Promise<void>;
|
||||
bindConflict?: boolean;
|
||||
}
|
||||
|
||||
interface OpenAICompatProxyHealthPayload {
|
||||
service?: string;
|
||||
profile?: string;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function verifyProxyProcessOwnership(
|
||||
pid: number,
|
||||
profileName?: string
|
||||
): ReturnType<typeof verifyProcessOwnership> {
|
||||
const profilePattern = profileName
|
||||
? new RegExp(`(^|\\s)--profile(?:\\s+|=)${escapeRegExp(profileName)}(?=\\s|$)`)
|
||||
: null;
|
||||
return verifyProcessOwnership(
|
||||
pid,
|
||||
(commandLine) =>
|
||||
commandLine.includes('--ccs-openai-proxy-daemon') &&
|
||||
commandLine.includes('proxy-daemon-entry') &&
|
||||
(!profilePattern || profilePattern.test(commandLine))
|
||||
);
|
||||
}
|
||||
|
||||
function generateProxyAuthToken(): string {
|
||||
return crypto.randomBytes(24).toString('hex');
|
||||
}
|
||||
@@ -70,35 +106,73 @@ async function withOpenAICompatProxyLock<T>(operation: () => Promise<T>): Promis
|
||||
}
|
||||
}
|
||||
|
||||
async function isPortOccupied(port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.createConnection({ host: '127.0.0.1', port });
|
||||
const finish = (occupied: boolean) => {
|
||||
socket.removeAllListeners();
|
||||
socket.destroy();
|
||||
resolve(occupied);
|
||||
};
|
||||
async function terminateDaemonProcess(pid?: number): Promise<void> {
|
||||
if (!pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.once('connect', () => finish(true));
|
||||
socket.once('error', () => finish(false));
|
||||
socket.setTimeout(500, () => {
|
||||
finish(false);
|
||||
});
|
||||
});
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
let attempts = 0;
|
||||
while (attempts < 10) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
attempts += 1;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Best-effort cleanup for a daemon we just spawned.
|
||||
}
|
||||
}
|
||||
|
||||
async function findOpenAICompatProxyPort(): Promise<number> {
|
||||
function listOpenAICompatProxyCandidatePorts(
|
||||
preferredPort: number,
|
||||
exact: boolean,
|
||||
excludedPorts: ReadonlySet<number> = new Set()
|
||||
): number[] {
|
||||
if (exact) {
|
||||
return excludedPorts.has(preferredPort) ? [] : [preferredPort];
|
||||
}
|
||||
|
||||
const candidates = new Set<number>();
|
||||
if (!excludedPorts.has(preferredPort)) {
|
||||
candidates.add(preferredPort);
|
||||
}
|
||||
|
||||
for (
|
||||
let candidate = OPENAI_COMPAT_PROXY_DEFAULT_PORT;
|
||||
candidate <= OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10;
|
||||
candidate += 1
|
||||
) {
|
||||
if (!(await isPortOccupied(candidate))) {
|
||||
return candidate;
|
||||
if (!excludedPorts.has(candidate)) {
|
||||
candidates.add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
function isPortBindConflictMessage(message?: string): boolean {
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
const normalized = message.toLowerCase();
|
||||
return (
|
||||
message.includes('EADDRINUSE') ||
|
||||
normalized.includes('address already in use') ||
|
||||
(normalized.includes('is port') && normalized.includes('in use'))
|
||||
);
|
||||
}
|
||||
|
||||
interface OpenAICompatProxyStateRecord {
|
||||
pid: number | null;
|
||||
session: OpenAICompatProxySession | null;
|
||||
source: 'profile' | 'legacy';
|
||||
}
|
||||
|
||||
async function resolveDaemonEntrypoint(): Promise<string | null> {
|
||||
@@ -113,7 +187,10 @@ async function resolveDaemonEntrypoint(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function isOpenAICompatProxyRunning(port: number): Promise<boolean> {
|
||||
export async function isOpenAICompatProxyRunning(
|
||||
port: number,
|
||||
expectedProfileName?: string
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const req = http.request(
|
||||
{ hostname: '127.0.0.1', port, path: '/health', method: 'GET', timeout: 3000 },
|
||||
@@ -127,8 +204,11 @@ export async function isOpenAICompatProxyRunning(port: number): Promise<boolean>
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(body) as { service?: string };
|
||||
resolve(payload.service === OPENAI_COMPAT_PROXY_SERVICE_NAME);
|
||||
const payload = JSON.parse(body) as OpenAICompatProxyHealthPayload;
|
||||
resolve(
|
||||
payload.service === OPENAI_COMPAT_PROXY_SERVICE_NAME &&
|
||||
(!expectedProfileName || payload.profile === expectedProfileName)
|
||||
);
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
@@ -144,33 +224,115 @@ export async function isOpenAICompatProxyRunning(port: number): Promise<boolean>
|
||||
});
|
||||
}
|
||||
|
||||
export async function getOpenAICompatProxyStatus(): Promise<OpenAICompatProxyStatus> {
|
||||
const session = readOpenAICompatProxySession();
|
||||
const port = session?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT;
|
||||
const running = await isOpenAICompatProxyRunning(port);
|
||||
async function getOpenAICompatProxyStatusForProfile(
|
||||
profileName: string
|
||||
): Promise<OpenAICompatProxyStatus> {
|
||||
const state = getOpenAICompatProxyStateForProfile(profileName);
|
||||
const session = state.session;
|
||||
if (!session) {
|
||||
return { running: false, profileName, pid: state.pid || undefined };
|
||||
}
|
||||
|
||||
const port = session.port;
|
||||
const running = await isOpenAICompatProxyRunning(port, profileName);
|
||||
return {
|
||||
running,
|
||||
pid: running ? getOpenAICompatProxyPid() || undefined : undefined,
|
||||
pid: running ? state.pid || undefined : undefined,
|
||||
...session,
|
||||
};
|
||||
}
|
||||
|
||||
async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; error?: string }> {
|
||||
const pid = getOpenAICompatProxyPid();
|
||||
async function getLegacyOpenAICompatProxyStatus(): Promise<OpenAICompatProxyStatus | null> {
|
||||
const session = readLegacyOpenAICompatProxySession();
|
||||
const pid = getLegacyOpenAICompatProxyPid();
|
||||
if (!session && !pid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const port = session?.port;
|
||||
const running =
|
||||
typeof port === 'number' ? await isOpenAICompatProxyRunning(port, session?.profileName) : false;
|
||||
return {
|
||||
running,
|
||||
pid: running ? pid || undefined : pid || undefined,
|
||||
...session,
|
||||
};
|
||||
}
|
||||
|
||||
function getOpenAICompatProxyStateForProfile(profileName: string): OpenAICompatProxyStateRecord {
|
||||
const session = readOpenAICompatProxySession(profileName);
|
||||
if (session) {
|
||||
return {
|
||||
pid: getOpenAICompatProxyPid(profileName),
|
||||
session,
|
||||
source: 'profile',
|
||||
};
|
||||
}
|
||||
|
||||
const legacySession = readLegacyOpenAICompatProxySession();
|
||||
if (legacySession?.profileName === profileName) {
|
||||
return {
|
||||
pid: getLegacyOpenAICompatProxyPid(),
|
||||
session: legacySession,
|
||||
source: 'legacy',
|
||||
};
|
||||
}
|
||||
|
||||
return { pid: null, session: null, source: 'profile' };
|
||||
}
|
||||
|
||||
export async function listOpenAICompatProxyStatuses(): Promise<OpenAICompatProxyStatus[]> {
|
||||
const profileNames = new Set(listOpenAICompatProxyProfileNames());
|
||||
const legacySession = readLegacyOpenAICompatProxySession();
|
||||
if (legacySession?.profileName) {
|
||||
profileNames.add(legacySession.profileName);
|
||||
}
|
||||
const profileStatuses = await Promise.all(
|
||||
[...profileNames].map((profileName) => getOpenAICompatProxyStatusForProfile(profileName))
|
||||
);
|
||||
const statuses = profileStatuses.filter((status) => status.profileName);
|
||||
if (!legacySession?.profileName) {
|
||||
const legacyStatus = await getLegacyOpenAICompatProxyStatus();
|
||||
if (legacyStatus) {
|
||||
statuses.push(legacyStatus);
|
||||
}
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
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 state = getOpenAICompatProxyStateForProfile(profileName);
|
||||
const pid = state.pid;
|
||||
if (!pid) {
|
||||
removeOpenAICompatProxySession();
|
||||
removeOpenAICompatProxyState(state, profileName);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const ownership = verifyProcessOwnership(
|
||||
pid,
|
||||
(commandLine) =>
|
||||
commandLine.includes('--ccs-openai-proxy-daemon') &&
|
||||
commandLine.includes('proxy-daemon-entry')
|
||||
);
|
||||
const ownership = verifyProxyProcessOwnership(pid, profileName);
|
||||
|
||||
if (ownership === 'not-owned') {
|
||||
removeOpenAICompatProxyPid();
|
||||
removeOpenAICompatProxyState(state, profileName);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -182,8 +344,7 @@ async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; erro
|
||||
}
|
||||
|
||||
if (ownership === 'not-running') {
|
||||
removeOpenAICompatProxyPid();
|
||||
removeOpenAICompatProxySession();
|
||||
removeOpenAICompatProxyState(state, profileName);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -214,13 +375,114 @@ async function stopOpenAICompatProxyUnlocked(): Promise<{ success: boolean; erro
|
||||
}
|
||||
}
|
||||
|
||||
removeOpenAICompatProxyPid();
|
||||
removeOpenAICompatProxySession();
|
||||
removeOpenAICompatProxyState(state, profileName);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
export async function stopOpenAICompatProxy(): Promise<{ success: boolean; error?: string }> {
|
||||
return withOpenAICompatProxyLock(() => stopOpenAICompatProxyUnlocked());
|
||||
async function stopLegacyOpenAICompatProxyUnlocked(): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
const legacySession = readLegacyOpenAICompatProxySession();
|
||||
if (legacySession?.profileName) {
|
||||
return stopOpenAICompatProxyUnlocked(legacySession.profileName);
|
||||
}
|
||||
|
||||
const pid = getLegacyOpenAICompatProxyPid();
|
||||
if (!pid) {
|
||||
removeLegacyOpenAICompatProxyPid();
|
||||
removeLegacyOpenAICompatProxySession();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const ownership = verifyProxyProcessOwnership(pid);
|
||||
|
||||
if (ownership === 'not-owned' || ownership === 'not-running') {
|
||||
removeLegacyOpenAICompatProxyPid();
|
||||
removeLegacyOpenAICompatProxySession();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
if (ownership === 'unknown') {
|
||||
return {
|
||||
success: false,
|
||||
error: `Refusing to stop PID ${pid}: unable to verify daemon ownership`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(pid, 'SIGTERM');
|
||||
let attempts = 0;
|
||||
while (attempts < 10) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
attempts += 1;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (attempts >= 10) {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
} catch {
|
||||
// Already exited.
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const err = error as NodeJS.ErrnoException;
|
||||
if (err.code !== 'ESRCH') {
|
||||
return { success: false, error: `Failed to stop daemon: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
removeLegacyOpenAICompatProxyPid();
|
||||
removeLegacyOpenAICompatProxySession();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function removeOpenAICompatProxyState(
|
||||
state: OpenAICompatProxyStateRecord,
|
||||
profileName: string
|
||||
): void {
|
||||
if (state.source === 'legacy') {
|
||||
removeLegacyOpenAICompatProxyPid();
|
||||
removeLegacyOpenAICompatProxySession();
|
||||
return;
|
||||
}
|
||||
|
||||
removeOpenAICompatProxyPid(profileName);
|
||||
removeOpenAICompatProxySession(profileName);
|
||||
}
|
||||
|
||||
export async function stopOpenAICompatProxy(
|
||||
profileName?: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
return withOpenAICompatProxyLock(async () => {
|
||||
if (profileName) {
|
||||
return stopOpenAICompatProxyUnlocked(profileName);
|
||||
}
|
||||
|
||||
const statuses = await listOpenAICompatProxyStatuses();
|
||||
const failures: string[] = [];
|
||||
for (const status of statuses) {
|
||||
const stopped = status.profileName
|
||||
? await stopOpenAICompatProxyUnlocked(status.profileName)
|
||||
: await stopLegacyOpenAICompatProxyUnlocked();
|
||||
if (!stopped.success) {
|
||||
failures.push(
|
||||
status.profileName
|
||||
? `${status.profileName}: ${stopped.error || 'failed to stop proxy'}`
|
||||
: `legacy proxy: ${stopped.error || 'failed to stop proxy'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
return { success: false, error: `Failed to stop some proxies: ${failures.join('; ')}` };
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
export async function startOpenAICompatProxy(
|
||||
@@ -228,53 +490,35 @@ export async function startOpenAICompatProxy(
|
||||
options: { port?: number; host?: string; insecure?: boolean } = {}
|
||||
): Promise<StartOpenAICompatProxyResult> {
|
||||
return withOpenAICompatProxyLock(async () => {
|
||||
const status = await getOpenAICompatProxyStatus();
|
||||
const status = await getOpenAICompatProxyStatus(profile.profileName);
|
||||
const host = options.host?.trim() || status.host || '127.0.0.1';
|
||||
const port =
|
||||
typeof options.port === 'number'
|
||||
? options.port
|
||||
: status.running && status.profileName === profile.profileName && status.port
|
||||
? status.port
|
||||
: await findOpenAICompatProxyPort();
|
||||
if (port === 0) {
|
||||
return {
|
||||
success: false,
|
||||
port: OPENAI_COMPAT_PROXY_DEFAULT_PORT,
|
||||
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
|
||||
) {
|
||||
const portPreference = resolveOpenAICompatProxyPortPreference(profile.profileName);
|
||||
const explicitPort = typeof options.port === 'number' ? options.port : undefined;
|
||||
const preferredPort =
|
||||
explicitPort ??
|
||||
(portPreference.source === 'profile'
|
||||
? portPreference.port
|
||||
: status.port || portPreference.port);
|
||||
const requiresExactPort = explicitPort !== undefined || portPreference.source === 'profile';
|
||||
if (status.running && status.port === preferredPort && (status.host || '127.0.0.1') === host) {
|
||||
return {
|
||||
success: true,
|
||||
alreadyRunning: true,
|
||||
pid: status.pid,
|
||||
port,
|
||||
port: preferredPort,
|
||||
authToken: status.authToken,
|
||||
};
|
||||
}
|
||||
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();
|
||||
if (!Number.isInteger(preferredPort) || preferredPort < 1 || preferredPort > 65535) {
|
||||
return { success: false, port: preferredPort, error: `Invalid port: ${preferredPort}` };
|
||||
}
|
||||
if (status.pid && !status.port) {
|
||||
const stopped = await stopOpenAICompatProxyUnlocked(profile.profileName);
|
||||
if (!stopped.success) {
|
||||
return {
|
||||
success: false,
|
||||
port,
|
||||
error: stopped.error || 'Failed to restart the running proxy',
|
||||
port: preferredPort,
|
||||
error: stopped.error || 'Failed to clear stale proxy state',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -283,107 +527,211 @@ export async function startOpenAICompatProxy(
|
||||
if (!daemonEntry) {
|
||||
return {
|
||||
success: false,
|
||||
port,
|
||||
port: preferredPort,
|
||||
error: 'OpenAI proxy daemon entrypoint not found. Run `bun run build` and retry.',
|
||||
};
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
const authToken = generateProxyAuthToken();
|
||||
|
||||
const finish = (result: StartOpenAICompatProxyResult) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (!result.success) {
|
||||
removeOpenAICompatProxyPid();
|
||||
removeOpenAICompatProxySession();
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const proc: ChildProcess = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
daemonEntry,
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
host,
|
||||
'--profile',
|
||||
profile.profileName,
|
||||
'--settings-path',
|
||||
profile.settingsPath,
|
||||
'--auth-token',
|
||||
authToken,
|
||||
...(options.insecure ? ['--insecure'] : []),
|
||||
'--ccs-openai-proxy-daemon',
|
||||
],
|
||||
{ stdio: 'ignore', detached: true }
|
||||
);
|
||||
|
||||
proc.unref();
|
||||
if (proc.pid) {
|
||||
writeOpenAICompatProxyPid(proc.pid);
|
||||
}
|
||||
writeOpenAICompatProxySession({
|
||||
profileName: profile.profileName,
|
||||
settingsPath: profile.settingsPath,
|
||||
host,
|
||||
port,
|
||||
baseUrl: profile.baseUrl,
|
||||
authToken,
|
||||
model: profile.model,
|
||||
insecure: options.insecure,
|
||||
});
|
||||
|
||||
let attempts = 0;
|
||||
const poll = async () => {
|
||||
attempts += 1;
|
||||
if (await isOpenAICompatProxyRunning(port)) {
|
||||
finish({ success: true, pid: proc.pid, port, authToken });
|
||||
return;
|
||||
}
|
||||
if (attempts >= 30) {
|
||||
finish({
|
||||
success: false,
|
||||
const launchOnPort = (
|
||||
port: number,
|
||||
persistState: boolean
|
||||
): Promise<OpenAICompatProxyLaunchResult> =>
|
||||
new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
let stderr = '';
|
||||
const authToken = generateProxyAuthToken();
|
||||
const commitState = () => {
|
||||
if (proc.pid) {
|
||||
writeOpenAICompatProxyPid(profile.profileName, proc.pid);
|
||||
}
|
||||
writeOpenAICompatProxySession({
|
||||
profileName: profile.profileName,
|
||||
settingsPath: profile.settingsPath,
|
||||
host,
|
||||
port,
|
||||
error: `Proxy daemon did not start within 30 seconds on port ${port}`,
|
||||
baseUrl: profile.baseUrl,
|
||||
authToken,
|
||||
model: profile.model,
|
||||
insecure: options.insecure,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const finish = (result: OpenAICompatProxyLaunchResult) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
if (timeout) clearTimeout(timeout);
|
||||
if (!result.success && persistState) {
|
||||
removeOpenAICompatProxyPid(profile.profileName);
|
||||
removeOpenAICompatProxySession(profile.profileName);
|
||||
}
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const proc: ChildProcess = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
daemonEntry,
|
||||
'--port',
|
||||
String(port),
|
||||
'--host',
|
||||
host,
|
||||
'--profile',
|
||||
profile.profileName,
|
||||
'--settings-path',
|
||||
profile.settingsPath,
|
||||
'--auth-token',
|
||||
authToken,
|
||||
...(options.insecure ? ['--insecure'] : []),
|
||||
'--ccs-openai-proxy-daemon',
|
||||
],
|
||||
{ stdio: ['ignore', 'ignore', 'pipe'], detached: true }
|
||||
);
|
||||
|
||||
proc.unref();
|
||||
proc.stderr?.setEncoding('utf8');
|
||||
proc.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
let attempts = 0;
|
||||
const poll = async () => {
|
||||
attempts += 1;
|
||||
if (await isOpenAICompatProxyRunning(port, profile.profileName)) {
|
||||
if (persistState) {
|
||||
commitState();
|
||||
}
|
||||
finish({
|
||||
success: true,
|
||||
pid: proc.pid,
|
||||
port,
|
||||
authToken,
|
||||
...(persistState
|
||||
? {}
|
||||
: {
|
||||
commitState,
|
||||
stop: async () => {
|
||||
await terminateDaemonProcess(proc.pid);
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (attempts >= 30) {
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: `Proxy daemon did not start within 30 seconds on port ${port}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
timeout = setTimeout(poll, 1000);
|
||||
};
|
||||
|
||||
timeout = setTimeout(poll, 1000);
|
||||
};
|
||||
|
||||
timeout = setTimeout(poll, 1000);
|
||||
proc.on('error', (error) => {
|
||||
finish({ success: false, port, error: error.message });
|
||||
});
|
||||
proc.on('exit', (code, signal) => {
|
||||
if (code === 0) {
|
||||
proc.on('error', (error) => {
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: 'Proxy daemon exited before becoming healthy',
|
||||
error: error.message,
|
||||
bindConflict: isPortBindConflictMessage(error.message),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (code !== null) {
|
||||
});
|
||||
proc.on('exit', (code, signal) => {
|
||||
const bindConflict = isPortBindConflictMessage(stderr);
|
||||
if (code === 0) {
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: 'Proxy daemon exited before becoming healthy',
|
||||
bindConflict,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (code !== null) {
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: stderr.trim() || `Proxy daemon exited with code ${code}`,
|
||||
bindConflict,
|
||||
});
|
||||
return;
|
||||
}
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: `Proxy daemon exited with code ${code}`,
|
||||
error: `Proxy daemon was killed by signal ${signal}`,
|
||||
bindConflict,
|
||||
});
|
||||
return;
|
||||
}
|
||||
finish({
|
||||
success: false,
|
||||
port,
|
||||
error: `Proxy daemon was killed by signal ${signal}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const launchProxy = async (persistState: boolean): Promise<OpenAICompatProxyLaunchResult> => {
|
||||
const attemptedPorts = new Set<number>();
|
||||
let lastResult: OpenAICompatProxyLaunchResult | null = null;
|
||||
const candidates = listOpenAICompatProxyCandidatePorts(
|
||||
preferredPort,
|
||||
requiresExactPort,
|
||||
attemptedPorts
|
||||
);
|
||||
if (candidates.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
port: preferredPort,
|
||||
error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`,
|
||||
};
|
||||
}
|
||||
|
||||
for (const port of candidates) {
|
||||
const result = await launchOnPort(port, persistState);
|
||||
if (result.success) {
|
||||
return result;
|
||||
}
|
||||
if (requiresExactPort && result.bindConflict) {
|
||||
return {
|
||||
...result,
|
||||
error: `Requested proxy port ${preferredPort} is already in use`,
|
||||
};
|
||||
}
|
||||
|
||||
lastResult = result;
|
||||
attemptedPorts.add(port);
|
||||
if (!result.bindConflict) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
lastResult ?? {
|
||||
success: false,
|
||||
port: preferredPort,
|
||||
error: requiresExactPort
|
||||
? `Requested proxy port ${preferredPort} is already in use`
|
||||
: 'No free proxy port found in the proxy port range',
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
if (status.running) {
|
||||
const launched = await launchProxy(false);
|
||||
if (!launched.success) {
|
||||
return launched;
|
||||
}
|
||||
|
||||
const stopped = await stopOpenAICompatProxyUnlocked(profile.profileName);
|
||||
if (!stopped.success) {
|
||||
await launched.stop?.();
|
||||
return {
|
||||
success: false,
|
||||
port: launched.port,
|
||||
error: stopped.error || 'Failed to replace the running proxy',
|
||||
};
|
||||
}
|
||||
|
||||
launched.commitState?.();
|
||||
return launched;
|
||||
}
|
||||
|
||||
return launchProxy(true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader';
|
||||
import { OPENAI_COMPAT_PROXY_DEFAULT_PORT } from './proxy-daemon-paths';
|
||||
|
||||
export interface OpenAICompatProxyPortPreference {
|
||||
port: number;
|
||||
source: 'default' | 'profile';
|
||||
}
|
||||
|
||||
export function resolveOpenAICompatProxyPortPreference(
|
||||
profileName: string
|
||||
): OpenAICompatProxyPortPreference {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
const profilePort = config.proxy?.profile_ports?.[profileName];
|
||||
if (typeof profilePort === 'number') {
|
||||
return { port: profilePort, source: 'profile' };
|
||||
}
|
||||
return {
|
||||
port: config.proxy?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT,
|
||||
source: 'default',
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveOpenAICompatProxyPreferredPort(profileName: string): number {
|
||||
return resolveOpenAICompatProxyPortPreference(profileName).port;
|
||||
}
|
||||
@@ -22,6 +22,10 @@ function runCli(args: string[], extraEnv: Record<string, string> = {}) {
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
if (process.env.CCS_E2E_SKIP_BUILD === '1') {
|
||||
expect(fs.existsSync(DIST_ENTRY)).toBe(true);
|
||||
return;
|
||||
}
|
||||
const result = spawnSync(process.execPath, ['run', 'build'], {
|
||||
encoding: 'utf8',
|
||||
env: process.env,
|
||||
@@ -45,6 +49,41 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('proxy command e2e', () => {
|
||||
it('shows help for subcommand help flags without executing the subcommand', () => {
|
||||
const help = runCli(['proxy', 'stop', '--help']);
|
||||
expect(help.status).toBe(0);
|
||||
expect(help.stdout).toContain('Usage: ccs proxy <start|stop|status|activate> [profile] [options]');
|
||||
expect(help.stdout).toContain('stop [profile] Stop the running proxy (or all proxies when omitted)');
|
||||
});
|
||||
|
||||
it('shows the last-known proxy state when no proxy is currently running', async () => {
|
||||
const stalePort = await getPort();
|
||||
const proxyDir = path.join(tempDir, '.ccs', 'proxy');
|
||||
fs.mkdirSync(proxyDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(proxyDir, 'stale.session.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
profileName: 'stale',
|
||||
settingsPath: path.join(tempDir, '.ccs', 'stale.settings.json'),
|
||||
host: '127.0.0.1',
|
||||
port: stalePort,
|
||||
baseUrl: 'http://127.0.0.1:11434',
|
||||
authToken: 'deadbeef',
|
||||
model: 'qwen3-coder',
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const status = runCli(['proxy', 'status']);
|
||||
expect(status.status).toBe(0);
|
||||
expect(status.stdout).toContain(`Proxy is not running (last known port ${stalePort})`);
|
||||
expect(status.stdout).toContain('Profile: stale');
|
||||
});
|
||||
|
||||
it('starts, reports status, activates, and stops via the built CLI', async () => {
|
||||
const port = await getPort();
|
||||
const ccsDir = path.join(tempDir, '.ccs');
|
||||
@@ -107,4 +146,50 @@ describe('proxy command e2e', () => {
|
||||
const stopped = runCli(['proxy', 'stop']);
|
||||
expect(stopped.status).toBe(0);
|
||||
}, 35000);
|
||||
|
||||
it('requires an explicit profile when activating with multiple running proxies', async () => {
|
||||
const firstPort = await getPort();
|
||||
const ccsDir = path.join(tempDir, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
const firstSettingsPath = path.join(ccsDir, 'ccg.settings.json');
|
||||
const secondSettingsPath = path.join(ccsDir, 'ccgm.settings.json');
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.json'),
|
||||
JSON.stringify({ profiles: { ccg: firstSettingsPath, ccgm: secondSettingsPath } }, null, 2),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
firstSettingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-ccg',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
secondSettingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-ccgm',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
expect(runCli(['proxy', 'start', 'ccg', '--port', String(firstPort)]).status).toBe(0);
|
||||
const secondPort = await getPort();
|
||||
expect(runCli(['proxy', 'start', 'ccgm', '--port', String(secondPort)]).status).toBe(0);
|
||||
|
||||
const activate = runCli(['proxy', 'activate', '--shell', 'bash']);
|
||||
expect(activate.status).toBe(1);
|
||||
expect(activate.stderr).toContain(
|
||||
'Multiple proxies are running. Specify a profile: ccs proxy activate <profile>'
|
||||
);
|
||||
}, 35000);
|
||||
});
|
||||
|
||||
@@ -5,10 +5,18 @@ import * as path from 'path';
|
||||
import getPort from 'get-port';
|
||||
import {
|
||||
getOpenAICompatProxyStatus,
|
||||
listOpenAICompatProxyStatuses,
|
||||
startOpenAICompatProxy,
|
||||
stopOpenAICompatProxy,
|
||||
} from '../../../src/proxy/proxy-daemon';
|
||||
import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-router';
|
||||
import {
|
||||
getLegacyOpenAICompatProxyPidPath,
|
||||
getLegacyOpenAICompatProxySessionPath,
|
||||
getOpenAICompatProxyPidPath,
|
||||
getOpenAICompatProxySessionPath,
|
||||
} from '../../../src/proxy/proxy-daemon-paths';
|
||||
import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempDir: string;
|
||||
@@ -80,7 +88,7 @@ describe('openai proxy daemon lifecycle', () => {
|
||||
expect((await getOpenAICompatProxyStatus()).running).toBe(false);
|
||||
}, 35000);
|
||||
|
||||
it('refuses to replace a running proxy for a different profile', async () => {
|
||||
it('allows different profiles to run on different ports', async () => {
|
||||
const firstPort = await getPort();
|
||||
const firstSettingsPath = path.join(tempDir, 'hf.settings.json');
|
||||
fs.writeFileSync(
|
||||
@@ -108,7 +116,20 @@ describe('openai proxy daemon lifecycle', () => {
|
||||
const firstStart = await startOpenAICompatProxy(firstProfile, { port: firstPort });
|
||||
expect(firstStart.success).toBe(true);
|
||||
|
||||
const secondProfile = resolveOpenAICompatProfileConfig('openai', path.join(tempDir, 'openai.settings.json'), {
|
||||
const secondPort = await getPort();
|
||||
const secondSettingsPath = path.join(tempDir, 'openai.settings.json');
|
||||
fs.writeFileSync(
|
||||
secondSettingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-openai',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
const secondProfile = resolveOpenAICompatProfileConfig('openai', secondSettingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-openai',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
@@ -117,11 +138,514 @@ describe('openai proxy daemon lifecycle', () => {
|
||||
throw new Error('Expected second OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const secondStart = await startOpenAICompatProxy(secondProfile, { port: await getPort() });
|
||||
expect(secondStart.success).toBe(false);
|
||||
expect(secondStart.error).toContain('Proxy already running for profile "hf"');
|
||||
const secondStart = await startOpenAICompatProxy(secondProfile, { port: secondPort });
|
||||
expect(secondStart.success).toBe(true);
|
||||
expect(secondStart.port).toBe(secondPort);
|
||||
|
||||
const health = await fetch(`http://127.0.0.1:${firstPort}/health`);
|
||||
expect(health.status).toBe(200);
|
||||
|
||||
const secondHealth = await fetch(`http://127.0.0.1:${secondPort}/health`);
|
||||
expect(secondHealth.status).toBe(200);
|
||||
});
|
||||
|
||||
it('keeps a legacy singleton daemon visible across upgrade', async () => {
|
||||
const port = await getPort();
|
||||
const settingsPath = path.join(tempDir, 'legacy.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-legacy',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile = resolveOpenAICompatProfileConfig('legacy', settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-legacy',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
if (!profile) {
|
||||
throw new Error('Expected a legacy OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const started = await startOpenAICompatProxy(profile, { port });
|
||||
expect(started.success).toBe(true);
|
||||
expect(started.pid).toBeDefined();
|
||||
|
||||
const proxyDir = path.dirname(getLegacyOpenAICompatProxyPidPath());
|
||||
fs.writeFileSync(getLegacyOpenAICompatProxyPidPath(), String(started.pid), 'utf8');
|
||||
fs.writeFileSync(
|
||||
getLegacyOpenAICompatProxySessionPath(),
|
||||
JSON.stringify(
|
||||
{
|
||||
profileName: profile.profileName,
|
||||
settingsPath: profile.settingsPath,
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
baseUrl: profile.baseUrl,
|
||||
authToken: started.authToken,
|
||||
model: profile.model,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
fs.rmSync(path.join(proxyDir, 'legacy.daemon.pid'), { force: true });
|
||||
fs.rmSync(path.join(proxyDir, 'legacy.session.json'), { force: true });
|
||||
|
||||
const status = await getOpenAICompatProxyStatus('legacy');
|
||||
expect(status.running).toBe(true);
|
||||
expect(status.port).toBe(port);
|
||||
|
||||
const restarted = await startOpenAICompatProxy(profile);
|
||||
expect(restarted.success).toBe(true);
|
||||
expect(restarted.alreadyRunning).toBe(true);
|
||||
expect(restarted.port).toBe(port);
|
||||
|
||||
const stopped = await stopOpenAICompatProxy('legacy');
|
||||
expect(stopped.success).toBe(true);
|
||||
expect(fs.existsSync(getLegacyOpenAICompatProxyPidPath())).toBe(false);
|
||||
expect(fs.existsSync(getLegacyOpenAICompatProxySessionPath())).toBe(false);
|
||||
}, 35000);
|
||||
|
||||
it('fails when an explicit port is already occupied', async () => {
|
||||
const occupiedPort = await getPort();
|
||||
const server = Bun.serve({
|
||||
port: occupiedPort,
|
||||
hostname: '127.0.0.1',
|
||||
fetch: () => new Response('busy'),
|
||||
});
|
||||
|
||||
try {
|
||||
const settingsPath = path.join(tempDir, 'occupied-explicit.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-explicit',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile = resolveOpenAICompatProfileConfig('explicit', settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-explicit',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
if (!profile) {
|
||||
throw new Error('Expected an explicit-port OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const started = await startOpenAICompatProxy(profile, { port: occupiedPort });
|
||||
expect(started.success).toBe(false);
|
||||
expect(started.port).toBe(occupiedPort);
|
||||
expect(started.error).toContain(`Requested proxy port ${occupiedPort} is already in use`);
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('fails when a configured profile port is already occupied', async () => {
|
||||
const occupiedPort = await getPort();
|
||||
const server = Bun.serve({
|
||||
port: occupiedPort,
|
||||
hostname: '127.0.0.1',
|
||||
fetch: () => new Response('busy'),
|
||||
});
|
||||
|
||||
try {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.proxy = {
|
||||
...(config.proxy ?? {}),
|
||||
profile_ports: { mapped: occupiedPort },
|
||||
};
|
||||
});
|
||||
|
||||
const settingsPath = path.join(tempDir, 'occupied-mapped.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-mapped',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile = resolveOpenAICompatProfileConfig('mapped', settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-mapped',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
if (!profile) {
|
||||
throw new Error('Expected a mapped-port OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const started = await startOpenAICompatProxy(profile);
|
||||
expect(started.success).toBe(false);
|
||||
expect(started.port).toBe(occupiedPort);
|
||||
expect(started.error).toContain(`Requested proxy port ${occupiedPort} is already in use`);
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the existing proxy running if replacement startup fails', async () => {
|
||||
const firstPort = await getPort();
|
||||
const occupiedPort = await getPort();
|
||||
const busyServer = Bun.serve({
|
||||
port: occupiedPort,
|
||||
hostname: '127.0.0.1',
|
||||
fetch: () => new Response('busy'),
|
||||
});
|
||||
|
||||
try {
|
||||
const settingsPath = path.join(tempDir, 'rollback.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-rollback',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile = resolveOpenAICompatProfileConfig('rollback', settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-rollback',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
if (!profile) {
|
||||
throw new Error('Expected a rollback OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const firstStart = await startOpenAICompatProxy(profile, { port: firstPort });
|
||||
expect(firstStart.success).toBe(true);
|
||||
|
||||
const restarted = await startOpenAICompatProxy(profile, { port: occupiedPort });
|
||||
expect(restarted.success).toBe(false);
|
||||
|
||||
const status = await getOpenAICompatProxyStatus('rollback');
|
||||
expect(status.running).toBe(true);
|
||||
expect(status.port).toBe(firstPort);
|
||||
expect((await fetch(`http://127.0.0.1:${firstPort}/health`)).status).toBe(200);
|
||||
} finally {
|
||||
busyServer.stop(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('reuses the last-known port even when it is outside the default fallback range', async () => {
|
||||
const preferredPort = await getPort();
|
||||
const settingsPath = path.join(tempDir, 'outside-range.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-outside-range',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile = resolveOpenAICompatProfileConfig('outside-range', settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-outside-range',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
if (!profile) {
|
||||
throw new Error('Expected an outside-range OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(getOpenAICompatProxySessionPath('outside-range')), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
getOpenAICompatProxySessionPath('outside-range'),
|
||||
JSON.stringify(
|
||||
{
|
||||
profileName: profile.profileName,
|
||||
settingsPath: profile.settingsPath,
|
||||
host: '127.0.0.1',
|
||||
port: preferredPort,
|
||||
baseUrl: profile.baseUrl,
|
||||
authToken: 'stale-token',
|
||||
model: profile.model,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const started = await startOpenAICompatProxy(profile);
|
||||
expect(started.success).toBe(true);
|
||||
expect(started.port).toBe(preferredPort);
|
||||
});
|
||||
|
||||
it('stops legacy daemons even when the legacy session is missing a profile name', async () => {
|
||||
const port = await getPort();
|
||||
const settingsPath = path.join(tempDir, 'legacy-missing-profile.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-legacy-missing-profile',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile = resolveOpenAICompatProfileConfig('legacy-missing-profile', settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
|
||||
ANTHROPIC_AUTH_TOKEN: 'ollama-legacy-missing-profile',
|
||||
ANTHROPIC_MODEL: 'qwen3-coder',
|
||||
CCS_DROID_PROVIDER: 'generic-chat-completion-api',
|
||||
});
|
||||
if (!profile) {
|
||||
throw new Error('Expected a legacy fallback OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const started = await startOpenAICompatProxy(profile, { port });
|
||||
expect(started.success).toBe(true);
|
||||
expect(started.pid).toBeDefined();
|
||||
|
||||
const proxyDir = path.dirname(getLegacyOpenAICompatProxyPidPath());
|
||||
fs.writeFileSync(getLegacyOpenAICompatProxyPidPath(), String(started.pid), 'utf8');
|
||||
fs.writeFileSync(
|
||||
getLegacyOpenAICompatProxySessionPath(),
|
||||
JSON.stringify(
|
||||
{
|
||||
settingsPath: profile.settingsPath,
|
||||
host: '127.0.0.1',
|
||||
port,
|
||||
baseUrl: profile.baseUrl,
|
||||
authToken: started.authToken,
|
||||
model: profile.model,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
fs.rmSync(path.join(proxyDir, 'legacy-missing-profile.daemon.pid'), { force: true });
|
||||
fs.rmSync(path.join(proxyDir, 'legacy-missing-profile.session.json'), { force: true });
|
||||
|
||||
const statuses = await listOpenAICompatProxyStatuses();
|
||||
expect(statuses.some((status) => status.port === port)).toBe(true);
|
||||
|
||||
const stopped = await stopOpenAICompatProxy();
|
||||
expect(stopped.success).toBe(true);
|
||||
expect(fs.existsSync(getLegacyOpenAICompatProxyPidPath())).toBe(false);
|
||||
expect(fs.existsSync(getLegacyOpenAICompatProxySessionPath())).toBe(false);
|
||||
}, 35000);
|
||||
|
||||
it('keeps the requested profile name in stopped status results', async () => {
|
||||
const status = await getOpenAICompatProxyStatus('never-started');
|
||||
expect(status.running).toBe(false);
|
||||
expect(status.profileName).toBe('never-started');
|
||||
});
|
||||
|
||||
it('does not treat another profile on the same port as already running', async () => {
|
||||
const sharedPort = await getPort();
|
||||
const firstSettingsPath = path.join(tempDir, 'profile-b.settings.json');
|
||||
fs.writeFileSync(
|
||||
firstSettingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-profile-b',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const firstProfile = resolveOpenAICompatProfileConfig('profile-b', firstSettingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-profile-b',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
});
|
||||
if (!firstProfile) {
|
||||
throw new Error('Expected first OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const firstStart = await startOpenAICompatProxy(firstProfile, { port: sharedPort });
|
||||
expect(firstStart.success).toBe(true);
|
||||
|
||||
const secondSettingsPath = path.join(tempDir, 'profile-a.settings.json');
|
||||
fs.writeFileSync(
|
||||
secondSettingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-profile-a',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const secondProfile = resolveOpenAICompatProfileConfig('profile-a', secondSettingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-profile-a',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
});
|
||||
if (!secondProfile) {
|
||||
throw new Error('Expected second OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
getOpenAICompatProxySessionPath('profile-a'),
|
||||
JSON.stringify(
|
||||
{
|
||||
profileName: 'profile-a',
|
||||
settingsPath: secondProfile.settingsPath,
|
||||
host: '127.0.0.1',
|
||||
port: sharedPort,
|
||||
baseUrl: secondProfile.baseUrl,
|
||||
authToken: 'stale-token-a',
|
||||
model: secondProfile.model,
|
||||
},
|
||||
null,
|
||||
2
|
||||
) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const secondStart = await startOpenAICompatProxy(secondProfile);
|
||||
expect(secondStart.success).toBe(true);
|
||||
expect(secondStart.alreadyRunning).not.toBe(true);
|
||||
expect(secondStart.authToken).not.toBe('stale-token-a');
|
||||
});
|
||||
|
||||
it('replaces pid-only proxy state before starting a new daemon', async () => {
|
||||
const firstPort = await getPort();
|
||||
const replacementPort = await getPort();
|
||||
const settingsPath = path.join(tempDir, 'pid-only.settings.json');
|
||||
fs.writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-pid-only',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const profile = resolveOpenAICompatProfileConfig('pid-only', settingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-pid-only',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
});
|
||||
if (!profile) {
|
||||
throw new Error('Expected pid-only OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const firstStart = await startOpenAICompatProxy(profile, { port: firstPort });
|
||||
expect(firstStart.success).toBe(true);
|
||||
expect(firstStart.pid).toBeDefined();
|
||||
|
||||
fs.unlinkSync(getOpenAICompatProxySessionPath('pid-only'));
|
||||
|
||||
const replacement = await startOpenAICompatProxy(profile, { port: replacementPort });
|
||||
expect(replacement.success).toBe(true);
|
||||
expect(replacement.port).toBe(replacementPort);
|
||||
expect(replacement.pid).toBeDefined();
|
||||
expect(replacement.pid).not.toBe(firstStart.pid);
|
||||
|
||||
const stalePidPath = getOpenAICompatProxyPidPath('pid-only');
|
||||
expect(fs.readFileSync(stalePidPath, 'utf8').trim()).toBe(String(replacement.pid));
|
||||
expect((await fetch(`http://127.0.0.1:${replacementPort}/health`)).status).toBe(200);
|
||||
});
|
||||
|
||||
it('does not stop another profile when a stale pid file points at its daemon', async () => {
|
||||
const firstPort = await getPort();
|
||||
const secondPort = await getPort();
|
||||
const firstSettingsPath = path.join(tempDir, 'profile-b-stale-pid.settings.json');
|
||||
fs.writeFileSync(
|
||||
firstSettingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-profile-b-stale-pid',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
const firstProfile = resolveOpenAICompatProfileConfig('profile-b-stale-pid', firstSettingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-profile-b-stale-pid',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
});
|
||||
if (!firstProfile) {
|
||||
throw new Error('Expected first OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
const firstStart = await startOpenAICompatProxy(firstProfile, { port: firstPort });
|
||||
expect(firstStart.success).toBe(true);
|
||||
expect(firstStart.pid).toBeDefined();
|
||||
|
||||
const secondSettingsPath = path.join(tempDir, 'profile-a-stale-pid.settings.json');
|
||||
fs.writeFileSync(
|
||||
secondSettingsPath,
|
||||
JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-profile-a-stale-pid',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
},
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
const secondProfile = resolveOpenAICompatProfileConfig('profile-a-stale-pid', secondSettingsPath, {
|
||||
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
|
||||
ANTHROPIC_AUTH_TOKEN: 'sk-profile-a-stale-pid',
|
||||
ANTHROPIC_MODEL: 'gpt-4.1',
|
||||
});
|
||||
if (!secondProfile) {
|
||||
throw new Error('Expected second OpenAI-compatible profile');
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
getOpenAICompatProxyPidPath('profile-a-stale-pid'),
|
||||
String(firstStart.pid),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const secondStart = await startOpenAICompatProxy(secondProfile, { port: secondPort });
|
||||
expect(secondStart.success).toBe(true);
|
||||
expect(secondStart.port).toBe(secondPort);
|
||||
expect((await fetch(`http://127.0.0.1:${firstPort}/health`)).status).toBe(200);
|
||||
expect((await fetch(`http://127.0.0.1:${secondPort}/health`)).status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { findPositionalArg } from '../../../src/commands/proxy-command';
|
||||
|
||||
describe('findPositionalArg', () => {
|
||||
it('skips option values before returning the first positional argument', () => {
|
||||
expect(findPositionalArg(['--port', '3456', 'ccg'], ['--port'])).toBe('ccg');
|
||||
});
|
||||
|
||||
it('skips flag options that do not take values', () => {
|
||||
expect(findPositionalArg(['--insecure', 'ccg'], ['--port', '--host'], ['--insecure'])).toBe(
|
||||
'ccg'
|
||||
);
|
||||
});
|
||||
|
||||
it('treats arguments after -- as positional', () => {
|
||||
expect(findPositionalArg(['--', '--port', '3456'], ['--port'])).toBe('--port');
|
||||
});
|
||||
|
||||
it('returns undefined when -- is the final argument', () => {
|
||||
expect(findPositionalArg(['--'], ['--port'])).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
getLegacyOpenAICompatProxySessionPath,
|
||||
getOpenAICompatProxyPidPath,
|
||||
getOpenAICompatProxySessionPath,
|
||||
} from '../../../src/proxy/proxy-daemon-paths';
|
||||
import { listOpenAICompatProxyProfileNames } from '../../../src/proxy/proxy-daemon-state';
|
||||
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-state-'));
|
||||
process.env.CCS_HOME = tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('listOpenAICompatProxyProfileNames', () => {
|
||||
it('ignores the legacy singleton session file', () => {
|
||||
fs.mkdirSync(path.dirname(getLegacyOpenAICompatProxySessionPath()), { recursive: true });
|
||||
fs.writeFileSync(getLegacyOpenAICompatProxySessionPath(), '{}\n', 'utf8');
|
||||
fs.writeFileSync(getOpenAICompatProxySessionPath('ccg'), '{}\n', 'utf8');
|
||||
|
||||
expect(listOpenAICompatProxyProfileNames()).toEqual(['ccg']);
|
||||
});
|
||||
|
||||
it('skips malformed percent-encoded profile keys', () => {
|
||||
fs.mkdirSync(path.dirname(getLegacyOpenAICompatProxySessionPath()), { recursive: true });
|
||||
fs.writeFileSync(path.join(path.dirname(getLegacyOpenAICompatProxySessionPath()), '%E0%A4%.session.json'), '{}\n', 'utf8');
|
||||
fs.writeFileSync(getOpenAICompatProxySessionPath('ccg'), '{}\n', 'utf8');
|
||||
|
||||
expect(listOpenAICompatProxyProfileNames()).toEqual(['ccg']);
|
||||
});
|
||||
|
||||
it('includes pid-only profile state in the discovered profile list', () => {
|
||||
fs.mkdirSync(path.dirname(getLegacyOpenAICompatProxySessionPath()), { recursive: true });
|
||||
fs.writeFileSync(getOpenAICompatProxyPidPath('ccg'), '123\n', 'utf8');
|
||||
|
||||
expect(listOpenAICompatProxyProfileNames()).toEqual(['ccg']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
import { resolveOpenAICompatProxyPreferredPort } from '../../../src/proxy/proxy-port-resolver';
|
||||
|
||||
let originalCcsHome: string | undefined;
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-config-'));
|
||||
process.env.CCS_HOME = tempDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('resolveOpenAICompatProxyPreferredPort', () => {
|
||||
it('returns the configured profile-scoped port when present', () => {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.proxy = {
|
||||
...(config.proxy ?? {}),
|
||||
port: 3456,
|
||||
profile_ports: { ccgm: 3461 },
|
||||
};
|
||||
});
|
||||
|
||||
expect(resolveOpenAICompatProxyPreferredPort('ccgm')).toBe(3461);
|
||||
});
|
||||
|
||||
it('falls back to the shared default port when no profile mapping exists', () => {
|
||||
expect(resolveOpenAICompatProxyPreferredPort('ccg')).toBe(3456);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user