@bitkyc08/opencodex 2.48.0 → 2.50.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 (141) hide show
  1. package/AGENTS_INSTALL.md +9 -1
  2. package/README.md +11 -5
  3. package/SPONSORS.md +1 -1
  4. package/assets/sponsors/orcarouter.png +0 -0
  5. package/assets/sponsors/packycode.png +0 -0
  6. package/gui/dist/assets/index-BoBRSehJ.css +1 -0
  7. package/gui/dist/assets/index-C39tnjXO.js +115 -0
  8. package/gui/dist/index.html +2 -2
  9. package/gui/dist/provider-icons/packycode.svg +19 -0
  10. package/gui/dist/provider-icons/qoder.svg +5 -0
  11. package/package.json +5 -3
  12. package/src/adapters/anthropic.ts +31 -16
  13. package/src/adapters/codebuddy/adapter.ts +85 -0
  14. package/src/adapters/codebuddy/profiles.ts +52 -0
  15. package/src/adapters/coding-agent/profile.ts +100 -0
  16. package/src/adapters/coding-agent/protocol.ts +463 -0
  17. package/src/adapters/coding-agent/turn.ts +353 -0
  18. package/src/adapters/google.ts +15 -11
  19. package/src/adapters/mimo-free.ts +3 -0
  20. package/src/adapters/openai-chat.ts +2 -2
  21. package/src/adapters/openai-responses.ts +18 -11
  22. package/src/adapters/qoder/adapter.ts +70 -0
  23. package/src/adapters/qoder/live-models.ts +89 -0
  24. package/src/adapters/qoder/profiles.ts +36 -0
  25. package/src/adapters/registry.ts +12 -0
  26. package/src/adapters/responses-tool-schema.ts +113 -8
  27. package/src/claude/inbound.ts +17 -5
  28. package/src/cli/account-api.ts +18 -3
  29. package/src/cli/account-auth.ts +8 -1
  30. package/src/cli/account-extended.ts +2 -1
  31. package/src/cli/account.ts +1 -0
  32. package/src/cli/capabilities.ts +15 -1
  33. package/src/cli/dispatch.ts +2 -0
  34. package/src/cli/doctor.ts +40 -0
  35. package/src/cli/effort.ts +24 -8
  36. package/src/cli/help.ts +2 -0
  37. package/src/cli/index.ts +29 -2
  38. package/src/cli/models-runtime.ts +8 -3
  39. package/src/cli/observe.ts +13 -3
  40. package/src/cli/provider-runtime.ts +2 -1
  41. package/src/cli/registry.ts +2 -2
  42. package/src/cli/system-command.ts +10 -3
  43. package/src/cli/usage-report.ts +9 -5
  44. package/src/clients/config-export/zcode.ts +24 -0
  45. package/src/codex/account-lifecycle.ts +35 -2
  46. package/src/codex/account-runtime-state.ts +6 -1
  47. package/src/codex/account-store.ts +72 -9
  48. package/src/codex/account-usability.ts +3 -2
  49. package/src/codex/auth-api.ts +113 -26
  50. package/src/codex/auth-collision.ts +12 -2
  51. package/src/codex/auth-context.ts +96 -7
  52. package/src/codex/catalog/parsing.ts +23 -0
  53. package/src/codex/catalog/provider-fetch.ts +144 -11
  54. package/src/codex/catalog/sync.ts +14 -0
  55. package/src/codex/inject.ts +128 -30
  56. package/src/codex/internal/catalog-writer.ts +3 -0
  57. package/src/codex/journal.ts +61 -12
  58. package/src/codex/model-cache.ts +11 -4
  59. package/src/codex/native-profile-startup.ts +72 -5
  60. package/src/codex/native-profile-store.ts +2 -2
  61. package/src/codex/ocx-compaction-history.ts +226 -0
  62. package/src/codex/project-config-warnings.ts +3 -1
  63. package/src/codex/quota-auto-refresh.ts +6 -1
  64. package/src/codex/quota.ts +71 -15
  65. package/src/codex/reserve-availability.ts +21 -5
  66. package/src/codex/runtime.ts +45 -1
  67. package/src/codex/sync.ts +5 -0
  68. package/src/combos/index.ts +2 -0
  69. package/src/combos/resolve.ts +52 -0
  70. package/src/config.ts +59 -0
  71. package/src/generated/compatibility-version.json +178 -114
  72. package/src/images/loop.ts +1 -0
  73. package/src/images/xai-video-client.ts +2 -0
  74. package/src/integrations/registry.ts +1 -0
  75. package/src/lib/errors.ts +8 -0
  76. package/src/lib/privacy.ts +25 -0
  77. package/src/lib/process-control.ts +52 -8
  78. package/src/lib/upstream-retry.ts +1 -0
  79. package/src/oauth/chatgpt.ts +83 -0
  80. package/src/oauth/health.ts +47 -12
  81. package/src/oauth/index.ts +46 -8
  82. package/src/oauth/token-guardian.ts +32 -6
  83. package/src/oauth/xai.ts +151 -8
  84. package/src/providers/api-key-selection-capture.ts +10 -0
  85. package/src/providers/api-key-selection.ts +2 -7
  86. package/src/providers/caller-authorization.ts +36 -0
  87. package/src/providers/codebuddy-models.ts +184 -0
  88. package/src/providers/derive.ts +5 -0
  89. package/src/providers/free-directory.ts +26 -2
  90. package/src/providers/google-ai-studio-model-discovery.ts +74 -0
  91. package/src/providers/openai-sidecar.ts +35 -11
  92. package/src/providers/opencode-zen-rate-limit.ts +75 -0
  93. package/src/providers/qoder-models.ts +25 -0
  94. package/src/providers/quota.ts +15 -0
  95. package/src/providers/registry.ts +140 -1
  96. package/src/responses/compaction.ts +4 -0
  97. package/src/responses/task-input.ts +21 -1
  98. package/src/router.ts +1 -1
  99. package/src/server/auth-cors.ts +6 -0
  100. package/src/server/chat-completions.ts +30 -13
  101. package/src/server/chat-native.ts +10 -1
  102. package/src/server/claude-messages.ts +17 -7
  103. package/src/server/images.ts +3 -2
  104. package/src/server/index.ts +25 -2
  105. package/src/server/management/account-selection-stream.ts +13 -4
  106. package/src/server/management/config-routes.ts +24 -5
  107. package/src/server/management/logs-usage-routes.ts +5 -1
  108. package/src/server/management/model-rows.ts +16 -1
  109. package/src/server/management/native-integration-routes.ts +2 -1
  110. package/src/server/management/oauth-account-routes.ts +6 -2
  111. package/src/server/management/provider-routes.ts +33 -2
  112. package/src/server/management/request-history-routes.ts +4 -2
  113. package/src/server/management/route-registry.ts +5 -4
  114. package/src/server/management/shared.ts +66 -3
  115. package/src/server/management-api.ts +15 -1
  116. package/src/server/port-reclaim.ts +11 -26
  117. package/src/server/request-decompress.ts +91 -3
  118. package/src/server/request-log.ts +16 -0
  119. package/src/server/responses/codex-ws-wire.ts +1 -1
  120. package/src/server/responses/collaboration.ts +4 -9
  121. package/src/server/responses/compact.ts +8 -2
  122. package/src/server/responses/context-overflow.ts +11 -0
  123. package/src/server/responses/core.ts +285 -57
  124. package/src/server/responses/fetch-helpers.ts +18 -7
  125. package/src/server/responses/policy-fallback.ts +18 -2
  126. package/src/server/search.ts +2 -2
  127. package/src/service.ts +128 -9
  128. package/src/storage/cleanup.ts +77 -45
  129. package/src/types/accounts.ts +18 -0
  130. package/src/types/config.ts +43 -1
  131. package/src/types/provider.ts +56 -0
  132. package/src/types.ts +4 -0
  133. package/src/usage/log.ts +24 -0
  134. package/src/vision/anthropic-describe.ts +1 -0
  135. package/src/web-search/anthropic-executor.ts +1 -0
  136. package/src/web-search/loop.ts +1 -0
  137. package/src/web-search/ollama-executor.ts +127 -0
  138. package/src/web-search/passthrough-bridge.ts +761 -0
  139. package/src/web-search/progress-stream.ts +4 -0
  140. package/gui/dist/assets/index-B5r7LNHN.js +0 -115
  141. package/gui/dist/assets/index-D5SiRo8X.css +0 -1
