@bitkyc08/opencodex 2.5.6 → 2.6.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.
Files changed (51) hide show
  1. package/README.ko.md +17 -6
  2. package/README.md +19 -7
  3. package/README.zh-CN.md +12 -3
  4. package/assets/architecture.png +0 -0
  5. package/assets/banner.png +0 -0
  6. package/assets/codex-app-picker.png +0 -0
  7. package/bin/ocx.mjs +88 -2
  8. package/bin/package-main.mjs +9 -0
  9. package/gui/dist/assets/index-BS4X1QDi.js +9 -0
  10. package/gui/dist/assets/{index-CKqUwc02.css → index-BwvDb198.css} +1 -1
  11. package/gui/dist/index.html +2 -2
  12. package/package.json +20 -6
  13. package/src/adapters/anthropic.ts +16 -5
  14. package/src/adapters/google.ts +9 -2
  15. package/src/adapters/openai-chat.ts +13 -5
  16. package/src/bun-runtime.ts +22 -1
  17. package/src/cli-help.ts +111 -0
  18. package/src/cli-status.ts +164 -0
  19. package/src/cli.ts +77 -186
  20. package/src/codex-account-store.ts +47 -8
  21. package/src/codex-auth-api.ts +111 -54
  22. package/src/codex-auth-collision.ts +5 -0
  23. package/src/codex-catalog.ts +24 -12
  24. package/src/codex-history-provider.ts +29 -13
  25. package/src/codex-inject.ts +46 -29
  26. package/src/codex-journal.ts +77 -13
  27. package/src/codex-quota.ts +11 -3
  28. package/src/codex-routing.ts +14 -4
  29. package/src/codex-shim.ts +71 -24
  30. package/src/codex-websocket-registry.ts +20 -4
  31. package/src/config.ts +138 -4
  32. package/src/init.ts +7 -2
  33. package/src/oauth/callback-server.ts +22 -15
  34. package/src/oauth/index.ts +18 -4
  35. package/src/oauth/login-cli.ts +8 -1
  36. package/src/oauth/store.ts +2 -1
  37. package/src/process-control.ts +36 -0
  38. package/src/provider-label.ts +8 -0
  39. package/src/responses/parser.ts +18 -1
  40. package/src/router.ts +61 -5
  41. package/src/server.ts +878 -94
  42. package/src/service-secrets.ts +6 -0
  43. package/src/service.ts +293 -28
  44. package/src/types.ts +26 -1
  45. package/src/update.ts +16 -9
  46. package/src/usage-debug.ts +65 -0
  47. package/src/usage-log.ts +62 -0
  48. package/src/usage-summary.ts +0 -0
  49. package/src/ws-bridge.ts +2 -2
  50. package/gui/README.md +0 -73
  51. package/gui/dist/assets/index-CSUvRNAX.js +0 -9
package/src/config.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { join } from "node:path";
4
+ import { join, resolve } from "node:path";
5
5
  import * as z from "zod/v4";
6
6
  import type { OcxConfig } from "./types";
7
7
 
@@ -16,8 +16,14 @@ export function atomicWriteFile(path: string, content: string): void {
16
16
  renameSync(tmp, path);
17
17
  }
18
18
 
19
+ let resolvedConfigDirCache: { raw: string | undefined; path: string } | null = null;
20
+
19
21
  function resolveConfigDir(): string {
20
- return process.env["OPENCODEX_HOME"] || join(homedir(), ".opencodex");
22
+ const raw = process.env["OPENCODEX_HOME"]?.trim() || undefined;
23
+ if (resolvedConfigDirCache && resolvedConfigDirCache.raw === raw) return resolvedConfigDirCache.path;
24
+ const path = raw ? resolve(raw) : join(homedir(), ".opencodex");
25
+ resolvedConfigDirCache = { raw, path };
26
+ return path;
21
27
  }
22
28
 
