@bitkyc08/opencodex 2.51.0 → 2.52.0-preview.20260912

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 (166) hide show
  1. package/gui/dist/assets/index-D_t6sCWs.js +115 -0
  2. package/gui/dist/assets/index-EdoPnm9_.css +1 -0
  3. package/gui/dist/index.html +2 -2
  4. package/gui/dist/provider-icons/devin.svg +49 -0
  5. package/gui/dist/provider-icons/omo.svg +42 -0
  6. package/package.json +3 -1
  7. package/src/AGENTS.md +1 -1
  8. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +0 -1
  9. package/src/adapters/command-code.ts +0 -1
  10. package/src/adapters/cursor/checkpoint-store.ts +37 -0
  11. package/src/adapters/cursor/live-transport.ts +74 -2
  12. package/src/adapters/cursor.ts +6 -0
  13. package/src/adapters/devin/cloud-direct/auth.ts +264 -0
  14. package/src/adapters/devin/cloud-direct/catalog.ts +306 -0
  15. package/src/adapters/devin/cloud-direct/chat.ts +1274 -0
  16. package/src/adapters/devin/cloud-direct/index.ts +65 -0
  17. package/src/adapters/devin/cloud-direct/metadata.ts +134 -0
  18. package/src/adapters/devin/cloud-direct/wire.ts +206 -0
  19. package/src/adapters/devin/live-models.ts +133 -0
  20. package/src/adapters/devin-cli/acp.ts +204 -0
  21. package/src/adapters/devin-cli/adapter.ts +345 -0
  22. package/src/adapters/devin-cli/binary.ts +69 -0
  23. package/src/adapters/devin-cli/models.ts +57 -0
  24. package/src/adapters/devin.ts +326 -0
  25. package/src/adapters/google.ts +1 -1
  26. package/src/adapters/openai-chat.ts +31 -10
  27. package/src/adapters/openai-responses.ts +16 -1
  28. package/src/adapters/registry.ts +25 -1
  29. package/src/bridge.ts +8 -2
  30. package/src/claude/inbound-cache-stabilize.ts +130 -0
  31. package/src/claude/inbound.ts +45 -5
  32. package/src/cli/account-auth.ts +60 -1
  33. package/src/cli/account-extended.ts +23 -30
  34. package/src/cli/account.ts +2 -1
  35. package/src/cli/capabilities.ts +50 -8
  36. package/src/cli/config-command.ts +2 -2
  37. package/src/cli/dispatch.ts +14 -2
  38. package/src/cli/export-command.ts +11 -25
  39. package/src/cli/help.ts +2 -2
  40. package/src/cli/opencode.ts +5 -0
  41. package/src/cli/registry.ts +15 -4
  42. package/src/clients/config-export/cline.ts +71 -0
  43. package/src/clients/config-export/contracts.ts +10 -1
  44. package/src/clients/config-export/model-metadata.ts +33 -0
  45. package/src/clients/config-export/zcode.ts +23 -12
  46. package/src/clients/config-export.ts +156 -4
  47. package/src/codex/account-store.ts +7 -2
  48. package/src/codex/auth-context.ts +1 -1
  49. package/src/codex/catalog/effort.ts +4 -3
  50. package/src/codex/catalog/metadata.ts +8 -4
  51. package/src/codex/catalog/native-models.ts +24 -4
  52. package/src/codex/catalog/parsing.ts +19 -1
  53. package/src/codex/catalog/provider-fetch.ts +57 -0
  54. package/src/codex/catalog/sync.ts +1 -1
  55. package/src/codex/context-compat.ts +97 -0
  56. package/src/codex/context-owner.ts +201 -0
  57. package/src/codex/history-provider.ts +161 -14
  58. package/src/codex/inject-coordination.ts +3 -2
  59. package/src/codex/inject.ts +128 -9
  60. package/src/codex/pool-rotation.ts +8 -292
  61. package/src/codex/quota.ts +41 -12
  62. package/src/codex/retired-model-migration.ts +41 -0
  63. package/src/codex/routing.ts +327 -22
  64. package/src/codex/warmup.ts +2 -2
  65. package/src/combos/failover.ts +5 -0
  66. package/src/combos/resolve.ts +11 -15
  67. package/src/config.ts +46 -14
  68. package/src/generated/compatibility-version.json +293 -113
  69. package/src/grok/grpc-web.ts +120 -0
  70. package/src/grok/reset-coupon-ledger.ts +139 -0
  71. package/src/grok/reset-coupons.ts +278 -0
  72. package/src/integrations/catalog-refresh.ts +1 -1
  73. package/src/integrations/cline-document.ts +73 -0
  74. package/src/integrations/cline-io.ts +149 -0
  75. package/src/integrations/cline-transaction.ts +42 -0
  76. package/src/integrations/config-io.ts +21 -2
  77. package/src/integrations/journal.ts +43 -1
  78. package/src/integrations/omp-yaml-source.ts +123 -4
  79. package/src/integrations/ownership-policy.ts +7 -0
  80. package/src/integrations/ownership.ts +36 -0
  81. package/src/integrations/registry.ts +27 -0
  82. package/src/integrations/state.ts +5 -2
  83. package/src/integrations/store.ts +6 -0
  84. package/src/integrations/writer.ts +53 -9
  85. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  86. package/src/lib/abort.ts +36 -0
  87. package/src/lib/local-destinations.ts +1 -1
  88. package/src/lib/upstream-retry.ts +36 -1
  89. package/src/oauth/account-quota-rank.ts +11 -0
  90. package/src/oauth/callback-server.ts +10 -5
  91. package/src/oauth/devin/api-base.ts +63 -0
  92. package/src/oauth/devin/login.ts +1 -0
  93. package/src/oauth/devin/register-user.ts +186 -0
  94. package/src/oauth/devin/types.ts +71 -0
  95. package/src/oauth/devin-cli.ts +149 -0
  96. package/src/oauth/devin.ts +170 -0
  97. package/src/oauth/generic-account-failover.ts +169 -1
  98. package/src/oauth/index.ts +20 -1
  99. package/src/oauth/login-cli.ts +19 -5
  100. package/src/oauth/pool-kernel.ts +321 -0
  101. package/src/oauth/pool-settings-capability.ts +127 -9
  102. package/src/oauth/store.ts +7 -3
  103. package/src/oauth/token-guardian.ts +1 -1
  104. package/src/providers/codebuddy-models.ts +0 -3
  105. package/src/providers/command-code-efforts.ts +23 -4
  106. package/src/providers/default-aliases.ts +4 -0
  107. package/src/providers/derive.ts +8 -0
  108. package/src/providers/devin-cli-authmode-migration.ts +57 -0
  109. package/src/providers/key-failover.ts +175 -0
  110. package/src/providers/model-rename-startup.ts +21 -1
  111. package/src/providers/qoder-models.ts +0 -1
  112. package/src/providers/quota-key-accounts.ts +50 -0
  113. package/src/providers/quota-routing-cache.ts +65 -6
  114. package/src/providers/quota-types.ts +7 -0
  115. package/src/providers/quota.ts +113 -30
  116. package/src/providers/registry.ts +237 -77
  117. package/src/providers/stale-context-window-migration.ts +92 -0
  118. package/src/providers/zai-responses-migration.ts +45 -0
  119. package/src/quota/reset-observer.ts +2 -1
  120. package/src/quota/reset-seen-store.ts +9 -1
  121. package/src/remote-control/crypto.ts +442 -0
  122. package/src/remote-control/host.ts +175 -0
  123. package/src/remote-control/index.ts +100 -0
  124. package/src/remote-control/protocol.ts +200 -0
  125. package/src/remote-control/relay.ts +162 -0
  126. package/src/remote-control/workspace-agent-protocol.ts +246 -0
  127. package/src/remote-control/workspace-rpc-framing.ts +128 -0
  128. package/src/remote-control/workspace-tools.ts +237 -0
  129. package/src/remote-control/workspace-utf8.ts +24 -0
  130. package/src/responses/custom-tool-compat.ts +23 -0
  131. package/src/router.ts +6 -1
  132. package/src/routing/compatibility/behavior.ts +3 -0
  133. package/src/server/adapter-resolve.ts +6 -2
  134. package/src/server/auth-cors.ts +71 -6
  135. package/src/server/chat-completions.ts +5 -3
  136. package/src/server/chat-native.ts +9 -0
  137. package/src/server/claude-messages.ts +25 -30
  138. package/src/server/context-history.ts +207 -0
  139. package/src/server/images.ts +28 -1
  140. package/src/server/index.ts +42 -22
  141. package/src/server/live.ts +2 -1
  142. package/src/server/management/agent-settings-routes.ts +7 -1
  143. package/src/server/management/config-routes.ts +3 -3
  144. package/src/server/management/grok-coupon-routes.ts +287 -0
  145. package/src/server/management/integration-routes.ts +6 -1
  146. package/src/server/management/oauth-account-routes.ts +124 -7
  147. package/src/server/management/provider-routes.ts +77 -2
  148. package/src/server/management/route-registry.ts +6 -1
  149. package/src/server/management-api.ts +7 -0
  150. package/src/server/request-log.ts +4 -0
  151. package/src/server/responses/codex-ws-exchange.ts +90 -14
  152. package/src/server/responses/codex-ws-wire.ts +51 -1
  153. package/src/server/responses/compact.ts +8 -1
  154. package/src/server/responses/core.ts +188 -22
  155. package/src/server/responses/ws-upstream.ts +1 -1
  156. package/src/server/responses-undeclared-tool-guard.ts +261 -12
  157. package/src/server/zai-responses-startup.ts +21 -0
  158. package/src/types/config.ts +33 -3
  159. package/src/types/provider.ts +54 -4
  160. package/src/types/request.ts +1 -1
  161. package/src/types/tools.ts +36 -6
  162. package/src/update/job.ts +33 -11
  163. package/src/vision/plan.ts +40 -37
  164. package/src/web-search/index.ts +1 -0
  165. package/gui/dist/assets/index-BoBRSehJ.css +0 -1
  166. package/gui/dist/assets/index-D7BdZpZm.js +0 -115
