@bitkyc08/opencodex 2.7.38-preview.20260724 → 2.7.39

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 (46) hide show
  1. package/README.ja.md +8 -1
  2. package/README.ko.md +7 -1
  3. package/README.md +7 -1
  4. package/README.ru.md +7 -1
  5. package/README.zh-CN.md +7 -1
  6. package/gui/dist/assets/index-B-cheu55.js +52 -0
  7. package/gui/dist/assets/index-oOZcqVmj.css +1 -0
  8. package/gui/dist/index.html +2 -2
  9. package/package.json +1 -1
  10. package/src/adapters/anthropic.ts +22 -2
  11. package/src/adapters/cursor/live-transport.ts +7 -0
  12. package/src/adapters/cursor/message-mapper.ts +3 -0
  13. package/src/adapters/cursor/protobuf-request.ts +223 -27
  14. package/src/adapters/cursor/request-builder.ts +41 -15
  15. package/src/adapters/cursor/thread-continuity.ts +67 -0
  16. package/src/adapters/cursor/types.ts +3 -1
  17. package/src/adapters/cursor.ts +44 -9
  18. package/src/adapters/google.ts +115 -62
  19. package/src/adapters/kiro.ts +3 -17
  20. package/src/adapters/openai-chat.ts +16 -5
  21. package/src/adapters/openai-responses.ts +56 -1
  22. package/src/adapters/run-turn-queue.ts +11 -1
  23. package/src/bridge.ts +139 -69
  24. package/src/chat/outbound.ts +135 -73
  25. package/src/cli/codex-shim-autorestore.ts +45 -0
  26. package/src/cli/index.ts +6 -2
  27. package/src/codex/auth-context.ts +18 -2
  28. package/src/codex/catalog/provider-fetch.ts +31 -8
  29. package/src/codex/model-cache.ts +44 -0
  30. package/src/codex/runtime.ts +17 -1
  31. package/src/codex/shim.ts +608 -10
  32. package/src/combos/resolve.ts +7 -2
  33. package/src/config.ts +11 -0
  34. package/src/lib/sse-decoder.ts +25 -6
  35. package/src/responses/parser.ts +1 -1
  36. package/src/responses/state.ts +10 -2
  37. package/src/server/auth-cors.ts +4 -1
  38. package/src/server/index.ts +182 -0
  39. package/src/server/live.ts +491 -0
  40. package/src/server/management/provider-routes.ts +2 -0
  41. package/src/server/responses/core.ts +184 -20
  42. package/src/server/responses/encrypted-payload.ts +118 -41
  43. package/src/server/ws-bridge.ts +7 -0
  44. package/src/types.ts +14 -0
  45. package/gui/dist/assets/index-BpX-hoSd.css +0 -1
  46. package/gui/dist/assets/index-CprFnVjr.js +0 -52
@@ -7,7 +7,11 @@ import {
7
7
  import { markAccountNeedsReauth } from "./account-runtime-state";
8
8
  import { isCodexAccountUsable } from "./account-usability";
9
9
  import { MAIN_CODEX_ACCOUNT_ID, getMainAccountToken } from "./main-account";
10
- import { getCodexAccountCooldownUntil, resolveCodexAccountForThreadDetailed } from "./routing";
10
+ import {
11
+ getCodexAccountCooldownUntil,
12
+ pickLowestUsageCodexAccount,
13
+ resolveCodexAccountForThreadDetailed,
14
+ } from "./routing";
11
15
  import { getAccountQuota } from "./quota";
12
16
  import type { CodexAccountMode, OcxConfig, OcxProviderConfig } from "../types";
13
17
  import { FORWARD_HEADERS } from "../adapters/openai-responses";
@@ -89,17 +93,29 @@ export function shouldMarkAccountNeedsReauthForCodexAuthFailure(cause: unknown):
89
93
  return !(cause instanceof CodexCredentialGenerationConflictError) && !(cause instanceof CodexCredentialRefreshLockTimeoutError);
90
94
  }
91
95
 
