@bitkyc08/opencodex 2.60.0 → 2.61.0-preview.20260922

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 (249) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-E64Rzjap.js +50 -0
  5. package/gui/dist/assets/Tray-_nfzD8k4.js +1 -0
  6. package/gui/dist/assets/index-DpdfZWMK.js +86 -0
  7. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  8. package/gui/dist/assets/usage-companion-chart-DtoK7T6h.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +5 -1
  13. package/src/adapters/anthropic.ts +16 -0
  14. package/src/adapters/coding-agent/protocol.ts +36 -6
  15. package/src/adapters/coding-agent/turn.ts +10 -2
  16. package/src/adapters/command-code.ts +2 -1
  17. package/src/adapters/cursor/catalog.ts +51 -7
  18. package/src/adapters/cursor/protobuf-request.ts +6 -3
  19. package/src/adapters/cursor/request-builder.ts +13 -3
  20. package/src/adapters/cursor.ts +11 -2
  21. package/src/adapters/declaration-carrier.ts +45 -0
  22. package/src/adapters/devin.ts +75 -23
  23. package/src/adapters/google-antigravity-wire.ts +5 -2
  24. package/src/adapters/google-errors.ts +7 -1
  25. package/src/adapters/google.ts +29 -5
  26. package/src/adapters/image.ts +4 -1
  27. package/src/adapters/input-media-guard.ts +21 -9
  28. package/src/adapters/kiro/usage.ts +3 -2
  29. package/src/adapters/kiro-tool-fallback.ts +1 -1
  30. package/src/adapters/ollama-native.ts +6 -0
  31. package/src/adapters/openai-chat/developer-role.ts +61 -0
  32. package/src/adapters/openai-chat/messages.ts +46 -27
  33. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  34. package/src/adapters/openai-chat/passthrough.ts +33 -9
  35. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  36. package/src/adapters/openai-chat.ts +18 -57
  37. package/src/adapters/openai-responses/passthrough.ts +2 -0
  38. package/src/adapters/registry.ts +3 -2
  39. package/src/adapters/run-turn-queue.ts +178 -29
  40. package/src/adapters/xai-web-search.ts +16 -1
  41. package/src/bridge/errors.ts +8 -2
  42. package/src/bridge/response-json.ts +9 -1
  43. package/src/bridge/sse.ts +10 -0
  44. package/src/chat/inbound.ts +141 -5
  45. package/src/claude/desktop-3p.ts +7 -1
  46. package/src/claude/desktop-first-party.ts +183 -0
  47. package/src/claude/desktop-gateway-state.ts +41 -0
  48. package/src/claude/inbound-content-options.ts +6 -0
  49. package/src/claude/inbound.ts +32 -6
  50. package/src/claude/intercept/connect-proxy.ts +179 -0
  51. package/src/claude/intercept/listener.ts +122 -0
  52. package/src/claude/intercept/local-ca.ts +298 -0
  53. package/src/claude/intercept/runtime.ts +98 -0
  54. package/src/claude/intercept/settings.ts +189 -0
  55. package/src/cli/access.ts +87 -0
  56. package/src/cli/account-auth.ts +19 -0
  57. package/src/cli/capabilities.ts +31 -0
  58. package/src/cli/claude-desktop.ts +206 -16
  59. package/src/cli/codex-shim-autorestore.ts +3 -0
  60. package/src/cli/companion.ts +56 -0
  61. package/src/cli/dispatch.ts +43 -4
  62. package/src/cli/ensure-desired-integrations.ts +43 -5
  63. package/src/cli/help.ts +7 -9
  64. package/src/cli/index.ts +200 -61
  65. package/src/cli/init.ts +8 -0
  66. package/src/cli/integrations.ts +7 -1
  67. package/src/cli/registry.ts +41 -2
  68. package/src/cli/resolve.ts +230 -0
  69. package/src/cli/root.ts +24 -1
  70. package/src/cli/start-ownership-publication.ts +56 -0
  71. package/src/cli/status-probes.ts +2 -18
  72. package/src/cli/status.ts +62 -0
  73. package/src/cli/stop-report.ts +143 -0
  74. package/src/cli/uninstall-plan.ts +9 -0
  75. package/src/client/machine-listener.ts +2 -5
  76. package/src/clients/aside-profiles.ts +4 -0
  77. package/src/clients/config-export/zcode-store.ts +157 -0
  78. package/src/clients/config-export.ts +36 -0
  79. package/src/codex/app-server-processes.ts +72 -40
  80. package/src/codex/auth-api/login-flow.ts +6 -1
  81. package/src/codex/autostart-health.ts +28 -0
  82. package/src/codex/catalog/build-entries.ts +2 -2
  83. package/src/codex/catalog/effort.ts +3 -3
  84. package/src/codex/catalog/provider-models.ts +24 -15
  85. package/src/codex/catalog/retained-sync.ts +2 -2
  86. package/src/codex/convergence.ts +2 -2
  87. package/src/codex/history-provider.ts +12 -1
  88. package/src/codex/inject/config-toml.ts +41 -6
  89. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  90. package/src/codex/inject.ts +18 -15
  91. package/src/codex/injected-marker.ts +18 -0
  92. package/src/codex/main-account.ts +6 -0
  93. package/src/codex/model-cache.ts +52 -6
  94. package/src/codex/model-entitlement-admission.ts +59 -0
  95. package/src/codex/model-entitlements.ts +87 -44
  96. package/src/codex/native-main-admission.ts +83 -0
  97. package/src/codex/routing/health-store.ts +39 -0
  98. package/src/codex/routing/selection.ts +37 -1
  99. package/src/codex/routing.ts +5 -41
  100. package/src/codex/shim-templates.ts +29 -3
  101. package/src/companion/settings.ts +132 -0
  102. package/src/config/atomic-write.ts +117 -5
  103. package/src/config/load-degrade.ts +34 -7
  104. package/src/config/process-state.ts +1 -1
  105. package/src/config/schema/config-schema.ts +27 -1
  106. package/src/config/schema/leaf-validators.ts +47 -0
  107. package/src/config.ts +1 -1
  108. package/src/generated/compatibility-version.json +418 -174
  109. package/src/integrations/config-io.ts +44 -10
  110. package/src/integrations/merge.ts +120 -13
  111. package/src/integrations/mutation-plan.ts +124 -18
  112. package/src/integrations/registry.ts +38 -0
  113. package/src/integrations/state.ts +78 -45
  114. package/src/integrations/target.ts +208 -0
  115. package/src/integrations/writer.ts +49 -11
  116. package/src/lab/conformance/fixture-provider.ts +5 -0
  117. package/src/lib/browser-launch-notice.ts +59 -0
  118. package/src/lib/bun-runtime.ts +6 -2
  119. package/src/lib/debug.ts +40 -0
  120. package/src/lib/open-url.ts +51 -7
  121. package/src/lib/package-tree-integrity.ts +2 -1
  122. package/src/lib/package-version.ts +8 -0
  123. package/src/lib/provider-egress.ts +310 -0
  124. package/src/lib/provider-outbound.ts +59 -14
  125. package/src/lib/proxy-env.ts +82 -7
  126. package/src/lib/request-execution-budget.ts +72 -0
  127. package/src/lib/request-failure-attribution.ts +183 -0
  128. package/src/lib/request-failure-model.ts +236 -0
  129. package/src/lib/request-resend-gate.ts +138 -0
  130. package/src/lib/standalone.ts +16 -0
  131. package/src/lib/upstream-retry.ts +167 -16
  132. package/src/lib/winsw.ts +2 -2
  133. package/src/oauth/index.ts +24 -1
  134. package/src/oauth/login-cli.ts +80 -29
  135. package/src/providers/api-key-resolve.ts +133 -0
  136. package/src/providers/api-key-selection.ts +5 -1
  137. package/src/providers/key-failover.ts +31 -1
  138. package/src/providers/key-store.ts +34 -110
  139. package/src/providers/model-rename-fields.ts +147 -0
  140. package/src/providers/model-rename-migration.ts +124 -37
  141. package/src/providers/quota/vendor-probes-key.ts +37 -22
  142. package/src/providers/reasoning-metadata.ts +43 -18
  143. package/src/providers/registry/entries-core.ts +9 -4
  144. package/src/providers/registry/entries-extended.ts +29 -4
  145. package/src/providers/registry/model-seeds.ts +47 -10
  146. package/src/providers/xai-transport.ts +12 -1
  147. package/src/reasoning-effort.ts +8 -0
  148. package/src/responses/function-call-compat.ts +38 -1
  149. package/src/responses/inline-document.ts +65 -0
  150. package/src/responses/input-media.ts +42 -8
  151. package/src/responses/muse-tool-name-alias.ts +19 -0
  152. package/src/responses/parser-content.ts +8 -2
  153. package/src/responses/parser-tools.ts +3 -0
  154. package/src/responses/parser.ts +3 -1
  155. package/src/responses/schema.ts +3 -0
  156. package/src/router.ts +17 -2
  157. package/src/server/admission-model-scope.ts +219 -0
  158. package/src/server/audio-live.ts +9 -3
  159. package/src/server/audio-upstream.ts +18 -0
  160. package/src/server/auth-cors.ts +26 -0
  161. package/src/server/chat-completions.ts +55 -2
  162. package/src/server/chat-native.ts +19 -4
  163. package/src/server/claude-messages.ts +55 -17
  164. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  165. package/src/server/gui-freshness.ts +103 -0
  166. package/src/server/gui-static.ts +7 -9
  167. package/src/server/images.ts +59 -6
  168. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  169. package/src/server/index/serve-options.ts +56 -10
  170. package/src/server/index/spend-ledger-lifecycle.ts +34 -8
  171. package/src/server/index/startup-warnings.ts +24 -0
  172. package/src/server/index.ts +21 -28
  173. package/src/server/lifecycle.ts +4 -4
  174. package/src/server/live-call-bindings.ts +6 -0
  175. package/src/server/live.ts +88 -3
  176. package/src/server/management/agent-settings-routes.ts +121 -36
  177. package/src/server/management/companion-routes.ts +77 -0
  178. package/src/server/management/logs-usage-routes.ts +19 -0
  179. package/src/server/management/native-integration-routes.ts +103 -6
  180. package/src/server/management/oauth-account-routes.ts +45 -7
  181. package/src/server/management/route-registry.ts +6 -0
  182. package/src/server/management/shared.ts +18 -1
  183. package/src/server/management/usage-timeline-routes.ts +44 -0
  184. package/src/server/management-api.ts +8 -9
  185. package/src/server/proxy-liveness.ts +75 -0
  186. package/src/server/relay.ts +19 -2
  187. package/src/server/request-log-failure-attribution.ts +99 -0
  188. package/src/server/request-log.ts +114 -0
  189. package/src/server/request-metrics.ts +92 -30
  190. package/src/server/responses/codex-ws-wire.ts +34 -8
  191. package/src/server/responses/combo-stream-preflight.ts +168 -6
  192. package/src/server/responses/compact.ts +11 -0
  193. package/src/server/responses/core-opaque-recovery.ts +90 -0
  194. package/src/server/responses/fetch-helpers.ts +124 -8
  195. package/src/server/responses/input-admission.ts +10 -0
  196. package/src/server/responses/passthrough-delivery.ts +14 -1
  197. package/src/server/responses/passthrough-dispatch.ts +179 -35
  198. package/src/server/responses/passthrough-error.ts +27 -8
  199. package/src/server/responses/request-prepare.ts +42 -1
  200. package/src/server/responses/request-send-budget.ts +12 -0
  201. package/src/server/responses/request-transport.ts +24 -4
  202. package/src/server/responses/reset-replay.ts +108 -0
  203. package/src/server/responses-request-tool-scope.ts +214 -0
  204. package/src/server/responses-undeclared-tool-guard.ts +4 -1
  205. package/src/server/search.ts +25 -1
  206. package/src/server/usage-ledger-retention.ts +73 -0
  207. package/src/service/cli.ts +48 -2
  208. package/src/service/health.ts +3 -2
  209. package/src/service/install-state-contract.d.mts +27 -0
  210. package/src/service/install-state-contract.mjs +34 -0
  211. package/src/service/launchd.ts +1 -1
  212. package/src/service/orchestration.ts +2 -4
  213. package/src/service/ownership-compatibility.ts +164 -0
  214. package/src/service/ownership-mutation-lease.d.mts +32 -0
  215. package/src/service/ownership-mutation-lease.mjs +211 -0
  216. package/src/service/repair.ts +45 -1
  217. package/src/service/state-lock.ts +269 -0
  218. package/src/service/state-record.d.mts +36 -0
  219. package/src/service/state-record.mjs +138 -0
  220. package/src/service/state.ts +582 -68
  221. package/src/service/windows-taskxml.ts +11 -10
  222. package/src/service.ts +7 -3
  223. package/src/tray/windows-tray.ps1 +1 -1
  224. package/src/types/config.ts +37 -0
  225. package/src/types/provider.ts +73 -0
  226. package/src/types/request.ts +28 -2
  227. package/src/types/tools.ts +19 -0
  228. package/src/types.ts +3 -0
  229. package/src/update/index.ts +207 -63
  230. package/src/update/job.ts +9 -5
  231. package/src/update/ownership-transaction.ts +47 -0
  232. package/src/update/restart-ownership.ts +54 -0
  233. package/src/update/runtime-ownership.d.mts +40 -0
  234. package/src/update/runtime-ownership.mjs +122 -0
  235. package/src/usage/attempt-delivery.ts +198 -0
  236. package/src/usage/cache-diagnostic.ts +305 -0
  237. package/src/usage/failure-fingerprint.ts +118 -0
  238. package/src/usage/failure-projection-cache.ts +174 -0
  239. package/src/usage/failure-projection.ts +174 -0
  240. package/src/usage/ledger-retention.ts +165 -0
  241. package/src/usage/log.ts +126 -79
  242. package/src/usage/request-outcome.ts +150 -0
  243. package/src/usage/retention-contract.ts +28 -0
  244. package/src/usage/summary.ts +2 -2
  245. package/src/usage/telemetry-contract.ts +237 -0
  246. package/src/usage/timeline.ts +236 -0
  247. package/src/web-search/alpha-search.ts +21 -1
  248. package/gui/dist/assets/index-BTuCbqQd.css +0 -1
  249. package/gui/dist/assets/index-DoBVdPHP.js +0 -134
