diff --git a/docs/openai-compatible-providers.md b/docs/openai-compatible-providers.md index b5a77043..a874d227 100644 --- a/docs/openai-compatible-providers.md +++ b/docs/openai-compatible-providers.md @@ -79,10 +79,16 @@ 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. +Port selection precedence is: + +1. CLI `--port` for an exact one-off pin +2. `proxy.profile_ports[profile]` for an exact per-profile pin +3. `proxy.port` for a shared preferred starting port +4. adaptive per-profile fallback when nothing is pinned + +Legacy shared `proxy.port: 3456` values are treated as unset so older configs +move onto the adaptive path instead of staying on the hot legacy default. If +you need an exact `3456` binding now, pin it via `--port` or `proxy.profile_ports`. `ccs proxy activate` now prints the full local runtime contract: @@ -107,10 +113,11 @@ runtime as a singleton. - `status` and `activate` always reflect the actual running port instead of an assumed default -If you want to pin ports explicitly, configure them in `~/.ccs/config.yaml`: +If you want to pin or guide ports explicitly, configure them in `~/.ccs/config.yaml`: ```yaml proxy: + port: 45000 profile_ports: hf: 3460 openai: 3461 diff --git a/src/commands/proxy-command.ts b/src/commands/proxy-command.ts index 060bd97e..258e5526 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 Pin an exact local proxy port (default: adaptive)'); + console.log(' --port Pin an exact local proxy port for this launch'); 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'); diff --git a/src/proxy/proxy-daemon.ts b/src/proxy/proxy-daemon.ts index 65a2e618..a96caf85 100644 --- a/src/proxy/proxy-daemon.ts +++ b/src/proxy/proxy-daemon.ts @@ -8,7 +8,6 @@ import type { OpenAICompatProfileConfig } from './profile-router'; import { 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'; @@ -485,16 +484,24 @@ 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 rawPreferredPort = - explicitPort ?? - (portPreference.source === 'profile' - ? 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 && + explicitPort === undefined && + portPreference.source !== 'profile' && + status.port && + (status.host || '127.0.0.1') === host + ) { + return { + success: true, + alreadyRunning: true, + pid: status.pid, + port: status.port, + authToken: status.authToken, + }; + } + + const preferredPort = explicitPort ?? portPreference.port; if (status.running && status.port === preferredPort && (status.host || '127.0.0.1') === host) { return { success: true, diff --git a/src/proxy/proxy-port-resolver.ts b/src/proxy/proxy-port-resolver.ts index 8cbd4b00..cd7cd9a3 100644 --- a/src/proxy/proxy-port-resolver.ts +++ b/src/proxy/proxy-port-resolver.ts @@ -2,6 +2,7 @@ import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; import { OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_END, OPENAI_COMPAT_PROXY_ADAPTIVE_PORT_START, + OPENAI_COMPAT_PROXY_LEGACY_DEFAULT_PORT, } from './proxy-daemon-paths'; export interface OpenAICompatProxyPortPreference { @@ -61,6 +62,12 @@ export function resolveOpenAICompatProxyPortPreference( } const sharedPort = config.proxy?.port; if (typeof sharedPort === 'number') { + if (sharedPort === OPENAI_COMPAT_PROXY_LEGACY_DEFAULT_PORT) { + return { + port: resolveOpenAICompatProxyAdaptivePort(profileName), + source: 'adaptive', + }; + } return { port: sharedPort, source: 'shared' }; } return { diff --git a/tests/e2e/proxy-command.e2e.test.ts b/tests/e2e/proxy-command.e2e.test.ts index 33043dc3..4d287221 100644 --- a/tests/e2e/proxy-command.e2e.test.ts +++ b/tests/e2e/proxy-command.e2e.test.ts @@ -75,7 +75,7 @@ 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).toContain('Pin an exact local proxy port for this launch'); expect(help.stdout).not.toContain('default: 3456'); }); diff --git a/tests/integration/proxy/daemon-lifecycle.test.ts b/tests/integration/proxy/daemon-lifecycle.test.ts index 3d8e7e24..0667c254 100644 --- a/tests/integration/proxy/daemon-lifecycle.test.ts +++ b/tests/integration/proxy/daemon-lifecycle.test.ts @@ -9,6 +9,7 @@ import { startOpenAICompatProxy, stopOpenAICompatProxy, } from '../../../src/proxy/proxy-daemon'; +import { resolveOpenAICompatProxyPreferredPort } from '../../../src/proxy/proxy-port-resolver'; import { resolveOpenAICompatProfileConfig } from '../../../src/proxy/profile-router'; import { getLegacyOpenAICompatProxyPidPath, @@ -345,6 +346,54 @@ describe('openai proxy daemon lifecycle', () => { } }); + it('falls back when a configured shared proxy.port is 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 ?? {}), + port: occupiedPort, + }; + }); + + const settingsPath = path.join(tempDir, 'shared-port.settings.json'); + fs.writeFileSync( + settingsPath, + JSON.stringify({ + env: { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-shared-port', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }, + }), + 'utf8' + ); + + const profile = resolveOpenAICompatProfileConfig('shared-port', settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', + ANTHROPIC_AUTH_TOKEN: 'ollama-shared-port', + ANTHROPIC_MODEL: 'qwen3-coder', + CCS_DROID_PROVIDER: 'generic-chat-completion-api', + }); + if (!profile) { + throw new Error('Expected shared-port OpenAI-compatible profile'); + } + + const started = await startOpenAICompatProxy(profile); + expect(started.success).toBe(true); + expect(started.port).not.toBe(occupiedPort); + } finally { + server.stop(true); + } + }); + it('keeps the existing proxy running if replacement startup fails', async () => { const firstPort = await getPort(); const occupiedPort = await getPort(); @@ -394,8 +443,8 @@ describe('openai proxy daemon lifecycle', () => { } }); - it('reuses the last-known port even when it is outside the default fallback range', async () => { - const preferredPort = await getPort(); + it('returns to the adaptive canonical port after a stale fallback session', async () => { + const stalePort = await getPort(); const settingsPath = path.join(tempDir, 'outside-range.settings.json'); fs.writeFileSync( settingsPath, @@ -428,7 +477,7 @@ describe('openai proxy daemon lifecycle', () => { profileName: profile.profileName, settingsPath: profile.settingsPath, host: '127.0.0.1', - port: preferredPort, + port: stalePort, baseUrl: profile.baseUrl, authToken: 'stale-token', model: profile.model, @@ -441,7 +490,8 @@ describe('openai proxy daemon lifecycle', () => { const started = await startOpenAICompatProxy(profile); expect(started.success).toBe(true); - expect(started.port).toBe(preferredPort); + expect(started.port).toBe(resolveOpenAICompatProxyPreferredPort('outside-range')); + expect(started.port).not.toBe(stalePort); }); it('does not keep a stopped shared-default profile anchored to legacy port 3456', async () => { diff --git a/tests/unit/proxy/proxy-port-resolver.test.ts b/tests/unit/proxy/proxy-port-resolver.test.ts index 39da1558..6ca2ae1c 100644 --- a/tests/unit/proxy/proxy-port-resolver.test.ts +++ b/tests/unit/proxy/proxy-port-resolver.test.ts @@ -5,6 +5,7 @@ import * as path from 'path'; import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader'; import { resolveOpenAICompatProxyAdaptivePort, + resolveOpenAICompatProxyPortPreference, resolveOpenAICompatProxyPreferredPort, } from '../../../src/proxy/proxy-port-resolver'; @@ -39,7 +40,7 @@ describe('resolveOpenAICompatProxyPreferredPort', () => { expect(resolveOpenAICompatProxyPreferredPort('ccgm')).toBe(3461); }); - it('preserves an explicit shared proxy port outside the adaptive default path', () => { + it('returns a configured shared proxy port as a shared preference', () => { mutateUnifiedConfig((config) => { config.proxy = { ...(config.proxy ?? {}), @@ -48,10 +49,13 @@ describe('resolveOpenAICompatProxyPreferredPort', () => { }; }); - expect(resolveOpenAICompatProxyPreferredPort('ccg')).toBe(45_000); + expect(resolveOpenAICompatProxyPortPreference('ccg')).toEqual({ + port: 45_000, + source: 'shared', + }); }); - it('preserves an explicit shared legacy 3456 port when the user configures it', () => { + it('treats legacy shared 3456 as an adaptive default on read', () => { mutateUnifiedConfig((config) => { config.proxy = { ...(config.proxy ?? {}), @@ -60,7 +64,10 @@ describe('resolveOpenAICompatProxyPreferredPort', () => { }; }); - expect(resolveOpenAICompatProxyPreferredPort('ccg')).toBe(3456); + expect(resolveOpenAICompatProxyPortPreference('ccg')).toEqual({ + port: resolveOpenAICompatProxyAdaptivePort('ccg'), + source: 'adaptive', + }); }); it('preserves an explicit shared 43456 port when the user configures it', () => { @@ -72,14 +79,20 @@ describe('resolveOpenAICompatProxyPreferredPort', () => { }; }); - expect(resolveOpenAICompatProxyPreferredPort('ccg')).toBe(43_456); + expect(resolveOpenAICompatProxyPortPreference('ccg')).toEqual({ + port: 43_456, + source: 'shared', + }); }); it('falls back to an adaptive shared default when no profile mapping exists', () => { - const preferredPort = resolveOpenAICompatProxyPreferredPort('ccg'); + const portPreference = resolveOpenAICompatProxyPortPreference('ccg'); - expect(preferredPort).toBe(resolveOpenAICompatProxyAdaptivePort('ccg')); - expect(preferredPort).not.toBe(3456); + expect(portPreference).toEqual({ + port: resolveOpenAICompatProxyAdaptivePort('ccg'), + source: 'adaptive', + }); + expect(resolveOpenAICompatProxyPreferredPort('ccg')).not.toBe(3456); }); it('derives a stable adaptive default that does not keep all profiles on 3456', () => {