96
+ export interface ResolveCodexAuthContextOptions {
97
+ excludeAccountId?: string;
98
+ }
99
+
92
100
  export async function resolveCodexAuthContext(
93
101
  headers: Headers,
94
102
  config: OcxConfig,
95
103
  mode: CodexAccountMode,
104
+ options: ResolveCodexAuthContextOptions = {},
96
105
  ): Promise<CodexAuthContext> {
97
106
  if (mode === "direct") {
98
107
  if (!hasCallerCodexBearer(headers)) throw new CodexDirectAuthenticationError();
99
108
  return { kind: "main", accountId: null };
100
109
  }
101
110
  const threadId = headers.get("x-codex-parent-thread-id");
102
- const resolution = resolveCodexAccountForThreadDetailed(threadId, config);
111
+ const resolution = options.excludeAccountId
112
+ ? (() => {
113
+ const accountId = pickLowestUsageCodexAccount(config, options.excludeAccountId);
114
+ return accountId
115
+ ? { status: "selected" as const, accountId }
116
+ : { status: "none" as const };
117
+ })()
118
+ : resolveCodexAccountForThreadDetailed(threadId, config);
103
119
  if (resolution.status === "expired") throw new CodexThreadAffinityExpiredError(resolution.accountId);
104
120
  const accountId = resolution.status === "selected" ? resolution.accountId : null;
105
121
  if (!accountId) throw new CodexPoolAuthenticationError();
@@ -4,7 +4,19 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync } from
4
4
  import { delimiter, dirname, join, resolve } from "node:path";
5
5
  import { atomicWriteFile, expandUserPath, getConfigDir, websocketsEnabled } from "../../config";
6
6
  import { CODEX_CONFIG_PATH, CODEX_MODELS_CACHE_PATH, DEFAULT_CATALOG_PATH, readRootTomlString, resolveCodexConfigPath } from "../paths";
7
- import { clearModelCache, DEFAULT_MODEL_CACHE_TTL_MS, getFreshCached, getStaleCached, isModelsFetchCoolingDown, markModelsFetchFailure, setCached } from "../model-cache";
7
+ import {
8
+ clearModelCache,
9
+ clearProviderDiscoveryStatus,
10
+ DEFAULT_MODEL_CACHE_TTL_MS,
11
+ getFreshCached,
12
+ getStaleCached,
13
+ isModelsFetchCoolingDown,
14
+ markModelsFetchFailure,
15
+ markProviderDiscoveryFailed,
16
+ markProviderDiscoveryOk,
17
+ setCached,
18
+ type ProviderModelDiscoveryFailure,
19
+ } from "../model-cache";
8
20
  import { buildModelsRequest, resolveModelsAuthToken } from "../../oauth";
9
21
  import type { OcxConfig, OcxProviderConfig } from "../../types";
10
22
  import { modelInList } from "../../types";
@@ -235,7 +247,11 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
235
247
  : models
236
248
  );
237
249
  if (prov.adapter === "cursor") {
238
- if (prov.liveModels === false || !apiKey) return configured;
250
+ if (prov.liveModels === false) {
251
+ clearProviderDiscoveryStatus(name);
252
+ return configured;
253
+ }
254
+ if (!apiKey) return configured;
239
255
  // Cursor uses a bespoke GetUsableModels RPC (not /models), returning the full effort-suffixed
240
256
  // variants this PLAN can use. Keep the base-model UX (the request builder appends the effort
241
257
  // suffix) but filter the static seed to the bases the account actually has — so models not on the
@@ -250,10 +266,12 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
250
266
  if (liveResult.ok) {
251
267
  const available = filterCursorConfiguredModelsByLiveDiscovery(configured, liveResult.models);
252
268
  const result = available.length > 0 ? available : configured;
269
+ markProviderDiscoveryOk(name);
253
270
  setCached(name, result);
254
271
  return result;
255
272
  }
256
273
  markModelsFetchFailure(name);
274
+ markProviderDiscoveryFailed(name, { reason: "provider" });
257
275
  console.warn(
258
276
  `[opencodex] Cursor model discovery for "${name}" failed [${liveResult.error}]${liveResult.detail ? `: ${liveResult.detail}` : ""}; using stale/static catalog degradation.`,
259
277
  );
@@ -267,6 +285,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
267
285
  return configured;
268
286
  }
269
287
  if (prov.liveModels === false) {
288
+ clearProviderDiscoveryStatus(name);
270
289
  return configured;
271
290
  }
272
291
  const fresh = getFreshCached(name, ttlMs);
@@ -281,8 +300,11 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
281
300
  const urlClass = new URL(url).hostname.endsWith("aiplatform.googleapis.com")
282
301
  ? "vertex-aiplatform"
283
302
  : "provider-models";
