@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
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Does an update own the runtime it is about to stop and restart?
3
+ *
4
+ * Both updaters ask this: `src/update/index.ts` on the Bun path and `bin/ocx.mjs` on the
5
+ * npm and pnpm path. It lives here as plain ESM for the same reason `stop-decision.mjs`
6
+ * does — the Node launcher has to be able to import it, and two lanes deciding the same
7
+ * situation separately is how a fix ships on one side only.
8
+ */
9
+
10
+ /**
11
+ * Decide how an update treats a runtime it may not own.
12
+ *
13
+ * `ocx update` replaces the package's files and then puts the proxy back: it stops the
14
+ * running server first, and afterwards runs `ocx service repair` to re-register and restart
15
+ * the background service. Under a desktop owner both halves are wrong. The running server is
16
+ * the app's own bundled sidecar rather than anything this package installed, so stopping it
17
+ * takes down a runtime the update has no way to bring back; and the repair would re-enable
18
+ * the npm launcher the takeover superseded, which is the exact reactivation the ownership
19
+ * marker exists to prevent. Neither half is needed either — the app updates its own runtime.
20
+ *
21
+ * The service registration itself is untouched in every case. It is kept by decision, not by
22
+ * accident, so a user who later runs `ocx service install` gets their npm service back.
23
+ *
24
+ * THE COST OF A STALE MARKER. This reads the recorded claim, not liveness. An app deleted
25
+ * without releasing ownership leaves a marker behind, and an update then declines to stop or
26
+ * refresh a runtime no app is managing any more. That is the orphan-recovery cost the
27
+ * two-record ownership design accepted; `ocx service install` clears the marker and restores
28
+ * the ordinary path.
29
+ *
30
+ * The three returned flags are separate authorities, not commands. In particular, leaving
31
+ * a runtime running is not permission to replace the package it may be executing from.
32
+ *
33
+ * A recorded desktop claim does not prove which binary is live. Until the bundled resolver
34
+ * carries installation identity, it therefore blocks package replacement as well as stop and
35
+ * restoration; the notice tells a stale-marker user how to take ownership back explicitly.
36
+ *
37
+ * @param {{ ownership: { owner: string, installId: string, consentGeneration: number } | null, ownershipUnknown?: boolean, serviceInstalled: boolean }} input
38
+ * @returns {{ mayReplacePackage: boolean, mayStopRuntime: boolean, mayRestoreService: boolean, notice: string | null }}
39
+ */
40
+ export function planUpdateRuntimeHandling({ ownership, ownershipUnknown = false, serviceInstalled }) {
41
+ // Unreadable, malformed or contradictory is not "nobody owns it". Reading it that way is
42
+ // how a permissions error reactivates the npm launcher over a consented takeover.
43
+ if (ownershipUnknown) {
44
+ return {
45
+ mayReplacePackage: false,
46
+ mayStopRuntime: false,
47
+ mayRestoreService: false,
48
+ notice: "⚠️ The background runtime's recorded owner could not be determined, so it was "
49
+ + "left running and the service registration was not touched. "
50
+ + "Run 'ocx service install' to re-register the service and take the runtime back.",
51
+ };
52
+ }
53
+ // Any owner that is not this CLI. Reading it this way rather than testing for "desktop"
54
+ // keeps a third kind of owner from silently falling into the branch that touches the npm
55
+ // registration.
56
+ if (ownership && ownership.owner !== "cli") {
57
+ return {
58
+ // A claim does not prove which binary is live. A stale desktop marker beside a
59
+ // manually started npm proxy would otherwise replace that proxy's executing files.
60
+ mayReplacePackage: false,
61
+ mayStopRuntime: false,
62
+ mayRestoreService: false,
63
+ notice: `🖥️ The desktop app owns the background runtime (install ${ownership.installId}, `
64
+ + `consent generation ${ownership.consentGeneration}). It and the npm package were left unchanged, and the `
65
+ + "service registration was neither re-enabled nor restarted. "
66
+ + "If the desktop app is gone, run 'ocx service install' to take the runtime back.",
67
+ };
68
+ }
69
+ return {
70
+ mayReplacePackage: true,
71
+ mayStopRuntime: true,
72
+ mayRestoreService: serviceInstalled,
73
+ notice: null,
74
+ };
75
+ }
76
+
77
+ /** Decide recovery after this updater already stopped the prior CLI-owned runtime. */
78
+ export function planStoppedRuntimeRecovery({
79
+ stopAttempted,
80
+ ownership,
81
+ ownershipUnknown = false,
82
+ sameOwner,
83
+ liveness,
84
+ serviceInstalled,
85
+ launcherUsable,
86
+ hadRuntimeState,
87
+ }) {
88
+ if (!stopAttempted) return { action: "none", reason: "not-stopped" };
89
+ if (ownershipUnknown) return { action: "manual", reason: "ownership-unknown" };
90
+ if (!sameOwner || (ownership && ownership.owner !== "cli")) {
91
+ return { action: "none", reason: "ownership-transferred" };
92
+ }
93
+ if (liveness !== "dead") return { action: "manual", reason: `runtime-${liveness}` };
94
+ if (!launcherUsable) return { action: "manual", reason: "launcher-unavailable" };
95
+ if (serviceInstalled) return { action: "service", reason: "same-cli-owner" };
96
+ if (hadRuntimeState) return { action: "direct", reason: "same-cli-owner" };
97
+ return { action: "none", reason: "nothing-to-restore" };
98
+ }
99
+
100
+ /**
101
+ * Re-read the current package runtime before probing. The result keeps an absent current
102
+ * record distinct from a dead captured endpoint while still projecting one fail-closed
103
+ * liveness verdict for replacement and recovery decisions.
104
+ */
105
+ export function inspectPackageRuntimeLiveness({ capturedTarget, readCurrentTarget, probe }) {
106
+ const currentTarget = readCurrentTarget();
107
+ const observations = new Map();
108
+ const inspect = target => {
109
+ const key = `${target.hostname}:${target.port}`;
110
+ if (!observations.has(key)) observations.set(key, probe(target));
111
+ return observations.get(key);
112
+ };
113
+ // Probe the fresh record first. It is the address a replacement runtime may have
114
+ // published while the updater was waiting on the ownership lease.
115
+ const current = currentTarget.kind === "target" ? inspect(currentTarget.target) : currentTarget.kind;
116
+ const captured = inspect(capturedTarget);
117
+ const verdicts = current === "absent" ? [captured] : [current, captured];
118
+ const overall = verdicts.includes("live")
119
+ ? "live"
120
+ : verdicts.includes("unknown") ? "unknown" : "dead";
121
+ return { current, captured, overall };
122
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Counting what an attempt delivered, without recording what it said.
3
+ *
4
+ * The recorder below is bound to a request-scoped object and reaches the CURRENT attempt through
5
+ * a callback rather than holding one. An attempt can be rotated mid-request -- a key-account
6
+ * change seals the old one and starts a fresh one -- and a recorder holding a reference would
7
+ * keep crediting frames to an attempt that had already been finalized and snapshotted.
8
+ *
9
+ * Nothing here reads a payload's content. `semanticBytes` is a length; the event classification
10
+ * reads only a frame's type name and an item's type name, both of which are protocol constants.
11
+ */
12
+ import type { AttemptDeliverySummary } from "./telemetry-contract";
13
+
14
+ export interface AttemptDeliveryTarget {
15
+ deliverySummary?: AttemptDeliverySummary;
16
+ }
17
+
18
+ export interface RelayedEventObservation {
19
+ semanticBytes?: number;
20
+ sideEffect?: boolean;
21
+ terminal?: boolean;
22
+ }
23
+
24
+ export interface AttemptDeliveryRecorder {
25
+ noteAdapterEvent(): void;
26
+ noteRelayedEvent(observation?: RelayedEventObservation): void;
27
+ noteBufferedDelivery(body: Record<string, unknown>): void;
28
+ }
29
+
30
+ export function createAttemptDeliverySummary(): AttemptDeliverySummary {
31
+ return { adapterEvents: 0, relayedEvents: 0, semanticBytes: 0, sideEffectEvents: 0, terminalEvents: 0 };
32
+ }
33
+
34
+ /**
35
+ * Saturating addition.
36
+ *
37
+ * A counter that wraps or drifts into a non-integer is worse than one that stops: the row would
38
+ * be dropped by the normalizer and the whole summary lost. A long-lived stream that somehow
39
+ * reaches the safe-integer ceiling keeps a readable, if pinned, number.
40
+ */
41
+ function bump(current: number, by: number): number {
42
+ if (!Number.isFinite(by) || by <= 0) return current;
43
+ return Math.min(Number.MAX_SAFE_INTEGER, current + Math.floor(by));
44
+ }
45
+
46
+ const SEMANTIC_DELTA_EVENTS: ReadonlySet<string> = new Set([
47
+ "response.output_text.delta",
48
+ "response.reasoning_summary_text.delta",
49
+ "response.reasoning_text.delta",
50
+ "response.function_call_arguments.delta",
51
+ "response.custom_tool_call_input.delta",
52
+ ]);
53
+
54
+ const TERMINAL_EVENTS: ReadonlySet<string> = new Set([
55
+ "response.completed",
56
+ "response.incomplete",
57
+ "response.failed",
58
+ ]);
59
+
60
+ const SIDE_EFFECT_ITEM_TYPES: ReadonlySet<string> = new Set([
61
+ "function_call",
62
+ "custom_tool_call",
63
+ "web_search_call",
64
+ ]);
65
+
66
+ /**
67
+ * What one relayed frame contributes, read from its type name alone.
68
+ *
69
+ * A side effect is counted when the item STARTS, not on its argument fragments and not again on
70
+ * the matching done frame, so one tool call is one effect however many deltas carried its
71
+ * arguments.
72
+ */
73
+ export function classifyRelayedResponseEvent(
74
+ name: string,
75
+ data: Record<string, unknown>,
76
+ ): RelayedEventObservation {
77
+ const observation: RelayedEventObservation = {};
78
+ if (SEMANTIC_DELTA_EVENTS.has(name) && typeof data.delta === "string") {
79
+ observation.semanticBytes = Buffer.byteLength(data.delta, "utf8");
80
+ }
81
+ if (name === "response.output_item.added") {
82
+ const item = data.item;
83
+ const type = item !== null && typeof item === "object"
84
+ ? (item as Record<string, unknown>).type
85
+ : undefined;
86
+ if (typeof type === "string" && SIDE_EFFECT_ITEM_TYPES.has(type)) observation.sideEffect = true;
87
+ }
88
+ if (TERMINAL_EVENTS.has(name)) observation.terminal = true;
89
+ return observation;
90
+ }
91
+
92
+ /**
93
+ * What one buffered response body delivered.
94
+ *
95
+ * A non-streaming turn has no frames: the whole answer reaches the client as one JSON body. Read
96
+ * naively that looks like total relay loss -- adapter events counted, nothing relayed -- which is
97
+ * precisely the signal these counters exist to raise, so a buffered response would raise it on
98
+ * every request and make it worthless. Everything the adapter produced DID reach the client here;
99
+ * it arrived in one piece. So the relayed total is set to the adapter total rather than left at
100
+ * zero, and the semantic facts are read from the body that was built.
101
+ *
102
+ * Fields are read defensively and by name. Keying this on the adapter event union would make a
103
+ * member added later a merge-time exhaustiveness failure in a counter that does not need one.
104
+ */
105
+ function observeBufferedBody(body: Record<string, unknown>): { semanticBytes: number; sideEffects: number } {
106
+ const output = Array.isArray(body.output) ? body.output : [];
107
+ let semanticBytes = 0;
108
+ let sideEffects = 0;
109
+ for (const entry of output) {
110
+ if (entry === null || typeof entry !== "object") continue;
111
+ const item = entry as Record<string, unknown>;
112
+ if (typeof item.type === "string" && SIDE_EFFECT_ITEM_TYPES.has(item.type)) sideEffects += 1;
113
+ if (typeof item.arguments === "string") semanticBytes += Buffer.byteLength(item.arguments, "utf8");
114
+ const content = Array.isArray(item.content) ? item.content : [];
115
+ for (const part of content) {
116
+ if (part === null || typeof part !== "object") continue;
117
+ const text = (part as Record<string, unknown>).text;
118
+ if (typeof text === "string") semanticBytes += Buffer.byteLength(text, "utf8");
119
+ }
120
+ }
121
+ return { semanticBytes, sideEffects };
122
+ }
123
+
124
+ const recordersByScope = new WeakMap<object, AttemptDeliveryRecorder>();
125
+
126
+ /**
127
+ * Bind a recorder to a request-scoped object.
128
+ *
129
+ * The scope is the request's translator budget, which every bridge on the delivery path already
130
+ * receives. Reusing it avoids threading a new parameter through six call sites where any one of
131
+ * them silently defaulting would leave a transport uncounted -- the failure mode that made
132
+ * `locallyAnswered` travel on the attempt instead of as an argument.
133
+ */
134
+ export function bindAttemptDeliveryRecorder(
135
+ scope: object,
136
+ currentAttempt: () => AttemptDeliveryTarget | undefined,
137
+ ): AttemptDeliveryRecorder {
138
+ const summaryFor = (): AttemptDeliverySummary | undefined => {
139
+ const attempt = currentAttempt();
140
+ if (!attempt) return undefined;
141
+ return attempt.deliverySummary ??= createAttemptDeliverySummary();
142
+ };
143
+ const recorder: AttemptDeliveryRecorder = {
144
+ noteAdapterEvent(): void {
145
+ const summary = summaryFor();
146
+ if (summary) summary.adapterEvents = bump(summary.adapterEvents, 1);
147
+ },
148
+ noteRelayedEvent(observation): void {
149
+ const summary = summaryFor();
150
+ if (!summary) return;
151
+ summary.relayedEvents = bump(summary.relayedEvents, 1);
152
+ if (observation?.semanticBytes) summary.semanticBytes = bump(summary.semanticBytes, observation.semanticBytes);
153
+ if (observation?.sideEffect) summary.sideEffectEvents = bump(summary.sideEffectEvents, 1);
154
+ if (observation?.terminal) summary.terminalEvents = bump(summary.terminalEvents, 1);
155
+ },
156
+ noteBufferedDelivery(body): void {
157
+ const summary = summaryFor();
158
+ if (!summary) return;
159
+ const observed = observeBufferedBody(body);
160
+ summary.relayedEvents = Math.max(summary.relayedEvents, summary.adapterEvents);
161
+ summary.semanticBytes = bump(summary.semanticBytes, observed.semanticBytes);
162
+ summary.sideEffectEvents = bump(summary.sideEffectEvents, observed.sideEffects);
163
+ summary.terminalEvents = bump(summary.terminalEvents, 1);
164
+ },
165
+ };
166
+ recordersByScope.set(scope, recorder);
167
+ return recorder;
168
+ }
169
+
170
+ export function attemptDeliveryRecorder(scope: object | undefined): AttemptDeliveryRecorder | undefined {
171
+ return scope ? recordersByScope.get(scope) : undefined;
172
+ }
173
+
174
+ /**
175
+ * A persisted summary is trusted only when all five counts are non-negative safe integers.
176
+ *
177
+ * The whole record is dropped rather than repaired: a partially trusted count is a number an
178
+ * operator would compare against another number, and half a summary is how a loss signal turns
179
+ * into a false one.
180
+ */
181
+ export function normalizeAttemptDeliverySummary(value: unknown): AttemptDeliverySummary | undefined {
182
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
183
+ const raw = value as Record<string, unknown>;
184
+ const counts = createAttemptDeliverySummary();
185
+ for (const key of Object.keys(counts) as Array<keyof AttemptDeliverySummary>) {
186
+ const count = raw[key];
187
+ if (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) return undefined;
188
+ counts[key] = count;
189
+ }
190
+ return counts;
191
+ }
192
+
193
+ /** A detached copy, so a snapshotted attempt cannot keep counting after it was finalized. */
194
+ export function cloneAttemptDeliverySummary(
195
+ summary: AttemptDeliverySummary | undefined,
196
+ ): AttemptDeliverySummary | undefined {
197
+ return summary ? { ...summary } : undefined;
198
+ }
@@ -0,0 +1,305 @@
1
+ /** Privacy-bounded, process-local cache diagnostics. Enable with OPENCODEX_CACHE_DEBUG=1. */
2
+ import { createHmac, randomBytes } from "node:crypto";
3
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { CODEX_AFFINITY_DEBUG_SAFE_HEADERS } from "../codex/affinity-debug";
6
+ import { getConfigDir } from "../config";
7
+ import { recordOwnedConfigPath } from "../lib/config-ownership";
8
+ import type { CacheTelemetryProvenance } from "./log";
9
+
10
+ const CACHE_DEBUG_KEY = randomBytes(32);
11
+ const MAX_BLOCKS = 128;
12
+ const MAX_DRAFTS = 512;
13
+ export const CACHE_DEBUG_MAX_LINES = 200;
14
+ export const CACHE_DEBUG_KEEP_LINES = 100;
15
+
16
+ export type PromptCacheKeySource = "caller" | "metadata-derived" | "system-derived" | "proxy-synthesized";
17
+ export interface TaggedPresence { present: boolean; tag?: string; source?: PromptCacheKeySource }
18
+ export interface TaggedSequence { present: boolean; count: number; tags: string[]; truncated?: true }
19
+ export interface PrefixFingerprint {
20
+ instructions: TaggedSequence;
21
+ tools: TaggedSequence;
22
+ messages: TaggedSequence;
23
+ }
24
+ export interface CacheDiagnosticDraft {
25
+ promptCacheKey?: { inbound?: TaggedPresence; outbound?: TaggedPresence };
26
+ session?: { inboundHeader?: TaggedPresence; outboundHeader?: TaggedPresence };
27
+ prefix?: { inbound?: PrefixFingerprint; outbound?: PrefixFingerprint };
28
+ }
29
+
30
+ export interface CacheDiagnosticFinalFacts {
31
+ requestId: string;
32
+ logicalRequestId?: string;
33
+ protocol: "responses" | "chat" | "messages";
34
+ provider: string;
35
+ model: string;
36
+ accountLogLabel?: string;
37
+ affinityMove?: string;
38
+ affinityReason?: string;
39
+ /**
40
+ * The cache counter exactly as the upstream usage object carried it, read before any
41
+ * client-wire defaulting. Undefined means the upstream object had no cache counter at
42
+ * all, which keeps a measured zero distinct from an absent-then-defaulted zero.
43
+ */
44
+ rawCacheCounterValue?: number;
45
+ normalizedCacheValue?: number;
46
+ cacheProvenance: CacheTelemetryProvenance;
47
+ draft?: CacheDiagnosticDraft;
48
+ }
49
+
50
+ const drafts = new Map<string, CacheDiagnosticDraft>();
51
+ const bodyDrafts = new WeakMap<object, CacheDiagnosticDraft>();
52
+
53
+ export function isCacheDiagnosticEnabled(): boolean {
54
+ return process.env.OPENCODEX_CACHE_DEBUG === "1";
55
+ }
56
+
57
+ export function cacheDiagnosticPath(): string {
58
+ return join(getConfigDir(), "cache-debug.jsonl");
59
+ }
60
+
61
+ function tag(domain: string, value: string): string {
62
+ return createHmac("sha256", CACHE_DEBUG_KEY)
63
+ .update(domain).update("\0").update(value).digest("hex").slice(0, 12);
64
+ }
65
+
66
+ export function tagCacheDiagnosticValue(domain: string, value: string): string {
67
+ return tag(`cache-debug:${domain}`, value);
68
+ }
69
+
70
+ function canonicalJson(value: unknown): string {
71
+ if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
72
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
73
+ const record = value as Record<string, unknown>;
74
+ return `{${Object.keys(record).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
75
+ }
76
+
77
+ function taggedPresence(value: unknown, domain: string, source?: PromptCacheKeySource): TaggedPresence {
78
+ if (typeof value !== "string" || value.length === 0) return { present: false };
79
+ return { present: true, tag: tag(domain, value), ...(source ? { source } : {}) };
80
+ }
81
+
82
+ function sessionPresence(headers: Headers | HeadersInit): TaggedPresence {
83
+ const normalized = headers instanceof Headers ? headers : new Headers(headers);
84
+ const values = CODEX_AFFINITY_DEBUG_SAFE_HEADERS.flatMap(name => {
85
+ const value = normalized.get(name);
86
+ return value === null ? [] : [[name, value] as const];
87
+ });
88
+ return values.length === 0
89
+ ? { present: false }
90
+ : { present: true, tag: tag("cache-debug:session-headers", canonicalJson(values)) };
91
+ }
92
+
93
+ function sequence(blocks: unknown[], domain: string): TaggedSequence {
94
+ const bounded = blocks.slice(0, MAX_BLOCKS);
95
+ return {
96
+ present: blocks.length > 0,
97
+ count: blocks.length,
98
+ tags: bounded.map(block => tag(domain, canonicalJson(block))),
99
+ ...(blocks.length > MAX_BLOCKS ? { truncated: true as const } : {}),
100
+ };
101
+ }
102
+
103
+ function requestBlocks(body: Record<string, unknown>): { instructions: unknown[]; messages: unknown[] } {
104
+ const instructions = body.instructions;
105
+ // The spread is load-bearing: an array-valued instructions field must be copied, never
106
+ // aliased, because the pushes below would otherwise mutate the live request body that
107
+ // the adapter is about to serialize upstream.
108
+ const instructionRows = instructions === undefined || instructions === null
109
+ ? [] : Array.isArray(instructions) ? [...instructions] : [instructions];
110
+ const input = Array.isArray(body.input) ? body.input : Array.isArray(body.messages) ? body.messages : [];
111
+ const messages: unknown[] = [];
112
+ for (const block of input) {
113
+ if (block && typeof block === "object" && !Array.isArray(block)) {
114
+ const row = block as Record<string, unknown>;
115
+ if (row.role === "system" || row.role === "developer") {
116
+ const content = row.content;
117
+ instructionRows.push(...(Array.isArray(content) ? content : [content]));
118
+ continue;
119
+ }
120
+ }
121
+ messages.push(block);
122
+ }
123
+ return { instructions: instructionRows, messages };
124
+ }
125
+
126
+ export function prefixFingerprint(body: unknown): PrefixFingerprint {
127
+ const record = body && typeof body === "object" && !Array.isArray(body)
128
+ ? body as Record<string, unknown> : {};
129
+ const tools = Array.isArray(record.tools) ? record.tools : [];
130
+ const blocks = requestBlocks(record);
131
+ return {
132
+ instructions: sequence(blocks.instructions, "cache-debug:prefix:instructions"),
133
+ tools: sequence(tools, "cache-debug:prefix:tools"),
134
+ messages: sequence(blocks.messages, "cache-debug:prefix:messages"),
135
+ };
136
+ }
137
+
138
+ function firstDivergence(inbound: PrefixFingerprint, outbound: PrefixFingerprint):
139
+ { section: "instructions" | "tools" | "messages"; index: number } | undefined {
140
+ for (const section of ["instructions", "tools", "messages"] as const) {
141
+ const before = inbound[section].tags;
142
+ const after = outbound[section].tags;
143
+ const compared = Math.min(before.length, after.length);
144
+ for (let index = 0; index < compared; index += 1) {
145
+ if (before[index] !== after[index]) return { section, index };
146
+ }
147
+ if (inbound[section].count !== outbound[section].count) return { section, index: compared };
148
+ }
149
+ return undefined;
150
+ }
151
+
152
+ export function observe(requestId: string, observation: Partial<CacheDiagnosticDraft>): CacheDiagnosticDraft {
153
+ const draft = drafts.get(requestId) ?? {};
154
+ Object.assign(draft, observation);
155
+ drafts.delete(requestId);
156
+ drafts.set(requestId, draft);
157
+ while (drafts.size > MAX_DRAFTS) drafts.delete(drafts.keys().next().value!);
158
+ return draft;
159
+ }
160
+
161
+ export function observeInbound(
162
+ body: unknown,
163
+ headers: Headers,
164
+ source: PromptCacheKeySource = "caller",
165
+ ): CacheDiagnosticDraft {
166
+ if (!isCacheDiagnosticEnabled()) return {};
167
+ try {
168
+ const record = body && typeof body === "object" && !Array.isArray(body)
169
+ ? body as Record<string, unknown> : {};
170
+ const draft: CacheDiagnosticDraft = {
171
+ promptCacheKey: { inbound: taggedPresence(record.prompt_cache_key, "cache-debug:prompt-cache-key", source) },
172
+ session: { inboundHeader: sessionPresence(headers) },
173
+ prefix: { inbound: prefixFingerprint(body) },
174
+ };
175
+ if (body && typeof body === "object") bodyDrafts.set(body, draft);
176
+ return draft;
177
+ } catch {
178
+ return {};
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Alias a later form of the same request body (for example after previous-response
184
+ * expansion) to an existing draft, so the outbound observation at the adapter seam can
185
+ * find it. The inbound fingerprint intentionally stays the literal pre-expansion body.
186
+ */
187
+ export function rebindCacheDiagnosticBodyAlias(body: unknown, draft: CacheDiagnosticDraft | undefined): void {
188
+ if (!isCacheDiagnosticEnabled() || !draft) return;
189
+ if (body && typeof body === "object") bodyDrafts.set(body, draft);
190
+ }
191
+
192
+ export function observeOutbound(
193
+ inboundBody: unknown,
194
+ outboundBody: unknown,
195
+ headers: HeadersInit,
196
+ source: PromptCacheKeySource = "proxy-synthesized",
197
+ ): void {
198
+ if (!isCacheDiagnosticEnabled()) return;
199
+ try {
200
+ if (!inboundBody || typeof inboundBody !== "object") return;
201
+ const draft = bodyDrafts.get(inboundBody);
202
+ if (!draft) return;
203
+ const record = outboundBody && typeof outboundBody === "object" && !Array.isArray(outboundBody)
204
+ ? outboundBody as Record<string, unknown> : {};
205
+ const outbound = taggedPresence(record.prompt_cache_key, "cache-debug:prompt-cache-key", source);
206
+ const inboundKey = draft.promptCacheKey?.inbound;
207
+ if (outbound.present && inboundKey && outbound.tag === inboundKey.tag) {
208
+ outbound.source = inboundKey.source;
209
+ }
210
+ (draft.promptCacheKey ??= {}).outbound = outbound;
211
+ (draft.session ??= {}).outboundHeader = sessionPresence(headers);
212
+ (draft.prefix ??= {}).outbound = prefixFingerprint(outboundBody);
213
+ } catch {
214
+ /* diagnostics must never affect request handling */
215
+ }
216
+ }
217
+
218
+ function ensureDir(): void {
219
+ const dir = getConfigDir();
220
+ recordOwnedConfigPath(dir, cacheDiagnosticPath());
221
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
222
+ try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
223
+ }
224
+
225
+ function trimRollingFile(path: string): void {
226
+ const lines = readFileSync(path, "utf8").split(/\r?\n/).filter(Boolean);
227
+ if (lines.length <= CACHE_DEBUG_MAX_LINES) return;
228
+ writeFileSync(path, `${lines.slice(-CACHE_DEBUG_KEEP_LINES).join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
229
+ try { chmodSync(path, 0o600); } catch { /* best-effort */ }
230
+ }
231
+
232
+ export function appendFinalCacheDiagnostic(facts: CacheDiagnosticFinalFacts): void {
233
+ if (!isCacheDiagnosticEnabled()) return;
234
+ try {
235
+ const draft = facts.draft ? observe(facts.requestId, facts.draft) : drafts.get(facts.requestId) ?? {};
236
+ drafts.delete(facts.requestId);
237
+ const inboundKey = draft.promptCacheKey?.inbound;
238
+ const outboundKey = draft.promptCacheKey?.outbound;
239
+ const inboundPrefix = draft.prefix?.inbound ?? prefixFingerprint(undefined);
240
+ const outboundPrefix = draft.prefix?.outbound ?? prefixFingerprint(undefined);
241
+ const record = {
242
+ version: 1 as const,
243
+ ts: Date.now(),
244
+ requestId: facts.requestId,
245
+ ...(facts.logicalRequestId ? { logicalRequestId: facts.logicalRequestId } : {}),
246
+ protocol: facts.protocol,
247
+ provider: facts.provider,
248
+ model: facts.model,
249
+ promptCacheKey: {
250
+ inbound: inboundKey ?? { present: false },
251
+ outbound: outboundKey ?? { present: false },
252
+ ...(inboundKey?.present && outboundKey?.present ? { equal: inboundKey.tag === outboundKey.tag } : {}),
253
+ },
254
+ session: {
255
+ inboundHeader: draft.session?.inboundHeader ?? { present: false },
256
+ outboundHeader: draft.session?.outboundHeader ?? { present: false },
257
+ },
258
+ prefix: {
259
+ inbound: inboundPrefix,
260
+ outbound: outboundPrefix,
261
+ ...(firstDivergence(inboundPrefix, outboundPrefix)
262
+ ? { firstDivergentBlock: firstDivergence(inboundPrefix, outboundPrefix) }
263
+ : {}),
264
+ },
265
+ route: {
266
+ provider: facts.provider,
267
+ model: facts.model,
268
+ ...(facts.accountLogLabel
269
+ ? { accountTag: tag("cache-debug:account-log-label", facts.accountLogLabel) }
270
+ : {}),
271
+ ...(facts.affinityMove ? { affinityMove: facts.affinityMove } : {}),
272
+ ...(facts.affinityReason ? { affinityReason: facts.affinityReason } : {}),
273
+ },
274
+ cache: {
275
+ rawUpstream: facts.rawCacheCounterValue !== undefined
276
+ ? { present: true, value: facts.rawCacheCounterValue }
277
+ : { present: false },
278
+ normalized: {
279
+ present: facts.normalizedCacheValue !== undefined,
280
+ ...(facts.normalizedCacheValue !== undefined ? { value: facts.normalizedCacheValue } : {}),
281
+ provenance: facts.cacheProvenance,
282
+ },
283
+ },
284
+ };
285
+ ensureDir();
286
+ const path = cacheDiagnosticPath();
287
+ appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
288
+ try { chmodSync(path, 0o600); } catch { /* best-effort */ }
289
+ if (existsSync(path)) trimRollingFile(path);
290
+ } catch {
291
+ /* diagnostics must never affect request handling */
292
+ }
293
+ }
294
+
295
+ const CACHE_DIAGNOSTIC_HOOK = Symbol.for("opencodex.cache-diagnostic.v1");
296
+ interface CacheDiagnosticHooks {
297
+ observeInbound(body: unknown, headers: Headers, source: PromptCacheKeySource): CacheDiagnosticDraft;
298
+ rebind(body: unknown, draft: CacheDiagnosticDraft | undefined): void;
299
+ finalize(facts: CacheDiagnosticFinalFacts): void;
300
+ }
301
+ (globalThis as Record<symbol, CacheDiagnosticHooks | undefined>)[CACHE_DIAGNOSTIC_HOOK] = {
302
+ observeInbound,
303
+ rebind: rebindCacheDiagnosticBodyAlias,
304
+ finalize: appendFinalCacheDiagnostic,
305
+ };