@@ -255,6 +255,14 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
255
255
  const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota");
256
256
  clearProviderQuotaCache();
257
257
  clearAccountQuotaCache(provider);
258
+ if (provider === "devin") {
259
+ // The cached user_jwt's payload contains the api_key, and the catalog is
260
+ // keyed by that key. Without this they outlive the credential in process
261
+ // memory until the JWT's own ~24 minute expiry.
262
+ const { clearCachedUserJwt, clearCachedCatalog } = await import("../../adapters/devin/cloud-direct");
263
+ clearCachedUserJwt();
264
+ clearCachedCatalog();
265
+ }
258
266
  return jsonResponse({ success: true });
259
267
  }
260
268
 
@@ -331,6 +339,12 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
331
339
  if (!(await setActiveAccount(provider, body.accountId))) return jsonResponse({ error: "account not found" }, 404);
332
340
  const { forgetGenericFailoverRoster } = await import("../../oauth/generic-account-failover");
333
341
  forgetGenericFailoverRoster(provider);
342
+ // Seed the rotation cursor on the operator's pick, or a sticky round-robin ring hands the
343
+ // very next dispatch back to whatever the pool had chosen. forgetGenericFailoverRoster
344
+ // only drops the presence count; it has never touched the cursor. Same defect the Codex
345
+ // side carries resetCodexRoutingForManualSelection for.
346
+ const { genericPoolKey, seedPoolRotationAccount } = await import("../../oauth/pool-kernel");
347
+ seedPoolRotationAccount(genericPoolKey(provider), body.accountId);
334
348
  if (provider === "anthropic") {
335
349
  const { resetAnthropicRoutingForManualSelection } = await import("../../oauth/anthropic-routing");
336
350
  resetAnthropicRoutingForManualSelection(body.accountId);
@@ -344,6 +358,91 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
344
358
  return jsonResponse({ ok: true, provider, activeAccountId: body.accountId });
345
359
  }
346
360
 