@@ -52,7 +52,7 @@ import { parseRequest } from "../../responses/parser";
52
52
  import { anthropicSessionKeyFromParts } from "../../oauth/anthropic-routing";
53
53
  import { isTranslatorBudgetExceededError } from "../../lib/translator-budget";
54
54
  import { bindTurnTerminationScope, rememberDeliveredFinalAnswer } from "../../responses/turn-termination";
55
- import { requestLogSpeedLabel, readConfiguredCodexServiceTier } from "../request-log";
55
+ import { observeCacheDiagnosticInbound, rebindCacheDiagnosticBody, requestLogSpeedLabel, readConfiguredCodexServiceTier } from "../request-log";
56
56
  import type { RouteResult } from "../../router";
57
57
  import {
58
58
  captureRouteStaticPolicy,
@@ -110,6 +110,13 @@ import {
110
110
  CODEX_RESERVE_OPT_IN_REQUIRED_MESSAGE,
111
111
  } from "../../codex/loopback-target";
112
112
  import { checkComboTargetInputAdmission, checkInputAdmission } from "./input-admission";
113
+ import {
114
+ admissionModelDeniedResponse,
115
+ AdmissionModelDeniedError,
116
+ assertRouteAllowedByScope,
117
+ resolveAdmissionModelScope,
118
+ routeAllowedByScope,
119
+ } from "../admission-model-scope";
113
120
  import { nativeContextLimits } from "../../codex/catalog";
114
121
  import { streamingContextOverflowResponse } from "./context-overflow";
115
122
  import {
@@ -150,6 +157,14 @@ export async function prepareResponsesRequest(
150
157
  }
151
158
  return decodeRequestErrorResponse(err, "responses");
152
159
  }
160
+ observeCacheDiagnosticInbound(
161
+ logCtx,
162
+ body,
163
+ req.headers,
164
+ options.promptCacheKeyIsSharedCohort === true
165
+ ? "system-derived"
166
+ : options.promptCacheKeyIsSharedCohort === false ? "metadata-derived" : "caller",
167
+ );
153
168
  if (!options.comboAttempt && !options.compactionRoutingOverride && inboundWire === "responses") {
154
169
  options.compactionRoutingOverride = applyCompactionRoutingOverride(body, req.headers, config, {
155
170
  endpoint: "responses",
@@ -293,6 +308,10 @@ export async function prepareResponsesRequest(
293
308
  try {
294
309
  parsed = parseRequest(body);
295
310
  parsed._promptCacheKeyIsSharedCohort = options.promptCacheKeyIsSharedCohort;
311
+ // The body may have been rebuilt since the inbound observation (previous-response
312
+ // expansion); alias the parsed raw body to the same draft so the outbound
313
+ // observation at the adapter seam still finds it.
314
+ rebindCacheDiagnosticBody(parsed._rawBody, logCtx.cacheDiagnosticDraft);
296
315
  // Captured before any parser mutates it, so both grammars see the client's id.
297
316
  const { fastRow, effortRow } = parseSyntheticRowId(parsed.modelId, config);
298
317
  if (fastRow) {
@@ -411,7 +430,18 @@ export async function prepareResponsesRequest(
411
430
 
412
431
  let route: RouteResult;
413
432
  let credentialDomainWasRewritten = false;
433
+ // The selector the caller actually sent, captured before shadow interception
434
+ // or a subagent fallback rewrites it, so a refusal names the client's own
435
+ // request rather than a destination it never asked for.
436
+ const inboundSelector = parsed.modelId;
437
+ const admissionScope = resolveAdmissionModelScope(config, options.admission);
414
438
  const captureInboundRoutePolicy = (candidate: RouteResult): RouteResult => {
439
+ // Every route this request path produces passes through here: the direct
440
+ // name, an alias, a policy or combo selection, a compaction override, a
441
+ // shadow-intercept target and both subagent-fallback re-routes. Checking
442
+ // the key's scope at this one point is what stops a rewrite from reaching
443
+ // a destination the front door would have refused.
444
+ assertRouteAllowedByScope(admissionScope, inboundSelector, candidate);
415
445
  candidate.staticPolicy = captureRouteStaticPolicy(
416
446
  candidate.providerName,
417
447
  candidate.modelId,
@@ -474,6 +504,7 @@ export async function prepareResponsesRequest(
474
504
  }
475
505
  logCtx.routeDecision = route.routeDecision;
476
506
  } catch (err) {
507
+ if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err);
477
508
  if (err instanceof NoAvailableComboTargetsError) {
478
509
  return comboUnavailable(err.comboId);
479
510
  }
@@ -673,6 +704,7 @@ export async function prepareResponsesRequest(
673
704
  credentialDomainWasRewritten = true;
674
705
  logCtx.routeDecision = route.routeDecision;
675
706
  } catch (err) {
707
+ if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err);
676
708
  if (err instanceof NoAvailableComboTargetsError) {
677
709
  return comboUnavailable(err.comboId);
678
710
  }
@@ -873,6 +905,7 @@ export async function prepareResponsesRequest(
873
905
  credentialDomainWasRewritten = true;
874
906
  logCtx.routeDecision = route.routeDecision;
875
907
  } catch (err) {
908
+ if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err);
876
909
  if (err instanceof NoAvailableComboTargetsError) {
877
910
  return comboUnavailable(err.comboId);
878
911
  }
@@ -1021,6 +1054,14 @@ export async function prepareResponsesRequest(
1021
1054
  inboundTransport: options.inboundTransport,
1022
1055
  claudeGoAffinity: options.claudeGoAffinity,
1023
1056
  });
1057
+ // Normalization is the last thing that can move the destination: resolving an
1058
+ // OpenAI virtual model rewrites route.modelId to the wire id that will
1059
+ // actually be billed. A scope checked only before this would authorize the
1060
+ // public selector and send the wire model, so the settled route is checked
1061
+ // once more here.
1062
+ if (!routeAllowedByScope(admissionScope, route)) {
1063
+ return admissionModelDeniedResponse(new AdmissionModelDeniedError(inboundSelector, route));
1064
+ }
1024
1065
  // Attribute local auth/cooldown failures to the public selector too; exact auth may fail before
1025
1066
  // the normal post-resolution provider label is assigned.
1026
1067
  if (route.codexAccountNamespace) {
@@ -139,6 +139,16 @@ export function createResponsesSendBudget(
139
139
  */
140
140
  const sendBudgetExhausted = (cap: number = TRANSIENT_RETRY_MAX_ATTEMPTS): boolean =>
141
141
  remainingTransientSendBudget(cap) === 0;
142
+ /**
143
+ * Spend one operator-granted replacement for an ambiguous failure of THIS logical request.
144
+ *
145
+ * The counter is the execution budget's, so a combo child that derives its own scope draws on
146
+ * the same grant. A budget that predates it -- a stub, or a caller that passed the narrow
147
+ * holder -- cannot grant anything, and refusing is the fail-closed answer for a send whose
148
+ * upstream state is unknown.
149
+ */
150
+ const claimAmbiguousResend = (limit: number): boolean =>
151
+ isRequestExecutionBudget(sendBudget) && sendBudget.claimAmbiguousResend?.(limit) === true;
142
152
  /**
143
153
  * A credential hop reserves the send its own replay will make, and that replay is a recovery
144
154
  * leg. The leg must SPEND the hop's reservation instead of taking a second one: the
@@ -263,6 +273,7 @@ export function createResponsesSendBudget(
263
273
  noteAdapterPhysicalSend,
264
274
  noteAdapterRecoveryWithheld,
265
275
  sendBudgetExhausted,
276
+ claimAmbiguousResend,
266
277
  get pendingHopPermit(): SingleUseDispatchPermit | undefined {
267
278
  return pendingHopPermit;
268
279
  },
@@ -301,6 +312,7 @@ function adapterDispatchBudgetView(
301
312
  get targetTransitions(): number { return budget.targetTransitions; },
302
313
  get lastTargetKey(): string | undefined { return budget.lastTargetKey; },
303
314
  remainingBaseSends: (cap: number): number => budget.remainingBaseSends(cap),
315
+ claimAmbiguousResend: (limit: number): boolean => budget.claimAmbiguousResend?.(limit) === true,
304
316
  reserveDispatch(intent: DispatchIntent): DispatchDecision {
305
317
  // A dispatch whose upstream state is unknown is refused on its own merits. A hop that
306
318
  // already paid does not make an unsafe replay safe, so that check stays with the budget.
@@ -64,6 +64,7 @@ import {
64
64
  recordKeyAttemptUsage,
65
65
  } from "../request-log";
66
66
  import type { AttemptRecoveryKind } from "../../usage/log";
67
+ import { bindAttemptDeliveryRecorder } from "../../usage/attempt-delivery";
67
68
  import { resolvePassiveRouteSubjectId } from "../passive-route-linker";
68
69
 
69
70
  /** Owns live credential selection and adapter bindings for one request. */
@@ -298,15 +299,25 @@ export async function prepareResponsesTransport(
298
299
  recordKeyAttemptUsage(logCtx, event.usage);
299
300
  }
300
301
  };
302
+ // Counted at the one seam every adapter parse passes, and counted for EVERY event rather
303
+ // than only usage-bearing ones: the number this pairs with is the frame count the client
304
+ // transport relayed, and a difference between the two is the loss signal (#3983). Reading
305
+ // the current attempt through logCtx rather than capturing one keeps the count with the
306
+ // attempt that is live when the event arrives, across a mid-request attempt rotation.
307
+ const delivery = bindAttemptDeliveryRecorder(translatorBudget, () => logCtx.activeAttempt);
308
+ const observeEvent = (event: AdapterEvent, response: object): void => {
309
+ delivery.noteAdapterEvent();
310
+ observeUsage(event, response);
311
+ };
301
312
  const parseStream = resolved.parseStream.bind(resolved);
302
313
  resolved.parseStream = async function* (...args) {
303
- for await (const event of parseStream(...args)) { observeUsage(event, args[0]); yield event; }
314
+ for await (const event of parseStream(...args)) { observeEvent(event, args[0]); yield event; }
304
315
  };
305
316
  if (resolved.parseResponse) {
306
317
  const parseResponse = resolved.parseResponse.bind(resolved);
307
318
  resolved.parseResponse = async (...args) => {
308
319
  const events = await parseResponse(...args);
309
- events.forEach(event => observeUsage(event, args[0]));
320
+ events.forEach(event => observeEvent(event, args[0]));
310
321
  return events;
311
322
  };
312
323
  }
@@ -321,7 +332,7 @@ export async function prepareResponsesTransport(
321
332
  const runTurn = resolved.runTurn.bind(resolved);
322
333
  rawRunTurns.set(resolved, (requestParsed, incoming, emit) => {
323
334
  const response = {};
324
- return runTurn(requestParsed, incoming, event => { observeUsage(event, response); emit(event); });
335
+ return runTurn(requestParsed, incoming, event => { observeEvent(event, response); emit(event); });
325
336
  });
326
337
  resolved.runTurn = (requestParsed, incoming, emit) => runSelectedTurn(resolved, requestParsed, incoming, emit);
327
338
  }
@@ -409,7 +420,16 @@ export async function prepareResponsesTransport(
409
420
  // Either way the send crosses the physical boundary, so the connection policy is
410
421
  // applied around whichever implementation was just selected (#4992).
411
422
  commitKeyAttemptSend();
412
- const response = await sendWithConnectionPolicy(fetchImpl, destination, { ...dispatchInit, redirect: "manual" });
423
+ // The binding travels with the send, so a rebuilt request resolves its provider route
424
+ // against the destination it is actually going to rather than the one this dispatch
425
+ // started with. Account reselection can move the upstream host, which would otherwise
426
+ // apply a host-scoped decision to a different host.
427
+ const response = await sendWithConnectionPolicy(
428
+ fetchImpl,
429
+ destination,
430
+ { ...dispatchInit, redirect: "manual" },
431
+ { providerName: route.providerName, provider: route.provider },
432
+ );
413
433
  if (!response.ok) await recordKeyAttemptFailure(logCtx, response, dispatchInit.signal ?? options.abortSignal);
414
434
  // Observe each physical response before retries replace it. The binding belongs to
415
435
  // this dispatch, so a manual switch cannot file A's headers against B. Header
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The operator opt-in that overrides the stage table's refusal, and the one request-wide
3
+ * allowance both ambiguous stages claim from.
4
+ *
5
+ * `resendPermission` answers `refused-ambiguous` for a native Responses send that died with
6
+ * the caller having observed nothing -- before the response head, or after it while the SSE
7
+ * body carried only control events. This module is the one place that answer is overridden,
8
+ * and it is narrow on three axes at once: the provider has to opt in, the request has to be
9
+ * one whose second send cannot do more than run the same inference again, and the whole
10
+ * logical request gets a fixed number of replacements no matter how many legs ask.
11
+ *
12
+ * The judgment is made on the inbound body the client sent, which is already parsed. It is
13
+ * conservative for the outbound request: the proxy expands `previous_response_id` and lowers
14
+ * hosted tools into client execution, so every hazard that reaches the wire was visible here,
15
+ * and a hazard visible here may already have been removed. A cheap fail-closed answer beats
16
+ * re-parsing a multi-megabyte outbound body on every send.
17
+ */
18
+ import type { OcxProviderConfig } from "../../types";
19
+ import { resetReplayPolicyFor } from "../../providers/key-failover";
20
+ import type { AmbiguousResendAllowance } from "../../lib/request-resend-gate";
21
+
22
+ /** Input items a client owns end to end: replaying them re-runs nothing but the model. */
23
+ const CLIENT_INPUT_ITEM_TYPES: ReadonlySet<string> = new Set([
24
+ "message", "reasoning", "compaction",
25
+ "function_call", "function_call_output",
26
+ "custom_tool_call", "custom_tool_call_output",
27
+ "tool_search_call",
28
+ ]);
29
+ const MESSAGE_ROLES: ReadonlySet<string> = new Set(["user", "assistant", "system", "developer"]);
30
+ /** Bounded traversal: a catalog is operator data, not a reason to walk forever. */
31
+ const MAX_TOOL_ENTRIES = 4096;
32
+ const MAX_TOOL_DEPTH = 4;
33
+
34
+ function record(value: unknown): value is Record<string, unknown> {
35
+ return value !== null && typeof value === "object" && !Array.isArray(value);
36
+ }
37
+
38
+ /**
39
+ * True when every tool in the catalog is executed by the client. Hosted tools (`web_search`,
40
+ * `mcp`, `code_interpreter`, ...) run on the origin during the turn, so an unknown or hosted
41
+ * type fails the whole catalog rather than being skipped: a tool this proxy does not
42
+ * recognise is a tool it cannot vouch for.
43
+ */
44
+ function clientExecutedTools(tools: unknown, budget: { remaining: number }, depth = 0): boolean {
45
+ if (!Array.isArray(tools) || depth > MAX_TOOL_DEPTH) return false;
46
+ return tools.every(tool => {
47
+ budget.remaining -= 1;
48
+ if (budget.remaining < 0 || !record(tool)) return false;
49
+ if (tool.type === "function" || tool.type === "custom") return true;
50
+ if (tool.type === "tool_search") return tool.execution === "client";
51
+ return tool.type === "namespace" && typeof tool.name === "string"
52
+ && clientExecutedTools(tool.tools, budget, depth + 1);
53
+ });
54
+ }
55
+
56
+ /**
57
+ * A Responses body whose second send can only repeat the inference: nothing stored, no
58
+ * server-side continuation state, complete input, and only client-executed tools. Deferred
59
+ * tool declarations inside `input` are checked by the same rule as the root catalog, so a
60
+ * hosted tool cannot ride in through `additional_tools` or a `tool_search_output`.
61
+ */
62
+ export function selfContainedResponsesBody(body: unknown): boolean {
63
+ if (!record(body)) return false;
64
+ if (body.store !== false || body.background === true) return false;
65
+ if (body.previous_response_id != null || body.conversation != null || Object.hasOwn(body, "stream_id")) return false;
66
+ const input = body.input;
67
+ if (typeof input !== "string" && !Array.isArray(input)) return false;
68
+ const budget = { remaining: MAX_TOOL_ENTRIES };
69
+ if (body.tools !== undefined && !clientExecutedTools(body.tools, budget)) return false;
70
+ if (typeof input === "string") return true;
71
+ return input.every(item => {
72
+ if (!record(item)) return false;
73
+ if (item.type === "additional_tools" || item.type === "tool_search_output") {
74
+ return clientExecutedTools(item.tools, budget);
75
+ }
76
+ if (item.type === undefined) return typeof item.role === "string" && MESSAGE_ROLES.has(item.role);
77
+ return typeof item.type === "string" && CLIENT_INPUT_ITEM_TYPES.has(item.type);
78
+ });
79
+ }
80
+
81
+ /**
82
+ * The allowance for ONE logical request, or nothing when the provider did not opt in.
83
+ *
84
+ * Built once per request and handed to every leg. `claim` spends the request's counter, which
85
+ * lives on the execution budget and is therefore shared with a combo child's derived scope --
86
+ * that sharing is the reason the pre-header helper takes a callback instead of a number.
87
+ *
88
+ * The body judgment is carried rather than applied here, because the gate has to be able to
89
+ * say WHY it refused: "the operator granted nothing" and "this request cannot be replayed" are
90
+ * different operator problems, and folding them together is what made the old refusal a single
91
+ * undifferentiated no.
92
+ *
93
+ * `selfContained` is a predicate rather than a body, and the getter below is why: a provider
94
+ * that never opted in must not pay to walk the input array, and the caller memoizes one answer
95
+ * across every leg of the request.
96
+ */
97
+ export function ambiguousResendAllowanceFor(
98
+ provider: Pick<OcxProviderConfig, "retryOnReset">,
99
+ requestIsSelfContained: () => boolean,
100
+ claim: (limit: number) => boolean,
101
+ ): AmbiguousResendAllowance | undefined {
102
+ const policy = resetReplayPolicyFor(provider);
103
+ if (policy === null) return undefined;
104
+ return {
105
+ get selfContained(): boolean { return requestIsSelfContained(); },
106
+ claim: () => claim(policy.replacements),
107
+ };
108
+ }
@@ -0,0 +1,214 @@
1
+ /**
2
+ * The tool selection a Responses request actually authorized, read from the final outbound body.
3
+ *
4
+ * The undeclared-tool guard answers whether a NAME was declared. This answers a different
5
+ * question: whether this request still permits a client tool call at all, and which names it
6
+ * permits. `tool_choice: "none"`, a forced selector and an `allowed_tools` allow-list each narrow
7
+ * the catalog without removing a declaration, so a name can be declared and forbidden at the same
8
+ * time — and a repair that rebuilds a terminal from collected items would otherwise hand the
9
+ * client a call the caller ruled out.
10
+ *
11
+ * The scope is read from the OUTBOUND body, after every removal, rename and translation, because
12
+ * that is the request the destination answered. A catalog that ends up empty there authorizes no
13
+ * client call whatever the selector still says.
14
+ */
15
+ import type { MuseToolNameAliases } from "../responses/muse-tool-name-alias";
16
+ import { museWireNameForOriginal } from "../responses/muse-tool-name-alias";
17
+ import type {
18
+ RoutedNamespaceToolAliases,
19
+ RoutedNamespaceToolIdentity,
20
+ } from "../responses/namespace-tool-compat";
21
+ import {
22
+ CLIENT_EXECUTED_CALL_TYPES,
23
+ collectDeclaredWireToolNames,
24
+ hasExplicitWireToolCatalog,
25
+ } from "./responses-undeclared-tool-guard";
26
+ import { isPlainObject } from "./responses-snapshot-codec";
27
+
28
+ type ToolKind = "function" | "custom";
29
+ const BUILTIN_FUNCTIONS_NAMESPACE = "functions";
30
+
31
+ type ToolIdentity = Readonly<{
32
+ kind: ToolKind;
33
+ name: string;
34
+ namespace?: string;
35
+ }>;
36
+
37
+ export type RequestToolScopeCorrespondence = Readonly<{
38
+ clientToolAuthorizationBody?: unknown;
39
+ routedNamespaceToolAliases?: RoutedNamespaceToolAliases;
40
+ routedMuseToolNameAliases?: MuseToolNameAliases;
41
+ convertedRoutedCustomToolNames?: ReadonlySet<string>;
42
+ }>;
43
+
44
+ function identityKey(identity: ToolIdentity): string {
45
+ return JSON.stringify([identity.kind, identity.namespace ?? null, identity.name]);
46
+ }
47
+
48
+ function clientNamespace(value: unknown): string | undefined {
49
+ return typeof value === "string" && value.length > 0 && value !== BUILTIN_FUNCTIONS_NAMESPACE
50
+ ? value
51
+ : undefined;
52
+ }
53
+
54
+ function selectorIdentity(selector: unknown): ToolIdentity | undefined {
55
+ if (!isPlainObject(selector)) return undefined;
56
+ if (selector.type !== "function" && selector.type !== "custom") return undefined;
57
+ if (typeof selector.name !== "string" || selector.name.length === 0) return undefined;
58
+ if ("namespace" in selector && typeof selector.namespace !== "string") return undefined;
59
+ const namespace = clientNamespace(selector.namespace);
60
+ return { kind: selector.type, name: selector.name, ...(namespace ? { namespace } : {}) };
61
+ }
62
+
63
+ function callIdentity(item: Record<string, unknown>): ToolIdentity | undefined {
64
+ const kind = item.type === "function_call"
65
+ ? "function"
66
+ : item.type === "custom_tool_call"
67
+ ? "custom"
68
+ : undefined;
69
+ if (!kind || typeof item.name !== "string" || item.name.length === 0) return undefined;
70
+ const namespace = clientNamespace(item.namespace);
71
+ return { kind, name: item.name, ...(namespace ? { namespace } : {}) };
72
+ }
73
+
74
+ function sameRestoredIdentity(left: ToolIdentity, right: RoutedNamespaceToolIdentity): boolean {
75
+ return left.namespace === right.namespace
76
+ && left.name === right.name
77
+ && left.kind === right.kind;
78
+ }
79
+
80
+ /**
81
+ * Exact identities this restored call could have used on the final outbound wire.
82
+ *
83
+ * Namespace spellings come only from namespace-tool-compat's request-scoped, ambiguity-checked
84
+ * aliases. Muse aliases then compose over those wire names. A kind change is admitted only when
85
+ * the request recorded that exact custom identity as converted to a function.
86
+ */
87
+ function outboundCallIdentities(
88
+ item: Record<string, unknown>,
89
+ correspondence: RequestToolScopeCorrespondence,
90
+ ): ReadonlySet<string> {
91
+ const restored = callIdentity(item);
92
+ if (!restored) return new Set();
93
+ const keys = new Set<string>([identityKey(restored)]);
94
+ const museAliases = correspondence.routedMuseToolNameAliases ?? new Map();
95
+ const convertedCustom = correspondence.convertedRoutedCustomToolNames ?? new Set();
96
+ const originalSelection = isPlainObject(correspondence.clientToolAuthorizationBody)
97
+ ? toolSelection(correspondence.clientToolAuthorizationBody)
98
+ : UNRESTRICTED;
99
+ const originalSelectionAllowsRestored = originalSelection.kind === "allow"
100
+ && originalSelection.identities.has(identityKey(restored));
101
+
102
+ const addWireIdentity = (
103
+ preMuseName: string,
104
+ clientKind: ToolKind,
105
+ conversionIdentityVerified: boolean,
106
+ ): void => {
107
+ const name = museWireNameForOriginal(preMuseName, museAliases);
108
+ if (name === undefined) return;
109
+ const kind = clientKind === "custom"
110
+ && convertedCustom.has(preMuseName)
111
+ && conversionIdentityVerified
112
+ ? "function"
113
+ : clientKind;
114
+ keys.add(identityKey({ kind, name }));
115
+ };
116
+
117
+ if (restored.namespace === undefined) {
118
+ addWireIdentity(restored.name, restored.kind, originalSelectionAllowsRestored);
119
+ }
120
+ for (const [wireName, identity] of correspondence.routedNamespaceToolAliases ?? new Map()) {
121
+ // namespace-tool-compat emits only aliases authorized under the outbound selector's kind,
122
+ // then restores `custom` provenance from the request's conversion set. That exact alias edge is
123
+ // already the proof that this custom-to-function transition belongs to this identity.
124
+ if (sameRestoredIdentity(restored, identity)) addWireIdentity(wireName, identity.kind, true);
125
+ }
126
+ return keys;
127
+ }
128
+
129
+ type ToolSelection =
130
+ | { readonly kind: "unrestricted" }
131
+ | { readonly kind: "deny_all" }
132
+ | { readonly kind: "allow"; readonly identities: ReadonlySet<string> };
133
+
134
+ const UNRESTRICTED: ToolSelection = { kind: "unrestricted" };
135
+
136
+ /**
137
+ * Read the selector only where it states a client-call boundary.
138
+ *
139
+ * `auto`, `required` and an absent selector restrict nothing. A hosted selector
140
+ * (`{ type: "web_search" }`) forces a tool the PROVIDER runs and does not describe the client
141
+ * calls this turn may contain, so it is left alone rather than read as a deny-all: a false
142
+ * refusal would drop a call the caller could have executed.
143
+ */
144
+ function toolSelection(body: Record<string, unknown>): ToolSelection {
145
+ const choice = body.tool_choice;
146
+ if (choice === "none") return { kind: "deny_all" };
147
+ if (!isPlainObject(choice)) return UNRESTRICTED;
148
+ if (choice.type === "allowed_tools") {
149
+ const identities = new Set<string>();
150
+ if (!Array.isArray(choice.tools)) return { kind: "allow", identities };
151
+ for (const entry of choice.tools) {
152
+ const identity = selectorIdentity(entry);
153
+ if (identity) identities.add(identityKey(identity));
154
+ }
155
+ // An allow-list carrying no client tool — emptied by normalization, or hosted entries only —
156
+ // still bounds this turn: it allows no client call.
157
+ return { kind: "allow", identities };
158
+ }
159
+ if (choice.type === "function" || choice.type === "custom") {
160
+ const identity = selectorIdentity(choice);
161
+ return {
162
+ kind: "allow",
163
+ identities: new Set(identity ? [identityKey(identity)] : []),
164
+ };
165
+ }
166
+ return UNRESTRICTED;
167
+ }
168
+
169
+ export type RequestToolScope = {
170
+ /**
171
+ * The name a client call is refused under, or undefined when this request permits it.
172
+ * A nameless call type is not answered here: only the declaration guard knows those.
173
+ */
174
+ forbiddenClientToolCallName(item: Record<string, unknown>): string | undefined;
175
+ };
176
+
177
+ /**
178
+ * The client-call boundary this request states, or undefined when it states none.
179
+ *
180
+ * Returning undefined for an unrestricted request keeps every ordinary turn on the path it
181
+ * already had: a caller that selected nothing gets no new refusal.
182
+ */
183
+ export function requestToolScope(
184
+ body: unknown,
185
+ correspondence: RequestToolScopeCorrespondence = {},
186
+ ): RequestToolScope | undefined {
187
+ if (!isPlainObject(body)) return undefined;
188
+ const selection = toolSelection(body);
189
+ // A readable catalog that declares no client-executable name is authoritative, exactly as it is
190
+ // for the declaration guard: an explicit empty list denies every client call. An absent catalog
191
+ // says nothing — a passthrough request may omit `tools` and still receive a call the client
192
+ // understands.
193
+ const catalogDeniesClientCalls = hasExplicitWireToolCatalog(body)
194
+ && collectDeclaredWireToolNames(body).size === 0;
195
+ if (selection.kind === "unrestricted" && !catalogDeniesClientCalls) return undefined;
196
+ return {
197
+ forbiddenClientToolCallName(item: Record<string, unknown>): string | undefined {
198
+ if (typeof item.type !== "string" || !CLIENT_EXECUTED_CALL_TYPES.has(item.type)) {
199
+ return undefined;
200
+ }
201
+ const restored = callIdentity(item);
202
+ const reported = restored?.name;
203
+ if (reported === undefined) return undefined;
204
+ if (catalogDeniesClientCalls || selection.kind === "deny_all") return reported;
205
+ if (selection.kind === "allow") {
206
+ const candidates = outboundCallIdentities(item, correspondence);
207
+ return [...candidates].some(candidate => selection.identities.has(candidate))
208
+ ? undefined
209
+ : reported;
210
+ }
211
+ return undefined;
212
+ },
213
+ };
214
+ }
@@ -13,7 +13,10 @@ import {
13
13
  import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
14
14
 
15
15
  /** Item types the client executes through a request-declared wire name. */
16
- const CLIENT_EXECUTED_CALL_TYPES = new Set(["function_call", "custom_tool_call"]);
16
+ export const CLIENT_EXECUTED_CALL_TYPES: ReadonlySet<string> = new Set([
17
+ "function_call",
18
+ "custom_tool_call",
19
+ ]);
17
20
  /** Codex groups ordinary top-level tools here; unlike an MCP namespace, it has no wire prefix. */
18
21
  const BUILTIN_FUNCTIONS_NAMESPACE = "functions";
19
22
 
@@ -25,6 +25,9 @@ import { codexAccountNamespaceForModel } from "../codex/account-namespace-match"
25
25
  import { NATIVE_RESERVE_MODEL } from "../codex/catalog/native-models";
26
26
  import { isCodexReserveRequestEligible } from "../codex/loopback-target";
27
27
  import type { DataPlaneAdmission } from "./auth-cors";
28
+ import {
29
+ admissionScopeDenial,
30
+ } from "./admission-model-scope";
28
31
  import { formatCodexProviderForLog } from "../codex/routing";
29
32
  import { signalWithTimeout } from "../lib/abort";
30
33
  import { readBoundedResponseBytes } from "../lib/bounded-body";
@@ -87,6 +90,11 @@ export async function handleSearch(
87
90
  if (!route.codexAccountId || route.codexAccountNamespace !== accountNamespace) {
88
91
  return formatErrorResponse(400, "invalid_request_error", "Invalid Codex account-qualified search model");
89
92
  }
93
+ // This branch resolves a model through the router and bills the account it
94
+ // names, so a scoped key is held to the same destination rule it is held
95
+ // to on the inference path.
96
+ const denial = admissionScopeDenial(config, admission, model, route);
97
+ if (denial) return denial;
90
98
  exactAccount = { accountId: route.codexAccountId, modelId: route.modelId };
91
99
  logCtx.provider = `${route.providerName}-${accountNamespace}`;
92
100
  logCtx.routeDecision = route.routeDecision;
@@ -108,7 +116,7 @@ export async function handleSearch(
108
116
  }
109
117
  const candidates = listOpenAiForwardSidecarCandidates(config);
110
118
  if (candidates.length === 0) {
111
- return handleAlphaSearchSidecarFallback(body, config, req.signal, logCtx);
119
+ return handleAlphaSearchSidecarFallback(body, config, req.signal, logCtx, admission);
112
120
  }
113
121
 
114
122
  let upstream: Awaited<ReturnType<typeof resolveFirstUsableOpenAiSidecar>>;
@@ -148,6 +156,22 @@ export async function handleSearch(
148
156
  throw err;
149
157
  }
150
158
 
159
+ if (!accountNamespace) {
160
+ // An unqualified search model is never routed: the caller's own string is
161
+ // relayed to whichever ChatGPT account this upstream resolved to, and that
162
+ // account is billed for it. The qualified branch above was already judged
163
+ // against the route it resolved, so it is not judged twice here.
164
+ const searchModel = typeof model === "string" && model.trim() ? model : undefined;
165
+ const denial = admissionScopeDenial(config, admission, searchModel, {
166
+ providerName: upstream.providerName,
167
+ modelId: searchModel,
168
+ });
169
+ if (denial) {
170
+ upstream.releaseProbeLease?.();
171
+ return denial;
172
+ }
173
+ }
174
+
151
175
  const headers: Record<string, string> = { "content-type": "application/json" };
152
176
  if (upstream.provider.headers) Object.assign(headers, upstream.provider.headers);
153
177
  for (const [name, value] of upstream.headers) headers[name] = value;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Runtime owner for the opt-in usage-ledger size limit (#5063).
3
+ *
4
+ * The compactor in `src/usage/ledger-retention.ts` knows how to publish a smaller ledger safely.
5
+ * This is the part that decides when, and -- the half #5063 was missing -- what has to be
6
+ * discarded afterwards.
7
+ *
8
+ * Deleting rows from usage.jsonl invalidates three readers that do not watch the file: the
9
+ * 2,000-entry Logs ring, which otherwise keeps serving rows the ledger no longer has; the
10
+ * retained usage aggregate and failure projection, whose checkpoints now point past a boundary
11
+ * that moved; and the request-history index, whose source identity has changed. A compaction
12
+ * that skips any of them makes the dashboard disagree with the ledger, which is the disagreement
13
+ * this batch exists to remove.
14
+ */
15
+ import { currentUsageLogRevision, setUsageLedgerAppendHook } from "../usage/log";
16
+ import { enforceUsageLedgerSizeLimit } from "../usage/ledger-retention";
17
+ import { discardRetainedFailureProjection } from "../usage/failure-projection-cache";
18
+ import { rehydrateRequestLogsAfterLedgerReplacement } from "./request-log";
19
+ import type { UsageLedgerRetentionStatus } from "../usage/retention-contract";
20
+
21
+ let configuredMaxBytes: number | undefined;
22
+ let enforcing = false;
23
+
24
+ function invalidateLedgerReaders(): void {
25
+ // Ordered cheapest-first, and each guarded on its own: a projection that fails to discard
26
+ // must not stop the ring from being rebuilt, because the ring is the surface an operator is
27
+ // looking at while this happens.
28
+ try { discardRetainedFailureProjection(); } catch { /* rebuildable by construction */ }
29
+ void (async () => {
30
+ try {
31
+ const { discardRetainedUsageAggregate } = await import("./management/usage-aggregate-cache");
32
+ discardRetainedUsageAggregate();
33
+ } catch { /* rebuildable by construction */ }
34
+ try {
35
+ const { closeRequestHistoryIndex } = await import("../routing/history/indexer");
36
+ closeRequestHistoryIndex();
37
+ } catch { /* the index rebuilds from its own source-identity contract */ }
38
+ })();
39
+ try { rehydrateRequestLogsAfterLedgerReplacement(); } catch { /* the ring refills as rows arrive */ }
40
+ }
41
+
42
+ function enforceNow(): void {
43
+ // Re-entrancy guard, not a lock. The compaction itself runs inside the append call stack, and
44
+ // its own publication writes nothing through appendUsageEntry -- this exists so a future
45
+ // caller on that path cannot start a second pass over a file the first one is replacing.
46
+ if (enforcing) return;
47
+ enforcing = true;
48
+ try {
49
+ const result = enforceUsageLedgerSizeLimit(configuredMaxBytes);
50
+ if (result.kind === "replaced") invalidateLedgerReaders();
51
+ } catch (error) {
52
+ // Never fail a request because history could not be trimmed. The limit is not enforced and
53
+ // says so; the next append tries again from a fresh revision.
54
+ console.warn(
55
+ `[usage-retention] could not enforce the usage ledger size limit: ${error instanceof Error ? error.message : String(error)}`,
56
+ );
57
+ } finally {
58
+ enforcing = false;
59
+ }
60
+ }
61
+
62
+ /** Install or update the policy. `undefined` removes the hook entirely. */
63
+ export function setUsageLedgerRetention(maxBytes: number | undefined): void {
64
+ configuredMaxBytes = maxBytes;
65
+ setUsageLedgerAppendHook(maxBytes === undefined ? null : enforceNow);
66
+ }
67
+
68
+ export function usageLedgerRetentionStatus(): UsageLedgerRetentionStatus {
69
+ return {
70
+ ...(configuredMaxBytes !== undefined ? { maxBytes: configuredMaxBytes } : {}),
71
+ currentBytes: currentUsageLogRevision()?.size ?? 0,
72
+ };
73
+ }