diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 71d2c34a..de9627cb 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -92,6 +92,20 @@ src/ │ ├── unified-config-loader.ts # Central config loader (546 lines) │ └── migration-manager.ts # Config migration logic │ +├── proxy/ # OpenAI-compatible proxy runtime +│ ├── index.ts # Barrel export +│ ├── proxy-daemon-entry.ts # Daemon entrypoint +│ ├── proxy-daemon.ts # Lifecycle, health, and port binding +│ ├── proxy-port-resolver.ts # Adaptive per-profile port selection +│ ├── request-router.ts # Request-time profile/model routing +│ ├── profile-router.ts # Profile resolution helpers +│ ├── proxy-env.ts # Local runtime env construction +│ ├── routing-config.ts # Proxy routing config parsing +│ ├── upstream-url.ts # Upstream endpoint resolution +│ ├── proxy-daemon-state.ts # Persistent running-state metadata +│ ├── server/ # HTTP server and routes +│ └── transformers/ # Request and SSE translation +│ ├── channels/ # Official Claude channel integration │ ├── official-channels-runtime.ts # Runtime gating, plugin specs, setup guidance │ └── official-channels-store.ts # Claude channel token/env storage helpers @@ -216,6 +230,7 @@ src/ | Targets | `bin/`, `targets/` | Multi-CLI adapter pattern (Claude Code, Factory Droid, Codex CLI, extensible) | | Auth | `auth/`, `cliproxy/auth/` | Authentication across providers | | Config | `config/`, `types/` | Configuration & type definitions | +| OpenAI Proxy | `proxy/` | Adaptive local OpenAI-compatible proxy runtime, profile routing, and SSE transforms | | Providers | `cliproxy/`, `copilot/`, `glmt/` | Provider integrations plus retained legacy transformer internals | | Quota | `cliproxy/quota-*.ts`, `account-manager.ts` | Hybrid quota management (v7.14) | | Remote Proxy | `cliproxy/remote-*.ts`, `proxy-config-resolver.ts` | Remote CLIProxy support (v7.1) | diff --git a/docs/openai-compatible-providers.md b/docs/openai-compatible-providers.md index 7b4e1b43..b5a77043 100644 --- a/docs/openai-compatible-providers.md +++ b/docs/openai-compatible-providers.md @@ -32,7 +32,7 @@ When to use CCS: When you launch a compatible settings profile with the Claude target, CCS now: -1. Starts a local proxy on `127.0.0.1` +1. Starts a local proxy on `127.0.0.1` using the resolved local port for that profile 2. Accepts Anthropic `/v1/messages` traffic from Claude Code 3. Translates requests into OpenAI chat-completions format 4. Forwards them to your configured upstream provider @@ -72,12 +72,18 @@ Useful variants: ```bash ccs proxy start hf --host 127.0.0.1 +ccs proxy start hf --port 3460 ccs proxy activate hf ccs proxy activate --fish ccs proxy status hf ccs proxy stop hf ``` +By default, CCS picks a deterministic local port for each compatible profile +and adapts automatically when that port is unavailable. Use `--port` for a +one-off pinned port, or set `proxy.profile_ports` in config when you want a +stable reserved port per profile. + `ccs proxy activate` now prints the full local runtime contract: - `ANTHROPIC_BASE_URL` @@ -98,14 +104,15 @@ runtime as a singleton. running - When multiple proxies are running, pass the profile explicitly to `activate`, `status`, or `stop` +- `status` and `activate` always reflect the actual running port instead of an + assumed default -If you want deterministic ports, configure them in `~/.ccs/config.yaml`: +If you want to pin ports explicitly, configure them in `~/.ccs/config.yaml`: ```yaml proxy: - port: 3456 profile_ports: - hf: 3456 + hf: 3460 openai: 3461 ``` @@ -301,10 +308,12 @@ That flag is respected by both: - Add `CCS_OPENAI_PROXY_INSECURE=1` to the profile settings - Restart the proxy after changing the setting -### Port conflict on `3456` +### Need to pin or verify the local port -- Start with a fixed port: `ccs proxy start hf --port 3457` -- Re-run `ccs proxy activate` after changing the port +- Check the active binding with `ccs proxy status hf` +- Pin a one-off port with `ccs proxy start hf --port 3460` +- Reserve a stable profile port with `proxy.profile_ports` +- Re-run `ccs proxy activate hf` after changing the port ### Provider returns `429` or empty upstream output diff --git a/src/commands/proxy-command.ts b/src/commands/proxy-command.ts index 119cc90e..060bd97e 100644 --- a/src/commands/proxy-command.ts +++ b/src/commands/proxy-command.ts @@ -64,7 +64,7 @@ function showHelp(): number { console.log(' activate [profile] Print shell exports for the running proxy'); console.log(''); console.log('Options:'); - console.log(' --port Override the local proxy port (default: 3456)'); + console.log(' --port Pin an exact local proxy port (default: adaptive)'); console.log(' --host Bind the proxy server to a specific host (default: 127.0.0.1)'); console.log(' --shell activate only: auto|bash|zsh|fish|powershell'); console.log(' --fish activate only: shorthand for --shell fish'); @@ -104,7 +104,19 @@ async function handleStart(args: string[]): Promise { const portValue = parseOptionValue(args, '--port'); const host = parseOptionValue(args, '--host'); - const port = portValue ? Number.parseInt(portValue, 10) || 3456 : undefined; + const parsedPort = portValue ? Number(portValue) : undefined; + if ( + portValue && + (parsedPort === undefined || + !/^\d+$/.test(portValue) || + !Number.isInteger(parsedPort) || + parsedPort < 1 || + parsedPort > 65535) + ) { + console.error(fail(`Invalid port: ${portValue}`)); + return 1; + } + const port = parsedPort; let profile; try { profile = resolveProfile(profileName); diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index de23e6ef..6a0ed62f 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -999,7 +999,6 @@ export const DEFAULT_CLIPROXY_SERVER_CONFIG: CliproxyServerConfig = { }; export const DEFAULT_OPENAI_COMPAT_PROXY_CONFIG: OpenAICompatProxyConfig = { - port: 3456, profile_ports: {}, routing: { longContextThreshold: 60_000, diff --git a/src/proxy/proxy-daemon-entry.ts b/src/proxy/proxy-daemon-entry.ts index 0013ef7e..d66b1f34 100644 --- a/src/proxy/proxy-daemon-entry.ts +++ b/src/proxy/proxy-daemon-entry.ts @@ -1,5 +1,6 @@ import { loadSettings } from '../utils/config-manager'; import { resolveOpenAICompatProfileConfig } from './profile-router'; +import { OPENAI_COMPAT_PROXY_DEFAULT_PORT } from './proxy-daemon-paths'; import { startOpenAICompatProxyServer } from './server/proxy-server'; interface RuntimeOptions { @@ -12,7 +13,7 @@ interface RuntimeOptions { } function parseArgs(argv: string[]): RuntimeOptions { - let port = 3456; + let port = OPENAI_COMPAT_PROXY_DEFAULT_PORT; let host = '127.0.0.1'; let profileName = ''; let settingsPath = ''; diff --git a/src/proxy/proxy-daemon-paths.ts b/src/proxy/proxy-daemon-paths.ts index 7119c2e9..843e1286 100644 --- a/src/proxy/proxy-daemon-paths.ts +++ b/src/proxy/proxy-daemon-paths.ts @@ -1,7 +1,10 @@ import * as path from 'path'; import { getCcsDir } from '../utils/config-manager'; -export const OPENAI_COMPAT_PROXY_DEFAULT_PORT = 3456; +export const OPENAI_COMPAT_PROXY_LEGACY_DEFAULT_PORT = 3456; +export const OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START = 43_456; +export const OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_END = 43_555; +export const OPENAI_COMPAT_PROXY_DEFAULT_PORT = OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START; export const OPENAI_COMPAT_PROXY_SERVICE_NAME = 'ccs-openai-compat-proxy'; export function getOpenAICompatProxyDir(): string { diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index 33ee9b92..65a2e618 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -6,7 +6,9 @@ import * as lockfile from 'proper-lockfile'; import { verifyProcessOwnership } from '../cursor/daemon-process-ownership'; import type { OpenAICompatProfileConfig } from './profile-router'; import { - OPENAI_COMPAT_PROXY_DEFAULT_PORT, + OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_END, + OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START, + OPENAI_COMPAT_PROXY_LEGACY_DEFAULT_PORT, OPENAI_COMPAT_PROXY_SERVICE_NAME, getOpenAICompatProxyDir, } from './proxy-daemon-paths'; @@ -25,7 +27,10 @@ import { writeOpenAICompatProxyPid, writeOpenAICompatProxySession, } from './proxy-daemon-state'; -import { resolveOpenAICompatProxyPortPreference } from './proxy-port-resolver'; +import { + listOpenAICompatProxyCandidatePorts as listFlexibleOpenAICompatProxyCandidatePorts, + resolveOpenAICompatProxyPortPreference, +} from './proxy-port-resolver'; export interface OpenAICompatProxyStatus extends Partial { running: boolean; @@ -131,6 +136,7 @@ async function terminateDaemonProcess(pid?: number): Promise { } function listOpenAICompatProxyCandidatePorts( + profileName: string, preferredPort: number, exact: boolean, excludedPorts: ReadonlySet = new Set() @@ -139,22 +145,7 @@ function listOpenAICompatProxyCandidatePorts( return excludedPorts.has(preferredPort) ? [] : [preferredPort]; } - const candidates = new Set(); - 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 (!excludedPorts.has(candidate)) { - candidates.add(candidate); - } - } - - return [...candidates]; + return listFlexibleOpenAICompatProxyCandidatePorts(profileName, preferredPort, excludedPorts); } function isPortBindConflictMessage(message?: string): boolean { @@ -494,11 +485,15 @@ export async function startOpenAICompatProxy( const host = options.host?.trim() || status.host || '127.0.0.1'; const portPreference = resolveOpenAICompatProxyPortPreference(profile.profileName); const explicitPort = typeof options.port === 'number' ? options.port : undefined; - const preferredPort = + const rawPreferredPort = explicitPort ?? (portPreference.source === 'profile' ? portPreference.port - : status.port || portPreference.port); + : portPreference.source === 'adaptive' && + status.port === OPENAI_COMPAT_PROXY_LEGACY_DEFAULT_PORT + ? portPreference.port + : status.port || portPreference.port); + const preferredPort = rawPreferredPort; const requiresExactPort = explicitPort !== undefined || portPreference.source === 'profile'; if (status.running && status.port === preferredPort && (status.host || '127.0.0.1') === host) { return { @@ -670,6 +665,7 @@ export async function startOpenAICompatProxy( const attemptedPorts = new Set(); let lastResult: OpenAICompatProxyLaunchResult | null = null; const candidates = listOpenAICompatProxyCandidatePorts( + profile.profileName, preferredPort, requiresExactPort, attemptedPorts @@ -678,7 +674,7 @@ export async function startOpenAICompatProxy( return { success: false, port: preferredPort, - error: `No free proxy port found in range ${OPENAI_COMPAT_PROXY_DEFAULT_PORT}-${OPENAI_COMPAT_PROXY_DEFAULT_PORT + 10}`, + error: `No free proxy port found in adaptive range ${OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START}-${OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_END}`, }; } @@ -707,7 +703,7 @@ export async function startOpenAICompatProxy( port: preferredPort, error: requiresExactPort ? `Requested proxy port ${preferredPort} is already in use` - : 'No free proxy port found in the proxy port range', + : 'No free proxy port found in the adaptive proxy port range', } ); }; diff --git a/src/proxy/proxy-port-resolver.ts b/src/proxy/proxy-port-resolver.ts index 418ca721..8cbd4b00 100644 --- a/src/proxy/proxy-port-resolver.ts +++ b/src/proxy/proxy-port-resolver.ts @@ -1,9 +1,54 @@ import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; -import { OPENAI_COMPAT_PROXY_DEFAULT_PORT } from './proxy-daemon-paths'; +import { + OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_END, + OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START, +} from './proxy-daemon-paths'; export interface OpenAICompatProxyPortPreference { port: number; - source: 'default' | 'profile'; + source: 'adaptive' | 'profile' | 'shared'; +} + +const ADAPTIVE_PORT_RANGE_SIZE = + OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_END - OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START + 1; + +function hashProfileName(profileName: string): number { + let hash = 0; + for (const char of profileName.trim()) { + hash = (hash * 31 + char.charCodeAt(0)) >>> 0; + } + return hash; +} + +export function resolveOpenAICompatProxyAdaptivePort(profileName: string): number { + return ( + OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START + + (hashProfileName(profileName) % ADAPTIVE_PORT_RANGE_SIZE) + ); +} + +export function listOpenAICompatProxyCandidatePorts( + profileName: string, + preferredPort: number, + excludedPorts: ReadonlySet = new Set() +): number[] { + const candidates = new Set(); + if (!excludedPorts.has(preferredPort)) { + candidates.add(preferredPort); + } + + const adaptiveStart = resolveOpenAICompatProxyAdaptivePort(profileName); + for (let offset = 0; offset < ADAPTIVE_PORT_RANGE_SIZE; offset += 1) { + const candidate = + OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START + + ((adaptiveStart - OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START + offset) % + ADAPTIVE_PORT_RANGE_SIZE); + if (!excludedPorts.has(candidate)) { + candidates.add(candidate); + } + } + + return [...candidates]; } export function resolveOpenAICompatProxyPortPreference( @@ -14,9 +59,13 @@ export function resolveOpenAICompatProxyPortPreference( if (typeof profilePort === 'number') { return { port: profilePort, source: 'profile' }; } + const sharedPort = config.proxy?.port; + if (typeof sharedPort === 'number') { + return { port: sharedPort, source: 'shared' }; + } return { - port: config.proxy?.port ?? OPENAI_COMPAT_PROXY_DEFAULT_PORT, - source: 'default', + port: resolveOpenAICompatProxyAdaptivePort(profileName), + source: 'adaptive', }; } diff --git a/tests/e2e/proxy-command.e2e.test.ts b/tests/e2e/proxy-command.e2e.test.ts index 0e10c90b..33043dc3 100644 --- a/tests/e2e/proxy-command.e2e.test.ts +++ b/tests/e2e/proxy-command.e2e.test.ts @@ -21,6 +21,27 @@ function runCli(args: string[], extraEnv: Record = {}) { }); } +function writeJson(filePath: string, data: unknown) { + fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8'); +} + +function createProfileConfig(profiles: Record>) { + const ccsDir = path.join(tempDir, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + const configProfiles = Object.fromEntries( + Object.keys(profiles).map((name) => [name, path.join(ccsDir, `${name}.settings.json`)]) + ); + writeJson(path.join(ccsDir, 'config.json'), { profiles: configProfiles }); + for (const [name, env] of Object.entries(profiles)) { + writeJson(configProfiles[name], { env }); + } +} + +function getRunningPort(statusOutput: string) { + const match = statusOutput.match(/Local URL: http:\/\/127\.0\.0\.1:(\d+)/); + expect(match).not.toBeNull(); + return Number(match?.[1]); +} beforeAll(() => { if (process.env.CCS_E2E_SKIP_BUILD === '1') { expect(fs.existsSync(DIST_ENTRY)).toBe(true); @@ -54,6 +75,8 @@ describe('proxy command e2e', () => { expect(help.status).toBe(0); expect(help.stdout).toContain('Usage: ccs proxy [profile] [options]'); expect(help.stdout).toContain('stop [profile] Stop the running proxy (or all proxies when omitted)'); + expect(help.stdout).toContain('Pin an exact local proxy port'); + expect(help.stdout).not.toContain('default: 3456'); }); it('shows the last-known proxy state when no proxy is currently running', async () => { @@ -84,45 +107,29 @@ describe('proxy command e2e', () => { 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'); - fs.mkdirSync(ccsDir, { recursive: true }); - const settingsPath = path.join(ccsDir, 'hf.settings.json'); - fs.writeFileSync( - path.join(ccsDir, 'config.json'), - JSON.stringify({ profiles: { hf: settingsPath } }, null, 2), - 'utf8' - ); - fs.writeFileSync( - settingsPath, - JSON.stringify({ - env: { - ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', - ANTHROPIC_AUTH_TOKEN: 'ollama', - ANTHROPIC_MODEL: 'qwen3-coder', - CCS_DROID_PROVIDER: 'generic-chat-completion-api', - }, - }), - 'utf8' - ); - - const started = runCli(['proxy', 'start', 'hf', '--port', String(port), '--host', '127.0.0.1']); - expect(started.status).toBe(0); - - const status = runCli(['proxy', 'status']); + it('surfaces the actual adaptive port in status and activation output', async () => { + createProfileConfig({ + hf: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }); + expect(runCli(['proxy', 'start', 'hf', '--host', '127.0.0.1']).status).toBe(0); + const status = runCli(['proxy', 'status', 'hf']); + expect(status.status).toBe(0); + const port = getRunningPort(status.stdout); expect(status.stdout).toContain(`Proxy running on port ${port}`); expect(status.stdout).toContain('Host: 127.0.0.1'); expect(status.stdout).toContain('Profile: hf'); - - const activate = runCli(['proxy', 'activate', '--shell', 'bash']); + const activate = runCli(['proxy', 'activate', 'hf', '--shell', 'bash']); expect(activate.stdout).toContain(`export ANTHROPIC_BASE_URL='http://127.0.0.1:${port}'`); expect(activate.stdout).toMatch(/export ANTHROPIC_AUTH_TOKEN='[a-f0-9]{48}'/); expect(activate.stdout).toContain("export DISABLE_TELEMETRY='1'"); expect(activate.stdout).toContain("export DISABLE_COST_WARNINGS='1'"); expect(activate.stdout).toContain("export API_TIMEOUT_MS='600000'"); expect(activate.stdout).toContain("export NO_PROXY='127.0.0.1,localhost'"); - const activateFish = runCli(['proxy', 'activate', '--fish']); expect(activateFish.stdout).toContain(`set -gx ANTHROPIC_BASE_URL 'http://127.0.0.1:${port}'`); @@ -147,41 +154,54 @@ describe('proxy command e2e', () => { expect(stopped.status).toBe(0); }, 35000); + it('respects an explicit --port override in status output', async () => { + const port = await getPort(); + createProfileConfig({ + hf: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }); + const started = runCli(['proxy', 'start', 'hf', '--port', String(port), '--host', '127.0.0.1']); + expect(started.status).toBe(0); + const status = runCli(['proxy', 'status', 'hf']); + expect(status.status).toBe(0); + expect(status.stdout).toContain(`Proxy running on port ${port}`); + expect(status.stdout).toContain(`Local URL: http://127.0.0.1:${port}`); + }, 35000); + + it('rejects malformed explicit port values instead of coercing numeric prefixes', () => { + createProfileConfig({ + hf: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }); + + const invalid = runCli(['proxy', 'start', 'hf', '--port', '3456junk']); + expect(invalid.status).toBe(1); + expect(invalid.stderr).toContain('Invalid port: 3456junk'); + }); + 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' - ); - + createProfileConfig({ + ccg: { + 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', + }, + ccgm: { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-ccgm', + ANTHROPIC_MODEL: 'gpt-4.1', + }, + }); 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); diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts index d2a83db3..3d8e7e24 100644 --- a/tests/integration/proxy/daemon-lifecycle.test.ts +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -159,6 +159,34 @@ describe('openai proxy daemon lifecycle', () => { expect(secondHealth.status).toBe(200); }); + it('uses an adaptive implicit port instead of defaulting to 3456 for shared defaults', async () => { + const settingsPath = path.join(tempDir, 'adaptive-default.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-adaptive-default', + ANTHROPIC_MODEL: 'gpt-4.1', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('adaptive-default', settingsPath, { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-adaptive-default', + ANTHROPIC_MODEL: 'gpt-4.1', + }); + if (!profile) { + throw new Error('Expected adaptive-default OpenAI-compatible profile'); + } + + const started = await startOpenAICompatProxy(profile); + expect(started.success).toBe(true); + expect(started.port).not.toBe(3456); + }); + it('keeps a legacy singleton daemon visible across upgrade', async () => { const port = await getPort(); const settingsPath = path.join(tempDir, 'legacy.settings.json'); @@ -416,6 +444,55 @@ describe('openai proxy daemon lifecycle', () => { expect(started.port).toBe(preferredPort); }); + it('does not keep a stopped shared-default profile anchored to legacy port 3456', async () => { + const settingsPath = path.join(tempDir, 'legacy-shared-default.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-legacy-shared-default', + ANTHROPIC_MODEL: 'gpt-4.1', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('legacy-shared-default', settingsPath, { + ANTHROPIC_BASE_URL: 'https://api.openai.com/v1', + ANTHROPIC_AUTH_TOKEN: 'sk-legacy-shared-default', + ANTHROPIC_MODEL: 'gpt-4.1', + }); + if (!profile) { + throw new Error('Expected legacy-shared-default OpenAI-compatible profile'); + } + + fs.mkdirSync(path.dirname(getOpenAICompatProxySessionPath('legacy-shared-default')), { + recursive: true, + }); + fs.writeFileSync( + getOpenAICompatProxySessionPath('legacy-shared-default'), + JSON.stringify( + { + profileName: profile.profileName, + settingsPath: profile.settingsPath, + host: '127.0.0.1', + port: 3456, + 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).not.toBe(3456); + }); + 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'); diff --git a/tests/unit/proxy/proxy-port-resolver.test.ts b/tests/unit/proxy/proxy-port-resolver.test.ts index c29d66c8..39da1558 100644 --- a/tests/unit/proxy/proxy-port-resolver.test.ts +++ b/tests/unit/proxy/proxy-port-resolver.test.ts @@ -3,7 +3,10 @@ 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'; +import { + resolveOpenAICompatProxyAdaptivePort, + resolveOpenAICompatProxyPreferredPort, +} from '../../../src/proxy/proxy-port-resolver'; let originalCcsHome: string | undefined; let tempDir: string; @@ -36,7 +39,57 @@ describe('resolveOpenAICompatProxyPreferredPort', () => { expect(resolveOpenAICompatProxyPreferredPort('ccgm')).toBe(3461); }); - it('falls back to the shared default port when no profile mapping exists', () => { + it('preserves an explicit shared proxy port outside the adaptive default path', () => { + mutateUnifiedConfig((config) => { + config.proxy = { + ...(config.proxy ?? {}), + port: 45_000, + profile_ports: {}, + }; + }); + + expect(resolveOpenAICompatProxyPreferredPort('ccg')).toBe(45_000); + }); + + it('preserves an explicit shared legacy 3456 port when the user configures it', () => { + mutateUnifiedConfig((config) => { + config.proxy = { + ...(config.proxy ?? {}), + port: 3456, + profile_ports: {}, + }; + }); + expect(resolveOpenAICompatProxyPreferredPort('ccg')).toBe(3456); }); + + it('preserves an explicit shared 43456 port when the user configures it', () => { + mutateUnifiedConfig((config) => { + config.proxy = { + ...(config.proxy ?? {}), + port: 43_456, + profile_ports: {}, + }; + }); + + expect(resolveOpenAICompatProxyPreferredPort('ccg')).toBe(43_456); + }); + + it('falls back to an adaptive shared default when no profile mapping exists', () => { + const preferredPort = resolveOpenAICompatProxyPreferredPort('ccg'); + + expect(preferredPort).toBe(resolveOpenAICompatProxyAdaptivePort('ccg')); + expect(preferredPort).not.toBe(3456); + }); + + it('derives a stable adaptive default that does not keep all profiles on 3456', () => { + const first = resolveOpenAICompatProxyPreferredPort('ccg'); + const second = resolveOpenAICompatProxyPreferredPort('ccg'); + const other = resolveOpenAICompatProxyPreferredPort('ccgm'); + + expect(first).toBe(second); + expect(first).not.toBe(3456); + expect(other).not.toBe(3456); + expect(other).not.toBe(first); + }); });