23
29
  function resolveConfigPath(): string {
@@ -28,6 +34,10 @@ function resolvePidPath(): string {
28
34
  return join(resolveConfigDir(), "ocx.pid");
29
35
  }
30
36
 
37
+ function resolveRuntimePortPath(): string {
38
+ return join(resolveConfigDir(), "runtime-port.json");
39
+ }
40
+
31
41
  const warnedConfigFallbacks = new Set<string>();
32
42
 
33
43
  const providerConfigSchema = z.object({
@@ -35,12 +45,35 @@ const providerConfigSchema = z.object({
35
45
  baseUrl: z.string().min(1),
36
46
  }).passthrough();
37
47
 
48
+ const RESERVED_PROVIDER_NAMES = new Set(["__proto__", "prototype", "constructor"]);
49
+ const PROVIDER_NAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$/;
50
+
51
+ export function isValidProviderName(name: string): boolean {
52
+ const trimmed = name.trim();
53
+ return trimmed === name
54
+ && PROVIDER_NAME_PATTERN.test(name)
55
+ && !RESERVED_PROVIDER_NAMES.has(name.toLowerCase());
56
+ }
57
+
58
+ export function hasOwnProvider(providers: Record<string, unknown>, name: string): boolean {
59
+ return Object.prototype.hasOwnProperty.call(providers, name);
60
+ }
61
+
38
62
  const configSchema = z.object({
39
63
  port: z.number().int().min(0).max(65535).default(10100),
40
64
  providers: z.record(z.string(), providerConfigSchema),
41
65
  defaultProvider: z.string().min(1).default("openai"),
42
66
  }).passthrough().superRefine((config, ctx) => {
43
- if (Object.keys(config.providers).length > 0 && !(config.defaultProvider in config.providers)) {
67
+ for (const name of Object.keys(config.providers)) {
68
+ if (!isValidProviderName(name)) {
69
+ ctx.addIssue({
70
+ code: "custom",
71
+ path: ["providers", name],
72
+ message: "provider names must use letters, numbers, dot, underscore, or hyphen and cannot be reserved JavaScript object keys",
73
+ });
74
+ }
75
+ }
76
+ if (!hasOwnProvider(config.providers, config.defaultProvider)) {
44
77
  ctx.addIssue({
45
78
  code: "custom",
46
79
  path: ["defaultProvider"],
@@ -70,6 +103,10 @@ export function getPidPath(): string {
70
103
  return resolvePidPath();
71
104
  }
72
105
 
106
+ export function getRuntimePortPath(): string {
107
+ return resolveRuntimePortPath();
108
+ }
109
+
73
110
  export function hardenConfigDir(): void {
74
111
  const dir = getConfigDir();
75
112
  if (existsSync(dir)) {
@@ -120,6 +157,57 @@ export function loadConfig(): OcxConfig {
120
157
  }
121
158
  }
122
159
 
160
+ export type ConfigDiagnostics = {
161
+ config: OcxConfig;
162
+ source: "default" | "file" | "fallback";
163
+ error: string | null;
164
+ };
165
+
166
+ function mergeConfigDefaults(parsed: unknown): unknown {
167
+ if (!parsed || typeof parsed !== "object") return parsed;
168
+ const defaults = getDefaultConfig();
169
+ const raw = parsed as Record<string, unknown>;
170
+ const merged: Record<string, unknown> = { ...defaults, ...raw };
171
+ if (raw.providers && typeof raw.providers === "object" && defaults.providers) {
172
+ merged.providers = { ...defaults.providers, ...(raw.providers as Record<string, unknown>) };
173
+ }
174
+ return merged;
175
+ }
176
+
177
+ function configIssuePaths(error: z.ZodError): string[] {
178
+ const paths = error.issues.map(issue => issue.path.join(".") || "config");
179
+ return [...new Set(paths)].sort();
180
+ }
181
+
182
+ function schemaDiagnosticsError(error: z.ZodError): string {
183
+ const paths = configIssuePaths(error);
184
+ return paths.length > 0 ? `schema_invalid: ${paths.join(", ")}` : "schema_invalid";
185
+ }
186
+
187
+ export function readConfigDiagnostics(): ConfigDiagnostics {
188
+ const configPath = getConfigPath();
189
+ if (!existsSync(configPath)) {
190
+ return { config: getDefaultConfig(), source: "default", error: null };
191
+ }
192
+ try {
193
+ const raw = readFileSync(configPath, "utf-8").replace(/^\uFEFF/, "");
194
+ const parsed = JSON.parse(raw);
195
+ const result = configSchema.safeParse(parsed);
196
+ if (result.success) {
197
+ return { config: result.data as OcxConfig, source: "file", error: null };
198
+ }
199
+
200
+ const retryResult = configSchema.safeParse(mergeConfigDefaults(parsed));
201
+ if (retryResult.success) {
202
+ return { config: retryResult.data as OcxConfig, source: "file", error: null };
203
+ }
204
+
205
+ return { config: getDefaultConfig(), source: "fallback", error: schemaDiagnosticsError(result.error) };
206
+ } catch {
207
+ return { config: getDefaultConfig(), source: "fallback", error: "invalid_json" };
208
+ }
209
+ }
210
+
123
211
  export function saveConfig(config: OcxConfig): void {
124
212
  const dir = getConfigDir();
125
213
  if (!existsSync(dir)) {
@@ -176,6 +264,34 @@ export function writePid(pid: number): void {
176
264
  atomicWriteFile(getPidPath(), String(pid));
177
265
  }
178
266
 
267
+ export type RuntimePortState = {
268
+ pid: number;
269
+ port: number;
270
+ hostname?: string;
271
+ };
272
+
273
+ function isValidRuntimePortState(value: unknown): value is RuntimePortState {
274
+ if (!value || typeof value !== "object") return false;
275
+ const state = value as Record<string, unknown>;
276
+ const hostnameOk = state.hostname === undefined || typeof state.hostname === "string";
277
+ return Number.isSafeInteger(state.pid)
278
+ && Number(state.pid) > 0
279
+ && Number.isInteger(state.port)
280
+ && Number(state.port) > 0
281
+ && Number(state.port) <= 65535
282
+ && hostnameOk;
283
+ }
284
+
285
+ export function writeRuntimePort(state: RuntimePortState): void {
286
+ const dir = getConfigDir();
287
+ if (!existsSync(dir)) {
288
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
289
+ } else {
290
+ hardenConfigDir();
291
+ }
292
+ atomicWriteFile(getRuntimePortPath(), JSON.stringify(state, null, 2) + "\n");
293
+ }
294
+
179
295
  export function readPid(): number | null {
180
296
  const pidPath = getPidPath();
181
297
  if (!existsSync(pidPath)) return null;
@@ -197,6 +313,17 @@ export function readPid(): number | null {
197
313
  }
198
314
  }
199
315
 
316
+ export function readRuntimePort(expectedPid?: number): RuntimePortState | null {
317
+ try {
318
+ const parsed = JSON.parse(readFileSync(getRuntimePortPath(), "utf-8"));
319
+ if (!isValidRuntimePortState(parsed)) return null;
320
+ if (expectedPid !== undefined && parsed.pid !== expectedPid) return null;
321
+ return parsed;
322
+ } catch {
323
+ return null;
324
+ }
325
+ }
326
+
200
327
  export function removePid(expectedPid?: number): void {
201
328
  if (expectedPid !== undefined && readPidFileValue() !== expectedPid) return;
202
329
  try {
@@ -219,6 +346,13 @@ function readPidFileValue(): number | null {
219
346
  }
220
347
  }
221
348
 
349
+ export function removeRuntimePort(expectedPid?: number): void {
350
+ if (expectedPid !== undefined && readRuntimePort(expectedPid) === null) return;
351
+ try {
352
+ unlinkSync(getRuntimePortPath());
353
+ } catch { /* ignore */ }
354
+ }
355
+
222
356
  export function parsePidFile(raw: string): number | null {
223
357
  const trimmed = raw.trim();
224
358
  if (!/^\d+$/.test(trimmed)) return null;
@@ -273,7 +407,7 @@ function warnAndBackupInvalidConfig(configPath: string, error: unknown): void {
273
407
  console.error(`Could not load opencodex config at ${configPath}: ${reason}. Using default config.${backupNote}`);
274
408
  }
275
409
 
276
- function backupInvalidConfig(configPath: string): string | null {
410
+ export function backupInvalidConfig(configPath: string): string | null {
277
411
  if (!existsSync(configPath)) return null;
278
412
  const backupPath = `${configPath}.invalid-${new Date().toISOString().replace(/[:.]/g, "-")}`;
279
413
  try {
package/src/init.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as readline from "node:readline";
2
2
  import { injectCodexConfig } from "./codex-inject";
3
- import { getDefaultConfig, saveConfig } from "./config";
3
+ import { getDefaultConfig, isValidProviderName, saveConfig } from "./config";
4
4
  import { enrichProviderFromCatalog } from "./oauth/key-providers";
5
5
  import { deriveInitProviders } from "./providers/derive";
6
6
  import type { OcxConfig, OcxProviderConfig } from "./types";
@@ -97,7 +97,12 @@ export async function runInit(): Promise<void> {
97
97
  enrichProviderFromCatalog(p.id, providerConfig);
98
98
  }
99
99
  } else {
100
- providerName = await prompt.ask("Provider name: ");
100
+ providerName = (await prompt.ask("Provider name: ")).trim();
101
+ if (!isValidProviderName(providerName)) {
102
+ console.error("Provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key.");
103
+ prompt.close();
104
+ process.exit(1);
105
+ }
101
106
  const baseUrl = await prompt.ask("Base URL (e.g. http://localhost:11434/v1): ");
102
107
  const adapter = await prompt.ask("Adapter [openai-chat]: ") || "openai-chat";
103
108
  const apiKey = await prompt.ask("API key (optional): ");
@@ -12,6 +12,7 @@ import type { OAuthController, OAuthCredentials } from "./types";
12
12
 
13
13
  const DEFAULT_TIMEOUT = 300_000;
14
14
  const DEFAULT_HOSTNAME = "localhost";
15
+ const DEFAULT_BIND_HOSTNAME = "127.0.0.1";
15
16
  const CALLBACK_PATH = "/callback";
16
17
 
17
18
  const SUCCESS_HTML =
@@ -67,13 +68,13 @@ export abstract class OAuthCallbackFlow {
67
68
  this.preferredPort = preferredPortOrOptions;
68
69
  this.callbackPath = callbackPath;
69
70
  this.callbackHostname = DEFAULT_HOSTNAME;
70
- this.callbackBindHostname = DEFAULT_HOSTNAME;
71
+ this.callbackBindHostname = DEFAULT_BIND_HOSTNAME;
71
72
  return;
72
73
  }
73
74
  this.preferredPort = preferredPortOrOptions.preferredPort;
74
75
  this.callbackPath = preferredPortOrOptions.callbackPath ?? CALLBACK_PATH;
75
76
  this.callbackHostname = preferredPortOrOptions.callbackHostname ?? DEFAULT_HOSTNAME;
76
- this.callbackBindHostname = preferredPortOrOptions.callbackBindHostname ?? this.callbackHostname;
77
+ this.callbackBindHostname = preferredPortOrOptions.callbackBindHostname ?? DEFAULT_BIND_HOSTNAME;
77
78
  this.redirectUri = preferredPortOrOptions.redirectUri;
78
79
  }
79
80
 
@@ -151,30 +152,36 @@ export abstract class OAuthCallbackFlow {
151
152
  const errorDescription = url.searchParams.get("error_description") || error;
152
153
 
153
154
  let ok = false;
155
+ let consumeFlow = false;
154
156
  let errMessage = "";
157
+ const stateMatches = !expectedState || state === expectedState;
155
158
  if (error) {
156
159
  errMessage = `Authorization failed: ${errorDescription}`;
160
+ consumeFlow = stateMatches;
157
161
  } else if (!code) {
158
162
  errMessage = "Missing authorization code";
159
- } else if (expectedState && state !== expectedState) {
163
+ } else if (!stateMatches) {
160
164
  errMessage = "State mismatch - possible CSRF attack";
161
165
  } else {
162
166
  ok = true;
167
+ consumeFlow = true;
163
168
  }
164
169
 
165
- // Capture refs before they could be cleared, then resolve on the next microtask.
166
- const resolve = this.#callbackResolve;
167
- const reject = this.#callbackReject;
168
- queueMicrotask(() => {
169
- if (ok && code) {
170
- resolve?.({ code, state });
171
- } else {
172
- reject?.(errMessage || "Unknown error");
173
- }
174
- });
170
+ if (consumeFlow) {
171
+ // Capture refs before they could be cleared, then resolve on the next microtask.
172
+ const resolve = this.#callbackResolve;
173
+ const reject = this.#callbackReject;
174
+ queueMicrotask(() => {
175
+ if (ok && code) {
176
+ resolve?.({ code, state });
177
+ } else {
178
+ reject?.(errMessage || "Unknown error");
179
+ }
180
+ });
181
+ }
175
182
 
176
183
  return new Response(ok ? SUCCESS_HTML : errorHtml(errMessage), {
177
- status: ok ? 200 : 500,
184
+ status: ok ? 200 : consumeFlow ? 500 : 400,
178
185
  headers: { "Content-Type": "text/html" },
179
186
  });
180
187
  }
@@ -204,7 +211,7 @@ export abstract class OAuthCallbackFlow {
204
211
  .then((input): CallbackResult | null => {
205
212
  const parsed = parseCallbackInput(input);
206
213
  if (!parsed.code) return null;
207
- if (expectedState && parsed.state && parsed.state !== expectedState) return null;
214
+ if (expectedState && parsed.state !== expectedState) return null;
208
215
  return { code: parsed.code, state: parsed.state ?? "" };
209
216
  })
210
217
  .catch((): CallbackResult | null => null),
@@ -69,12 +69,26 @@ export function listOAuthProviders(): string[] {
69
69
  return Object.keys(OAUTH_PROVIDERS);
70
70
  }
71
71
 
72
+ export class UnsupportedOAuthProviderError extends Error {
73
+ constructor(provider: string) {
74
+ super(`Unsupported OAuth provider in config: ${provider}`);
75
+ this.name = "UnsupportedOAuthProviderError";
76
+ }
77
+ }
78
+
79
+ export class OAuthLoginRequiredError extends Error {
80
+ constructor(provider: string) {
81
+ super(`Not logged in to ${provider}. Run: ocx login ${provider}`);
82
+ this.name = "OAuthLoginRequiredError";
83
+ }
84
+ }
85
+
72
86
  /** Return a valid access token, refreshing + persisting if expired. Throws if not logged in. */
73
87
  export async function getValidAccessToken(provider: string): Promise<string> {
74
88
  const def = OAUTH_PROVIDERS[provider];
75
- if (!def) throw new Error(`Unknown OAuth provider: ${provider}`);
89
+ if (!def) throw new UnsupportedOAuthProviderError(provider);
76
90
  const cred = getCredential(provider);
77
- if (!cred) throw new Error(`Not logged in to ${provider}. Run: ocx login ${provider}`);
91
+ if (!cred) throw new OAuthLoginRequiredError(provider);
78
92
  if (cred.expires > Date.now() + REFRESH_SKEW_MS) return cred.access;
79
93
  const fresh = await def.refresh(cred.refresh);
80
94
  saveCredential(provider, fresh);
@@ -190,7 +204,7 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
190
204
  /** Run the login flow, persist the credential + upsert the provider entry to disk, return cred. */
191
205
  export async function runLogin(provider: string, ctrl: OAuthController, opts?: LoginOpts): Promise<OAuthCredentials> {
192
206
  const def = OAUTH_PROVIDERS[provider];
193
- if (!def) throw new Error(`Unknown OAuth provider: ${provider}`);
207
+ if (!def) throw new UnsupportedOAuthProviderError(provider);
194
208
  const cred = await def.login(ctrl, opts);
195
209
  saveCredential(provider, cred);
196
210
  const config = loadConfig();
@@ -231,7 +245,7 @@ export function cancelLoginFlow(provider: string): boolean {
231
245
 
232
246
  export async function startLoginFlow(provider: string, opts?: LoginOpts): Promise<{ url: string; instructions?: string }> {
233
247
  const def = OAUTH_PROVIDERS[provider];
234
- if (!def) throw new Error(`Unknown OAuth provider: ${provider}`);
248
+ if (!def) throw new UnsupportedOAuthProviderError(provider);
235
249
  const existing = loginState.get(provider);
236
250
  if (existing && !existing.done) {
237
251
  throw new Error(`A login for ${provider} is already in progress`);
@@ -5,6 +5,13 @@ import { OAUTH_PROVIDERS, runLogin } from "./index";
5
5
  import { KEY_LOGIN_PROVIDERS, isKeyLoginProvider, validateApiKey, type KeyLoginProvider } from "./key-providers";
6
6
  import type { OcxProviderConfig } from "../types";
7
7
 
8
+ export function runningProxyUpdateHeaders(): Headers {
9
+ const headers = new Headers({ "Content-Type": "application/json" });
10
+ const apiToken = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
11
+ if (apiToken) headers.set("X-OpenCodex-API-Key", apiToken);
12
+ return headers;
13
+ }
14
+
8
15
  /** Push the new provider into a running proxy's live config so it routes without a restart. */
9
16
  async function notifyRunningProxy(name: string, provider: unknown): Promise<void> {
10
17
  if (!readPid()) return;
@@ -12,7 +19,7 @@ async function notifyRunningProxy(name: string, provider: unknown): Promise<void
12
19
  try {
13
20
  await fetch(`http://localhost:${cfg.port}/api/providers`, {
14
21
  method: "POST",
15
- headers: { "Content-Type": "application/json" },
22
+ headers: runningProxyUpdateHeaders(),
16
23
  body: JSON.stringify({ name, provider }),
17
24
  });
18
25
  } catch {
@@ -1,7 +1,7 @@
1
1
  /** OAuth token store at ~/.opencodex/auth.json, keyed by provider name. */
2
2
  import { existsSync, mkdirSync, readFileSync, chmodSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import { getConfigDir, atomicWriteFile, hardenConfigDir, hardenExistingSecret } from "../config";
4
+ import { getConfigDir, atomicWriteFile, backupInvalidConfig, hardenConfigDir, hardenExistingSecret } from "../config";
5
5
  import type { OAuthCredentials } from "./types";
6
6
 
7
7
  type AuthStore = Record<string, OAuthCredentials>;
@@ -18,6 +18,7 @@ export function loadAuthStore(): AuthStore {
18
18
  try {
19
19
  return JSON.parse(readFileSync(path, "utf-8")) as AuthStore;
20
20
  } catch {
21
+ backupInvalidConfig(path);
21
22
  return {};
22
23
  }
23
24
  }
@@ -0,0 +1,36 @@
1
+ import { execFileSync } from "node:child_process";
2
+
3
+ export function isProcessAlive(pid: number): boolean {
4
+ try {
5
+ process.kill(pid, 0);
6
+ return true;
7
+ } catch {
8
+ return false;
9
+ }
10
+ }
11
+
12
+ export function waitForExit(pid: number, timeoutMs: number): boolean {
13
+ const deadline = Date.now() + timeoutMs;
14
+ const marker = new Int32Array(new SharedArrayBuffer(4));
15
+ while (Date.now() < deadline) {
16
+ if (!isProcessAlive(pid)) return true;
17
+ Atomics.wait(marker, 0, 0, 50);
18
+ }
19
+ return !isProcessAlive(pid);
20
+ }
21
+
22
+ export function killProxy(pid: number): void {
23
+ if (!isProcessAlive(pid)) return;
24
+ if (process.platform === "win32") {
25
+ const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
26
+ try {
27
+ execFileSync(taskkill, ["/PID", String(pid), "/T", "/F"], { stdio: "pipe" });
28
+ } catch (err) {
29
+ if (isProcessAlive(pid)) throw err;
30
+ }
31
+ } else {
32
+ process.kill(pid, "SIGTERM");
33
+ if (!waitForExit(pid, 5000)) process.kill(pid, "SIGKILL");
34
+ }
35
+ if (!waitForExit(pid, 5000)) throw new Error(`process ${pid} did not exit`);
36
+ }
@@ -0,0 +1,8 @@
1
+ import { CODEX_ACCOUNT_LOG_LABEL_RE } from "./codex-account-label";
2
+
3
+ export function baseProviderLabel(provider: string): string {
4
+ const cut = provider.lastIndexOf("-");
5
+ if (cut <= 0) return provider;
6
+ const suffix = provider.slice(cut + 1);
7
+ return CODEX_ACCOUNT_LOG_LABEL_RE.test(suffix) ? provider.slice(0, cut) : provider;
8
+ }
@@ -12,7 +12,7 @@ import type {
12
12
  } from "../types";
13
13
  import { namespacedToolName } from "../types";
14
14
  import { responsesRequestSchema } from "./schema";
15
- import { extractHostedWebSearch } from "../web-search/synthetic-tool";
15
+ import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool";
16
16
 
17
17
  function isObj(v: unknown): v is Record<string, unknown> {
18
18
  return typeof v === "object" && v !== null && !Array.isArray(v);
@@ -73,11 +73,27 @@ function mapToolChoice(value: unknown): OcxRequestOptions["toolChoice"] {
73
73
  if ((t === "function" || t === "custom") && "name" in value) {
74
74
  return { name: (value as { name: string }).name };
75
75
  }
76
+ if (t === "allowed_tools" && Array.isArray(value.tools)) {
77
+ const names = value.tools
78
+ .map(allowedToolName)
79
+ .filter((name): name is string => Boolean(name));
80
+ return names.length > 0
81
+ ? { allowedTools: [...new Set(names)], mode: value.mode === "required" ? "required" : "auto" }
82
+ : "none";
83
+ }
76
84
  return "auto";
77
85
  }
78
86
  return undefined;
79
87
  }
80
88
 
89
+ function allowedToolName(tool: unknown): string | undefined {
90
+ if (!isObj(tool)) return undefined;
91
+ if (typeof tool.name === "string" && tool.name.length > 0) return tool.name;
92
+ if (tool.type === "web_search" || tool.type === "web_search_preview") return WEB_SEARCH_TOOL_NAME;
93
+ if (tool.type === "tool_search") return "tool_search";
94
+ return undefined;
95
+ }
96
+
81
97
  function buildTools(tools: unknown[] | undefined): OcxTool[] | undefined {
82
98
  if (!tools) return undefined;
83
99
  const out: OcxTool[] = [];
@@ -379,6 +395,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
379
395
  if (!summaryMode || summaryMode === "none") options.hideThinkingSummary = true;
380
396
  if (data.presence_penalty !== undefined) options.presencePenalty = data.presence_penalty;
381
397
  if (data.frequency_penalty !== undefined) options.frequencyPenalty = data.frequency_penalty;
398
+ if (data.service_tier !== undefined) options.serviceTier = data.service_tier;
382
399
 
383
400
  // Stash the hosted web_search config (if Codex enabled it) so the proxy can run searches via the
384
401
  // gpt-mini sidecar for routed providers. buildTools still drops the hosted tool; the sidecar path
package/src/router.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { OcxConfig, OcxProviderConfig } from "./types";
2
- import { resolveEnvValue } from "./config";
2
+ import { hasOwnProvider, resolveEnvValue } from "./config";
3
3
  import { PROVIDER_REGISTRY } from "./providers/registry";
4
4
 
5
5
  interface RouteResult {
@@ -42,17 +42,73 @@ function mergeNestedRecord(
42
42
  return out;
43
43
  }
44
44
 
45
+ function mergeStringArray(
46
+ seed: string[] | undefined,
47
+ user: string[] | undefined,
48
+ ): string[] | undefined {
49
+ if (!seed && !user) return undefined;
50
+ return [...new Set([...(seed ?? []), ...(user ?? [])])];
51
+ }
52
+
53
+ function mergeRecordFill<T>(
54
+ seed: Record<string, T> | undefined,
55
+ user: Record<string, T> | undefined,
56
+ ): Record<string, T> | undefined {
57
+ if (!seed && !user) return undefined;
58
+ return { ...(seed ?? {}), ...(user ?? {}) };
59
+ }
60
+
61
+ function mergeStringArrayRecord(
62
+ seed: Record<string, string[]> | undefined,
63
+ user: Record<string, string[]> | undefined,
64
+ ): Record<string, string[]> | undefined {
65
+ if (!seed && !user) return undefined;
66
+ const out: Record<string, string[]> = {};
67
+ for (const [key, value] of Object.entries(seed ?? {})) out[key] = [...value];
68
+ for (const [key, value] of Object.entries(user ?? {})) out[key] = [...value];
69
+ return out;
70
+ }
71
+
45
72
  function routedProviderConfig(providerName: string, provider: OcxProviderConfig): OcxProviderConfig {
46
73
  const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName);
47
74
  if (!registryEntry) return { ...provider, apiKey: resolveEnvValue(provider.apiKey) };
75
+ const canonicalAuthMode = registryEntry.authKind === "forward" || registryEntry.authKind === "oauth"
76
+ ? registryEntry.authKind
77
+ : provider.authMode === "forward" ? undefined : provider.authMode;
48
78
  const reasoningEffortMap = mergeRecord(registryEntry.reasoningEffortMap, provider.reasoningEffortMap);
49
79
  const modelReasoningEffortMap = mergeNestedRecord(registryEntry.modelReasoningEffortMap, provider.modelReasoningEffortMap);
80
+ const modelReasoningEfforts = mergeStringArrayRecord(registryEntry.modelReasoningEfforts, provider.modelReasoningEfforts);
81
+ const modelContextWindows = mergeRecordFill(registryEntry.modelContextWindows, provider.modelContextWindows);
82
+ const modelInputModalities = mergeRecordFill(registryEntry.modelInputModalities, provider.modelInputModalities);
83
+ const noVisionModels = mergeStringArray(registryEntry.noVisionModels, provider.noVisionModels);
84
+ const noReasoningModels = mergeStringArray(registryEntry.noReasoningModels, provider.noReasoningModels);
85
+ const noTemperatureModels = mergeStringArray(registryEntry.noTemperatureModels, provider.noTemperatureModels);
86
+ const noTopPModels = mergeStringArray(registryEntry.noTopPModels, provider.noTopPModels);
87
+ const noPenaltyModels = mergeStringArray(registryEntry.noPenaltyModels, provider.noPenaltyModels);
88
+ const autoToolChoiceOnlyModels = mergeStringArray(registryEntry.autoToolChoiceOnlyModels, provider.autoToolChoiceOnlyModels);
89
+ const preserveReasoningContentModels = mergeStringArray(registryEntry.preserveReasoningContentModels, provider.preserveReasoningContentModels);
50
90
 
51
91
  return {
52
92
  ...provider,
93
+ adapter: registryEntry.adapter,
94
+ baseUrl: registryEntry.baseUrl,
95
+ authMode: canonicalAuthMode,
53
96
  apiKey: resolveEnvValue(provider.apiKey),
97
+ ...(provider.contextWindow === undefined && registryEntry.contextWindow !== undefined ? { contextWindow: registryEntry.contextWindow } : {}),
98
+ ...(provider.reasoningEfforts === undefined && registryEntry.reasoningEfforts !== undefined ? { reasoningEfforts: registryEntry.reasoningEfforts } : {}),
99
+ ...(provider.escapeBuiltinToolNames === undefined && registryEntry.escapeBuiltinToolNames !== undefined ? { escapeBuiltinToolNames: registryEntry.escapeBuiltinToolNames } : {}),
100
+ ...(modelContextWindows ? { modelContextWindows } : {}),
101
+ ...(modelInputModalities ? { modelInputModalities } : {}),
102
+ ...(modelReasoningEfforts ? { modelReasoningEfforts } : {}),
54
103
  ...(reasoningEffortMap ? { reasoningEffortMap } : {}),
55
104
  ...(modelReasoningEffortMap ? { modelReasoningEffortMap } : {}),
105
+ ...(noVisionModels ? { noVisionModels } : {}),
106
+ ...(noReasoningModels ? { noReasoningModels } : {}),
107
+ ...(noTemperatureModels ? { noTemperatureModels } : {}),
108
+ ...(noTopPModels ? { noTopPModels } : {}),
109
+ ...(noPenaltyModels ? { noPenaltyModels } : {}),
110
+ ...(autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels } : {}),
111
+ ...(preserveReasoningContentModels ? { preserveReasoningContentModels } : {}),
56
112
  };
57
113
  }
58
114
 
@@ -64,8 +120,8 @@ export function routeModel(config: OcxConfig, modelId: string): RouteResult {
64
120
  const slash = modelId.indexOf("/");
65
121
  if (slash > 0) {
66
122
  const provName = modelId.slice(0, slash);
67
- const prov = config.providers[provName];
68
- if (prov) {
123
+ if (hasOwnProvider(config.providers, provName)) {
124
+ const prov = config.providers[provName];
69
125
  return {
70
126
  providerName: provName,
71
127
  provider: routedProviderConfig(provName, prov),
@@ -110,8 +166,8 @@ export function routeModel(config: OcxConfig, modelId: string): RouteResult {
110
166
  }
111
167
  }
112
168
 
113
- const defaultProv = config.providers[config.defaultProvider];
114
- if (defaultProv) {
169
+ if (hasOwnProvider(config.providers, config.defaultProvider)) {
170
+ const defaultProv = config.providers[config.defaultProvider];
115
171
  return {
116
172
  providerName: config.defaultProvider,
117
173
  provider: routedProviderConfig(config.defaultProvider, defaultProv),