361
+ // The unified pool-settings contract (#695 wp5c). The three legacy paths keep working and
362
+ // keep their own shapes -- goldens pin them -- but this is the one an operator or a dashboard
363
+ // should read, because it answers with the same keys for every kind and DECLARES which of
364
+ // them that kind honours.
365
+ if (url.pathname === "/api/pool/settings" && (req.method === "GET" || req.method === "PUT" || req.method === "PATCH")) {
366
+ const {
367
+ poolSettingsCapability, parseGenericPoolStrategy, parseGenericAutoSwitchThreshold, parseGenericStickyLimit,
368
+ unifiedPoolSettingsDto,
369
+ } = await import("../../oauth/pool-settings-capability");
370
+ const rawBody = req.method === "GET" ? {} : await readManagementJsonBodyOr(req, {});
371
+ if (req.method !== "GET" && !isPlainRecord(rawBody)) {
372
+ return jsonResponse({ error: "body must be an object" }, 400);
373
+ }
374
+ const fields = rawBody as { provider?: unknown; enabled?: unknown; strategy?: unknown; stickyLimit?: unknown; autoSwitchThreshold?: unknown; quotaWindow?: unknown };
375
+ const provider = req.method === "GET"
376
+ ? (url.searchParams.get("provider") ?? "").trim().toLowerCase()
377
+ : (typeof fields.provider === "string" ? fields.provider.trim().toLowerCase() : "");
378
+ const kind = provider ? poolSettingsCapability(provider, config.providers?.[provider]) : null;
379
+ if (!provider || !kind) {
380
+ return jsonResponse({ error: "pool settings are only available for the codex, anthropic and generic OAuth pools" }, 400);
381
+ }
382
+ // Validated by the SHARED parsers before any kind-specific write, so a bad strategy or
383
+ // sticky limit is refused identically whichever pool is addressed.
384
+ let strategy: string | undefined;
385
+ if (fields.strategy !== undefined) {
386
+ const parsed = parseGenericPoolStrategy(fields.strategy);
387
+ if (parsed === null) return jsonResponse({ error: "strategy must be one of: quota, round-robin, fill-first" }, 400);
388
+ strategy = parsed;
389
+ }
390
+ let stickyLimit: number | undefined;
391
+ if (fields.stickyLimit !== undefined) {
392
+ const parsed = parseGenericStickyLimit(fields.stickyLimit);
393
+ if (parsed === null) return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400);
394
+ stickyLimit = parsed;
395
+ }
396
+ let autoSwitchThreshold: number | undefined;
397
+ if (fields.autoSwitchThreshold !== undefined) {
398
+ const parsed = parseGenericAutoSwitchThreshold(fields.autoSwitchThreshold);
399
+ if (parsed === null) return jsonResponse({ error: "autoSwitchThreshold must be an integer 0-100" }, 400);
400
+ autoSwitchThreshold = parsed;
401
+ }
402
+ if (fields.quotaWindow !== undefined && kind !== "anthropic") {
403
+ return jsonResponse({ error: "quotaWindow is only part of the anthropic pool contract" }, 400);
404
+ }
405
+ let quotaWindow: string | undefined;
406
+ if (fields.quotaWindow !== undefined) {
407
+ const parsed = parseAccountPoolQuotaWindow(fields.quotaWindow);
408
+ if (parsed === null) return jsonResponse({ error: "quotaWindow must be one of: five-hour, weekly, max-utilization" }, 400);
409
+ quotaWindow = parsed;
410
+ }
411
+ if (fields.enabled !== undefined) {
412
+ if (kind === "codex") return jsonResponse({ error: "enabled is not part of the codex pool contract" }, 400);
413
+ if (typeof fields.enabled !== "boolean") return jsonResponse({ error: "enabled must be a boolean" }, 400);
414
+ }
415
+
416
+ if (req.method !== "GET") {
417
+ if (kind === "codex") {
418
+ if (strategy !== undefined) config.accountPoolStrategy = strategy as never;
419
+ if (stickyLimit !== undefined) config.accountPoolStickyLimit = stickyLimit;
420
+ if (autoSwitchThreshold !== undefined) config.autoSwitchThreshold = autoSwitchThreshold;
421
+ } else if (kind === "anthropic") {
422
+ const pool = { ...(config.anthropicAccountPool ?? {}) };
423
+ if (fields.enabled !== undefined) pool.enabled = fields.enabled as boolean;
424
+ if (strategy !== undefined) pool.strategy = strategy as never;
425
+ if (stickyLimit !== undefined) pool.stickyLimit = stickyLimit;
426
+ if (autoSwitchThreshold !== undefined) pool.autoSwitchThreshold = autoSwitchThreshold;
427
+ if (quotaWindow !== undefined) pool.quotaWindow = quotaWindow as never;
428
+ config.anthropicAccountPool = pool;
429
+ } else {
430
+ const prov = config.providers[provider]!;
431
+ const next = { ...(prov.oauthAccountFailover ?? {}) };
432
+ if (fields.enabled !== undefined) next.enabled = fields.enabled as boolean;
433
+ if (strategy !== undefined) next.strategy = strategy as never;
434
+ if (stickyLimit !== undefined) next.stickyLimit = stickyLimit;
435
+ if (autoSwitchThreshold !== undefined) next.autoSwitchThreshold = autoSwitchThreshold;
436
+ if (Object.keys(next).length > 0) prov.oauthAccountFailover = next;
437
+ else delete prov.oauthAccountFailover;
438
+ }
439
+ saveConfigPreservingClaudeCode(config);
440
+ reconcileLiveStateStores();
441
+ }
442
+ return jsonResponse(unifiedPoolSettingsDto(config, provider, kind));
443
+ }
444
+
445
+
347
446
  // Opt-in Anthropic OAuth account pool (#294): enable/threshold/strategy + clear cooldown.
348
447
  if (url.pathname === "/api/oauth/accounts/pool" && req.method === "GET") {
349
448
  const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
@@ -357,7 +456,7 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
357
456
  if (!provider || !prov || poolSettingsCapability(provider, prov) !== "generic") {
358
457
  return jsonResponse({ error: "pool config is only supported for anthropic and generic OAuth providers" }, 400);
359
458
  }
360
- return jsonResponse(genericPoolSettingsDto(provider, prov));
459
+ return jsonResponse(genericPoolSettingsDto(provider, prov, config.pool?.kernel === true));
361
460
  }
