Skip to content

Commit 5373a1e

Browse files
Serhii DziupinSerhii Dziupin
andcommitted
feat: bind Command Code proxy on a dynamic free port
Prefer 8797, then scan upward (or OS ephemeral) when busy. Env OPENCODE_COMMANDCODE_PROXY_PORT still pins an exact port. Start the proxy before seeding provider baseURL so OpenCode hits the live listener. Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
1 parent 7bdba6d commit 5373a1e

6 files changed

Lines changed: 98 additions & 47 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ Laguna S 2.1 free requires an active Go (or higher) account with credits on file
104104
```text
105105
OpenCode
106106
└─ /v1/chat/completions
107-
└─ Bun.serve proxy (port 8797)
107+
└─ Bun.serve proxy (dynamic port, prefer 8797)
108108
└─ POST https://api.commandcode.ai/alpha/generate
109109
└─ poolside/laguna-s-2.1-free (default)
110110
```
@@ -132,7 +132,7 @@ Debug logging: `OPENCODE_COMMANDCODE_DEBUG=1`.
132132

133133
Optional knobs:
134134

135-
- `OPENCODE_COMMANDCODE_PROXY_PORT` — fixed local proxy port (default `8797`; must match static config)
135+
- `OPENCODE_COMMANDCODE_PROXY_PORT` — pin a fixed local proxy port (otherwise dynamic: prefer `8797`, then scan upward / ephemeral)
136136
- `OPENCODE_COMMANDCODE_CWD` — working directory reported to the gateway
137137
- `COMMANDCODE_API_URL` / `OPENCODE_COMMANDCODE_API_URL` — override API base (default `https://api.commandcode.ai`)
138138

