@bitkyc08/opencodex 2.60.0 → 2.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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-CH6C5H7x.js +50 -0
  5. package/gui/dist/assets/Tray-CLZh48fM.js +1 -0
  6. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  7. package/gui/dist/assets/index-wpTOyepx.js +86 -0
  8. package/gui/dist/assets/usage-companion-chart-a0N58rRI.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
@@ -35,12 +35,13 @@ import { clearThreadAccountMap } from "../../codex/routing";
35
35
  import { primeCodexPoolQuotas } from "../../codex/auth-api";
36
36
  import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "../../providers/context-cap";
37
37
  import { resolveCodexHomeDir } from "../../codex/home";
38
- import { readUsageEntries } from "../../usage/log";
38
+ import { isKnownRequestFailureCause, isKnownRequestFailureStage, readUsageEntries } from "../../usage/log";
39
39
  import { getUsageDebugLogEntries } from "../../usage/debug";
40
40
  import { cacheObservationFromUsage, parseRange, parseUsageSurface, summarizeUsage } from "../../usage/summary";
41
41
  import { stripCodexRuntimeProviderFields } from "../../codex/auth-context";
42
42
  import { getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry";
43
43
  import { getDebugLogEntries } from "../../lib/debug-log-buffer";
44
+ import { resendPermission } from "../../lib/request-failure-model";
44
45
  import { getInjectionDebugLogEntries } from "../../lib/injection-debug-log";
45
46
  import {
46
47
  clearDebugSettings,
@@ -210,12 +211,27 @@ export function costResult(entry: MetricSource): CostResult {
210
211
  * a Logs-page metric, and widening a separate endpoint's response shape is not this change's
211
212
  * business. Flipping it on later is one argument.
212
213
  */
214
+
215
+ /**
216
+ * Whether this proxy could have sent the row again, derived at READ time from the stage and
217
+ * cause the recorder stored.
218
+ *
219
+ * Deliberately not persisted. The verdict is a function of two tables that this build owns, and
220
+ * a row written months ago must not be able to assert a permission the current tables would
221
+ * refuse -- the whole point of INV-RESEND-01 is that the refusal rules are one statement, and a
222
+ * stored verdict would be a second one with no way to correct it.
223
+ */
224
+ function resendVerdict(row: { failureStage?: string; failureCause?: string }): { resendPermission?: string } {
225
+ if (!isKnownRequestFailureStage(row.failureStage) || !isKnownRequestFailureCause(row.failureCause)) return {};
226
+ return { resendPermission: resendPermission(row.failureStage, row.failureCause) };
227
+ }
213
228
  export function requestLogDto(
214
229
  entry: RequestLogEntry,
215
230
  { includeDecodeRate = true }: { includeDecodeRate?: boolean } = {},
216
231
  ): Record<string, unknown> {
217
232
  return {
218
233
  ...entry,
234
+ ...resendVerdict(entry),
219
235
  displayMetrics: {
220
236
  tokPerSecond: tokPerSecondResult(entry),
221
237
  // The parent uses the REQUEST's own TTFT. A combo parent must not borrow an attempt's,
@@ -227,6 +243,7 @@ export function requestLogDto(
227
243
  ? {
228
244
  attempts: entry.attempts.map(attempt => ({
229
245
  ...attempt,
246
+ ...resendVerdict(attempt),
230
247
  displayMetrics: {
231
248
  tokPerSecond: tokPerSecondResult(attempt),
232
249
  // Each attempt measures its own attempt-relative TTFT.
@@ -0,0 +1,44 @@
1
+ import { readUsageSnapshotForManagement } from "../../usage/log";
2
+ import { createTimelineAccumulator, parseTimelineQuery } from "../../usage/timeline";
3
+ import { jsonResponse } from "../auth-cors";
4
+ import type { ManagementContext } from "./context";
5
+
6
+ const TIMELINE_CACHE_TTL_MS = 15_000;
7
+ const cache = new Map<string, { expiresAt: number; promise: Promise<ReturnType<ReturnType<typeof createTimelineAccumulator>["finish"]>> }>();
8
+
9
+ export async function handleUsageTimelineRoutes(ctx: ManagementContext): Promise<Response | undefined> {
10
+ const { req, url } = ctx;
11
+ if (url.pathname !== "/api/usage/timeline" || req.method !== "GET") return undefined;
12
+ const query = parseTimelineQuery(url.searchParams, Date.now());
13
+ if ("error" in query) return jsonResponse(query, 400, req, ctx.config);
14
+ const bucketMs = query.bucketMinutes * 60_000;
15
+ const roundedNow = Math.floor(query.now / bucketMs) * bucketMs;
16
+ const normalized = { ...query, now: roundedNow };
17
+ const key = JSON.stringify(normalized);
18
+ const current = Date.now();
19
+ const cached = cache.get(key);
20
+ if (cached && cached.expiresAt > current) return jsonResponse(await cached.promise, 200, req, ctx.config);
21
+ let promise: Promise<ReturnType<ReturnType<typeof createTimelineAccumulator>["finish"]>>;
22
+ promise = (async () => {
23
+ const accumulator = createTimelineAccumulator(normalized);
24
+ const snapshot = await readUsageSnapshotForManagement(ctx.config.managementUsageMaxReadBytes);
25
+ for (const entry of snapshot.entries) accumulator.add(entry);
26
+ return {
27
+ ...accumulator.finish(),
28
+ truncated: snapshot.truncatedPrefixBytes > 0 || snapshot.entriesTruncated,
29
+ };
30
+ })().catch(error => {
31
+ const entry = cache.get(key);
32
+ if (entry?.promise === promise) cache.delete(key);
33
+ throw error;
34
+ });
35
+ cache.set(key, { expiresAt: current + TIMELINE_CACHE_TTL_MS, promise });
36
+ try {
37
+ return jsonResponse(await promise, 200, req, ctx.config);
38
+ } finally {
39
+ setTimeout(() => {
40
+ const entry = cache.get(key);
41
+ if (entry?.promise === promise && entry.expiresAt <= Date.now()) cache.delete(key);
42
+ }, TIMELINE_CACHE_TTL_MS + 1);
43
+ }
44
+ }
@@ -1,6 +1,5 @@
1
1
  import { remoteWorkspaceEnabled } from "../remote-control/workspace-activation";
2
2
  import { randomUUID } from "node:crypto";
3
- import { readFileSync } from "node:fs";
4
3
  import type { CatalogModel } from "../codex/catalog";
5
4
  import { catalogModelSlug, invalidateCodexModelsCache, nativeContextLimits, nativeModelRows, uniqueCatalogModelsForPublicList } from "../codex/catalog";
6
5
  import {
@@ -71,6 +70,8 @@ import { handleOauthAccountRoutes } from "./management/oauth-account-routes";
71
70
  import { handleComboRoutes } from "./management/combo-routes";
72
71
  import { handleSystemRoutes } from "./management/system-routes";
73
72
  import { handleSidebarRoutes } from "./management/sidebar-routes";
73
+ import { handleUsageTimelineRoutes } from "./management/usage-timeline-routes";
74
+ import { handleCompanionRoutes } from "./management/companion-routes";
74
75
  import { handleCodexPromptRoutes } from "./management/codex-prompt-routes";
75
76
  import { handleIntegrationRoutes } from "./management/integration-routes";
76
77
  import { handleNativeIntegrationRoutes } from "./management/native-integration-routes";
@@ -84,15 +85,11 @@ import type { CatalogDisposition, ConvergeCodex } from "../codex/convergence-typ
84
85
  import { normalizeCatalogDisposition } from "../codex/catalog-refresh-status";
85
86
  import { managementBodyTooLargeResponse } from "./management/body";
86
87
  import { handleSessionRoutes } from "./management/session-routes";
88
+ import { packageVersion } from "../lib/package-version";
87
89
 
88
90
  // installed npm version instead of a stale hardcode.
89
- export const VERSION = (() => {
90
- try {
91
- return JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version as string;
92
- } catch {
93
- return "0.0.0";
94
- }
95
- })();
91
+ const MANAGEMENT_VERSION_FALLBACK = "0.0.0";
92
+ export const VERSION = packageVersion(MANAGEMENT_VERSION_FALLBACK);
96
93
 
97
94
  const managementConvergenceBindings = new WeakMap<object, Readonly<{
98
95
  factory: (config: Readonly<OcxConfig>) => ConvergeCodex;
@@ -266,7 +263,7 @@ export async function handleManagementAPI(
266
263
  } catch { /* best-effort */ }
267
264
  }
268
265
  const ctx: ManagementContext = { req, url, config, deps, version: VERSION, principal, sessionControl, convergeCodexCatalog, syncClaudeAgentDefsBestEffort };
269
- let routed: Response | null;
266
+ let routed: Response | null | undefined;
270
267
  try {
271
268
  routed = handleSessionRoutes(ctx)
272
269
  ?? (await handleRemoteWorkspaceRoutesOnDemand(ctx))
@@ -291,6 +288,8 @@ export async function handleManagementAPI(
291
288
  ?? (await handleComboRoutes(ctx))
292
289
  ?? (await handleSystemRoutes(ctx))
293
290
  ?? (await handleLabRoutesOnDemand(ctx))
291
+ ?? (await handleUsageTimelineRoutes(ctx))
292
+ ?? (await handleCompanionRoutes(ctx))
294
293
  ?? (await handleSidebarRoutes(ctx));
295
294
  } catch (error) {
296
295
  const tooLarge = managementBodyTooLargeResponse(error, req, config);
@@ -32,6 +32,8 @@ export interface HealthzIdentity {
32
32
  guiPairCapability?: unknown;
33
33
  }
34
34
 
35
+ export type EndpointLiveness = "live" | "dead" | "unknown";
36
+
35
37
  export interface LivenessIo {
36
38
  fetchFn?: typeof fetch;
37
39
  readPidFn?: () => number | null;
@@ -85,6 +87,11 @@ export const START_OWNERSHIP_LIVENESS: Pick<LivenessIo, "timeoutMs" | "attempts"
85
87
  attempts: 3,
86
88
  };
87
89
 
90
+ type LivenessFetch = (
91
+ input: string | URL | Request,
92
+ init?: RequestInit,
93
+ ) => Promise<Response>;
94
+
88
95
  export interface LiveProxy {
89
96
  pid: number | null;
90
97
  port: number;
@@ -148,6 +155,74 @@ export function isOpencodexHealthz(body: HealthzIdentity | null): boolean {
148
155
  return body.status === "ok" && typeof body.version === "string" && typeof body.uptime === "number";
149
156
  }
150
157
 
158
+ /**
159
+ * "Nothing is listening" is narrower than "the probe failed". Only a connect-phase refusal
160
+ * proves the endpoint is free; a timeout, reset, or other transport failure leaves the
161
+ * question open.
162
+ */
163
+ export function isConnectionRefused(error: unknown): boolean {
164
+ const visit = (current: unknown, depth: number): boolean => {
165
+ if (depth >= 4) return false;
166
+ if (current === null || (typeof current !== "object" && typeof current !== "function")) return false;
167
+ const record = current as { code?: unknown; cause?: unknown; errors?: unknown };
168
+ if (record.code === "ECONNREFUSED" || record.code === "ConnectionRefused") return true;
169
+ if (typeof record.code === "string" && record.code.endsWith("ECONNREFUSED")) return true;
170
+ if (Array.isArray(record.errors) && record.errors.length > 0) {
171
+ // One connect attempt fanned out over several addresses reports a single AggregateError.
172
+ // Only a unanimous refusal proves the endpoint is free: a bundle that mixes ECONNREFUSED
173
+ // with a timeout means one address answered nothing at all, and an address whose state is
174
+ // unreadable is unknown, not absence. Collapsing it to "refused" is how a second runtime
175
+ // gets started on a port that already has one.
176
+ return record.errors.every(error => visit(error, depth + 1));
177
+ }
178
+ return visit(record.cause, depth + 1);
179
+ };
180
+ return visit(error, 0);
181
+ }
182
+
183
+ async function classifyHealthz(
184
+ url: string,
185
+ fetchFn: LivenessFetch,
186
+ timeoutMs: number,
187
+ ): Promise<EndpointLiveness> {
188
+ try {
189
+ const response = await fetchFn(url, { signal: AbortSignal.timeout(timeoutMs) });
190
+ if (response.status !== 200) return "unknown";
191
+ const body = (await response.json().catch(() => undefined)) as HealthzIdentity | null | undefined;
192
+ if (body === undefined) return "unknown";
193
+ return isOpencodexHealthz(body) ? "live" : "dead";
194
+ } catch (error) {
195
+ return isConnectionRefused(error) ? "dead" : "unknown";
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Tri-state probe of one endpoint, the in-process counterpart of
201
+ * `src/update/proxy-liveness-probe.mjs`. Only a connect-phase refusal or a clean 200 that is
202
+ * not ours proves "dead"; a timeout, reset, non-200 or unreadable body leaves the question
203
+ * open. Loopback endpoints are checked on both IPv4 and IPv6 because a listener may bind only
204
+ * one family. Runs in-process because a compiled standalone binary cannot fork `execPath -e`.
205
+ */
206
+ export async function probeEndpointLiveness(
207
+ endpoint: { port: number; hostname?: string },
208
+ io: Pick<LivenessIo, "fetchFn" | "timeoutMs"> = {},
209
+ ): Promise<EndpointLiveness> {
210
+ if (!Number.isFinite(endpoint.port) || endpoint.port <= 0 || endpoint.port > 65535) return "dead";
211
+ const fetchFn = io.fetchFn ?? directLocalHttpFetch;
212
+ const timeoutMs = io.timeoutMs ?? 1500;
213
+ let sawUnknown = false;
214
+ for (const hostname of loopbackProbeHosts(endpoint.hostname)) {
215
+ const result = await classifyHealthz(
216
+ `http://${hostname}:${endpoint.port}/healthz`,
217
+ fetchFn,
218
+ timeoutMs,
219
+ );
220
+ if (result === "live") return "live";
221
+ if (result === "unknown") sawUnknown = true;
222
+ }
223
+ return sawUnknown ? "unknown" : "dead";
224
+ }
225
+
151
226
  /** Identity-checked /healthz probe; null when unreachable, non-OK, or not our proxy. */
152
227
  export async function proxyIdentityAt(
153
228
  port: number,
@@ -12,6 +12,7 @@ import {
12
12
  } from "../lib/errors";
13
13
  import { redactSecretString } from "../lib/redact";
14
14
  import { isTranslatorBudgetExceededError } from "../lib/translator-budget";
15
+ import { carryReplayRefusal } from "../lib/upstream-retry";
15
16
  import { isUsageDebugEnabled } from "../usage/debug";
16
17
  import {
17
18
  addRequestLog,
@@ -833,11 +834,14 @@ export function responseWithDeferredRequestLog(
833
834
  }, addLog);
834
835
  },
835
836
  });
836
- return new Response(body, {
837
+ // Logging re-wraps the response, and an in-process verdict does not survive a re-wrap on
838
+ // its own. A replay refusal that lost it here would read to a later quota recorder or
839
+ // Retry-After synthesizer as a 429 some upstream produced.
840
+ return carryReplayRefusal(response, new Response(body, {
837
841
  status: response.status,
838
842
  statusText: response.statusText,
839
843
  headers: response.headers,
840
- });
844
+ }));
841
845
  }
842
846
  if (isUsageDebugEnabled() && logCtx.usageDebugBodyKind === undefined) {
843
847
  logCtx.usageDebugBodyKind = response.body ? "other" : "none";
@@ -989,6 +993,14 @@ export type SseInspectorHandlers = {
989
993
  * with an empty `output`.
990
994
  */
991
995
  onParsedPayload?: (payload: unknown) => void;
996
+ /**
997
+ * A complete data payload that did not parse as a JSON event, `[DONE]` included.
998
+ *
999
+ * An inspector that only hears about parsed events cannot tell "nothing has been emitted"
1000
+ * from "something was emitted that I could not read", and a replay decision needs that
1001
+ * difference: an unreadable payload is a payload the caller may already have seen.
1002
+ */
1003
+ onOpaquePayload?: () => void;
992
1004
  onFirstOutput?: () => void;
993
1005
  /**
994
1006
  * Provider-scoped compatibility: persist the completed snapshot under the
@@ -1177,6 +1189,11 @@ export function createSseInspector(handlers: SseInspectorHandlers): SseInspector
1177
1189
  if (handlers.onParsedPayload && parsed !== undefined) {
1178
1190
  try { handlers.onParsedPayload(parsed); } catch { /* inspection must never throw into the pump */ }
1179
1191
  }
1192
+ // The other half of the same observation. A payload that did not parse still reached the
1193
+ // caller, so a consumer deciding whether anything has been emitted has to hear about it.
1194
+ if (handlers.onOpaquePayload && parsed === undefined) {
1195
+ try { handlers.onOpaquePayload(); } catch { /* inspection must never throw into the pump */ }
1196
+ }
1180
1197
  reportFirstOutput.parsed(parsed);
1181
1198
  const status = terminalStatusFromParsed(parsed);
1182
1199
  const policyTerminal = status === "failed"
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Where a request's failure attribution is derived and stamped.
3
+ *
4
+ * A sibling of `request-log.ts` rather than a section inside it. That file carries the whole
5
+ * request-logging surface and sits against the 2,000-line repository ceiling, which only ever
6
+ * moves down; this is the extraction that rule asks for, not a cap that was negotiated.
7
+ *
8
+ * It holds the two places a stage and cause are decided and written. Both read the recorder's
9
+ * own closed facts and neither reads `errorCode` or `upstreamError`, which are assembled partly
10
+ * from upstream text.
11
+ */
12
+ import { deriveRequestFailureAttribution } from "../lib/request-failure-attribution";
13
+ import { causeForRecoveryKind } from "../lib/request-failure-model";
14
+ import type { AttemptRecoveryKind, PersistedUsageAttempt, RequestFailureCause, RequestFailureStage } from "../usage/log";
15
+ import type { ResponsesTerminalStatus } from "../bridge";
16
+
17
+ /** The subset of the log context this derivation may see. Narrow on purpose. */
18
+ export interface FinalRequestAttributionFacts {
19
+ readonly status: number;
20
+ readonly terminalStatus?: ResponsesTerminalStatus | undefined;
21
+ readonly closeReason?: "terminal" | "client_cancel" | "non_stream" | "body_stall" | "body_overflow" | undefined;
22
+ readonly transportPhase?: "pre_headers" | "mid_stream" | "terminal_sse" | undefined;
23
+ readonly terminalSource?: "upstream" | "synthetic" | undefined;
24
+ /** The REQUEST's first-output observation, not the final attempt's. See below. */
25
+ readonly outputObserved: boolean;
26
+ readonly locallyAnswered: boolean;
27
+ readonly attempt?: PersistedUsageAttempt | undefined;
28
+ }
29
+
30
+ export interface StampedAttribution {
31
+ failureStage?: RequestFailureStage;
32
+ failureCause?: RequestFailureCause;
33
+ }
34
+
35
+ /**
36
+ * Derive the attribution for a finished logical request and stamp the attempt that ended it.
37
+ *
38
+ * Called from the one seam every request passes exactly once, and BEFORE the attempt snapshot,
39
+ * so the row that reaches disk and the live attempt object carry the same pair. Deriving it at
40
+ * each transport's own exit would give the same request a different attribution per transport,
41
+ * which is the disagreement the shared terminal classifier already removed once.
42
+ *
43
+ * `outputObserved` is the request's observation rather than the final attempt's. A request that
44
+ * relayed output on its first attempt and then failed over has committed that output to the
45
+ * caller whatever the last attempt saw, so the logical row and the attempt that ended it carry
46
+ * the same answer. Reading the attempt-local value would produce a MORE permissive resend verdict
47
+ * for exactly that case, and a permission decision has to fail in the safe direction.
48
+ */
49
+ export function attributeFinalRequest(facts: FinalRequestAttributionFacts): StampedAttribution {
50
+ const attribution = deriveRequestFailureAttribution({
51
+ status: facts.status,
52
+ ...(facts.terminalStatus ? { terminalStatus: facts.terminalStatus } : {}),
53
+ ...(facts.closeReason ? { closeReason: facts.closeReason } : {}),
54
+ ...(facts.transportPhase ? { transportPhase: facts.transportPhase } : {}),
55
+ ...(facts.terminalSource ? { terminalSource: facts.terminalSource } : {}),
56
+ ...(facts.attempt?.streamAborted === true ? { streamAborted: true } : {}),
57
+ outputObserved: facts.outputObserved,
58
+ // The one fact that can raise a stage above `semantic-output`, and the reason it is counted
59
+ // at the transport rather than at the adapter: an emitted tool call the client never
60
+ // received has committed nothing, and a resend for it is still safe (#3983).
61
+ sideEffectObserved: (facts.attempt?.deliverySummary?.sideEffectEvents ?? 0) > 0,
62
+ locallyAnswered: facts.locallyAnswered,
63
+ recoveryKinds: facts.attempt?.recoveryKinds ?? [],
64
+ });
65
+ // The final row and the attempt that ended it describe the same exchange, so they carry the
66
+ // same pair rather than each deriving one from a different slice of the facts.
67
+ if (facts.attempt) {
68
+ if (attribution?.stage) facts.attempt.failureStage = attribution.stage;
69
+ else delete facts.attempt.failureStage;
70
+ if (attribution?.cause) facts.attempt.failureCause = attribution.cause;
71
+ else delete facts.attempt.failureCause;
72
+ }
73
+ return {
74
+ ...(attribution?.stage ? { failureStage: attribution.stage } : {}),
75
+ ...(attribution?.cause ? { failureCause: attribution.cause } : {}),
76
+ };
77
+ }
78
+
79
+ /**
80
+ * Attribute an attempt being sealed because a named recovery rejected it.
81
+ *
82
+ * The recovery kind is direct evidence here rather than an inference from history: this attempt
83
+ * is ending precisely because that recovery was needed. Without it the sealed attempt would reach
84
+ * the ledger with no attribution at all, because the finalization seam only ever sees the last
85
+ * attempt of a request.
86
+ */
87
+ export function attributeSealedAttempt(
88
+ attempt: PersistedUsageAttempt,
89
+ recovery: AttemptRecoveryKind | undefined,
90
+ ): void {
91
+ const attribution = deriveRequestFailureAttribution({
92
+ status: attempt.status,
93
+ outputObserved: attempt.firstOutputMs !== undefined,
94
+ sideEffectObserved: (attempt.deliverySummary?.sideEffectEvents ?? 0) > 0,
95
+ ...(recovery ? { causeHint: causeForRecoveryKind(recovery) } : {}),
96
+ });
97
+ if (attribution?.stage) attempt.failureStage = attribution.stage;
98
+ if (attribution?.cause) attempt.failureCause = attribution.cause;
99
+ }
@@ -40,6 +40,7 @@ import {
40
40
  isLogicalRequestId,
41
41
  isValidReasoningWireValue,
42
42
  normalizeClaudeCompatibilityUsageLog,
43
+ normalizeRequestFailureAttribution,
43
44
  normalizeRequestSpend,
44
45
  readRecentUsageEntries,
45
46
  usageForFinalLog,
@@ -52,9 +53,13 @@ import {
52
53
  type PersistedUsageAttempt,
53
54
  type PersistedUsageEntry,
54
55
  type PersistedClaudeCompatibilityLog,
56
+ type RequestFailureCause,
57
+ type RequestFailureStage,
55
58
  type UsageStatus,
56
59
  } from "../usage/log";
57
60
  import type { RequestExecutionBudget } from "../lib/request-execution-budget";
61
+ import { attributeFinalRequest, attributeSealedAttempt } from "./request-log-failure-attribution";
62
+ import { debugAttemptDeliverySummary } from "../lib/debug";
58
63
  import {
59
64
  appendUsageDebug,
60
65
  isUsageDebugEnabled,
@@ -70,6 +75,21 @@ import { KIRO_MODEL_CONTEXT_WINDOWS, normalizeKiroModelId } from "../providers/k
70
75
  import { DEVIN_MODEL_CONTEXT_WINDOWS } from "../adapters/devin/live-models";
71
76
  import { modelRecordValue } from "../reasoning-effort";
72
77
  import type { RequestMetricsRecorder } from "./request-metrics";
78
+ import type {
79
+ CacheDiagnosticDraft,
80
+ CacheDiagnosticFinalFacts,
81
+ PromptCacheKeySource,
82
+ } from "../usage/cache-diagnostic";
83
+
84
+ const CACHE_DIAGNOSTIC_HOOK = Symbol.for("opencodex.cache-diagnostic.v1");
85
+ interface CacheDiagnosticHooks {
86
+ observeInbound(body: unknown, headers: Headers, source: PromptCacheKeySource): CacheDiagnosticDraft;
87
+ rebind(body: unknown, draft: CacheDiagnosticDraft | undefined): void;
88
+ finalize(facts: CacheDiagnosticFinalFacts): void;
89
+ }
90
+ function cacheDiagnosticHooks(): CacheDiagnosticHooks | undefined {
91
+ return (globalThis as Record<symbol, CacheDiagnosticHooks | undefined>)[CACHE_DIAGNOSTIC_HOOK];
92
+ }
73
93
 
74
94
  export interface RequestLogContext {
75
95
  model: string;
@@ -83,6 +103,8 @@ export interface RequestLogContext {
83
103
  * budget minted at ingress; a retry leg, a repair refetch and a combo child share it.
84
104
  */
85
105
  logicalRequestId?: string;
106
+ /** Process-local privacy-bounded cache diagnostic; never persisted with request logs. */
107
+ cacheDiagnosticDraft?: CacheDiagnosticDraft;
86
108
  /**
87
109
  * Internal live reference to this request's execution budget; omitted from RequestLogEntry and
88
110
  * JSONL. Read at final-log time so the row reports the budget's FINAL state rather than a
@@ -209,6 +231,24 @@ export interface RequestLogContext {
209
231
  claudeCompatibility?: PersistedClaudeCompatibilityLog;
210
232
  }
211
233
 
234
+ export function observeCacheDiagnosticInbound(
235
+ logCtx: RequestLogContext,
236
+ body: unknown,
237
+ headers: Headers,
238
+ source: PromptCacheKeySource,
239
+ ): void {
240
+ const draft = cacheDiagnosticHooks()?.observeInbound(body, headers, source);
241
+ if (draft) logCtx.cacheDiagnosticDraft = draft;
242
+ }
243
+
244
+ /** Alias a rebuilt form of the request body to the request's diagnostic draft. */
245
+ export function rebindCacheDiagnosticBody(
246
+ body: unknown,
247
+ draft: CacheDiagnosticDraft | undefined,
248
+ ): void {
249
+ cacheDiagnosticHooks()?.rebind(body, draft);
250
+ }
251
+
212
252
  export interface RequestLogEntry {
213
253
  requestId: string;
214
254
  /** The logical request this row belongs to (#4546); absent on rows written without a budget. */
@@ -295,6 +335,13 @@ export interface RequestLogEntry {
295
335
  routeDecision?: RouteDecisionTraceV1;
296
336
  /** Closed Claude protocol codes; no request or header values. */
297
337
  claudeCompatibility?: PersistedClaudeCompatibilityLog;
338
+ /**
339
+ * How far this request got and why it failed, in the shared stage and cause vocabulary
340
+ * (#2366). Derived once at the single finalization seam and carried on the row so the
341
+ * dashboard, the durable ledger and the exporter read one answer instead of three.
342
+ */
343
+ failureStage?: RequestFailureStage;
344
+ failureCause?: RequestFailureCause;
298
345
  }
299
346
 
300
347
  const requestLog: RequestLogEntry[] = [];
@@ -420,6 +467,7 @@ export function requestLogEntryFromPersistedUsage(entry: PersistedUsageEntry): R
420
467
  ...(entry.conversationStateScrub === "account-change"
421
468
  ? { conversationStateScrub: "account-change" }
422
469
  : {}),
470
+ ...normalizeRequestFailureAttribution(entry),
423
471
  };
424
472
  }
425
473
 
@@ -480,6 +528,24 @@ export function hydrateRequestLogsFromDisk(
480
528
  }
481
529
  }
482
530
 
531
+ /**
532
+ * Rebuild the Logs ring after retention deleted rows from the ledger.
533
+ *
534
+ * Without this a compaction is invisible where an operator actually looks: the ring holds up to
535
+ * 2,000 entries independently of the file, so rows deleted from disk keep serving through
536
+ * /api/logs until eviction or a restart -- the dashboard showing history the ledger no longer
537
+ * has. Observers are deliberately not replayed; they exist to watch NEW rows arrive, and
538
+ * replaying a rehydration through them would announce two thousand arrivals that did not happen.
539
+ */
540
+ export function rehydrateRequestLogsAfterLedgerReplacement(
541
+ reader: () => PersistedUsageEntry[] = () => readRecentUsageEntries(MAX_LOG_SIZE),
542
+ ): number {
543
+ requestLog.length = 0;
544
+ requestLogBytes = 0;
545
+ requestLogsHydratedFromDisk = false;
546
+ return hydrateRequestLogsFromDisk(reader);
547
+ }
548
+
483
549
  export function addRequestLog(entry: RequestLogEntry) {
484
550
  // Sanitize ONCE, at the ingress, and use that one value for both destinations.
485
551
  //
@@ -566,6 +632,10 @@ export function addRequestLog(entry: RequestLogEntry) {
566
632
  ...(isKnownTransportPhase(entry.transportPhase) ? { transportPhase: entry.transportPhase } : {}),
567
633
  ...(isKnownTerminalSource(entry.terminalSource) ? { terminalSource: entry.terminalSource } : {}),
568
634
  ...failureDiagnostics,
635
+ // Rebuilt explicitly, like every other field here: this function does not spread the
636
+ // entry, so a pair omitted at this line would reach /api/logs and never reach
637
+ // usage.jsonl, which is the surface the derived failure projection reads.
638
+ ...normalizeRequestFailureAttribution(entry),
569
639
  ...(entry.routeDecision ? { routeDecision: entry.routeDecision } : {}),
570
640
  ...(entry.claudeCompatibility ? { claudeCompatibility: entry.claudeCompatibility } : {}),
571
641
  ...(entry.conversationStateScrub === "account-change"
@@ -1334,6 +1404,18 @@ export function addFinalRequestLog(
1334
1404
  if (errorCode) logCtx.activeAttempt.errorCode = errorCode;
1335
1405
  else delete logCtx.activeAttempt.errorCode;
1336
1406
  }
1407
+ // Derived and stamped in a sibling module, before the attempt snapshot below. Every input is
1408
+ // a closed value; the open error strings are deliberately not among them.
1409
+ const attribution = attributeFinalRequest({
1410
+ status: effectiveStatus,
1411
+ ...(meta?.terminalStatus ? { terminalStatus: meta.terminalStatus } : {}),
1412
+ ...(closeReason ? { closeReason } : {}),
1413
+ ...(logCtx.transportPhase ? { transportPhase: logCtx.transportPhase } : {}),
1414
+ ...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}),
1415
+ outputObserved: logCtx.firstOutputMs !== undefined,
1416
+ locallyAnswered: logCtx.localTerminalReason !== undefined,
1417
+ ...(logCtx.activeAttempt ? { attempt: logCtx.activeAttempt } : {}),
1418
+ });
1337
1419
  // The one seam every request passes exactly once, whatever transport served it and however
1338
1420
  // it ended. The terminal usage belongs to the last send that left; the ledger resolves every
1339
1421
  // earlier send of this request as unresolved spend rather than handing its tokens back.
@@ -1351,6 +1433,10 @@ export function addFinalRequestLog(
1351
1433
  ...(attempt.recoveryWithheld?.length ? { recoveryWithheld: [...attempt.recoveryWithheld] } : {}),
1352
1434
  ...(attempt.usage ? { usage: { ...attempt.usage } } : {}),
1353
1435
  ...(attempt.tierOutcome ? { tierOutcome: { ...attempt.tierOutcome } } : {}),
1436
+ // Detached, like every mutable field beside it: the live summary keeps counting if the
1437
+ // stream is still draining, and a shared reference would let a finalized row change after
1438
+ // it was written.
1439
+ ...(attempt.deliverySummary ? { deliverySummary: { ...attempt.deliverySummary } } : {}),
1354
1440
  }));
1355
1441
  const isCombo = logCtx.comboId !== undefined && (attempts?.length ?? 0) > 0;
1356
1442
  const aggregate = isCombo ? aggregateAttemptUsage(attempts ?? []) : null;
@@ -1368,11 +1454,30 @@ export function addFinalRequestLog(
1368
1454
  ...(closeReason ? { closeReason } : {}),
1369
1455
  ...(attempts !== undefined ? { attempts } : {}),
1370
1456
  ...(spend ? { spendSends: spend.sends } : {}),
1457
+ ...(attribution.failureCause ? { failureCause: attribution.failureCause } : {}),
1371
1458
  });
1372
1459
  const cacheProvenance = classifyCacheTelemetryProvenance(loggedUsage, {
1373
1460
  wireParsed: logCtx.usageWireParsed === true,
1374
1461
  });
1375
1462
  const logicalRequestId = logCtx.logicalRequestId ?? logCtx.executionBudget?.logicalRequestId;
1463
+ const normalizedCacheValue = loggedUsage?.cacheReadInputTokens ?? loggedUsage?.cachedInputTokens;
1464
+ cacheDiagnosticHooks()?.finalize({
1465
+ requestId,
1466
+ ...(isLogicalRequestId(logicalRequestId) ? { logicalRequestId } : {}),
1467
+ protocol: logCtx.inboundProtocol ?? "responses",
1468
+ provider: logCtx.provider,
1469
+ model: logCtx.model,
1470
+ ...(isCodexUsageAccountLogLabel(logCtx.accountLogLabel) ? { accountLogLabel: logCtx.accountLogLabel } : {}),
1471
+ ...(logCtx.affinity ? { affinityMove: logCtx.affinity } : {}),
1472
+ ...(logCtx.affinityReason ? { affinityReason: logCtx.affinityReason } : {}),
1473
+ // loggedUsage carries the upstream cache counter by reference all the way from the
1474
+ // adapter extraction for the native Responses route, so an undefined read here is a
1475
+ // genuinely absent counter rather than a defaulted one.
1476
+ ...(normalizedCacheValue !== undefined ? { rawCacheCounterValue: normalizedCacheValue } : {}),
1477
+ ...(normalizedCacheValue !== undefined ? { normalizedCacheValue } : {}),
1478
+ cacheProvenance,
1479
+ ...(logCtx.cacheDiagnosticDraft ? { draft: logCtx.cacheDiagnosticDraft } : {}),
1480
+ });
1376
1481
  // Sanitize at the logging layer, not only at the one call site that populates this today.
1377
1482
  // The value originates in an upstream-supplied model id, so an unsanitized newline would
1378
1483
  // let a single field forge a record boundary in any line-oriented log viewer. Doing it here
@@ -1440,7 +1545,10 @@ export function addFinalRequestLog(
1440
1545
  ...(logCtx.terminalSource ? { terminalSource: logCtx.terminalSource } : {}),
1441
1546
  ...(logCtx.routeDecision ? { routeDecision: logCtx.routeDecision } : {}),
1442
1547
  ...(claudeCompatibility ? { claudeCompatibility } : {}),
1548
+ ...attribution,
1443
1549
  });
1550
+ // Formatted from the finalized snapshot, so the ring shows exactly what the ledger holds.
1551
+ for (const attempt of attempts ?? []) debugAttemptDeliverySummary(requestId, attempt);
1444
1552
  if (isUsageDebugEnabled()) {
1445
1553
  appendUsageDebug({
1446
1554
  ts: Date.now(),
@@ -1687,8 +1795,14 @@ export function noteProviderAttemptSend(
1687
1795
  finishRequestAttempt(attempt, attempt.status >= 100 ? attempt.status
1688
1796
  : recovery === "key-401" ? 401 : recovery?.includes("429") ? 429 : 502,
1689
1797
  Date.now() - (logCtx.activeAttemptStartedAt ?? Date.now()), attempt.usage);
1798
+ // This attempt is being sealed because a NAMED recovery rejected it, so the recovery kind
1799
+ // is direct evidence here rather than an inference from history. Without this the sealed
1800
+ // attempt would reach the ledger with no attribution at all: the finalization seam below
1801
+ // only ever sees the last attempt of the request.
1802
+ attributeSealedAttempt(attempt, recovery);
1690
1803
  const completed = { ...attempt, recoveryKinds: [...attempt.recoveryKinds],
1691
1804
  ...(attempt.usage ? { usage: { ...attempt.usage } } : {}),
1805
+ ...(attempt.deliverySummary ? { deliverySummary: { ...attempt.deliverySummary } } : {}),
1692
1806
  ...(attempt.tierOutcome ? { tierOutcome: { ...attempt.tierOutcome } } : {}) };
1693
1807
  const attempts = logCtx.attempts ??= [attempt];
1694
1808
  const index = attempts.indexOf(attempt);