362
461
  const pool = config.anthropicAccountPool ?? {};
363
462
  return jsonResponse({
@@ -387,13 +486,14 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
387
486
  if (provider !== "anthropic") {
388
487
  const {
389
488
  poolSettingsCapability, genericPoolSettingsDto, parseGenericPoolStrategy, parseGenericAutoSwitchThreshold,
489
+ parseGenericStickyLimit,
390
490
  } = await import("../../oauth/pool-settings-capability");
391
491
  const prov = config.providers[provider];
392
492
  if (!provider || !prov || poolSettingsCapability(provider, prov) !== "generic") {
393
493
  return jsonResponse({ error: "pool config is only supported for anthropic and generic OAuth providers" }, 400);
394
494
  }
395
- if (body.stickyLimit !== undefined || body.quotaWindow !== undefined) {
396
- return jsonResponse({ error: "stickyLimit and quotaWindow are not part of the generic pool contract yet" }, 400);
495
+ if (body.quotaWindow !== undefined) {
496
+ return jsonResponse({ error: "quotaWindow is not part of the generic pool contract yet" }, 400);
397
497
  }
398
498
  const next = { ...(prov.oauthAccountFailover ?? {}) };
399
499
  if (body.enabled !== undefined) {
@@ -416,10 +516,18 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
416
516
  next.autoSwitchThreshold = parsed;
417
517
  }
418
518
  }
519
+ if (body.stickyLimit !== undefined) {
520
+ if (body.stickyLimit === null) delete next.stickyLimit;
521
+ else {
522
+ const parsed = parseGenericStickyLimit(body.stickyLimit);
523
+ if (parsed === null) return jsonResponse({ error: "stickyLimit must be an integer 1-100" }, 400);
524
+ next.stickyLimit = parsed;
525
+ }
526
+ }
419
527
  if (Object.keys(next).length > 0) prov.oauthAccountFailover = next;
420
528
  else delete prov.oauthAccountFailover;
421
529
  saveConfigPreservingClaudeCode(config);
422
- return jsonResponse({ ok: true, ...genericPoolSettingsDto(provider, prov) });
530
+ return jsonResponse({ ok: true, ...genericPoolSettingsDto(provider, prov, config.pool?.kernel === true) });
423
531
  }
424
532
  let enabled = config.anthropicAccountPool?.enabled === true;
425
533
  if (body.enabled !== undefined) {
@@ -623,8 +731,11 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
623
731
  clearModelCache(name);
624
732
  const { clearProviderQuotaCache } = await import("../../providers/quota");
625
733
  clearProviderQuotaCache();
626
- const { clearKeyCooldowns } = await import("../../providers/key-failover");
734
+ const { clearKeyCooldowns, forgetApiKeyRotationCursor } = await import("../../providers/key-failover");
627
735
  clearKeyCooldowns(name); // manual key management resets 429 cooldown state
736
+ // ...and the rotation cursor with it. A cursor that predates the operator's choice would
737
+ // hand the next proactive pick straight back to whichever key the pool had reached.
738
+ forgetApiKeyRotationCursor(name);
628
739
  return jsonResponse({ ok: true, id: result.id }, 201);
629
740
  }
630
741
  // Opt-in OS keychain storage (#1221): move the active key and pool into the OS credential
@@ -667,8 +778,11 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
667
778
  clearModelCache(name);
668
779
  const { clearProviderQuotaCache } = await import("../../providers/quota");
669
780
  clearProviderQuotaCache();
670
- const { clearKeyCooldowns } = await import("../../providers/key-failover");
781
+ const { clearKeyCooldowns, forgetApiKeyRotationCursor } = await import("../../providers/key-failover");
671
782
  clearKeyCooldowns(name); // manual key management resets 429 cooldown state
783
+ // ...and the rotation cursor with it. A cursor that predates the operator's choice would
784
+ // hand the next proactive pick straight back to whichever key the pool had reached.
785
+ forgetApiKeyRotationCursor(name);
672
786
  return jsonResponse({ ok: true, name, activeId: body.id });
673
787
  }
674
788
  if (url.pathname === "/api/providers/keys/alias" && req.method === "PUT") {
@@ -696,8 +810,11 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise<
696
810
  clearModelCache(name);
697
811
  const { clearProviderQuotaCache } = await import("../../providers/quota");
698
812
  clearProviderQuotaCache();
699
- const { clearKeyCooldowns } = await import("../../providers/key-failover");
813
+ const { clearKeyCooldowns, forgetApiKeyRotationCursor } = await import("../../providers/key-failover");
700
814
  clearKeyCooldowns(name); // manual key management resets 429 cooldown state
815
+ // ...and the rotation cursor with it. A cursor that predates the operator's choice would
816
+ // hand the next proactive pick straight back to whichever key the pool had reached.
817
+ forgetApiKeyRotationCursor(name);
701
818
  return jsonResponse({ ok: true });
702
819
  }
703
820
 
@@ -57,7 +57,10 @@ import {
57
57
  import { extractGoogleAiStudioModelItems } from "../../providers/google-ai-studio-model-discovery";
58
58
  import { routedSlug, slugEquals } from "../../providers/slug-codec";
59
59
  import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota";
60
- import { clearKeyCooldowns } from "../../providers/key-failover";
60
+ import { getCachedProviderRoutingQuota } from "../../providers/quota-routing-cache";
61
+ import { PROVIDER_QUOTA_MAX_AGE_MS, type ProviderRoutingQuota } from "../../providers/quota-types";
62
+ import { cachedProviderQuotaIsExhausted } from "../../combos/resolve";
63
+ import { clearKeyCooldowns, forgetApiKeyRotationCursor } from "../../providers/key-failover";
61
64
  import { providerRequestPacingStatus } from "../../providers/request-pacing";
62
65
  import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
63
66
  import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match";
@@ -112,6 +115,7 @@ import {
112
115
  XAI_RESPONSES_DEFAULT_VERSION,
113
116
  xaiResponsesOptInState,
114
117
  } from "../../providers/xai-responses-opt-in";
118
+ import { ZAI_PROVIDER_ID } from "../../providers/zai-responses-migration";
115
119
  import { dropProviderCustomModels } from "../../providers/provider-id-rewrite";
116
120
 
117
121
  import { isPlainRecord, parseDebugLogQuery, tokPerSecondResult, unavailableCostReason, costResult, requestLogDto, stripRegistryOnlyStaticHeaders, fetchAllModels } from "./shared";
@@ -573,6 +577,19 @@ function applyProviderPatchFields(
573
577
  }
574
578
  touched = true;
575
579
  }
580
+ if (Object.hasOwn(rawBody, "noJsonSchemaModels")) {
581
+ const value = rawBody.noJsonSchemaModels;
582
+ if (value === null) {
583
+ delete next.noJsonSchemaModels;
584
+ } else {
585
+ const error = nonBlankStringArrayConfigError(value, "noJsonSchemaModels");
586
+ if (error) return { error };
587
+ const models = normalizeNonBlankStringArray(value as string[]);
588
+ if (models.length > 0) next.noJsonSchemaModels = models;
589
+ else delete next.noJsonSchemaModels;
590
+ }
591
+ touched = true;
592
+ }
576
593
  if (Object.hasOwn(rawBody, "retainModels")) {
577
594
  const value = rawBody.retainModels;
578
595
  if (value === null) {
@@ -689,12 +706,54 @@ function canonicalOpenAiBudgetPatchError(
689
706
  ?? providerEmptyToolOutputConfigError("openai", applied.next);
690
707
  }
691
708
 
709
+ function providerRoutingQuota(config: OcxConfig, name: string, now: number): ProviderRoutingQuota {
710
+ const provider = hasOwnProvider(config.providers, name) ? config.providers[name] : undefined;
711
+ const quota = getCachedProviderRoutingQuota(name, provider, now);
712
+ if (!quota || !Number.isFinite(quota.updatedAt) || quota.updatedAt < 0 || quota.updatedAt > now
713
+ || now >= quota.updatedAt + PROVIDER_QUOTA_MAX_AGE_MS) return { state: "unknown" };
714
+
715
+ // Removing search/MCP windows may leave only a timestamp. That is not inference evidence.
716
+ const percentages = [quota.fiveHourPercent, quota.weeklyPercent, quota.monthlyPercent,
717
+ ...(quota.customWindows ?? []).map(window => window.percent)];
718
+ const hasPercentage = percentages.some(value => typeof value === "number" && Number.isFinite(value) && value >= 0);
719
+ const credits = quota.creditsUsd;
720
+ const hasCredits = credits !== undefined && Number.isFinite(credits.percent)
721
+ && credits.percent >= 0 && Number.isFinite(credits.remaining);
722
+ if (!hasPercentage && !hasCredits) return { state: "unknown" };
723
+
724
+ const state = cachedProviderQuotaIsExhausted(quota, now) ? "exhausted" : "available";
725
+ let validUntil = quota.updatedAt + PROVIDER_QUOTA_MAX_AGE_MS;
726
+ if (state === "exhausted") {
727
+ const resets = [quota.fiveHourResetAt, quota.weeklyResetAt, quota.monthlyResetAt,
728
+ ...(quota.customWindows ?? []).map(window => window.resetAt)]
729
+ .filter((reset): reset is number => typeof reset === "number" && Number.isFinite(reset)
730
+ && reset > now && reset < validUntil)
731
+ .sort((left, right) => left - right);
732
+ // Reuse dispatch's predicate: another exhausted window or USD cap may still block.
733
+ for (const reset of resets) {
734
+ if (!cachedProviderQuotaIsExhausted(quota, reset)) {
735
+ validUntil = reset;
736
+ break;
737
+ }
738
+ }
739
+ }
740
+ return { state, updatedAt: quota.updatedAt, validUntil };
741
+ }
742
+
692
743
  export async function handleProviderRoutes(ctx: ManagementContext): Promise<Response | null> {
693
744
  const { req, url, config, deps, principal, convergeCodexCatalog, syncClaudeAgentDefsBestEffort } = ctx;
694
745
 
695
746
  if (url.pathname === "/api/provider-quotas" && req.method === "GET") {
696
747
  const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true";
697
- return jsonResponse(await fetchProviderQuotaReports(config, forceRefresh));
748
+ const snapshot = await fetchProviderQuotaReports(config, forceRefresh);
749
+ const now = Date.now();
750
+ return jsonResponse({
751
+ ...snapshot,
752
+ reports: snapshot.reports.map(report => ({
753
+ ...report,
754
+ routingQuota: providerRoutingQuota(config, report.provider, now),
755
+ })),
756
+ });
698
757
  }
699
758
 
700
759
  if (url.pathname === "/api/provider-request-pacing" && req.method === "GET") {
@@ -730,6 +789,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
730
789
  modelAutoCompactTokenLimits: p.modelAutoCompactTokenLimits,
731
790
  modelSupportsServiceTier: p.modelSupportsServiceTier,
732
791
  noStructuredOutputModels: p.noStructuredOutputModels,
792
+ noJsonSchemaModels: p.noJsonSchemaModels,
733
793
  retainModels: p.retainModels,
734
794
  omitReasoningEffortWithToolsModels: p.omitReasoningEffortWithToolsModels,
735
795
  upstreamHttpVersion: p.upstreamHttpVersion,
@@ -816,6 +876,9 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
816
876
  (deps.clearProviderQuotaCache ?? clearProviderQuotaCache)();
817
877
  clearAccountQuotaCache(name);
818
878
  clearKeyCooldowns(name);
879
+ // The rotation cursor describes a pool this edit just changed; keeping it would let a
880
+ // stale position steer the next proactive pick.
881
+ forgetApiKeyRotationCursor(name);
819
882
  clearModelCache(name);
820
883
  if (name === "openai") (deps.clearThreadAccountMap ?? clearThreadAccountMap)();
821
884
  const catalogRefresh = await convergeCodexCatalog();
@@ -915,6 +978,10 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
915
978
  (deps.clearProviderQuotaCache ?? clearProviderQuotaCache)();
916
979
  clearAccountQuotaCache();
917
980
  clearKeyCooldowns();
981
+ // Cursors too, for the same reason the cooldown clear above takes no name: this PUT
982
+ // replaces the whole roster, and a cursor that outlives it still names a real id, so
983
+ // round-robin would resume after the pre-edit position instead of the new roster head.
984
+ forgetApiKeyRotationCursor();
918
985
  clearModelCache();
919
986
  (deps.clearThreadAccountMap ?? clearThreadAccountMap)();
920
987
  const catalogRefresh = await convergeCodexCatalog();
@@ -1061,6 +1128,14 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise<Resp
1061
1128
  prov.xaiResponsesDefaultVersion = latest.xaiResponsesDefaultVersion;
1062
1129
  }
1063
1130
  }
1131
+ // Same reason for the Z.AI marker: the provider form never carries it, and losing it on an
1132
+ // unrelated edit would let the one-time wire rewrite run a second time.
1133
+ if (name === ZAI_PROVIDER_ID) {
1134
+ const latest = config.providers[name];
1135
+ if (latest?.zaiResponsesDefaultVersion !== undefined) {
1136
+ prov.zaiResponsesDefaultVersion = latest.zaiResponsesDefaultVersion;
1137
+ }
1138
+ }
1064
1139
  // Reapply pins to the latest live row after DNS/import awaits, then validate the
1065
1140
  // complete draft before adopting any provider/default state.
1066
1141
  const latest = config.providers[name];
@@ -107,7 +107,7 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
107
107
  { method: "PUT", path: "/api/codex-auth/active", module: "codex/auth-api", mutates: true },
108
108
  { method: "PUT", path: "/api/codex-auth/auto-switch", module: "codex/auth-api", mutates: true },
109
109
  { method: "PUT", path: "/api/codex-auth/failover", module: "codex/auth-api", mutates: true },
110
- { method: "PUT", path: "/api/codex-auth/pool-strategy", module: "codex/auth-api", mutates: true },
110
+ { method: "PUT", path: "/api/codex-auth/pool-strategy", module: "codex/auth-api", mutates: true, exempt: { reason: "compatibility-alias", why: "Superseded by PUT /api/pool/settings, which the CLI now drives. Kept working for existing clients and pinned by exact-body goldens in tests/server/account-pool-management-api.test.ts; no CLI verb targets it any more." } },
111
111
  // codex/native-profile-api
112
112
  { method: "GET", path: "/api/native-main-profiles", module: "codex/native-profile-api", mutates: false },
113
113
  { method: "GET", path: "/api/native-main-profiles/doctor", module: "codex/native-profile-api", mutates: false },
@@ -131,6 +131,8 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
131
131
  { method: "GET", path: "/api/v2", module: "server/management/agent-settings-routes", mutates: false },
132
132
  { method: "POST", path: "/api/claude-desktop/apply", module: "server/management/agent-settings-routes", mutates: true },
133
133
  { method: "POST", path: "/api/grok/apply", module: "server/management/agent-settings-routes", mutates: true },
134
+ { method: "GET", path: "/api/grok/reset-coupons", module: "server/management/grok-coupon-routes", mutates: false },
135
+ { method: "POST", path: "/api/grok/reset-coupons/consume", module: "server/management/grok-coupon-routes", mutates: true },
134
136
  { method: "PUT", path: "/api/claude-code", module: "server/management/agent-settings-routes", mutates: true },
135
137
  { method: "PUT", path: "/api/claude-desktop", module: "server/management/agent-settings-routes", mutates: true },
136
138
  { method: "PUT", path: "/api/codex-auth/features/default-mode-request-user-input", module: "server/management/agent-settings-routes", mutates: true },
@@ -261,6 +263,9 @@ export const MANAGEMENT_ROUTES: readonly ManagementRoute[] = [
261
263
  { method: "GET", path: "/api/oauth/accounts", module: "server/management/oauth-account-routes", mutates: false },
262
264
  { method: "GET", path: "/api/accounts/events", module: "server/management/oauth-account-routes", mutates: false, exempt: { reason: "gui-invalidation", why: "Dashboard selection invalidation stream; CLI account commands read the authoritative account/key resources directly rather than subscribing to browser refresh notifications." } },
263
265
  { method: "GET", path: "/api/oauth/accounts/pool", module: "server/management/oauth-account-routes", mutates: false },
266
+ { method: "GET", path: "/api/pool/settings", module: "server/management/oauth-account-routes", mutates: false },
267
+ { method: "PUT", path: "/api/pool/settings", module: "server/management/oauth-account-routes", mutates: true },
268
+ { method: "PATCH", path: "/api/pool/settings", module: "server/management/oauth-account-routes", mutates: true },
264
269
  { method: "GET", path: "/api/oauth/providers", module: "server/management/oauth-account-routes", mutates: false },
265
270
  { method: "GET", path: "/api/oauth/status", module: "server/management/oauth-account-routes", mutates: false },
266
271
  { method: "GET", path: "/api/providers/keys", module: "server/management/oauth-account-routes", mutates: false },
@@ -143,6 +143,12 @@ async function handleQuotaResetRoutesOnDemand(ctx: ManagementContext): Promise<R
143
143
  return handleQuotaResetRoutes(ctx);
144
144
  }
145
145
 
146
+ async function handleGrokCouponRoutesOnDemand(ctx: ManagementContext): Promise<Response | null> {
147
+ if (!pathInManagementNamespace(ctx.url.pathname, "/api/grok/reset-coupons", true)) return null;
148
+ const { handleGrokCouponRoutes } = await import("./management/grok-coupon-routes");
149
+ return handleGrokCouponRoutes(ctx);
150
+ }
151
+
146
152
  export async function handleManagementAPI(
147
153
  req: Request,
148
154
  url: URL,
@@ -241,6 +247,7 @@ export async function handleManagementAPI(
241
247
  ?? (await handleLogsUsageRoutes(ctx))
242
248
  ?? (await handleRequestHistoryRoutes(ctx))
243
249
  ?? (await handleQuotaResetRoutesOnDemand(ctx))
250
+ ?? (await handleGrokCouponRoutesOnDemand(ctx))
244
251
  ?? (await handleRoutingAnalyticsRoutes(ctx))
245
252
  ?? (await handleRoutingProfileRoutesOnDemand(ctx))
246
253
  ?? (await handleProviderRoutes(ctx))
@@ -50,6 +50,7 @@ import { enforceAppOwnedMemoryBudget, type RetainedStoreSnapshot } from "../lib/
50
50
  import { capEstimateAtContextWindow } from "../lib/token-estimate";
51
51
  import { inferCursorContextWindow } from "../adapters/cursor/discovery";
52
52
  import { KIRO_MODEL_CONTEXT_WINDOWS, normalizeKiroModelId } from "../providers/kiro-models";
53
+ import { DEVIN_MODEL_CONTEXT_WINDOWS } from "../adapters/devin/live-models";
53
54
  import { modelRecordValue } from "../reasoning-effort";
54
55
 
55
56
  export interface RequestLogContext {
@@ -1190,6 +1191,9 @@ function contextWindowForModel(adapter: string, modelId: string | undefined): nu
1190
1191
  if (adapter === "cursor" || adapter.startsWith("cursor-")) {
1191
1192
  return inferCursorContextWindow(modelId);
1192
1193
  }
1194
+ if (adapter === "devin") {
1195
+ return modelRecordValue(DEVIN_MODEL_CONTEXT_WINDOWS, modelId);
1196
+ }
1193
1197
  return undefined;
1194
1198
  }
1195
1199
 
@@ -4,9 +4,9 @@ import { CodexWsMetadata, type CodexWsQuotaObserver } from "./codex-ws-metadata"
4
4
  import { CODEX_RESPONSES_HTTP_URL, type PreparedCodexWsRequest } from "./codex-ws-request";
5
5
  import { CodexWsCorrelation } from "./codex-ws-correlation";
6
6
  import type { CodexWsSession } from "./codex-ws-session";
7
- import { UPGRADE_DEADLINE_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES,
7
+ import { UPGRADE_DEADLINE_MS, CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES,
8
8
  MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage,
9
- codexWsFailureDetail, type CodexWsFailureStage } from "./codex-ws-wire";
9
+ codexWsFailureDetail, codexWsPreResponseFailure, type CodexWsFailureStage } from "./codex-ws-wire";
10
10
 
11
11
  interface ExchangeOptions {
12
12
  session: CodexWsSession;
@@ -101,6 +101,8 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
101
101
  let upstreamFrames = 0;
102
102
  let controlFrames = 0;
103
103
  let relayedEvents = 0;
104
+ let pings = 0;
105
+ let pongs = 0;
104
106
  let sentAt: number | null = null;
105
107
  let firstFrameAt: number | null = null;
106
108
  let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
@@ -108,7 +110,11 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
108
110
  const metadata = url === CODEX_RESPONSES_HTTP_URL ? new CodexWsMetadata(onQuota) : null;
109
111
  const correlation = session.retainable ? new CodexWsCorrelation(session.reused, id => session.hasCompleted(id)) : null;
110
112
  let detachOwner = () => {};
111
- let preludeTimer: ReturnType<typeof setTimeout> | undefined;
113
+ // Liveness while waiting for the first response event (metadata path only): the
114
+ // silence timer is re-armed by every inbound frame or pong; the pinger runs on a fixed
115
+ // interval so a peer that answers pings can never trip the silence bound while alive.
116
+ let silenceTimer: ReturnType<typeof setTimeout> | undefined;
117
+ let pingTimer: ReturnType<typeof setTimeout> | undefined;
112
118
  const stream = new ReadableStream<Uint8Array>({
113
119
  start(c) { controller = c; },
114
120
  cancel() {
@@ -121,7 +127,8 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
121
127
 
122
128
  const cleanup = () => {
123
129
  clearTimeout(upgradeTimer);
124
- clearTimeout(preludeTimer);
130
+ clearTimeout(silenceTimer);
131
+ clearTimeout(pingTimer);
125
132
  signal?.removeEventListener("abort", onAbort);
126
133
  metadata?.finish();
127
134
  correlation?.finish();
@@ -130,6 +137,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
130
137
  ws.removeEventListener("message", onMessage);
131
138
  ws.removeEventListener("close", onClose);
132
139
  ws.removeEventListener("error", onError);
140
+ ws.removeEventListener("pong", onPong);
133
141
  };
134
142
 
135
143
  /**
@@ -145,12 +153,15 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
145
153
  relayedEvents,
146
154
  firstFrameMs: sentAt !== null && firstFrameAt !== null ? Math.max(0, firstFrameAt - sentAt) : null,
147
155
  elapsedMs: sentAt !== null ? Math.max(0, Date.now() - sentAt) : null,
156
+ pings,
157
+ pongs,
148
158
  });
149
159
 
150
160
  const commitResponse = () => {
151
161
  if (responseCommitted) return;
152
162
  responseCommitted = true;
153
- clearTimeout(preludeTimer);
163
+ clearTimeout(silenceTimer);
164
+ clearTimeout(pingTimer);
154
165
  const responseHeaders = metadata?.snapshot() ?? new Headers();
155
166
  responseHeaders.set("content-type", "text/event-stream; charset=utf-8");
156
167
  const response = new Response(stream, { status: 200, headers: responseHeaders });
@@ -159,17 +170,61 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
159
170
  resolve(response);
160
171
  };
161
172
 
162
- const failStream = (error: unknown) => {
173
+ const failStream = (error: unknown, status: 502 | 504 = 502) => {
163
174
  if (terminal) return;
164
175
  terminal = true;
165
- // A frame may already be executing upstream. Settle as a body failure,
166
- // never a fetch rejection/5xx that the pre-stream wrapper could resend.
176
+ if (sent && !responseCommitted && metadata) {
177
+ // Nothing has been promised to the client yet, so the honest answer is a gateway
178
+ // status, not a 200 whose body then fails. The frame may already be executing
179
+ // upstream: the response is marked non-replayable so no layer of this process sends
180
+ // it again, and the client applies its own retry policy as it would on the direct
181
+ // path. Same settle order as a refused create: snapshot, detach, close, dispose.
182
+ const prelude = metadata.snapshot();
183
+ // Claim the commit slot so no later path can resolve a second, 200 Response.
184
+ responseCommitted = true;
185
+ cleanup();
186
+ try { controller?.close(); } catch { /* unused stream already closed */ }
187
+ session.dispose();
188
+ const message = error instanceof Error ? error.message : String(error);
189
+ resolve(codexWsPreResponseFailure(status, message, prelude));
190
+ return;
191
+ }
192
+ // A response is already flowing (or this transport has no metadata channel and
193
+ // committed at send). Settle as a body failure, never a fetch rejection/5xx that the
194
+ // pre-stream wrapper could resend.
167
195
  if (sent) commitResponse();
168
196
  cleanup();
169
197
  try { controller?.error(typeof error === "string" ? new Error(error) : error); } catch { /* stream already done */ }
170
198
  session.dispose();
171
199
  };
172
200
 
201
+ /** (Re)start the silence bound; every inbound frame or pong is proof of life. */
202
+ const armSilence = () => {
203
+ clearTimeout(silenceTimer);
204
+ if (responseCommitted || terminal) return;
205
+ silenceTimer = setTimeout(
206
+ () => failStream(`codex websocket response prelude timed out${codexWsFailureDetail(failureStage())}`, 504),
207
+ CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS,
208
+ );
209
+ };
210
+ /** Ping on a fixed interval until the response starts; a socket without ping() is never pinged. */
211
+ const schedulePing = () => {
212
+ const ping = (ws as WebSocket & { ping?: (data?: string) => void }).ping;
213
+ if (typeof ping !== "function" || responseCommitted || terminal) return;
214
+ pingTimer = setTimeout(() => {
215
+ if (responseCommitted || terminal) return;
216
+ try { ping.call(ws); } catch { return; }
217
+ pings += 1;
218
+ // ping() may close the socket synchronously and settle the exchange; re-check.
219
+ if (!terminal) schedulePing();
220
+ }, CODEX_WS_LIVENESS_PING_INTERVAL_MS);
221
+ };
222
+ const onPong = () => {
223
+ if (terminal) return;
224
+ pongs += 1;
225
+ if (!responseCommitted) armSilence();
226
+ };
227
+
173
228
  const upgradeTimer = setTimeout(() => {
174
229
  if (opened || settledPreOpen) return;
175
230
  settledPreOpen = true;
@@ -188,6 +243,25 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
188
243
  reject(reason);
189
244
  return;
190
245
  }
246
+ if (!responseCommitted && metadata) {
247
+ // Sent, unacknowledged. The proxy's own connect deadline is an origin-silence
248
+ // verdict and settles like one; a caller abort is the caller's decision, so the
249
+ // exchange rejects with that reason and disposing the socket cancels the turn.
250
+ // Both arrive on the same composite signal (fetchWithHeaderTimeout joins the
251
+ // caller's controller with its own), so the reason is the only discriminator: the
252
+ // deadline aborts with a TimeoutError DOMException, and every caller abort in this
253
+ // process (upstream.abort() in core.ts) carries the default AbortError. A future
254
+ // proxy-side deadline that aborts with TimeoutError would still be honestly a 504.
255
+ if ((reason as { name?: unknown } | null)?.name === "TimeoutError") {
256
+ failStream(`codex websocket response did not start before the connect deadline${codexWsFailureDetail(failureStage())}`, 504);
257
+ return;
258
+ }
259
+ terminal = true;
260
+ cleanup();
261
+ session.dispose();
262
+ reject(reason);
263
+ return;
264
+ }
191
265
  failStream(reason);
192
266
  };
193
267
  const onAbort = () => cancelExchange(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError"));
@@ -208,6 +282,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
208
282
  ws.removeEventListener("message", onMessage);
209
283
  ws.removeEventListener("close", onClose);
210
284
  ws.removeEventListener("error", onError);
285
+ ws.removeEventListener("pong", onPong);
211
286
  session.dispose();
212
287
  reject(error);
213
288
  return;
@@ -236,16 +311,15 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
236
311
  }
237
312
  if (!metadata) commitResponse();
238
313
  else if (!responseCommitted && !terminal) {
239
- preludeTimer = setTimeout(
240
- () => failStream(`codex websocket response prelude timed out${codexWsFailureDetail(failureStage())}`),
241
- CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS,
242
- );
314
+ armSilence();
315
+ schedulePing();
243
316
  }
244
317
  };
245
318
 
246
319
  const onMessage = (event: MessageEvent) => {
247
320
  if (!controller || terminal) return;
248
321
  received = true;
322
+ if (!responseCommitted) armSilence();
249
323
  upstreamFrames += 1;
250
324
  if (firstFrameAt === null) firstFrameAt = Date.now();
251
325
  const text = typeof event.data === "string" ? event.data : "";
@@ -288,8 +362,9 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
288
362
  if (!controlFrame && !type.startsWith("response.") && type !== "error") return;
289
363
  if (!controlFrame) {
290
364
  try { correlation?.accept(normalized.payload); } catch (error) { failStream(error); return; }
291
- // Correlation must run first: a reused socket's foreign-stream error
292
- // must not become an HTTP refusal that could authorize account replay.
365
+ // Correlation must run first: a reused socket's foreign-stream error settles as a
366
+ // non-replayable 502 above, never as the refused-create 4xx projection below, which
367
+ // is the one status family that could authorize an account replay.
293
368
  if (metadata && sent && !responseCommitted && type === "error") {
294
369
  let rejection: Response | null;
295
370
  try { rejection = wrappedRejectionResponse(normalized.payload, metadata.snapshot()); }
@@ -366,6 +441,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
366
441
  ws.addEventListener("message", onMessage);
367
442
  ws.addEventListener("close", onClose);
368
443
  ws.addEventListener("error", onError);
444
+ ws.addEventListener("pong", onPong);
369
445
  if (signal?.aborted) onAbort();
370
446
  else if (session.opened) onOpen();
371
447
  });