@@ -336,6 +336,9 @@ function mergeAccountQuota(
336
336
  }
337
337
 
338
338
  if (snapshotHasCustom(quota)) next.customWindows = quota.customWindows;
339
+ // Ordinary response headers omit model-specific windows reported by WHAM.
340
+ // Absence is a partial update; an explicit list (including []) still replaces it.
341
+ else if (existing?.customWindows !== undefined) next.customWindows = existing.customWindows;
339
342
 
340
343
  if (quota.resetCredits !== undefined) next.resetCredits = quota.resetCredits;
341
344
  else if (existing?.resetCredits !== undefined) next.resetCredits = existing.resetCredits;
@@ -404,7 +407,20 @@ export function flushQuotaObservationsForTests(): Promise<void> {
404
407
  return pendingObservation;
405
408
  }
406
409
 
407
- export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQuota, "updatedAt"> | null {
410
+ /** Wire marker shared by Spark-family models, whose upstream limit family is model-specific. */
411
+ const SPARK_MODEL_MARKER = "codex-spark";
412
+ /**
413
+ * Custom-window label for the Spark 5h window. The WHAM parser and the response-header path
414
+ * must write the SAME label so a header refresh replaces the WHAM reading instead of doubling it.
415
+ */
416
+ const SPARK_SHORT_WINDOW_LABEL = "GPT-5.3-Codex-Spark 5h";
417
+
418
+ /** True when the routed model belongs to the Spark family, which carries its own rate limit. */
419
+ function isCodexSparkModel(modelId: string | undefined): boolean {
420
+ return typeof modelId === "string" && modelId.includes(SPARK_MODEL_MARKER);
421
+ }
422
+
423
+ export function parseUpstreamQuotaHeaders(headers: Headers, options?: { modelId?: string }): Omit<StoredAccountQuota, "updatedAt"> | null {
408
424
  const primaryRaw = headers.get("x-codex-primary-used-percent");
409
425
  const secondaryRaw = headers.get("x-codex-secondary-used-percent");
410
426
  const tertiaryRaw = headers.get("x-codex-tertiary-used-percent");
@@ -427,6 +443,10 @@ export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQ
427
443
  // it into weeklyPercent both discards the real weekly reading and leaves the account looking
428
444
  // exhausted long after the burst window resets. Duration decides, exactly as parseUsageQuota
429
445
  // already does for the WHAM payload — the two parsers must not disagree about the same data.
446
+ // One more attribution layer (#4122): on a Spark-family model response the sub-day primary is
447
+ // the MODEL-SPECIFIC limit, not an account window. Filing it as the account short tuple made
448
+ // one pool account display a 5h bar its identically-limited peers did not have, and fed a
449
+ // model limit to the account-policy readers (main-account hard lock, five-hour auto-refresh).
430
450
  const primaryIsShort = isExplicitShortWindowMinutes(primaryWindowMinutes);
431
451
 
432
452
  if (primaryIsMonthly) {
@@ -443,10 +463,21 @@ export function parseUpstreamQuotaHeaders(headers: Headers): Omit<StoredAccountQ
443
463
  if (secondaryResetAt !== undefined) quota.weeklyResetAt = secondaryResetAt;
444
464
  }
445
465
  } else if (primaryIsShort) {
446
- if (primaryPercent !== undefined) quota.shortPercent = primaryPercent;
447
- if (primaryResetAt !== undefined) quota.shortResetAt = primaryResetAt;
448
- const minutes = windowMinutes_(primaryWindowMinutes);
449
- if (minutes !== undefined) quota.shortWindowSeconds = Math.round(minutes * 60);
466
+ if (isCodexSparkModel(options?.modelId)) {
467
+ if (primaryPercent !== undefined) {
468
+ const sparkWindow: { label: string; percent: number; resetAt?: number } = {
469
+ label: SPARK_SHORT_WINDOW_LABEL,
470
+ percent: primaryPercent,
471
+ };
472
+ if (primaryResetAt !== undefined) sparkWindow.resetAt = primaryResetAt;
473
+ quota.customWindows = [sparkWindow];
474
+ }
475
+ } else {
476
+ if (primaryPercent !== undefined) quota.shortPercent = primaryPercent;
477
+ if (primaryResetAt !== undefined) quota.shortResetAt = primaryResetAt;
478
+ const minutes = windowMinutes_(primaryWindowMinutes);
479
+ if (minutes !== undefined) quota.shortWindowSeconds = Math.round(minutes * 60);
480
+ }
450
481
  // The burst window vacates the primary slot, so the weekly reading is the secondary — which
451
482
  // is where it was all along. Without this the true weekly value is silently dropped.
452
483
  if (secondaryPercent !== undefined) {
@@ -477,13 +508,31 @@ export function applyAccountQuotaFromUpstreamHeaders(
477
508
  headers: Headers,
478
509
  writerGeneration = captureConfigGeneration(),
479
510
  mainWriter?: MainQuotaWriter,
511
+ options?: { modelId?: string },
480
512
  ): void {
481
- const quota = parseUpstreamQuotaHeaders(headers);
513
+ const quota = parseUpstreamQuotaHeaders(headers, options);
482
514
  if (!quota) return;
483
515
  const policyQuota = [
484
516
  "x-codex-primary-used-percent", "x-codex-secondary-used-percent", "x-codex-tertiary-used-percent",
485
517
  ].some(name => isInvalidPolicyUsagePercent(headers.get(name))) ? null : filterMainPolicyMonthlyQuota(quota);
486
- setAccountQuotaFromParsed(accountId, quota, writerGeneration, mainWriter, policyQuota);
518
+ // A header-observed Spark window is a partial update against the WHAM-recorded custom windows:
519
+ // merge by label so the weekly Spark entry survives, and hydrate first so the first call in a
520
+ // process does not merge against an empty map. The merged list goes only to the legacy
521
+ // snapshot — the identity-bound policy evidence keeps exactly what this response said.
522
+ let legacyQuota = quota;
523
+ if (quota.customWindows !== undefined) {
524
+ hydrateAccountQuotasFromDisk();
525
+ const existing = accountQuota.get(accountId)?.customWindows;
526
+ if (existing !== undefined) {
527
+ const incoming = new Map(quota.customWindows.map(window => [window.label, window]));
528
+ const merged = existing.map(window => incoming.get(window.label) ?? window);
529
+ for (const window of quota.customWindows) {
530
+ if (!existing.some(entry => entry.label === window.label)) merged.push(window);
531
+ }
532
+ legacyQuota = { ...quota, customWindows: merged };
533
+ }
534
+ }
535
+ setAccountQuotaFromParsed(accountId, legacyQuota, writerGeneration, mainWriter, policyQuota);
487
536
  }
488
537
 
489
538
  export function updateAccountQuota(
@@ -795,6 +844,10 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
795
844
  });
796
845
  const sparkWindows = [spark?.rate_limit?.primary_window, spark?.rate_limit?.secondary_window]
797
846
  .filter((window): window is WhamUsageWindow => !!window);
847
+ const sparkShort = sparkWindows.find(window => {
848
+ const percent = normalizeUsagePercent(window.used_percent);
849
+ return percent !== undefined && isExplicitShortWindow(window);
850
+ });
798
851
  const sparkWeekly = sparkWindows.find(window => {
799
852
  const percent = normalizeUsagePercent(window.used_percent);
800
853
  const seconds = window.limit_window_seconds;
@@ -803,16 +856,19 @@ export function parseUsageQuota(data: WhamUsageResponse): Omit<StoredAccountQuot
803
856
  && !isExplicitMonthlyWindow(window)
804
857
  && (seconds === undefined || seconds >= WEEKLY_WINDOW_MIN_SECONDS);
805
858
  });
806
- const sparkPercent = normalizeUsagePercent(sparkWeekly?.used_percent);
807
- if (sparkPercent !== undefined) {
808
- const sparkWindow: { label: string; percent: number; resetAt?: number } = {
809
- label: "GPT-5.3-Codex-Spark Weekly",
810
- percent: sparkPercent,
811
- };
812
- const resetAt = normalizeResetAt(sparkWeekly?.reset_at);
859
+ const sparkCustomWindows: Array<{ label: string; percent: number; resetAt?: number }> = [];
860
+ for (const [label, window] of [
861
+ [SPARK_SHORT_WINDOW_LABEL, sparkShort],
862
+ ["GPT-5.3-Codex-Spark Weekly", sparkWeekly],
863
+ ] as const) {
864
+ const percent = normalizeUsagePercent(window?.used_percent);
865
+ if (percent === undefined) continue;
866
+ const sparkWindow: { label: string; percent: number; resetAt?: number } = { label, percent };
867
+ const resetAt = normalizeResetAt(window?.reset_at);
813
868
  if (resetAt !== undefined) sparkWindow.resetAt = resetAt;
814
- quota.customWindows = [sparkWindow];
869
+ sparkCustomWindows.push(sparkWindow);
815
870
  }
871
+ if (sparkCustomWindows.length > 0) quota.customWindows = sparkCustomWindows;
816
872
  if (resetCredits !== undefined) quota.resetCredits = resetCredits;
817
873
 
818
874
  return hasKnownQuotaValue(quota) || resetCredits !== undefined ? quota : null;
@@ -44,14 +44,30 @@ function owned(token: Token, writer: MainQuotaWriter): boolean {
44
44
  function record(value: unknown): value is Record<string, unknown> {
45
45
  return value !== null && typeof value === "object" && !Array.isArray(value);
46
46
  }
47
- function userId(token: string): string | undefined {
47
+ /**
48
+ * The ChatGPT per-user identity carried by a native credential, plus whether the token's own two
49
+ * encodings of it disagree. Precedence stays on the RAW claims, so an empty or non-string
50
+ * `chatgpt_user_id` still blocks the `user_id` fallback exactly as before; `conflict` is a
51
+ * separate observation for callers that must fail closed on an ambiguous identity.
52
+ */
53
+ export function nativeUserIdClaims(token: string): { userId: string | undefined; conflict: boolean } {
54
+ const none = { userId: undefined, conflict: false };
48
55
  try {
49
56
  const payload: unknown = JSON.parse(Buffer.from(token.split(".")[1] ?? "", "base64url").toString("utf8"));
50
57
  const auth = record(payload) ? payload["https://api.openai.com/auth"] : undefined;
51
- if (!record(auth)) return;
52
- const value = auth.chatgpt_user_id ?? auth.user_id;
53
- return typeof value === "string" && value.length > 0 ? value : undefined;
54
- } catch { return; }
58
+ if (!record(auth)) return none;
59
+ const named = (value: unknown): string | undefined =>
60
+ typeof value === "string" && value.length > 0 ? value : undefined;
61
+ const primary = named(auth.chatgpt_user_id);
62
+ const secondary = named(auth.user_id);
63
+ return {
64
+ userId: named(auth.chatgpt_user_id ?? auth.user_id),
65
+ conflict: primary !== undefined && secondary !== undefined && primary !== secondary,
66
+ };
67
+ } catch { return none; }
68
+ }
69
+ function userId(token: string): string | undefined {
70
+ return nativeUserIdClaims(token).userId;
55
71
  }
56
72
  function identityMatches(data: WhamUsageResponse, token: Token): boolean {
57
73
  if (data.account_id != null && data.account_id !== token.chatgptAccountId) return false;
@@ -86,6 +86,8 @@ export interface PersistedCodexRuntimeState {
86
86
 
87
87
  const PERSIST_FILE = "codex-runtime.json";
88
88
  const CLAMP_PERSIST_FILE = "codex-runtime-clamp.json";
89
+ /** Probe rejection for an absolute candidate whose file is gone. Matched when retiring a dead pin (#4035). */
90
+ const PATH_MISSING_REASON = "path does not exist";
89
91
 
90
92
  function cloneAndDeepFreeze<T>(value: T): DeepReadonly<T> {
91
93
  const clone = (current: unknown): unknown => {
@@ -283,6 +285,31 @@ export function persistCodexRuntime(
283
285
  atomicWriteFile(codexRuntimeStatePath(configDir), `${JSON.stringify(payload, null, 2)}\n`);
284
286
  }
285
287
 
288
+ /**
289
+ * Delete `codex-runtime.json`. Used to retire a pin whose path no longer exists, so a
290
+ * later resolve stops re-probing it (#4035).
291
+ *
292
+ * Invalidates the process resolve memo the same way `persistCodexRuntime` does: the memo
293
+ * folds the persisted `updatedAt` into its key, and a removed file has no stamp to fold.
294
+ */
295
+ export function clearPersistedCodexRuntime(deps: ResolveCodexRuntimeDeps = {}): void {
296
+ const configDir = deps.configDir ?? getConfigDir();
297
+ clearCodexRuntimeResolveCache();
298
+ try {
299
+ unlinkSync(codexRuntimeStatePath(configDir));
300
+ } catch (error) {
301
+ // An already-missing file is the success case: the pin is gone, which is the point.
302
+ // Anything else means the pin SURVIVES and stays authoritative, so every later
303
+ // resolve re-probes the same dead path — #4035 unfixed, silently. Say so once.
304
+ const code = (error as NodeJS.ErrnoException | null)?.code;
305
+ if (code === "ENOENT") return;
306
+ console.warn(
307
+ `[opencodex] Could not remove the stale Codex runtime pin at ${displayCodexRuntimePath(codexRuntimeStatePath(configDir))}`
308
+ + ` (${code ?? "unknown error"}). It will be re-probed until the file is removed.`,
309
+ );
310
+ }
311
+ }
312
+
286
313
  function probeVersion(
287
314
  command: string,
288
315
  deps: ResolveCodexRuntimeDeps,
@@ -290,7 +317,7 @@ function probeVersion(
290
317
  const platform = deps.platform ?? process.platform;
291
318
  if (command.includes("/") || command.includes("\\") || /^[A-Za-z]:/.test(command)) {
292
319
  const exists = deps.existsSync ?? existsSync;
293
- if (!exists(command)) return { ok: false, reason: "path does not exist" };
320
+ if (!exists(command)) return { ok: false, reason: PATH_MISSING_REASON };
294
321
  if (!isSpawnableCodexCandidate(command, platform)) {
295
322
  return { ok: false, reason: "not a spawnable Codex launcher on this platform" };
296
323
  }
@@ -654,6 +681,23 @@ export function resolveAndPersistCodexRuntime(
654
681
  return cloneAndDeepFreeze({ ...result, persistError });
655
682
  }
656
683
  }
684
+ // A pin whose path has vanished must be RETIRED, not merely skipped. A Codex App update
685
+ // replaces the hashed plugin directory the pin names, the probe rejects it with
686
+ // "path does not exist", nothing else resolves, and the selection degrades to `fallback` —
687
+ // which the write guard above declines. The dead entry then survived every later resolve
688
+ // and each one re-probed a path that cannot exist (#4035). Bound narrowly: only when the
689
+ // degraded result is `fallback`, only for the persisted command, and only for the
690
+ // path-does-not-exist rejection, so a present-but-unusable binary is left for the operator.
691
+ else if (result.runtime.source === "fallback" && persistedRuntime?.command) {
692
+ const pinVanished = result.failures.some(
693
+ // Exact comparison, not `sameRuntimeCommand`: that helper lowercases, and on a
694
+ // case-sensitive filesystem `/plugins/Codex` and `/plugins/codex` are different
695
+ // files. A missing lowercase path must not retire a live uppercase pin.
696
+ failure => failure.command.trim() === persistedRuntime.command.trim()
697
+ && failure.reason === PATH_MISSING_REASON,
698
+ );
699
+ if (pinVanished) clearPersistedCodexRuntime(deps);
700
+ }
657
701
  return result;
658
702
  }
659
703
 
package/src/codex/sync.ts CHANGED
@@ -8,6 +8,7 @@ import { summarizeComboCatalogOmissions, type ComboCatalogOmission } from "./cat
8
8
  import { shouldSyncCodexOnStart } from "./desired-state";
9
9
  import { admitCodexWrite, type CodexAdmission } from "./admission";
10
10
  import type { CodexCatalogSyncOptions } from "./catalog/sync";
11
+ import { resetCodexAppServerCatalogStateCache } from "./app-server-processes";
11
12
 
12
13
  export interface CodexSyncResult {
13
14
  /**
@@ -116,6 +117,10 @@ export async function syncModelsToCodex(
116
117
  message: admission.message,
117
118
  };
118
119
  }
120
+ // Config injection is a relevant Codex write even when the catalog bytes are unchanged.
121
+ // Drop cached process evidence before async discovery so a process that appeared since the
122
+ // last read cannot make native-default guidance report active after this sync.
123
+ resetCodexAppServerCatalogStateCache();
119
124
  const p = port ?? config.port ?? 10100;
120
125
  const externalProvider = (deps.currentExternalCodexModelProvider ?? currentExternalCodexModelProvider)();
121
126
 
@@ -27,9 +27,11 @@ export {
27
27
  noteComboSuccess,
28
28
  pickComboTarget,
29
29
  pickComboTargetWithWait,
30
+ quotaInactiveReason,
30
31
  tryPickComboModel,
31
32
  UnknownComboError,
32
33
  type ComboPick,
34
+ type QuotaInactiveReason,
33
35
  } from "./resolve";
34
36
  export {
35
37
  clearComboTargetCooldowns,
@@ -92,6 +92,58 @@ export function cachedProviderQuotaIsExhausted(
92
92
  return false;
93
93
  }
94
94
 
95
+ /**
96
+ * Why a catalog row is offered but cannot currently serve a request (#1711).
97
+ *
98
+ * Only one reason exists today. It is a string rather than a boolean so a later cause — a
99
+ * cooldown, a revoked key — can be told apart by a consumer that already reads the field.
100
+ */
101
+ export type QuotaInactiveReason = "no_credit";
102
+
103
+ /**
104
+ * `"no_credit"` when every USABLE target of a catalog row has positive exhaustion evidence
105
+ * (#1711), otherwise undefined.
106
+ *
107
+ * This deliberately reuses the runtime rules in `targetProviderIsUsable` above rather than the
108
+ * Dashboard's `quotaStateFromReport`, which is harsher: it treats `remaining <= 0` as exhausted
109
+ * without requiring `percent >= 100` and ignores an elapsed `resetAt`. A catalog row marked
110
+ * inactive on the harsher rule would contradict the router, which would still happily send the
111
+ * request.
112
+ *
113
+ * Three rules carry the correctness, all inherited rather than restated:
114
+ *
115
+ * - A target the operator has removed or disabled is not usable and is not evidence either way;
116
+ * it drops out before the vote. If nothing is left, the row is unavailable for an operator
117
+ * reason rather than a quota one, so this returns undefined.
118
+ * - The canonical ChatGPT forward provider is exempt. Native account selection owns model-scoped
119
+ * quota, and a provider-level summary cannot veto it.
120
+ * - A stale cache is NOT exhaustion. `getCachedProviderQuota` returns null past its 30-minute
121
+ * window, and a null reading ends the vote rather than counting as evidence, so an unprobed
122
+ * provider is never marked inactive.
123
+ *
124
+ * "Every" is the bar on purpose: one target that can still serve makes the row serviceable, which
125
+ * is exactly what the combo loop concludes at request time.
126
+ */
127
+ export function quotaInactiveReason(
128
+ config: OcxConfig,
129
+ targets: readonly { provider: string }[],
130
+ now = Date.now(),
131
+ ): QuotaInactiveReason | undefined {
132
+ const usable = targets.filter(target => {
133
+ if (!Object.hasOwn(config.providers, target.provider)) return false;
134
+ const provider = config.providers[target.provider];
135
+ return !!provider && provider.disabled !== true;
136
+ });
137
+ if (usable.length === 0) return undefined;
138
+ for (const target of usable) {
139
+ const provider = config.providers[target.provider]!;
140
+ if (isCanonicalOpenAiForwardProvider(provider)) return undefined;
141
+ const quota = getCachedProviderQuota(target.provider, now);
142
+ if (!quota || !cachedProviderQuotaIsExhausted(quota, now)) return undefined;
143
+ }
144
+ return "no_credit";
145
+ }
146
+
95
147
  function smoothWeightedIndex(
96
148
  targets: Required<OcxComboTarget>[],
97
149
  state: SelectionState,
package/src/config.ts CHANGED
@@ -73,6 +73,7 @@ import {
73
73
  MODEL_ADAPTER_OVERRIDE_ALLOWED,
74
74
  OPENAI_PROVIDER_TIER_VERSION,
75
75
  pinnedWireAdapter,
76
+ PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS,
76
77
  UPSTREAM_HTTP_VERSION_VALUES,
77
78
  type OcxClaudeCodeConfig,
78
79
  type OcxConfig,
@@ -497,6 +498,47 @@ export function requestPacingConfigError(value: unknown): string | null {
497
498
  return "requestPacing must contain enabled and a valid requestsPerMinute/minIntervalMs provider rule or model overrides";
498
499
  }
499
500
 
501
+ /**
502
+ * Bounds for the opt-in passthrough web-search bridge (`providers.<name>.webSearchBridge`,
503
+ * #3761). Strict for the same reason `retryOn429` is: a misspelled key here would silently
504
+ * leave the bridge disarmed while the operator believes they enabled it. `endpoint` is only
505
+ * shape-checked here; `planPassthroughWebSearchBridge` re-validates the origin before any key
506
+ * is sent to it, because config validation is not an authorization boundary.
507
+ */
508
+ const providerWebSearchBridgeSchema = z.object({
509
+ enabled: z.boolean().optional(),
510
+ backend: z.enum(PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS).optional(),
511
+ maxSearches: z.number().int().min(1).max(10).optional(),
512
+ timeoutMs: z.number().int().min(1_000).max(600_000).optional(),
513
+ endpoint: z.string().min(1).optional(),
514
+ }).strict();
515
+
516
+ export function providerWebSearchBridgeConfigError(value: unknown): string | null {
517
+ if (value === undefined) return null;
518
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
519
+ return "webSearchBridge must be a plain object";
520
+ }
521
+ const parsed = providerWebSearchBridgeSchema.safeParse(value);
522
+ if (!parsed.success) {
523
+ return "webSearchBridge accepts only enabled (boolean), backend "
524
+ + `(${PROVIDER_WEB_SEARCH_BRIDGE_BACKENDS.join("|")}), maxSearches (1..10), `
525
+ + "timeoutMs (1000..600000), and endpoint (absolute http(s) URL)";
526
+ }
527
+ const endpoint = parsed.data.endpoint;
528
+ if (endpoint !== undefined) {
529
+ let url: URL;
530
+ try {
531
+ url = new URL(endpoint);
532
+ } catch {
533
+ return "webSearchBridge.endpoint must be an absolute http(s) URL";
534
+ }
535
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
536
+ return "webSearchBridge.endpoint must be an absolute http(s) URL";
537
+ }
538
+ }
539
+ return null;
540
+ }
541
+
500
542
  const fastWireSchema = z.object({
501
543
  kind: z.string(),
502
544
  canonicalToWire: z.record(z.string().trim(), z.string().trim()),
@@ -600,6 +642,10 @@ const providerConfigSchema = z.object({
600
642
  repairInvalidIds: z.boolean().optional(),
601
643
  }).strict().optional(),
602
644
  responsesSnapshotRepair: z.boolean().optional(),
645
+ // Invalid blocks degrade to "absent" rather than failing the whole config load: an unusable
646
+ // bridge block must never send an operator through invalid-config recovery for an opt-in
647
+ // feature that is off by default. The management write boundary still rejects it loudly.
648
+ webSearchBridge: providerWebSearchBridgeSchema.optional().catch(undefined),
603
649
  xaiResponsesXSearch: z.boolean().optional(),
604
650
  xaiResponsesDefaultVersion: z.number().int().positive().optional().catch(undefined),
605
651
  }).passthrough();
@@ -1100,6 +1146,9 @@ const configSchema = z.object({
1100
1146
  // candidates are rejected explicitly by remoteGuiConfigError below.
1101
1147
  hub: hubConfigSchema.optional().catch(undefined),
1102
1148
  remoteGui: remoteGuiConfigSchema.optional().catch(undefined),
1149
+ // A malformed privacy block must never be read as "unmask": .catch(undefined) drops it and
1150
+ // emailMaskingEnabled then falls back to masked, which is also what an absent block means.
1151
+ privacy: z.object({ maskEmails: z.boolean().optional() }).strict().optional().catch(undefined),
1103
1152
  // A malformed present client block must remain diagnosable from raw config and
1104
1153
  // fail closed through src/client/state.ts; unrelated provider state still loads.
1105
1154
  client: clientConnectionSchema.optional().catch(undefined),
@@ -1118,6 +1167,15 @@ const configSchema = z.object({
1118
1167
  .min(0)
1119
1168
  .optional()
1120
1169
  .catch(undefined),
1170
+ // Opt-in inbound body ceiling (#3573). An invalid hand edit degrades to the 256 MiB default
1171
+ // rather than failing the parse, matching the outbound guard above: a malformed number must
1172
+ // not change what the proxy admits. The hard ceiling is NOT enforced here — because of that
1173
+ // `.catch`, and because a config object can be built without this schema at all — but in
1174
+ // `resolveInboundBodyLimitBytes()`, which every reader goes through.
1175
+ maxInboundBodyBytes: z.number().int()
1176
+ .min(0)
1177
+ .optional()
1178
+ .catch(undefined),
1121
1179
  appOwnedMemoryBudgetMb: z.number().int()
1122
1180
  .min(MIN_APP_OWNED_MEMORY_BUDGET_MB)
1123
1181
  .max(MAX_APP_OWNED_MEMORY_BUDGET_MB)
@@ -1189,6 +1247,7 @@ const configSchema = z.object({
1189
1247
  ).optional().catch(undefined),
1190
1248
  codexShimAutoRestore: z.boolean().optional(),
1191
1249
  codexDesktopAuthless: z.boolean().optional().catch(undefined),
1250
+ codexClientCompaction: z.boolean().optional().catch(undefined),
1192
1251
  pausedCodexAccountIds: z.array(z.string().regex(/^[a-zA-Z0-9._-]{1,64}$/)).optional(),
1193
1252
  codexQuotaAutoRefresh: codexQuotaAutoRefreshSchema.optional().catch(undefined),
1194
1253
  codexAccountNamespaces: codexAccountNamespacesSchema.optional(),