284
- const failedDiscoveryFallback = (): { models: CatalogModel[]; fallback: "stale" | "configured" } => {
303
+ const failedDiscoveryFallback = (
304
+ failure: ProviderModelDiscoveryFailure,
305
+ ): { models: CatalogModel[]; fallback: "stale" | "configured" } => {
285
306
  markModelsFetchFailure(name);
307
+ markProviderDiscoveryFailed(name, failure);
286
308
  const stale = getStaleCached(name);
287
309
  return {
288
310
  models: stale
@@ -297,7 +319,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
297
319
  allowPrivateNetwork: prov.allowPrivateNetwork,
298
320
  });
299
321
  if (destinationError) {
300
- const { models, fallback } = failedDiscoveryFallback();
322
+ const { models, fallback } = failedDiscoveryFallback({ reason: "blocked" });
301
323
  console.warn(
302
324
  `[opencodex] Provider model discovery for "${name}" was blocked by destination policy: ${destinationError} [urlClass=${urlClass}, fallback=${fallback}].`,
303
325
  );
@@ -306,7 +328,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
306
328
 
307
329
  const res = await fetch(url, { headers, signal: AbortSignal.timeout(8000) });
308
330
  if (!res.ok) {
309
- const { models, fallback } = failedDiscoveryFallback();
331
+ const { models, fallback } = failedDiscoveryFallback({ reason: "http", httpStatus: res.status });
310
332
  console.warn(
311
333
  `[opencodex] Provider model discovery for "${name}" failed with HTTP ${res.status} [urlClass=${urlClass}, fallback=${fallback}].`,
312
334
  );
@@ -321,7 +343,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
321
343
  try {
322
344
  json = JSON.parse(body) as unknown;
323
345
  } catch {
324
- const { models, fallback } = failedDiscoveryFallback();
346
+ const { models, fallback } = failedDiscoveryFallback({ reason: "invalid_response" });
325
347
  const diagnostic = contentType === "application/json" || contentType.endsWith("+json")
326
348
  ? "returned invalid JSON in a 2xx response"
327
349
  : "returned a non-JSON 2xx response";
@@ -334,7 +356,7 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
334
356
  ? (json as { data?: unknown }).data
335
357
  : undefined;
336
358
  if (!isProviderModelsApiItems(data)) {
337
- const { models, fallback } = failedDiscoveryFallback();
359
+ const { models, fallback } = failedDiscoveryFallback({ reason: "invalid_response" });
338
360
  console.warn(
339
361
  `[opencodex] Provider model discovery for "${name}" returned malformed 2xx data [status=${res.status}, contentType=${contentType}, urlClass=${urlClass}, fallback=${fallback}].`,
340
362
  );
@@ -376,10 +398,11 @@ export async function fetchProviderModels(name: string, prov: OcxProviderConfig,
376
398
  && !QUIET_AUTHORITATIVE_CATALOG_PROVIDERS.has(name)) {
377
399
  warnDroppedConfiguredIdsOnce(name, droppedConfiguredIds);
378
400
  }
401
+ markProviderDiscoveryOk(name);
379
402
  setCached(name, live);
380
403
  return live;
381
404
  } catch (error) {
382
- const { models, fallback } = failedDiscoveryFallback();
405
+ const { models, fallback } = failedDiscoveryFallback({ reason: "network" });
383
406
  console.warn(
384
407
  `[opencodex] Provider model discovery for "${name}" threw ${error instanceof Error ? error.name : "unknown"} [urlClass=${urlClass}, fallback=${fallback}].`,
385
408
  );
@@ -17,6 +17,28 @@ interface CacheEntry {
17
17
  fetchedAt: number;
18
18
  }
19
19
 
20
+ export type ProviderModelDiscoveryFailureReason =
21
+ | "http"
22
+ | "blocked"
23
+ | "invalid_response"
24
+ | "network"
25
+ | "provider";
26
+
27
+ export type ProviderModelDiscoveryStatus =
28
+ | { status: "ok" }
29
+ | { status: "failed"; reason: "http"; httpStatus: number }
30
+ | {
31
+ status: "failed";
32
+ reason: Exclude<ProviderModelDiscoveryFailureReason, "http">;
33
+ httpStatus?: never;
34
+ };
35
+
36
+ export type ProviderModelDiscoveryFailure = ProviderModelDiscoveryStatus extends infer Status
37
+ ? Status extends { status: "failed" }
38
+ ? Omit<Status, "status">
39
+ : never
40
+ : never;
41
+
20
42
  const cache = new Map<string, CacheEntry>();
21
43
 
22
44
  /** Cooldown after a failed live `/models` fetch, so a dead/unreachable provider doesn't re-pay
@@ -24,11 +46,31 @@ const cache = new Map<string, CacheEntry>();
24
46
  export const MODELS_FETCH_FAILURE_COOLDOWN_MS = 30_000;
25
47
 
26
48
  const failureAt = new Map<string, number>();
49
+ const discoveryStatus = new Map<string, ProviderModelDiscoveryStatus>();
27
50
 
28
51
  export function markModelsFetchFailure(provider: string, now = Date.now()): void {
29
52
  failureAt.set(provider, now);
30
53
  }
31
54
 
55
+ export function markProviderDiscoveryOk(provider: string): void {
56
+ discoveryStatus.set(provider, { status: "ok" });
57
+ }
58
+
59
+ export function markProviderDiscoveryFailed(
60
+ provider: string,
61
+ failure: ProviderModelDiscoveryFailure,
62
+ ): void {
63
+ discoveryStatus.set(provider, { status: "failed", ...failure });
64
+ }
65
+
66
+ export function clearProviderDiscoveryStatus(provider: string): void {
67
+ discoveryStatus.delete(provider);
68
+ }
69
+
70
+ export function getProviderDiscoveryStatus(provider: string): ProviderModelDiscoveryStatus | undefined {
71
+ return discoveryStatus.get(provider);
72
+ }
73
+
32
74
  export function isModelsFetchCoolingDown(provider: string, cooldownMs = MODELS_FETCH_FAILURE_COOLDOWN_MS, now = Date.now()): boolean {
33
75
  const at = failureAt.get(provider);
34
76
  return at !== undefined && now - at < cooldownMs;
@@ -55,8 +97,10 @@ export function clearModelCache(provider?: string): void {
55
97
  if (provider) {
56
98
  cache.delete(provider);
57
99
  failureAt.delete(provider);
100
+ discoveryStatus.delete(provider);
58
101
  } else {
59
102
  cache.clear();
60
103
  failureAt.clear();
104
+ discoveryStatus.clear();
61
105
  }
62
106
  }
@@ -1,5 +1,6 @@
1
1
  import { execFileSync } from "node:child_process";
2
- import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, unlinkSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
3
4
  import { delimiter, join } from "node:path";
4
5
  import { atomicWriteFile, getConfigDir } from "../config";
5
6
  import { codexExecInvocation, isSpawnableCodexCandidate } from "./exec-invocation";
@@ -50,6 +51,7 @@ export type RuntimeExecFile = (
50
51
  windowsHide: boolean;
51
52
  shell?: boolean;
52
53
  windowsVerbatimArguments?: boolean;
54
+ env?: NodeJS.ProcessEnv;
53
55
  },
54
56
  ) => string;
55
57
 
@@ -239,7 +241,14 @@ function probeVersion(
239
241
  }
240
242
  }
241
243
  const execFile = deps.execFileSync ?? (execFileSync as unknown as RuntimeExecFile);
244
+ // Sandbox the probe's CODEX_HOME: a real Codex CLI creates state (tmp/, logs) under
245
+ // CODEX_HOME even for `--version`, and the probe inherits the caller's env — so a
246
+ // read-only `ocx status` would dirty the user's CODEX_HOME. Redirect it to a
247
+ // throwaway dir; if the sandbox cannot be created, skip the probe rather than
248
+ // probe with the inherited env (keeps probeVersion total: it never throws).
249
+ let probeHome: string | undefined;
242
250
  try {
251
+ probeHome = mkdtempSync(join(tmpdir(), "ocx-codex-probe-"));
243
252
  const invocation = codexExecInvocation(command, ["--version"], platform, {
244
253
  env: deps.env,
245
254
  exists: deps.existsSync,
@@ -249,6 +258,7 @@ function probeVersion(
249
258
  stdio: ["ignore", "pipe", "ignore"],
250
259
  timeout: 8_000,
251
260
  windowsHide: true,
261
+ env: { ...(deps.env ?? process.env), CODEX_HOME: probeHome },
252
262
  ...invocation.options,
253
263
  });
254
264
  const version = parseCodexVersionOutput(output);
@@ -257,9 +267,15 @@ function probeVersion(
257
267
  }
258
268
  return { ok: true, version };
259
269
  } catch (error) {
270
+ if (!probeHome) return { ok: false, reason: "probe sandbox unavailable" };
260
271
  const message = error instanceof Error ? error.message : String(error);
261
272
  const redacted = redactUserPath(redactSecretString(message)).slice(0, 160);
262
273
  return { ok: false, reason: `failed --version (${redacted})` };
274
+ } finally {
275
+ if (probeHome) {
276
+ // Nested catch: a transient Windows EBUSY must never mask the probe result.
277
+ try { rmSync(probeHome, { recursive: true, force: true }); } catch { /* best effort */ }
278
+ }
263
279
  }
264
280
  }
265
281