‎src/index.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -289,9 +289,7 @@ export const CommandCodePlugin: Plugin = async (
289289

290290
return {
291291
async config(config) {
292-
// Always seed Laguna catalog so the provider is discoverable pre-login.
293-
ensureProviderConfig(config as Record<string, any>, getCommandModels());
294-
292+
// Bind proxy first so provider baseURL matches the actual listening port.
295293
await startProxy(async () => {
296294
try {
297295
const authClient = input.client.auth as {
@@ -311,6 +309,9 @@ export const CommandCodePlugin: Plugin = async (
311309
const synced = syncCommandCodeCredentialsToOpenCode();
312310
return synced?.access ?? null;
313311
});
312+
313+
// Always seed Laguna catalog so the provider is discoverable pre-login.
314+
ensureProviderConfig(config as Record<string, any>, getCommandModels());
314315
},
315316

316317
"chat.headers": async (hookInput, output) => {

‎src/proxy.ts‎

Lines changed: 68 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -50,14 +50,16 @@ import {
5050
} from "./usage.js";
5151

5252
const DEFAULT_PROXY_PORT = 8797;
53+
const PROXY_PORT_SCAN_ATTEMPTS = 40;
5354
const SHARED_PROXY_HEALTH_TIMEOUT_MS = 750;
5455

55-
function configuredProxyPort(): number {
56+
/** Explicit pin via env, or `null` → pick a free port dynamically. */
57+
function envProxyPort(): number | null {
5658
const raw = process.env.OPENCODE_COMMANDCODE_PROXY_PORT;
5759
const parsed = raw ? Number(raw) : NaN;
5860
return Number.isInteger(parsed) && parsed > 0 && parsed < 65536
5961
? parsed
60-
: DEFAULT_PROXY_PORT;
62+
: null;
6163
}
6264

6365
const SSE_HEADERS = {
@@ -93,11 +95,12 @@ export function setStreamGenerateForTests(
9395
}
9496

9597
export function getCommandProxyBaseUrl(): string {
96-
return `http://127.0.0.1:${proxyPort ?? configuredProxyPort()}/v1`;
98+
const port = proxyPort ?? envProxyPort() ?? DEFAULT_PROXY_PORT;
99+
return `http://127.0.0.1:${port}/v1`;
97100
}
98101

99102
export function getProxyPort(): number | null {
100-
return proxyPort ?? configuredProxyPort();
103+
return proxyPort ?? envProxyPort();
101104
}
102105

103106
function isAddrInUseError(err: unknown): boolean {
@@ -111,19 +114,16 @@ function isAddrInUseError(err: unknown): boolean {
111114
);
112115
}
113116

114-
async function isSharedProxyHealthy(): Promise<boolean> {
117+
async function isProxyHealthyOn(port: number): Promise<boolean> {
115118
const controller = new AbortController();
116119
const timeout = setTimeout(
117120
() => controller.abort(),
118121
SHARED_PROXY_HEALTH_TIMEOUT_MS,
119122
);
120123
try {
121-
const res = await fetch(
122-
`http://127.0.0.1:${configuredProxyPort()}/v1/models`,
123-
{
124-
signal: controller.signal,
125-
},
126-
);
124+
const res = await fetch(`http://127.0.0.1:${port}/v1/models`, {
125+
signal: controller.signal,
126+
});
127127
if (!res.ok) return false;
128128
const body = (await res.json().catch(() => undefined)) as
129129
| { object?: unknown; data?: unknown }
@@ -136,44 +136,76 @@ async function isSharedProxyHealthy(): Promise<boolean> {
136136
}
137137
}
138138

139+
async function bindProxy(port: number): Promise<number> {
140+
const hostname = "127.0.0.1";
141+
server = Bun.serve({
142+
hostname,
143+
port,
144+
async fetch(req) {
145+
return handleRequest(req);
146+
},
147+
});
148+
proxyPort = server.port ?? port;
149+
log.info(
150+
`[opencode-commandcode] proxy listening on ${getCommandProxyBaseUrl()}`,
151+
);
152+
return proxyPort;
153+
}
154+
155+
/**
156+
* Start the local OpenAI-compatible proxy.
157+
*
158+
* Port selection:
159+
* - `OPENCODE_COMMANDCODE_PROXY_PORT` → bind/reuse that exact port
160+
* - otherwise prefer `8797`, then scan upward, then fall back to OS ephemeral (`0`)
161+
*/
139162
export async function startProxy(tokenProvider: TokenProvider): Promise<number> {
140163
getAccessToken = tokenProvider;
141164
if (server && proxyPort) return proxyPort;
142165

143-
const desiredPort = configuredProxyPort();
166+
const fixed = envProxyPort();
167+
if (fixed !== null) {
168+
if (await isProxyHealthyOn(fixed)) {
169+
proxyPort = fixed;
170+
log.info(
171+
`[opencode-commandcode] reusing healthy proxy on ${getCommandProxyBaseUrl()}`,
172+
);
173+
return proxyPort;
174+
}
175+
try {
176+
return await bindProxy(fixed);
177+
} catch (err) {
178+
if (isAddrInUseError(err) && (await isProxyHealthyOn(fixed))) {
179+
proxyPort = fixed;
180+
log.info(
181+
`[opencode-commandcode] port ${fixed} in use; reusing existing proxy`,
182+
);
183+
return proxyPort;
184+
}
185+
throw err;
186+
}
187+
}
144188

145-
if (await isSharedProxyHealthy()) {
146-
proxyPort = desiredPort;
189+
// Dynamic: reuse preferred default when a healthy proxy is already there.
190+
if (await isProxyHealthyOn(DEFAULT_PROXY_PORT)) {
191+
proxyPort = DEFAULT_PROXY_PORT;
147192
log.info(
148193
`[opencode-commandcode] reusing healthy proxy on ${getCommandProxyBaseUrl()}`,
149194
);
150195
return proxyPort;
151196
}
152197

153-
const hostname = "127.0.0.1";
154-
try {
155-
server = Bun.serve({
156-
hostname,
157-
port: desiredPort,
158-
async fetch(req) {
159-
return handleRequest(req);
160-
},
161-
});
162-
proxyPort = server.port ?? desiredPort;
163-
log.info(
164-
`[opencode-commandcode] proxy listening on ${getCommandProxyBaseUrl()}`,
165-
);
166-
return proxyPort;
167-
} catch (err) {
168-
if (isAddrInUseError(err) && (await isSharedProxyHealthy())) {
169-
proxyPort = desiredPort;
170-
log.info(
171-
`[opencode-commandcode] port ${desiredPort} in use; reusing existing proxy`,
172-
);
173-
return proxyPort;
198+
for (let i = 0; i < PROXY_PORT_SCAN_ATTEMPTS; i++) {
199+
const candidate = DEFAULT_PROXY_PORT + i;
200+
try {
201+
return await bindProxy(candidate);
202+
} catch (err) {
203+
if (!isAddrInUseError(err)) throw err;
174204
}
175-
throw err;
176205
}
206+
207+
// Last resort — let the OS pick an ephemeral port.
208+
return await bindProxy(0);
177209
}
178210

179211
export async function stopProxy(): Promise<void> {

‎test/live-laguna.ts‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,6 @@ function sseAssistantText(sse: string): string {
7171
}
7272

7373
async function main() {
74-
// Avoid colliding with an OpenCode CLI proxy already bound to :8797.
75-
process.env.OPENCODE_COMMANDCODE_PROXY_PORT ||= "8798";
76-
7774
const apiKey = requireAuth();
7875
setStreamGenerateForTests(null);
7976
resetUsageStore();

‎test/live-matrix.ts‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,6 @@ const TOOL_MCPISH = {
202202
};
203203

204204
async function main() {
205-
// Keep live matrix off the default OpenCode proxy port (8797).
206-
process.env.OPENCODE_COMMANDCODE_PROXY_PORT ||= "8798";
207-
208205
const apiKey = requireAuth();
209206
setStreamGenerateForTests(null);
210207
resetUsageStore();

‎test/smoke.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,30 @@ async function main() {
596596

597597
await stopProxy();
598598

599+
// Dynamic port: when preferred 8797 is occupied by a non-proxy listener,
600+
// startProxy must bind another free port (not fail).
601+
{
602+
delete process.env.OPENCODE_COMMANDCODE_PROXY_PORT;
603+
const blocker = Bun.serve({
604+
hostname: "127.0.0.1",
605+
port: 8797,
606+
fetch() {
607+
return new Response("blocked");
608+
},
609+
});
610+
try {
611+
const dynPort = await startProxy(async () => "test-api-key");
612+
assert.ok(dynPort > 0);
613+
assert.notEqual(dynPort, 8797);
614+
assert.ok(getCommandProxyBaseUrl().includes(String(dynPort)));
615+
const healthDyn = await fetch(`http://127.0.0.1:${dynPort}/health`);
616+
assert.equal(healthDyn.status, 200);
617+
await stopProxy();
618+
} finally {
619+
blocker.stop(true);
620+
}
621+
}
622+
599623
// TypeScript build
600624
const build = spawnSync("bun", ["run", "build"], {
601625
cwd: new URL("..", import.meta.url).pathname,

0 commit comments

Comments
 (0)