Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ Laguna S 2.1 free requires an active Go (or higher) account with credits on file
```text
OpenCode
└─ /v1/chat/completions
└─ Bun.serve proxy (port 8797)
└─ Bun.serve proxy (dynamic port, prefer 8797)
└─ POST https://api.commandcode.ai/alpha/generate
└─ poolside/laguna-s-2.1-free (default)
```
Expand All @@ -111,7 +111,7 @@ Debug: `OPENCODE_COMMANDCODE_DEBUG=1`.

Optional knobs:

- `OPENCODE_COMMANDCODE_PROXY_PORT` — fixed local proxy port (default `8797`)
- `OPENCODE_COMMANDCODE_PROXY_PORT` — pin a fixed local proxy port (otherwise dynamic: prefer `8797`, then scan upward / ephemeral)
- `OPENCODE_COMMANDCODE_CWD` — working directory reported to the gateway
- `COMMANDCODE_API_URL` — override API base (default `https://api.commandcode.ai`)

Expand Down
7 changes: 4 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,9 +289,7 @@ export const CommandCodePlugin: Plugin = async (

return {
async config(config) {
// Always seed Laguna catalog so the provider is discoverable pre-login.
ensureProviderConfig(config as Record<string, any>, getCommandModels());

// Bind proxy first so provider baseURL matches the actual listening port.
await startProxy(async () => {
try {
const authClient = input.client.auth as {
Expand All @@ -311,6 +309,9 @@ export const CommandCodePlugin: Plugin = async (
const synced = syncCommandCodeCredentialsToOpenCode();
return synced?.access ?? null;
});

// Always seed Laguna catalog so the provider is discoverable pre-login.
ensureProviderConfig(config as Record<string, any>, getCommandModels());
},

"chat.headers": async (hookInput, output) => {
Expand Down
104 changes: 68 additions & 36 deletions src/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,16 @@ import {
} from "./usage.js";

const DEFAULT_PROXY_PORT = 8797;
const PROXY_PORT_SCAN_ATTEMPTS = 40;
const SHARED_PROXY_HEALTH_TIMEOUT_MS = 750;

function configuredProxyPort(): number {
/** Explicit pin via env, or `null` → pick a free port dynamically. */
function envProxyPort(): number | null {
const raw = process.env.OPENCODE_COMMANDCODE_PROXY_PORT;
const parsed = raw ? Number(raw) : NaN;
return Number.isInteger(parsed) && parsed > 0 && parsed < 65536
? parsed
: DEFAULT_PROXY_PORT;
: null;
}

const SSE_HEADERS = {
Expand Down Expand Up @@ -93,11 +95,12 @@ export function setStreamGenerateForTests(
}

export function getCommandProxyBaseUrl(): string {
return `http://127.0.0.1:${proxyPort ?? configuredProxyPort()}/v1`;
const port = proxyPort ?? envProxyPort() ?? DEFAULT_PROXY_PORT;
return `http://127.0.0.1:${port}/v1`;
}

export function getProxyPort(): number | null {
return proxyPort ?? configuredProxyPort();
return proxyPort ?? envProxyPort();
}

function isAddrInUseError(err: unknown): boolean {
Expand All @@ -111,19 +114,16 @@ function isAddrInUseError(err: unknown): boolean {
);
}

async function isSharedProxyHealthy(): Promise<boolean> {
async function isProxyHealthyOn(port: number): Promise<boolean> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
SHARED_PROXY_HEALTH_TIMEOUT_MS,
);
try {
const res = await fetch(
`http://127.0.0.1:${configuredProxyPort()}/v1/models`,
{
signal: controller.signal,
},
);
const res = await fetch(`http://127.0.0.1:${port}/v1/models`, {
signal: controller.signal,
});
if (!res.ok) return false;
const body = (await res.json().catch(() => undefined)) as
| { object?: unknown; data?: unknown }
Expand All @@ -136,44 +136,76 @@ async function isSharedProxyHealthy(): Promise<boolean> {
}
}

async function bindProxy(port: number): Promise<number> {
const hostname = "127.0.0.1";
server = Bun.serve({
hostname,
port,
async fetch(req) {
return handleRequest(req);
},
});
proxyPort = server.port ?? port;
log.info(
`[opencode-commandcode] proxy listening on ${getCommandProxyBaseUrl()}`,
);
return proxyPort;
}

/**
* Start the local OpenAI-compatible proxy.
*
* Port selection:
* - `OPENCODE_COMMANDCODE_PROXY_PORT` → bind/reuse that exact port
* - otherwise prefer `8797`, then scan upward, then fall back to OS ephemeral (`0`)
*/
export async function startProxy(tokenProvider: TokenProvider): Promise<number> {
getAccessToken = tokenProvider;
if (server && proxyPort) return proxyPort;

const desiredPort = configuredProxyPort();
const fixed = envProxyPort();
if (fixed !== null) {
if (await isProxyHealthyOn(fixed)) {
proxyPort = fixed;
log.info(
`[opencode-commandcode] reusing healthy proxy on ${getCommandProxyBaseUrl()}`,
);
return proxyPort;
}
try {
return await bindProxy(fixed);
} catch (err) {
if (isAddrInUseError(err) && (await isProxyHealthyOn(fixed))) {
proxyPort = fixed;
log.info(
`[opencode-commandcode] port ${fixed} in use; reusing existing proxy`,
);
return proxyPort;
}
throw err;
}
}

if (await isSharedProxyHealthy()) {
proxyPort = desiredPort;
// Dynamic: reuse preferred default when a healthy proxy is already there.
if (await isProxyHealthyOn(DEFAULT_PROXY_PORT)) {
proxyPort = DEFAULT_PROXY_PORT;
log.info(
`[opencode-commandcode] reusing healthy proxy on ${getCommandProxyBaseUrl()}`,
);
return proxyPort;
}

const hostname = "127.0.0.1";
try {
server = Bun.serve({
hostname,
port: desiredPort,
async fetch(req) {
return handleRequest(req);
},
});
proxyPort = server.port ?? desiredPort;
log.info(
`[opencode-commandcode] proxy listening on ${getCommandProxyBaseUrl()}`,
);
return proxyPort;
} catch (err) {
if (isAddrInUseError(err) && (await isSharedProxyHealthy())) {
proxyPort = desiredPort;
log.info(
`[opencode-commandcode] port ${desiredPort} in use; reusing existing proxy`,
);
return proxyPort;
for (let i = 0; i < PROXY_PORT_SCAN_ATTEMPTS; i++) {
const candidate = DEFAULT_PROXY_PORT + i;
try {
return await bindProxy(candidate);
} catch (err) {
if (!isAddrInUseError(err)) throw err;
}
throw err;
}

// Last resort — let the OS pick an ephemeral port.
return await bindProxy(0);
}

export async function stopProxy(): Promise<void> {
Expand Down
3 changes: 0 additions & 3 deletions test/live-laguna.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,6 @@ function sseAssistantText(sse: string): string {
}

async function main() {
// Avoid colliding with an OpenCode CLI proxy already bound to :8797.
process.env.OPENCODE_COMMANDCODE_PROXY_PORT ||= "8798";

const apiKey = requireAuth();
setStreamGenerateForTests(null);
resetUsageStore();
Expand Down
3 changes: 0 additions & 3 deletions test/live-matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,9 +202,6 @@ const TOOL_MCPISH = {
};

async function main() {
// Keep live matrix off the default OpenCode proxy port (8797).
process.env.OPENCODE_COMMANDCODE_PROXY_PORT ||= "8798";

const apiKey = requireAuth();
setStreamGenerateForTests(null);
resetUsageStore();
Expand Down
24 changes: 24 additions & 0 deletions test/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,30 @@ async function main() {

await stopProxy();

// Dynamic port: when preferred 8797 is occupied by a non-proxy listener,
// startProxy must bind another free port (not fail).
{
delete process.env.OPENCODE_COMMANDCODE_PROXY_PORT;
const blocker = Bun.serve({
hostname: "127.0.0.1",
port: 8797,
fetch() {
return new Response("blocked");
},
});
try {
const dynPort = await startProxy(async () => "test-api-key");
assert.ok(dynPort > 0);
assert.notEqual(dynPort, 8797);
assert.ok(getCommandProxyBaseUrl().includes(String(dynPort)));
const healthDyn = await fetch(`http://127.0.0.1:${dynPort}/health`);
assert.equal(healthDyn.status, 200);
await stopProxy();
} finally {
blocker.stop(true);
}
}

// TypeScript build
const build = spawnSync("bun", ["run", "build"], {
cwd: new URL("..", import.meta.url).pathname,
Expand Down