@bitkyc08/opencodex 0.2.1 → 1.9.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,140 @@
1
+ import type { OcxProviderConfig } from "../types";
2
+
3
+ export type ProviderAuthKind = "forward" | "oauth" | "key" | "local";
4
+ export type MetadataModelIdNormalize = "case-insensitive";
5
+
6
+ export interface ProviderRegistryEntry {
7
+ id: string;
8
+ label: string;
9
+ adapter: string;
10
+ baseUrl: string;
11
+ authKind: ProviderAuthKind;
12
+ featured?: boolean;
13
+ note?: string;
14
+ dashboardUrl?: string;
15
+ defaultModel?: string;
16
+ models?: string[];
17
+ noVisionModels?: string[];
18
+ noReasoningModels?: string[];
19
+ oauthId?: string;
20
+ jawcodeBundle?: string;
21
+ extraMetadataAliases?: string[];
22
+ metadataModelIdNormalize?: MetadataModelIdNormalize;
23
+ }
24
+
25
+ export type ProviderConfigSeed = Pick<
26
+ OcxProviderConfig,
27
+ "adapter" | "baseUrl" | "authMode" | "defaultModel" | "models" | "noVisionModels" | "noReasoningModels"
28
+ >;
29
+
30
+ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [
31
+ {
32
+ id: "openai",
33
+ label: "OpenAI (ChatGPT login)",
34
+ adapter: "openai-responses",
35
+ baseUrl: "https://chatgpt.com/backend-api/codex",
36
+ authKind: "forward",
37
+ featured: true,
38
+ note: "Uses your codex login — no API key",
39
+ },
40
+ {
41
+ id: "xai",
42
+ label: "xAI Grok",
43
+ adapter: "openai-chat",
44
+ baseUrl: "https://api.x.ai/v1",
45
+ authKind: "oauth",
46
+ featured: true,
47
+ oauthId: "xai",
48
+ jawcodeBundle: "xai",
49
+ note: "Log in with your Grok account",
50
+ models: ["grok-4.3", "grok-4.20-0309-reasoning", "grok-4.20-0309-non-reasoning", "grok-build-0.1", "grok-composer-2.5-fast"],
51
+ defaultModel: "grok-4.3",
52
+ noReasoningModels: ["grok-build-0.1", "grok-composer-2.5-fast"],
53
+ noVisionModels: ["grok-build-0.1", "grok-composer-2.5-fast"],
54
+ },
55
+ {
56
+ id: "anthropic",
57
+ label: "Anthropic Claude",
58
+ adapter: "anthropic",
59
+ baseUrl: "https://api.anthropic.com",
60
+ authKind: "oauth",
61
+ featured: true,
62
+ oauthId: "anthropic",
63
+ jawcodeBundle: "anthropic",
64
+ note: "Log in with your Claude account",
65
+ models: ["claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5"],
66
+ defaultModel: "claude-sonnet-4-6",
67
+ },
68
+ {
69
+ id: "kimi",
70
+ label: "Kimi",
71
+ adapter: "openai-chat",
72
+ baseUrl: "https://api.kimi.com/coding/v1",
73
+ authKind: "oauth",
74
+ featured: true,
75
+ oauthId: "kimi",
76
+ jawcodeBundle: "moonshot",
77
+ note: "Log in with your Kimi account",
78
+ models: ["kimi-k2.6", "kimi-k2.5"],
79
+ defaultModel: "kimi-k2.6",
80
+ },
81
+ { id: "openai-apikey", label: "OpenAI (API key)", adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authKind: "key", featured: true, dashboardUrl: "https://platform.openai.com/api-keys", defaultModel: "gpt-5.5" },
82
+ { id: "opencode-go", label: "opencode go", adapter: "openai-chat", baseUrl: "https://opencode.ai/zen/go/v1", authKind: "key", featured: true, dashboardUrl: "https://opencode.ai/auth", defaultModel: "kimi-k2.6", jawcodeBundle: "opencode-go", note: "GLM, DeepSeek, Kimi, Qwen, MiMo…" },
83
+ { id: "openrouter", label: "OpenRouter", adapter: "openai-chat", baseUrl: "https://openrouter.ai/api/v1", authKind: "key", featured: true, dashboardUrl: "https://openrouter.ai/keys", jawcodeBundle: "openrouter" },
84
+ { id: "groq", label: "Groq", adapter: "openai-chat", baseUrl: "https://api.groq.com/openai/v1", authKind: "key", featured: true, dashboardUrl: "https://console.groq.com/keys" },
85
+ { id: "google", label: "Google Gemini", adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", authKind: "key", featured: true, dashboardUrl: "https://aistudio.google.com/apikey", defaultModel: "gemini-3-pro", jawcodeBundle: "google", extraMetadataAliases: ["gemini"] },
86
+ { id: "azure-openai", label: "Azure OpenAI", adapter: "azure-openai", baseUrl: "https://{resource}.openai.azure.com/openai/deployments/{deployment}", authKind: "key", featured: true, dashboardUrl: "https://portal.azure.com" },
87
+ { id: "ollama", label: "Ollama (local)", adapter: "openai-chat", baseUrl: "http://localhost:11434/v1", authKind: "local", featured: true, note: "Local — key usually blank" },
88
+ { id: "vllm", label: "vLLM (local)", adapter: "openai-chat", baseUrl: "http://localhost:8000/v1", authKind: "local", featured: true, note: "Local — key usually blank" },
89
+ { id: "lm-studio", label: "LM Studio (local)", adapter: "openai-chat", baseUrl: "http://localhost:1234/v1", authKind: "local", featured: true, note: "Local — no key needed" },
90
+ { id: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.deepseek.com/api_keys", models: ["deepseek-chat", "deepseek-reasoner"], defaultModel: "deepseek-chat" },
91
+ { id: "cerebras", label: "Cerebras", baseUrl: "https://api.cerebras.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://cloud.cerebras.ai/platform/apikeys", defaultModel: "llama-3.3-70b" },
92
+ { id: "together", label: "Together", baseUrl: "https://api.together.xyz/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://api.together.xyz/settings/api-keys" },
93
+ { id: "fireworks", label: "Fireworks", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys" },
94
+ { id: "firepass", label: "Fire Pass (Fireworks Kimi)", baseUrl: "https://api.fireworks.ai/inference/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://fireworks.ai/account/api-keys" },
95
+ { id: "moonshot", label: "Moonshot (Kimi API)", baseUrl: "https://api.moonshot.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.moonshot.ai/console/api-keys", defaultModel: "kimi-k2-0905-preview", jawcodeBundle: "moonshot" },
96
+ { id: "huggingface", label: "Hugging Face", baseUrl: "https://router.huggingface.co/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://huggingface.co/settings/tokens" },
97
+ { id: "nvidia", label: "NVIDIA NIM", baseUrl: "https://integrate.api.nvidia.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://build.nvidia.com" },
98
+ { id: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://venice.ai/settings/api" },
99
+ { id: "zai", label: "Z.AI (GLM Coding)", baseUrl: "https://api.z.ai/api/coding/paas/v4", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://z.ai/manage-apikey/apikey-list", defaultModel: "glm-4.6" },
100
+ { id: "nanogpt", label: "NanoGPT", baseUrl: "https://nano-gpt.com/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://nano-gpt.com/api" },
101
+ { id: "synthetic", label: "Synthetic", baseUrl: "https://api.synthetic.new/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://synthetic.new" },
102
+ { id: "qwen-portal", label: "Qwen Portal", baseUrl: "https://portal.qwen.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://portal.qwen.ai" },
103
+ { id: "qianfan", label: "Qianfan (Baidu)", baseUrl: "https://qianfan.baidubce.com/v2", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.bce.baidu.com/iam/#/iam/apikey/list" },
104
+ { id: "alibaba", label: "Alibaba Coding Plan", baseUrl: "https://coding-intl.dashscope.aliyuncs.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://dashscope.console.aliyun.com/apiKey" },
105
+ { id: "parallel", label: "Parallel", baseUrl: "https://platform.parallel.ai", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.parallel.ai" },
106
+ { id: "zenmux", label: "ZenMux", baseUrl: "https://zenmux.ai/api/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://zenmux.ai" },
107
+ { id: "litellm", label: "LiteLLM (self-hosted)", baseUrl: "http://localhost:4000/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://docs.litellm.ai/docs/proxy/quick_start" },
108
+ {
109
+ id: "ollama-cloud",
110
+ label: "Ollama Cloud",
111
+ baseUrl: "https://ollama.com/v1",
112
+ adapter: "openai-chat",
113
+ authKind: "key",
114
+ dashboardUrl: "https://ollama.com/settings/keys",
115
+ models: ["glm-5.2", "deepseek-v4-pro", "qwen3-coder", "gpt-oss:120b", "kimi-k2.6", "minimax-m3", "qwen3.5", "gemma4"],
116
+ defaultModel: "glm-5.2",
117
+ noVisionModels: [
118
+ "glm-5.2", "glm-5.1", "glm-5", "glm-4.7",
119
+ "minimax-m2.7", "minimax-m2.5", "minimax-m2.1",
120
+ "nemotron-3-ultra", "nemotron-3-super",
121
+ "deepseek-v4-pro", "deepseek-v4-flash",
122
+ "gpt-oss", "qwen3-coder",
123
+ ],
124
+ },
125
+ { id: "mistral", label: "Mistral", baseUrl: "https://api.mistral.ai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://console.mistral.ai/api-keys", defaultModel: "codestral-latest" },
126
+ { id: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.minimax.io", defaultModel: "MiniMax-M2.5", jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive" },
127
+ { id: "minimax-cn", label: "MiniMax (CN)", baseUrl: "https://api.minimaxi.com/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.minimaxi.com", defaultModel: "MiniMax-M2.5", jawcodeBundle: "minimax", metadataModelIdNormalize: "case-insensitive" },
128
+ { id: "kimi-code", label: "Kimi (coding)", baseUrl: "https://api.kimi.com/coding/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://platform.moonshot.cn/console/api-keys", defaultModel: "kimi-k2.5" },
129
+ { id: "opencode-zen", label: "opencode zen", baseUrl: "https://opencode.ai/zen/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://opencode.ai/auth" },
130
+ { id: "vercel-ai-gateway", label: "Vercel AI Gateway", baseUrl: "https://ai-gateway.vercel.sh/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://vercel.com/dashboard" },
131
+ { id: "xiaomi", label: "Xiaomi MiMo", baseUrl: "https://api.xiaomimimo.com/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://xiaomimimo.com", defaultModel: "mimo-v2.5-pro" },
132
+ { id: "kilo", label: "Kilo", baseUrl: "https://api.kilo.ai/api/gateway", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://kilo.ai" },
133
+ { id: "cloudflare-ai-gateway", label: "Cloudflare AI Gateway", baseUrl: "https://gateway.ai.cloudflare.com/v1/{account-id}/{gateway}/anthropic", adapter: "anthropic", authKind: "key", dashboardUrl: "https://dash.cloudflare.com/?to=/:account/ai/ai-gateway" },
134
+ { id: "github-copilot", label: "GitHub Copilot", baseUrl: "https://api.githubcopilot.com", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://github.com/settings/copilot" },
135
+ { id: "gitlab-duo", label: "GitLab Duo", baseUrl: "https://cloud.gitlab.com/ai/v1/proxy/openai/v1", adapter: "openai-chat", authKind: "key", dashboardUrl: "https://gitlab.com/-/user_settings/personal_access_tokens" },
136
+ ];
137
+
138
+ export function getProviderRegistryEntry(id: string): ProviderRegistryEntry | undefined {
139
+ return PROVIDER_REGISTRY.find(entry => entry.id === id);
140
+ }
@@ -386,7 +386,12 @@ export function parseRequest(body: unknown): OcxParsedRequest {
386
386
  const structuredOutput = detectStructuredOutput(data.text);
387
387
 
388
388
  return {
389
- modelId: data.model, context, stream: data.stream === true, options, _rawBody: body,
389
+ modelId: data.model,
390
+ ...(data.previous_response_id ? { previousResponseId: data.previous_response_id } : {}),
391
+ context,
392
+ stream: data.stream === true,
393
+ options,
394
+ _rawBody: body,
390
395
  ...(webSearch ? { _webSearch: webSearch } : {}),
391
396
  ...(structuredOutput ? { _structuredOutput: true } : {}),
392
397
  };
package/src/server.ts CHANGED
@@ -6,7 +6,17 @@ import { createGoogleAdapter } from "./adapters/google";
6
6
  import { createOpenAIChatAdapter } from "./adapters/openai-chat";
7
7
  import { createResponsesPassthroughAdapter } from "./adapters/openai-responses";
8
8
  import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse } from "./bridge";
9
- import { DEFAULT_SUBAGENT_MODELS, loadConfig, saveConfig } from "./config";
9
+ import {
10
+ buildWarmupCompletionFrames,
11
+ buildWsErrorFrame,
12
+ selectForwardHeaders,
13
+ sendJsonFrame,
14
+ sendResponseToWebSocket,
15
+ sendTextFrame,
16
+ type WsData,
17
+ } from "./ws-bridge";
18
+ import type { ServerWebSocket } from "bun";
19
+ import { DEFAULT_SUBAGENT_MODELS, loadConfig, saveConfig, websocketsEnabled } from "./config";
10
20
  import { parseRequest } from "./responses/parser";
11
21
  import { routeModel } from "./router";
12
22
  import { namespacedToolName } from "./types";
@@ -19,6 +29,7 @@ import { buildWebSearchTool, planWebSearch, runWithWebSearch } from "./web-searc
19
29
  import { describeImagesInPlace, planVisionSidecar } from "./vision";
20
30
  import { removeCredential } from "./oauth/store";
21
31
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "./oauth/key-providers";
32
+ import { deriveProviderPresets } from "./providers/derive";
22
33
  import type { OcxConfig, OcxProviderConfig } from "./types";
23
34
 
24
35
  const VERSION = "0.0.1";
@@ -67,7 +78,7 @@ function serveGuiFile(pathname: string): Response | null {
67
78
  });
68
79
  }
69
80
 
70
- function resolveAdapter(providerConfig: OcxProviderConfig) {
81
+ export function resolveAdapter(providerConfig: OcxProviderConfig) {
71
82
  switch (providerConfig.adapter) {
72
83
  case "openai-chat":
73
84
  return createOpenAIChatAdapter(providerConfig);
@@ -77,6 +88,7 @@ function resolveAdapter(providerConfig: OcxProviderConfig) {
77
88
  return createResponsesPassthroughAdapter(providerConfig);
78
89
  case "google":
79
90
  return createGoogleAdapter(providerConfig);
91
+ case "azure":
80
92
  case "azure-openai":
81
93
  return createAzureAdapter(providerConfig);
82
94
  default:
@@ -84,7 +96,12 @@ function resolveAdapter(providerConfig: OcxProviderConfig) {
84
96
  }
85
97
  }
86
98
 
87
- async function handleResponses(req: Request, config: OcxConfig, logCtx: { model: string; provider: string }): Promise<Response> {
99
+ async function handleResponses(
100
+ req: Request,
101
+ config: OcxConfig,
102
+ logCtx: { model: string; provider: string },
103
+ options: { forceEmptyResponseId?: boolean; abortSignal?: AbortSignal } = {},
104
+ ): Promise<Response> {
88
105
  let body: unknown;
89
106
  try {
90
107
  body = await req.json();
@@ -133,24 +150,30 @@ async function handleResponses(req: Request, config: OcxConfig, logCtx: { model:
133
150
  // with text BEFORE the main call, so the text-only model can reason about it.
134
151
  const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, req.headers);
135
152
  if (visionPlan) {
136
- await describeImagesInPlace(parsed, visionPlan.forwardProvider, req.headers, visionPlan.settings);
153
+ await describeImagesInPlace(parsed, visionPlan.forwardProvider, req.headers, visionPlan.settings, options.abortSignal);
137
154
  }
138
155
 
139
156
  const adapter = resolveAdapter(route.provider);
140
157
 
141
158
  if ("passthrough" in adapter && adapter.passthrough) {
142
159
  const request = adapter.buildRequest(parsed, { headers: req.headers });
160
+ // Abort the upstream if the client disconnects. A directly-relayed body does not propagate the
161
+ // consumer's cancel to a signalled fetch, so we pass the signal and relay through relayWithAbort,
162
+ // whose cancel() aborts the upstream — preventing leaked connections (RC2, passthrough path).
163
+ const upstream = new AbortController();
164
+ linkAbortSignal(upstream, options.abortSignal);
143
165
  let upstreamResponse: Response;
144
166
  try {
145
167
  upstreamResponse = await fetch(request.url, {
146
168
  method: request.method,
147
169
  headers: request.headers,
148
170
  body: request.body,
171
+ signal: upstream.signal,
149
172
  });
150
173
  } catch (err) {
151
174
  return formatErrorResponse(502, "upstream_error", `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`);
152
175
  }
153
- return new Response(upstreamResponse.body, {
176
+ return new Response(relayWithAbort(upstreamResponse.body, upstream), {
154
177
  status: upstreamResponse.status,
155
178
  headers: sanitizePassthroughHeaders(upstreamResponse.headers),
156
179
  });
@@ -169,17 +192,23 @@ async function handleResponses(req: Request, config: OcxConfig, logCtx: { model:
169
192
  incomingHeaders: req.headers,
170
193
  settings: wsPlan.settings,
171
194
  maxSearches: wsPlan.maxSearches,
195
+ abortSignal: options.abortSignal,
172
196
  });
173
197
  }
174
198
 
175
199
  const request = adapter.buildRequest(parsed, { headers: req.headers });
176
200
 
201
+ // Abort the upstream fetch if the client (Codex) disconnects mid-stream, so a cancelled turn does
202
+ // not leak the upstream connection or keep draining tokens. The bridge's cancel() fires upstream.abort() (RC2).
203
+ const upstream = new AbortController();
204
+ linkAbortSignal(upstream, options.abortSignal);
177
205
  let upstreamResponse: Response;
178
206
  try {
179
207
  upstreamResponse = await fetch(request.url, {
180
208
  method: request.method,
181
209
  headers: request.headers,
182
210
  body: request.body,
211
+ signal: upstream.signal,
183
212
  });
184
213
  } catch (err) {
185
214
  return formatErrorResponse(502, "upstream_error", `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`);
@@ -202,7 +231,16 @@ async function handleResponses(req: Request, config: OcxConfig, logCtx: { model:
202
231
  if (t.freeform) freeformToolNames.add(t.name);
203
232
  if (t.toolSearch) toolSearchToolNames.add(t.name);
204
233
  }
205
- const sseStream = bridgeToResponsesSSE(eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames);
234
+ const sseStream = bridgeToResponsesSSE(
235
+ eventStream,
236
+ parsed.modelId,
237
+ toolNsMap,
238
+ freeformToolNames,
239
+ toolSearchToolNames,
240
+ () => upstream.abort(),
241
+ 2_000,
242
+ options.forceEmptyResponseId ? { responseId: "" } : undefined,
243
+ );
206
244
  return new Response(sseStream, {
207
245
  headers: {
208
246
  "Content-Type": "text/event-stream",
@@ -224,6 +262,15 @@ async function handleResponses(req: Request, config: OcxConfig, logCtx: { model:
224
262
  return formatErrorResponse(500, "internal_error", "Non-streaming not supported by this adapter");
225
263
  }
226
264
 
265
+ export function linkAbortSignal(upstream: AbortController, signal?: AbortSignal): void {
266
+ if (!signal) return;
267
+ if (signal.aborted) {
268
+ upstream.abort(signal.reason);
269
+ return;
270
+ }
271
+ signal.addEventListener("abort", () => upstream.abort(signal.reason), { once: true });
272
+ }
273
+
227
274
  const requestLog: { timestamp: number; model: string; provider: string; status: number; durationMs: number }[] = [];
228
275
  const MAX_LOG_SIZE = 200;
229
276
 
@@ -232,6 +279,39 @@ function addRequestLog(entry: typeof requestLog[number]) {
232
279
  if (requestLog.length > MAX_LOG_SIZE) requestLog.shift();
233
280
  }
234
281
 
282
+ /**
283
+ * Relay an upstream body verbatim while wiring client-cancel -> upstream.abort(). A body returned
284
+ * directly from fetch does NOT propagate the consumer's cancel to a signalled fetch, so a client
285
+ * disconnect would leak the upstream connection. Pumping through this stream (whose cancel() aborts
286
+ * the upstream) fixes the leak with zero byte changes — passthrough fidelity is preserved (RC2).
287
+ */
288
+ export function relayWithAbort(
289
+ body: ReadableStream<Uint8Array> | null,
290
+ upstream: AbortController,
291
+ ): ReadableStream<Uint8Array> | null {
292
+ if (!body) return null;
293
+ const reader = body.getReader();
294
+ return new ReadableStream<Uint8Array>({
295
+ async pull(controller) {
296
+ try {
297
+ const { done, value } = await reader.read();
298
+ if (done) {
299
+ controller.close();
300
+ return;
301
+ }
302
+ controller.enqueue(value);
303
+ } catch (err) {
304
+ try { controller.error(err); } catch { /* already torn down */ }
305
+ }
306
+ },
307
+ cancel(reason) {
308
+ // Client disconnected: abort the upstream fetch and release the reader so we do not leak it.
309
+ upstream.abort(reason);
310
+ reader.cancel(reason).catch(() => {});
311
+ },
312
+ });
313
+ }
314
+
235
315
  /**
236
316
  * Bun's fetch auto-decompresses the response body but leaves the upstream `content-encoding`
237
317
  * (and a now-stale `content-length`) on `response.headers`. Relaying those with the already-decoded
@@ -239,7 +319,18 @@ function addRequestLog(entry: typeof requestLog[number]) {
239
319
  * Drop encoding + hop-by-hop headers; relay everything else (content-type, etc.) verbatim.
240
320
  */
241
321
  export function sanitizePassthroughHeaders(upstream: Headers): Headers {
242
- const DROP = new Set(["content-encoding", "content-length", "transfer-encoding", "connection", "keep-alive"]);
322
+ const DROP = new Set([
323
+ "content-encoding",
324
+ "content-length",
325
+ "transfer-encoding",
326
+ "connection",
327
+ "keep-alive",
328
+ "proxy-authenticate",
329
+ "proxy-authorization",
330
+ "te",
331
+ "trailer",
332
+ "upgrade",
333
+ ]);
243
334
  const out = new Headers();
244
335
  upstream.forEach((value, key) => {
245
336
  if (!DROP.has(key.toLowerCase())) out.set(key, value);
@@ -361,6 +452,12 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
361
452
  return jsonResponse({ providers: listKeyLoginProviders() });
362
453
  }
363
454
 
455
+ // Complete GUI picker presets, derived from the canonical provider registry. The GUI is a
456
+ // standalone Vite package, so it consumes this runtime view instead of importing repo-root src.
457
+ if (url.pathname === "/api/provider-presets" && req.method === "GET") {
458
+ return jsonResponse({ providers: deriveProviderPresets() });
459
+ }
460
+
364
461
  // Subagent model picker: which ≤5 routed models Codex's spawn_agent advertises (it shows the
365
462
  // first 5 routed catalog entries). PUT reorders the injected catalog so the chosen ones lead.
366
463
  if (url.pathname === "/api/subagent-models" && req.method === "GET") {
@@ -452,7 +549,7 @@ export function startServer(port?: number) {
452
549
  }
453
550
  const listenPort = port ?? config.port ?? 10100;
454
551
 
455
- const server = Bun.serve({
552
+ const server = Bun.serve<WsData>({
456
553
  port: listenPort,
457
554
  async fetch(req) {
458
555
  const url = new URL(req.url);
@@ -461,6 +558,13 @@ export function startServer(port?: number) {
461
558
  return new Response(null, { status: 204, headers: corsHeaders() });
462
559
  }
463
560
 
561
+ // Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is
562
+ // handshake-time only, so capture inbound headers and thread them into the pipeline.
563
+ if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
564
+ if (server.upgrade(req, { data: { headers: selectForwardHeaders(req.headers) } })) return undefined as unknown as Response;
565
+ return formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed");
566
+ }
567
+
464
568
  if (url.pathname === "/healthz" && req.method === "GET") {
465
569
  return jsonResponse({ status: "ok", version: VERSION, uptime: process.uptime() });
466
570
  }
@@ -481,7 +585,7 @@ export function startServer(port?: number) {
481
585
  // Codex client → Codex catalog shape: native gpt + namespaced routed models,
482
586
  // cloned from a native template so required fields (base_instructions, etc.) are present.
483
587
  // Pass the subagent picks so featured models lead by priority (matches the on-disk file).
484
- return jsonResponse({ models: buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels) });
588
+ return jsonResponse({ models: buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config)) });
485
589
  }
486
590
  // OpenAI list shape: native gpt bare + routed models namespaced "<provider>/<id>"
487
591
  const data = [
@@ -510,6 +614,75 @@ export function startServer(port?: number) {
510
614
 
511
615
  return formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`);
512
616
  },
617
+ websocket: {
618
+ // Responses WebSocket data plane (phase 120.2). Re-frames the same SSE pipeline onto the
619
+ // socket: parse response.create → run handleResponses unchanged → pump its SSE body as WS
620
+ // Text frames. response.processed is a no-op ack. close() aborts the upstream (RC2 parity).
621
+ message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
622
+ let frame: Record<string, unknown>;
623
+ try {
624
+ frame = JSON.parse(typeof raw === "string" ? raw : raw.toString()) as Record<string, unknown>;
625
+ } catch {
626
+ return; // text-only contract; ignore unparseable frames
627
+ }
628
+ if (frame.type === "response.processed") return; // ack — no-op
629
+ if (frame.type !== "response.create") return;
630
+
631
+ ws.data.cancel?.();
632
+ const turnId = (ws.data.turnId ?? 0) + 1;
633
+ ws.data.turnId = turnId;
634
+ const isCurrent = () => ws.data.turnId === turnId;
635
+ const turnAbort = new AbortController();
636
+ const cancelTurn = () => {
637
+ turnAbort.abort("websocket turn superseded or closed");
638
+ };
639
+ ws.data.cancel = cancelTurn;
640
+
641
+ if (frame.generate === false) {
642
+ for (const payload of buildWarmupCompletionFrames(frame)) {
643
+ if (!isCurrent()) return;
644
+ sendTextFrame(ws, payload);
645
+ }
646
+ if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
647
+ return;
648
+ }
649
+
650
+ const payload: Record<string, unknown> = { ...frame };
651
+ delete payload.type;
652
+ void (async () => {
653
+ const logCtx = { model: "unknown", provider: "unknown" };
654
+ const fwd = new Headers({ "content-type": "application/json" });
655
+ ws.data.headers?.forEach((value, key) => fwd.set(key, value));
656
+ const req = new Request("http://localhost/v1/responses", {
657
+ method: "POST",
658
+ headers: fwd,
659
+ body: JSON.stringify({ ...payload, stream: true }),
660
+ });
661
+ try {
662
+ const response = await handleResponses(req, config, logCtx, {
663
+ forceEmptyResponseId: true,
664
+ abortSignal: turnAbort.signal,
665
+ });
666
+ await sendResponseToWebSocket(ws, response, isCurrent);
667
+ } catch (err) {
668
+ if (!isCurrent()) return;
669
+ try {
670
+ sendJsonFrame(ws, buildWsErrorFrame(502, {
671
+ type: "proxy_error",
672
+ message: err instanceof Error ? err.message : String(err),
673
+ }));
674
+ } catch {
675
+ /* socket already gone or send dropped */
676
+ }
677
+ } finally {
678
+ if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
679
+ }
680
+ })();
681
+ },
682
+ close(ws: ServerWebSocket<WsData>) {
683
+ ws.data.cancel?.(); // RC2: abort the upstream when the client disconnects
684
+ },
685
+ },
513
686
  });
514
687
 
515
688
  console.log(`🚀 opencodex proxy running on http://localhost:${listenPort}`);
package/src/service.ts CHANGED
@@ -28,10 +28,25 @@ function logPath(): string {
28
28
  return join(getConfigDir(), "service.log");
29
29
  }
30
30
 
31
+ function windowsServiceScriptPath(): string {
32
+ return join(getConfigDir(), "opencodex-service.cmd");
33
+ }
34
+
35
+ function plistString(value: string): string {
36
+ return value
37
+ .replace(/&/g, "&amp;")
38
+ .replace(/</g, "&lt;")
39
+ .replace(/>/g, "&gt;")
40
+ .replace(/"/g, "&quot;")
41
+ .replace(/'/g, "&apos;");
42
+ }
43
+
31
44
  export function buildPlist(): string {
32
45
  const { bun, cli } = cliEntry();
33
46
  const log = logPath();
34
47
  const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
48
+ const codexHome = process.env.CODEX_HOME?.trim();
49
+ const codexHomeXml = codexHome ? ` <key>CODEX_HOME</key><string>${plistString(codexHome)}</string>` : "";
35
50
  return `<?xml version="1.0" encoding="UTF-8"?>
36
51
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
37
52
  <plist version="1.0">
@@ -39,8 +54,8 @@ export function buildPlist(): string {
39
54
  <key>Label</key><string>${LABEL}</string>
40
55
  <key>ProgramArguments</key>
41
56
  <array>
42
- <string>${bun}</string>
43
- <string>${cli}</string>
57
+ <string>${plistString(bun)}</string>
58
+ <string>${plistString(cli)}</string>
44
59
  <string>start</string>
45
60
  </array>
46
61
  <key>RunAtLoad</key><true/>
@@ -48,19 +63,57 @@ export function buildPlist(): string {
48
63
  <key>EnvironmentVariables</key>
49
64
  <dict>
50
65
  <key>OCX_SERVICE</key><string>1</string>
51
- <key>PATH</key><string>${path}</string>
52
- </dict>
53
- <key>StandardOutPath</key><string>${log}</string>
54
- <key>StandardErrorPath</key><string>${log}</string>
66
+ <key>PATH</key><string>${plistString(path)}</string>
67
+ ${codexHomeXml ? `${codexHomeXml}\n` : ""} </dict>
68
+ <key>StandardOutPath</key><string>${plistString(log)}</string>
69
+ <key>StandardErrorPath</key><string>${plistString(log)}</string>
55
70
  </dict>
56
71
  </plist>
57
72
  `;
58
73
  }
59
74
 
75
+ function systemdQuote(value: string): string {
76
+ return `"${value
77
+ .replace(/\\/g, "\\\\")
78
+ .replace(/"/g, "\\\"")
79
+ .replace(/%/g, "%%")
80
+ .replace(/\n/g, "\\n")}"`;
81
+ }
82
+
83
+ function systemdEnvironmentAssignment(name: string, value: string | undefined): string | null {
84
+ if (!value) return null;
85
+ return `Environment=${systemdQuote(`${name}=${value}`)}`;
86
+ }
87
+
60
88
  function sh(cmd: string): string {
61
89
  return execSync(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }).trim();
62
90
  }
63
91
 
92
+ function windowsBatchValue(value: string): string {
93
+ return value.replace(/%/g, "%%").replace(/[\r\n]/g, "");
94
+ }
95
+
96
+ function windowsBatchSet(name: string, value: string | undefined): string | null {
97
+ if (!value) return null;
98
+ return `set "${name}=${windowsBatchValue(value)}"`;
99
+ }
100
+
101
+ export function buildWindowsServiceScript(): string {
102
+ const { bun, cli } = cliEntry();
103
+ const path = process.env.PATH ?? "";
104
+ const lines = [
105
+ "@echo off",
106
+ "setlocal",
107
+ windowsBatchSet("OCX_SERVICE", "1"),
108
+ windowsBatchSet("PATH", path),
109
+ windowsBatchSet("CODEX_HOME", process.env.CODEX_HOME?.trim()),
110
+ `"${bun}" "${cli}" start`,
111
+ "set \"OCX_EXIT=%ERRORLEVEL%\"",
112
+ "endlocal & exit /b %OCX_EXIT%",
113
+ ].filter((line): line is string => Boolean(line));
114
+ return `${lines.join("\r\n")}\r\n`;
115
+ }
116
+
64
117
  // ── macOS (launchd) ──
65
118
  function installLaunchd(): void {
66
119
  const dir = join(homedir(), "Library", "LaunchAgents");
@@ -82,14 +135,19 @@ function uninstallLaunchd(): void {
82
135
 
83
136
  // ── Windows (Task Scheduler) ──
84
137
  function installWindows(): void {
85
- const { bun, cli } = cliEntry();
86
- sh(`schtasks /create /tn ${TASK} /tr "\\"${bun}\\" \\"${cli}\\" start" /sc onlogon /rl highest /f`);
138
+ if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true });
139
+ const script = windowsServiceScriptPath();
140
+ writeFileSync(script, buildWindowsServiceScript(), "utf8");
141
+ sh(`schtasks /create /tn ${TASK} /tr "\\"${script}\\"" /sc onlogon /rl highest /f`);
87
142
  sh(`schtasks /run /tn ${TASK}`);
88
143
  }
89
144
  function startWindows(): void { sh(`schtasks /run /tn ${TASK}`); }
90
145
  function stopWindows(): void { try { sh(`schtasks /end /tn ${TASK}`); } catch { /* not running */ } }
91
146
  function statusWindows(): string { try { return sh(`schtasks /query /tn ${TASK}`); } catch { return ""; } }
92
- function uninstallWindows(): void { try { sh(`schtasks /delete /tn ${TASK} /f`); } catch { /* absent */ } }
147
+ function uninstallWindows(): void {
148
+ try { sh(`schtasks /delete /tn ${TASK} /f`); } catch { /* absent */ }
149
+ if (existsSync(windowsServiceScriptPath())) unlinkSync(windowsServiceScriptPath());
150
+ }
93
151
 
94
152
  // ── Linux (systemd user unit) ──
95
153
  function unitDir(): string {
@@ -104,6 +162,12 @@ export function buildUnit(): string {
104
162
  const { bun, cli } = cliEntry();
105
163
  const log = logPath();
106
164
  const path = process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin";
165
+ const codexHome = systemdEnvironmentAssignment("CODEX_HOME", process.env.CODEX_HOME?.trim());
166
+ const envLines = [
167
+ systemdEnvironmentAssignment("OCX_SERVICE", "1"),
168
+ systemdEnvironmentAssignment("PATH", path),
169
+ codexHome,
170
+ ].filter((line): line is string => Boolean(line)).join("\n");
107
171
  return `[Unit]
108
172
  Description=OpenCodex Proxy Server
109
173
  After=network-online.target
@@ -111,13 +175,12 @@ Wants=network-online.target
111
175
 
112
176
  [Service]
113
177
  Type=simple
114
- ExecStart=${bun} ${cli} start
178
+ ExecStart=${systemdQuote(bun)} ${systemdQuote(cli)} start
115
179
  Restart=on-failure
116
180
  RestartSec=5
117
- Environment=OCX_SERVICE=1
118
- Environment=PATH=${path}
119
- StandardOutput=append:${log}
120
- StandardError=append:${log}
181
+ ${envLines}
182
+ StandardOutput=${systemdQuote(`append:${log}`)}
183
+ StandardError=${systemdQuote(`append:${log}`)}
121
184
 
122
185
  [Install]
123
186
  WantedBy=default.target