@koreanhole/opencodex 2.97.0 → 2.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,467 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { getConfigDir, isLoopbackHost, type RuntimeConfig } from "../config";
5
+ import {
6
+ codexHome,
7
+ removeManagedCodexCatalogRows,
8
+ resolveCodexCatalogPath,
9
+ } from "./codex-catalog";
10
+
11
+ const JOURNAL_VERSION = 1;
12
+ const MANAGED_MARKER = "# Managed by opencodex: provider routing";
13
+ const LOCK_STALE_MS = 30_000;
14
+
15
+ export class CodexInjectionError extends Error {
16
+ constructor(message: string, readonly code: string) {
17
+ super(message);
18
+ this.name = "CodexInjectionError";
19
+ }
20
+ }
21
+
22
+ export interface CodexInjectionEndpoint {
23
+ host: string;
24
+ port: number;
25
+ }
26
+
27
+ export interface CodexInjectionResult {
28
+ success: boolean;
29
+ action: "inject" | "restore" | "restore-back";
30
+ changed: boolean;
31
+ config: { state: "ok" | "skipped" | "failed"; changed: boolean; message: string };
32
+ catalog: { state: "ok" | "skipped" | "failed"; changed: boolean; removed: number; message: string };
33
+ }
34
+
35
+ interface CodexJournal {
36
+ version: 1;
37
+ codexHome: string;
38
+ configPath: string;
39
+ originalConfigBase64: string | null;
40
+ injectedConfigHash: string;
41
+ catalogPath: string | null;
42
+ originalCatalogBase64: string | null;
43
+ managedCatalogHash: string | null;
44
+ createdAt: string;
45
+ }
46
+
47
+ interface MaybeFile {
48
+ exists: boolean;
49
+ bytes: Buffer | null;
50
+ }
51
+
52
+ function configPath(env: NodeJS.ProcessEnv): string {
53
+ return join(codexHome(env), "config.toml");
54
+ }
55
+
56
+ function journalPath(env: NodeJS.ProcessEnv): string {
57
+ return join(getConfigDir(env), "codex", "inject-journal.json");
58
+ }
59
+
60
+ function lockPath(env: NodeJS.ProcessEnv): string {
61
+ return join(getConfigDir(env), "codex", "inject.lock");
62
+ }
63
+
64
+ function hash(bytes: string | Buffer): string {
65
+ return createHash("sha256").update(bytes).digest("hex");
66
+ }
67
+
68
+ function b64(bytes: Buffer | null): string | null {
69
+ return bytes ? bytes.toString("base64") : null;
70
+ }
71
+
72
+ function unb64(value: string | null): Buffer | null {
73
+ return value ? Buffer.from(value, "base64") : null;
74
+ }
75
+
76
+ async function readMaybe(path: string): Promise<MaybeFile> {
77
+ try {
78
+ return { exists: true, bytes: await readFile(path) };
79
+ } catch (error) {
80
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false, bytes: null };
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ async function writeAtomic(path: string, bytes: string | Buffer, mode = 0o600): Promise<void> {
86
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
87
+ const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
88
+ try {
89
+ await writeFile(temporary, bytes, { mode, flag: "wx" });
90
+ await chmod(temporary, mode);
91
+ await rename(temporary, path);
92
+ await chmod(path, mode).catch(() => undefined);
93
+ } finally {
94
+ await rm(temporary, { force: true }).catch(() => undefined);
95
+ }
96
+ }
97
+
98
+ function alive(pid: number): boolean {
99
+ try { process.kill(pid, 0); return true; } catch { return false; }
100
+ }
101
+
102
+ async function withLock<T>(env: NodeJS.ProcessEnv, fn: () => Promise<T>): Promise<T> {
103
+ const path = lockPath(env);
104
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
105
+ const acquire = async (): Promise<boolean> => {
106
+ try {
107
+ await writeFile(path, JSON.stringify({ pid: process.pid, at: Date.now() }), { mode: 0o600, flag: "wx" });
108
+ return true;
109
+ } catch (error) {
110
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
111
+ let stale = false;
112
+ try {
113
+ const info = await stat(path);
114
+ let pid = 0;
115
+ try { pid = (JSON.parse(await readFile(path, "utf8")) as { pid?: number }).pid ?? 0; } catch { /* malformed */ }
116
+ stale = Date.now() - info.mtimeMs > LOCK_STALE_MS || (pid > 0 && !alive(pid));
117
+ } catch {
118
+ stale = true;
119
+ }
120
+ if (stale) {
121
+ await rm(path, { force: true }).catch(() => undefined);
122
+ return await acquire();
123
+ }
124
+ return false;
125
+ }
126
+ };
127
+ if (!await acquire()) throw new CodexInjectionError("Another Codex configuration change is in progress; retry after it finishes.", "codex_config_busy");
128
+ try {
129
+ return await fn();
130
+ } finally {
131
+ await rm(path, { force: true }).catch(() => undefined);
132
+ }
133
+ }
134
+
135
+ function isProviderHeader(line: string): boolean {
136
+ return /^\s*\[\s*model_providers\.opencodex(?:\s*\.|\s*\])/.test(line);
137
+ }
138
+
139
+ function isTableHeader(line: string): boolean {
140
+ return /^\s*\[/.test(line);
141
+ }
142
+
143
+ function rootEnd(lines: readonly string[]): number {
144
+ const index = lines.findIndex(isTableHeader);
145
+ return index < 0 ? lines.length : index;
146
+ }
147
+
148
+ function rootValue(text: string, key: string): string | undefined {
149
+ const lines = text.split(/\r?\n/);
150
+ const end = rootEnd(lines);
151
+ const re = new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, "m");
152
+ for (let index = 0; index < end; index++) {
153
+ const match = re.exec(lines[index]!);
154
+ if (match) return match[1] ?? "";
155
+ }
156
+ return undefined;
157
+ }
158
+
159
+ function setRootValue(text: string, key: string, value: string | undefined): string {
160
+ const lines = text.split(/\r?\n/);
161
+ let end = rootEnd(lines);
162
+ let found = false;
163
+ const re = new RegExp(`^\\s*${key}\\s*=`);
164
+ for (let index = 0; index < end; index++) {
165
+ if (!re.test(lines[index]!)) continue;
166
+ found = true;
167
+ if (value === undefined) {
168
+ lines.splice(index, 1);
169
+ end -= 1;
170
+ } else {
171
+ lines[index] = `${key} = ${JSON.stringify(value)}`;
172
+ }
173
+ break;
174
+ }
175
+ if (!found && value !== undefined) {
176
+ lines.splice(end, 0, `${key} = ${JSON.stringify(value)}`);
177
+ end += 1;
178
+ }
179
+ return lines.join("\n");
180
+ }
181
+
182
+ function hasUnmarkedProviderTable(text: string): boolean {
183
+ const lines = text.split(/\r?\n/);
184
+ for (let index = 0; index < lines.length; index++) {
185
+ if (!isProviderHeader(lines[index]!)) continue;
186
+ let previous = index - 1;
187
+ while (previous >= 0 && lines[previous]!.trim() === "") previous--;
188
+ if (lines[previous]?.trim() !== MANAGED_MARKER) return true;
189
+ }
190
+ return false;
191
+ }
192
+
193
+ function stripProviderTables(text: string): string {
194
+ const lines = text.split(/\r?\n/);
195
+ const out: string[] = [];
196
+ let skipping = false;
197
+ for (const line of lines) {
198
+ if (isTableHeader(line)) {
199
+ if (isProviderHeader(line)) {
200
+ skipping = true;
201
+ while (out.length > 0 && out[out.length - 1]!.trim() === MANAGED_MARKER) out.pop();
202
+ continue;
203
+ }
204
+ skipping = false;
205
+ }
206
+ if (skipping) continue;
207
+ if (line.trim() === MANAGED_MARKER) continue;
208
+ out.push(line);
209
+ }
210
+ return out.join("\n");
211
+ }
212
+
213
+ function providerBlock(endpoint: CodexInjectionEndpoint): string {
214
+ const base = `http://${endpoint.host}:${endpoint.port}/v1`;
215
+ return [
216
+ MANAGED_MARKER,
217
+ "[model_providers.opencodex]",
218
+ 'name = "opencodex (Cline or Gemini CLI)"',
219
+ `base_url = ${JSON.stringify(base)}`,
220
+ 'wire_api = "responses"',
221
+ "requires_openai_auth = false",
222
+ `model_catalog_url = ${JSON.stringify(`${base}/model-catalog`)}`,
223
+ ].join("\n");
224
+ }
225
+
226
+ function routedModel(original: string, config: RuntimeConfig): string {
227
+ const current = rootValue(original, "model");
228
+ if (current?.startsWith("cline-cli/") || current?.startsWith("gemini-cli/")) return current;
229
+ return `${config.defaultProvider}/${config.providers[config.defaultProvider].defaultModel}`;
230
+ }
231
+
232
+ function injectedConfig(text: string, endpoint: CodexInjectionEndpoint, config: RuntimeConfig): string {
233
+ if (hasUnmarkedProviderTable(text)) {
234
+ throw new CodexInjectionError(
235
+ "Codex config already defines a different [model_providers.opencodex] table; refusing to overwrite it.",
236
+ "codex_provider_conflict",
237
+ );
238
+ }
239
+ let next = stripProviderTables(text);
240
+ next = setRootValue(next, "model_provider", "opencodex");
241
+ next = setRootValue(next, "model", routedModel(text, config));
242
+ const body = next.trimEnd();
243
+ return `${body}${body ? "\n\n" : ""}${providerBlock(endpoint)}\n`;
244
+ }
245
+
246
+ function restoreRoots(current: string, original: string | null): string {
247
+ let next = stripProviderTables(current);
248
+ for (const key of ["model_provider", "model"] as const) {
249
+ next = setRootValue(next, key, original === null ? undefined : rootValue(original, key));
250
+ }
251
+ return next;
252
+ }
253
+
254
+ async function readJournal(env: NodeJS.ProcessEnv): Promise<CodexJournal | null> {
255
+ let raw: string;
256
+ try {
257
+ raw = await readFile(journalPath(env), "utf8");
258
+ } catch (error) {
259
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
260
+ throw error;
261
+ }
262
+ let parsed: Partial<CodexJournal>;
263
+ try {
264
+ parsed = JSON.parse(raw) as Partial<CodexJournal>;
265
+ } catch {
266
+ throw new CodexInjectionError("The Codex restore journal is malformed; it was preserved.", "codex_journal_malformed");
267
+ }
268
+ if (parsed.version !== JOURNAL_VERSION || typeof parsed.configPath !== "string" || typeof parsed.injectedConfigHash !== "string") {
269
+ throw new CodexInjectionError("The Codex restore journal is invalid; it was preserved.", "codex_journal_invalid");
270
+ }
271
+ return parsed as CodexJournal;
272
+ }
273
+
274
+ async function writeJournal(env: NodeJS.ProcessEnv, journal: CodexJournal): Promise<void> {
275
+ await writeAtomic(journalPath(env), `${JSON.stringify(journal, null, 2)}\n`);
276
+ }
277
+
278
+ function endpointError(endpoint: CodexInjectionEndpoint): void {
279
+ if (!Number.isInteger(endpoint.port) || endpoint.port < 1 || endpoint.port > 65_535) {
280
+ throw new CodexInjectionError("The Codex proxy endpoint port is invalid.", "codex_endpoint_invalid");
281
+ }
282
+ if (!isLoopbackHost(endpoint.host)) {
283
+ throw new CodexInjectionError("Codex injection requires a loopback proxy endpoint.", "codex_endpoint_non_loopback");
284
+ }
285
+ }
286
+
287
+ function validateToml(text: string): void {
288
+ if (!text.trim()) return;
289
+ try {
290
+ Bun.TOML.parse(text);
291
+ } catch {
292
+ throw new CodexInjectionError("Codex config is not valid TOML; it was preserved.", "codex_config_malformed");
293
+ }
294
+ }
295
+
296
+ /** Inject routing and persist the pre-image before touching Codex config. */
297
+ export async function injectCodexConfig(options: {
298
+ env: NodeJS.ProcessEnv;
299
+ endpoint: CodexInjectionEndpoint;
300
+ config: RuntimeConfig;
301
+ }): Promise<CodexInjectionResult> {
302
+ endpointError(options.endpoint);
303
+ return await withLock(options.env, async () => {
304
+ const path = configPath(options.env);
305
+ const current = await readMaybe(path);
306
+ if (current.exists) validateToml(current.bytes!.toString("utf8"));
307
+ const existing = await readJournal(options.env);
308
+ const originalConfig = existing ? unb64(existing.originalConfigBase64) : current.bytes;
309
+ const originalCatalog = existing ? unb64(existing.originalCatalogBase64) : null;
310
+ const catalogPath = existing?.catalogPath ?? await resolveCodexCatalogPath(options.env) ?? null;
311
+ const catalog = catalogPath ? await readMaybe(catalogPath) : { exists: false, bytes: null };
312
+ const before = current.bytes?.toString("utf8") ?? "";
313
+ if (existing && !hasManagedProvider(before)) {
314
+ throw new CodexInjectionError(
315
+ "A Codex restore journal exists but Codex routing is already native; run `ocx restore` before starting again.",
316
+ "codex_journal_state_conflict",
317
+ );
318
+ }
319
+ const next = injectedConfig(before, options.endpoint, options.config);
320
+ const journal: CodexJournal = {
321
+ version: JOURNAL_VERSION,
322
+ codexHome: codexHome(options.env),
323
+ configPath: path,
324
+ originalConfigBase64: b64(originalConfig),
325
+ injectedConfigHash: hash(next),
326
+ catalogPath: existing?.catalogPath ?? catalogPath,
327
+ originalCatalogBase64: existing ? existing.originalCatalogBase64 : b64(catalog.bytes),
328
+ managedCatalogHash: existing?.managedCatalogHash ?? null,
329
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
330
+ };
331
+ await writeJournal(options.env, journal);
332
+ try {
333
+ await writeAtomic(path, next);
334
+ } catch (error) {
335
+ if (!existing) await rm(journalPath(options.env), { force: true }).catch(() => undefined);
336
+ throw error;
337
+ }
338
+ return {
339
+ success: true,
340
+ action: "inject",
341
+ changed: true,
342
+ config: { state: "ok", changed: true, message: "Codex routing is pointed at the local proxy." },
343
+ catalog: { state: "ok", changed: false, removed: 0, message: originalCatalog ? "Original catalog is journaled." : "Catalog state is journaled." },
344
+ };
345
+ });
346
+ }
347
+
348
+ function hasManagedProvider(text: string): boolean {
349
+ return text.includes(MANAGED_MARKER);
350
+ }
351
+
352
+ async function restoreCatalog(journal: CodexJournal): Promise<CodexInjectionResult["catalog"]> {
353
+ const path = journal.catalogPath;
354
+ if (!path) return { state: "skipped", changed: false, removed: 0, message: "No managed catalog path was journaled." };
355
+ const current = await readMaybe(path);
356
+ if (!current.exists) return { state: "skipped", changed: false, removed: 0, message: "No managed catalog file was present." };
357
+ const currentHash = hash(current.bytes!);
358
+ const original = unb64(journal.originalCatalogBase64);
359
+ if (journal.managedCatalogHash && currentHash === journal.managedCatalogHash) {
360
+ if (original === null) {
361
+ await rm(path, { force: true });
362
+ return { state: "ok", changed: true, removed: 0, message: "Removed the opencodex-owned catalog file." };
363
+ }
364
+ await writeAtomic(path, original);
365
+ return { state: "ok", changed: true, removed: 0, message: "Restored the original Codex catalog." };
366
+ }
367
+ try {
368
+ const removed = await removeManagedCodexCatalogRows(path);
369
+ return { state: "ok", changed: removed.changed, removed: removed.removed, message: removed.message };
370
+ } catch (error) {
371
+ return { state: "failed", changed: false, removed: 0, message: error instanceof Error ? error.message : String(error) };
372
+ }
373
+ }
374
+
375
+ /** Restore native Codex config/catalog; no-op when nothing was managed. */
376
+ export async function restoreCodexConfig(options: {
377
+ env: NodeJS.ProcessEnv;
378
+ removeProviderTable?: boolean;
379
+ }): Promise<CodexInjectionResult> {
380
+ return await withLock(options.env, async () => {
381
+ const journal = await readJournal(options.env);
382
+ const path = configPath(options.env);
383
+ const current = await readMaybe(path);
384
+ if (current.exists) validateToml(current.bytes!.toString("utf8"));
385
+ if (!journal) {
386
+ let catalog: CodexInjectionResult["catalog"] = {
387
+ state: "skipped", changed: false, removed: 0, message: "No journal was present.",
388
+ };
389
+ const catalogPath = await resolveCodexCatalogPath(options.env);
390
+ if (catalogPath) {
391
+ try {
392
+ const removed = await removeManagedCodexCatalogRows(catalogPath);
393
+ catalog = { state: "ok", changed: removed.changed, removed: removed.removed, message: removed.message };
394
+ } catch (error) {
395
+ catalog = { state: "failed", changed: false, removed: 0, message: error instanceof Error ? error.message : String(error) };
396
+ }
397
+ }
398
+ if (!current.exists) {
399
+ return {
400
+ success: catalog.state !== "failed", action: "restore", changed: catalog.changed,
401
+ config: { state: "skipped", changed: false, message: "Codex config was not present." },
402
+ catalog,
403
+ };
404
+ }
405
+ const text = current.bytes!.toString("utf8");
406
+ if (hasUnmarkedProviderTable(text) && !options.removeProviderTable) {
407
+ throw new CodexInjectionError("Codex contains an unmarked opencodex provider table; refusing to remove it.", "codex_provider_conflict");
408
+ }
409
+ if (!hasManagedProvider(text) && !options.removeProviderTable) {
410
+ return {
411
+ success: catalog.state !== "failed", action: "restore", changed: catalog.changed,
412
+ config: { state: "skipped", changed: false, message: "Codex is already using its native configuration." },
413
+ catalog,
414
+ };
415
+ }
416
+ const restored = restoreRoots(text, null);
417
+ await writeAtomic(path, restored);
418
+ return {
419
+ success: catalog.state !== "failed", action: "restore", changed: true || catalog.changed,
420
+ config: { state: "ok", changed: true, message: "Removed managed Codex routing; no pre-image journal was available." },
421
+ catalog,
422
+ };
423
+ }
424
+ const original = unb64(journal.originalConfigBase64);
425
+ const text = current.bytes?.toString("utf8") ?? "";
426
+ let changed = false;
427
+ let message = "Codex config was already native.";
428
+ if (current.exists && hash(current.bytes!) === journal.injectedConfigHash) {
429
+ if (original === null) await rm(path, { force: true });
430
+ else await writeAtomic(path, original);
431
+ changed = true;
432
+ message = original === null ? "Removed the managed Codex config." : "Restored the original Codex config.";
433
+ } else if (hasManagedProvider(text)) {
434
+ if (hasUnmarkedProviderTable(text) && !options.removeProviderTable) {
435
+ throw new CodexInjectionError("Codex contains an unmarked opencodex provider table; refusing to remove it.", "codex_provider_conflict");
436
+ }
437
+ await writeAtomic(path, restoreRoots(text, original?.toString("utf8") ?? null));
438
+ changed = true;
439
+ message = "Removed managed Codex routing while preserving unrelated edits.";
440
+ }
441
+ const catalog = await restoreCatalog(journal);
442
+ if (catalog.state === "failed") {
443
+ return {
444
+ success: false, action: "restore", changed,
445
+ config: { state: "ok", changed, message },
446
+ catalog,
447
+ };
448
+ }
449
+ await rm(journalPath(options.env), { force: true });
450
+ return {
451
+ success: true, action: "restore", changed: changed || catalog.changed,
452
+ config: { state: changed ? "ok" : "skipped", changed, message },
453
+ catalog,
454
+ };
455
+ });
456
+ }
457
+
458
+ /** Record the post-sync catalog hash so a later exact restore can remove it. */
459
+ export async function recordCodexCatalogState(env: NodeJS.ProcessEnv): Promise<void> {
460
+ await withLock(env, async () => {
461
+ const journal = await readJournal(env);
462
+ if (!journal?.catalogPath) return;
463
+ const current = await readMaybe(journal.catalogPath);
464
+ journal.managedCatalogHash = current.exists ? hash(current.bytes!) : null;
465
+ await writeJournal(env, journal);
466
+ });
467
+ }
@@ -13,8 +13,13 @@ import type { GeminiTurnDeps } from "../adapters/cli-agent/gemini/adapter";
13
13
  import { discoverClineLiveModels } from "../adapters/cli-agent/cline/live-models";
14
14
  import { discoverGeminiLiveModels } from "../adapters/cli-agent/gemini/live-models";
15
15
  import { buildCodexCatalogDocument, discoverCodexCatalogModels } from "../lib/codex-catalog";
16
- import { handleResponses, modelList, RequestValidationError, UnsupportedMediaError, type ProviderModels } from "./responses";
16
+ import { handleResponses, modelList, RequestValidationError, UnsupportedMediaError, UnsupportedOptionError, type ProviderModels } from "./responses";
17
17
  import { handleCountTokens, handleMessages } from "./messages";
18
+ import {
19
+ createWebSocketHandlers,
20
+ type WebSocketData,
21
+ type WebSocketRequestObservation,
22
+ } from "./websocket";
18
23
 
19
24
  const MAX_REQUEST_BYTES = 32 * 1024 * 1024;
20
25
  const REQUEST_TIMEOUT_MS = 300_000;
@@ -24,6 +29,7 @@ export interface ServerOptions {
24
29
  clineDeps?: ClineTurnDeps;
25
30
  geminiDeps?: GeminiTurnDeps;
26
31
  geminiPackageRoot?: string;
32
+ onWebSocketRequest?: (observation: WebSocketRequestObservation) => void;
27
33
  }
28
34
 
29
35
  export interface RunningServer {
@@ -66,6 +72,9 @@ async function boundedJson(req: Request): Promise<unknown> {
66
72
  }
67
73
 
68
74
  function errorResponse(error: unknown): Response {
75
+ if (error instanceof UnsupportedOptionError) {
76
+ return Response.json({ error: { message: error.message, type: error.type, code: error.code } }, { status: error.status });
77
+ }
69
78
  if (error instanceof UnsupportedMediaError) {
70
79
  return Response.json({ error: { message: error.message, type: error.type, code: error.code } }, { status: error.status });
71
80
  }
@@ -114,7 +123,7 @@ export function startServer(config: RuntimeConfig | number, options: ServerOptio
114
123
  const token = apiToken(config, env);
115
124
  let server: ReturnType<typeof Bun.serve>;
116
125
 
117
- const fetch = async (req: Request): Promise<Response> => {
126
+ const fetch = async (req: Request, bunServer: Bun.Server<WebSocketData>): Promise<Response | undefined> => {
118
127
  const url = new URL(req.url);
119
128
  if (url.pathname === "/healthz") {
120
129
  return Response.json({ ok: true, service: "opencodex", version: packageInfo.version });
@@ -122,6 +131,11 @@ export function startServer(config: RuntimeConfig | number, options: ServerOptio
122
131
  if (!authorized(req, token)) {
123
132
  return Response.json({ error: { message: "Unauthorized", type: "authentication_error", code: "unauthorized" } }, { status: 401 });
124
133
  }
134
+ if (req.method === "GET" && url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
135
+ const data: WebSocketData = { headers: req.headers, queue: Promise.resolve(), closed: false };
136
+ if (bunServer.upgrade(req, { data })) return undefined;
137
+ return new Response("WebSocket upgrade failed.", { status: 400 });
138
+ }
125
139
  try {
126
140
  if (req.method === "GET" && url.pathname === "/v1/models") {
127
141
  const known = await discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot);
@@ -158,7 +172,20 @@ export function startServer(config: RuntimeConfig | number, options: ServerOptio
158
172
  }
159
173
  };
160
174
 
161
- server = Bun.serve({ hostname: config.host, port: config.port, idleTimeout: 255, fetch });
175
+ const websocketContext = {
176
+ knownModels: () => discoverModels(env, options.clineDeps, options.geminiDeps, options.geminiPackageRoot),
177
+ deps: turnDeps,
178
+ defaultProvider: config.defaultProvider,
179
+ timeoutMs: REQUEST_TIMEOUT_MS,
180
+ ...(options.onWebSocketRequest ? { onRequest: options.onWebSocketRequest } : {}),
181
+ };
182
+ server = Bun.serve({
183
+ hostname: config.host,
184
+ port: config.port,
185
+ idleTimeout: 255,
186
+ fetch,
187
+ websocket: createWebSocketHandlers(websocketContext),
188
+ });
162
189
  return {
163
190
  port: server.port ?? config.port,
164
191
  host: config.host,