@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,237 @@
1
+ /**
2
+ * The telemetry vocabulary both the proxy and the dashboard read.
3
+ *
4
+ * This module has NO imports, and that is its entire job. The dashboard is a separate TypeScript
5
+ * project with `erasableSyntaxOnly`, and a type-only import still pulls the imported file's whole
6
+ * import graph into that project. Importing these names from `./log` therefore dragged
7
+ * `node:fs`, `node:crypto` and the config barrel into the browser build, where a parameter
8
+ * property in `src/config/atomic-write.ts` fails to compile. The names below are the ones a
9
+ * browser legitimately needs, so they live where a browser can reach them.
10
+ *
11
+ * Anything added here must stay free of imports. A contract that acquires a dependency stops
12
+ * being a contract.
13
+ */
14
+
15
+ /**
16
+ * Recovery kinds recorded per attempt in the usage log; the dashboard renders localized labels
17
+ * for these wire values.
18
+ *
19
+ * The roster is the single statement of this vocabulary and the type is derived from it. It was
20
+ * written twice once -- as a union and as the read-back whitelist -- and the two are not
21
+ * interchangeable: a member added only to the union compiles, is written to disk, and is dropped
22
+ * on the next read, so the row loses the field that says why it recovered. One declaration cannot
23
+ * drift from itself, and the dashboard now reads this one rather than keeping a third copy.
24
+ */
25
+ export const ATTEMPT_RECOVERY_KIND_ROSTER = Object.freeze([
26
+ "transient-5xx",
27
+ "connection-reset",
28
+ "oauth-401",
29
+ "key-401",
30
+ "key-429",
31
+ "rate-limit-429",
32
+ "anthropic-oauth-429",
33
+ "oauth-account-429",
34
+ "image-413",
35
+ "console-go-upload-retry",
36
+ "opaque-blob-rejection",
37
+ "empty-completion",
38
+ "reasoning-effort-downgrade",
39
+ ] as const);
40
+
41
+ export type AttemptRecoveryKind = typeof ATTEMPT_RECOVERY_KIND_ROSTER[number];
42
+
43
+ /**
44
+ * Why a recovery this request was otherwise willing to make did not happen.
45
+ *
46
+ * Recorded separately from `recoveryKinds` and from `sendCount` because the question it answers
47
+ * is different from either. A log showing one physical send and no recovery kind used to be
48
+ * ambiguous: nothing was eligible, or something was and the send budget withheld it. Those need
49
+ * opposite follow-ups and the second was invisible (#5044).
50
+ *
51
+ * `sendCount` deliberately does not move for these. A refused attempt is not a physical send, and
52
+ * inflating the count to signal the refusal would corrupt the one number that means "requests this
53
+ * proxy actually made".
54
+ */
55
+ export const ATTEMPT_RECOVERY_WITHHELD_ROSTER = Object.freeze([
56
+ "retry-send-budget",
57
+ "rotation-send-budget",
58
+ ] as const);
59
+
60
+ export type AttemptRecoveryWithheld = typeof ATTEMPT_RECOVERY_WITHHELD_ROSTER[number];
61
+
62
+ /**
63
+ * How far a failed exchange got, ordered by how much the DOWNSTREAM CLIENT observed.
64
+ *
65
+ * The order is by client observation rather than by upstream progress, because the question it
66
+ * answers is whether resending can duplicate something the caller already saw. An upstream that
67
+ * completed a turn we never relayed has committed nothing downstream; an upstream that emitted
68
+ * one token has.
69
+ *
70
+ * The roster lives here rather than beside the resend tables for the reason stated at the top of
71
+ * this file: the dashboard renders a label per member, and reaching the table module for the
72
+ * names would drag its import graph into the browser project. `src/lib/request-failure-model.ts`
73
+ * re-exports it, so every existing importer keeps its path and there is still exactly one
74
+ * declaration.
75
+ */
76
+ export const REQUEST_FAILURE_STAGES = Object.freeze([
77
+ /** No response head exists. Whether the origin began the turn is not known from the stage alone. */
78
+ "pre-header",
79
+ /** A status line and headers exist, and no protocol body event has been parsed yet. */
80
+ "headers-only",
81
+ /** The protocol body began with control events only -- `response.created`, quota frames. */
82
+ "protocol-prelude",
83
+ /** At least one output-bearing event reached the caller. */
84
+ "semantic-output",
85
+ /** A tool call or other externally visible effect was emitted. */
86
+ "side-effect",
87
+ /** A terminal event settled the turn after its answer reached the caller. */
88
+ "terminal",
89
+ ] as const);
90
+
91
+ export type RequestFailureStage = typeof REQUEST_FAILURE_STAGES[number];
92
+
93
+ /**
94
+ * Why the request failed, as one closed dictionary for every layer.
95
+ *
96
+ * Bounded on purpose: these are wire values a maintainer reads and a metric labels by, never a
97
+ * credential, an account identifier, an upstream body or prompt content. That bound is what lets
98
+ * the value be a Prometheus label and a grouping key without a masking pass -- a closed roster
99
+ * has nothing to mask.
100
+ */
101
+ export const REQUEST_FAILURE_CAUSES = Object.freeze([
102
+ /** The bytes provably never reached the origin: connect refused, DNS failure, TLS handshake. */
103
+ "transport-unsent",
104
+ /** The bytes left and the connection died before a head. The origin may be running the turn. */
105
+ "transport-ambiguous",
106
+ /** The origin answered that it would not start the turn now: 503, overloaded, backpressure. */
107
+ "upstream-declined",
108
+ /** A 429 rate limit. Capacity is momentarily gone; waiting is the remedy. */
109
+ "rate-limit",
110
+ /** Plan or credit quota is gone. Waiting out a retry window does not help; the account must change. */
111
+ "quota-exhausted",
112
+ /** Credentials were rejected: 401, 403 on identity. */
113
+ "credential-rejected",
114
+ /** The origin evaluated the content and refused it. Identical bytes get the identical refusal. */
115
+ "policy-refusal",
116
+ /**
117
+ * The origin rejected a request PARAMETER rather than the content: an unsupported reasoning
118
+ * effort, an unknown field. Distinct from `policy-refusal` because the remedy is opposite --
119
+ * the same content succeeds once the parameter is adjusted.
120
+ */
121
+ "parameter-rejected",
122
+ /** Opaque replay state was rejected as unverifiable. Only a request without it can succeed. */
123
+ "ciphertext-refusal",
124
+ /**
125
+ * The payload exceeded a size the origin accepts. A smaller rebuild of the same turn can
126
+ * succeed, which is why this is not the same answer as `payload-rejected`.
127
+ */
128
+ "payload-too-large",
129
+ /** The payload was rejected on its merits: unsupported media, malformed part. No repair helps. */
130
+ "payload-rejected",
131
+ /**
132
+ * The origin returned a server-side fault. Whether it had already begun the turn is not
133
+ * knowable from the status, so this is the honest classification for the mixed 5xx set the
134
+ * transient layer retries: 503 really did decline, 500 may not have.
135
+ */
136
+ "upstream-fault",
137
+ /** The turn settled carrying no usable output. */
138
+ "empty-output",
139
+ /** The caller went away. */
140
+ "client-cancelled",
141
+ /** This proxy refused before dispatch: send budget, route policy, replay refusal. */
142
+ "local-refusal",
143
+ ] as const);
144
+
145
+ export type RequestFailureCause = typeof REQUEST_FAILURE_CAUSES[number];
146
+
147
+ /**
148
+ * Whether this proxy may send the request again. Derived at READ time from the stage and the
149
+ * cause and never persisted, so a stored row cannot carry a verdict that the current table
150
+ * would no longer reach.
151
+ *
152
+ * Every refusal names WHY it refused, because the three reasons need different operator
153
+ * responses and used to arrive as one undifferentiated "no retry".
154
+ */
155
+ export const RESEND_PERMISSIONS = Object.freeze([
156
+ /** The same request may be sent again. */
157
+ "permitted",
158
+ /** Only a modified request may be sent: rotated credential, stripped ciphertext. */
159
+ "permitted-after-repair",
160
+ /** Upstream execution state is unknown. No AUTOMATIC resend. */
161
+ "refused-ambiguous",
162
+ /** The caller already observed output or an externally visible effect. */
163
+ "refused-committed",
164
+ /** Identical bytes would get the identical answer. */
165
+ "refused-futile",
166
+ ] as const);
167
+
168
+ export type ResendPermission = typeof RESEND_PERMISSIONS[number];
169
+
170
+ /**
171
+ * Where a terminal or failure was observed on the wire.
172
+ *
173
+ * Declared here because three modules read it as a closed set -- the durable row's validator,
174
+ * the failure attribution and the failure fingerprint -- and a fourth restatement in a test is
175
+ * how a member added later leaves an "exhaustive" cross product green without exercising it.
176
+ */
177
+ export const REQUEST_TRANSPORT_PHASES = Object.freeze([
178
+ "pre_headers",
179
+ "mid_stream",
180
+ "terminal_sse",
181
+ ] as const);
182
+
183
+ export type RequestTransportPhase = typeof REQUEST_TRANSPORT_PHASES[number];
184
+
185
+ /**
186
+ * What an attempt actually delivered, as five bounded counts (#3983).
187
+ *
188
+ * #3983 wanted these signals and emitted one debug line per event to get them. That is a second
189
+ * durable record: `emitDebugLine` writes the in-process ring AND stderr, and stderr is redirected
190
+ * to the service log under both launchd and systemd, so an installed service ends up with a
191
+ * per-event history beside the ledger, carrying its own retention, sequencing and identity. It
192
+ * also fingerprinted each payload under a process-global random key, which makes every repeated
193
+ * prompt fragment, tool name and error message correlatable for the process lifetime.
194
+ *
195
+ * Counts answer the same questions -- a missing terminal, adapter-to-client loss, empty output,
196
+ * partial output size -- and cannot carry content at all. They ride the attempt, so they inherit
197
+ * the ledger's normalization, masking and retention rather than acquiring their own.
198
+ *
199
+ * Counted where the event is DELIVERED, not where it is read. An adapter event the client never
200
+ * received is exactly the discrepancy worth seeing, and counting both ends at the reader would
201
+ * make the two numbers equal by construction.
202
+ */
203
+ export interface AttemptDeliverySummary {
204
+ /** Events this attempt's adapter produced. */
205
+ adapterEvents: number;
206
+ /** Frames that reached the client transport, after a successful enqueue. */
207
+ relayedEvents: number;
208
+ /** UTF-8 bytes of output-bearing delta actually relayed. Never the content itself. */
209
+ semanticBytes: number;
210
+ /** Externally visible effects relayed: a tool call or a search call starting. */
211
+ sideEffectEvents: number;
212
+ /** Terminal frames relayed. Zero on a delivered stream is the missing-terminal signal. */
213
+ terminalEvents: number;
214
+ }
215
+
216
+ /**
217
+ * What one logical request spent upstream, decomposed by how much of it is explained.
218
+ *
219
+ * The counting half of the durable spend record, without the routing detail that sits beside it.
220
+ * Every surface that reports a send total reads these three numbers and none of them recomputes a
221
+ * total of its own -- a recomputed total is how the exporter and the dashboard ended up reporting
222
+ * different send counts for the same request.
223
+ */
224
+ export interface RequestSpendTotals {
225
+ /** Physical upstream sends summed across every attempt, combo children included. */
226
+ sends: number;
227
+ /** Sends whose attempt reached a terminal status, so the spend has a known outcome. */
228
+ settled: number;
229
+ /**
230
+ * Sends charged with no terminal outcome behind them: an attempt abandoned mid-flight, or a
231
+ * budget charge no attempt row ever accounted for. Never folded into `settled` -- an unexplained
232
+ * send is the exact quantity this record exists to make visible.
233
+ */
234
+ unresolved: number;
235
+ /** Model sends the request execution budget charged. Absent when no budget was attached. */
236
+ reserved?: number;
237
+ }
@@ -0,0 +1,236 @@
1
+ import { cacheTokensFromUsage, usageAttributions } from "./summary";
2
+ import type { PersistedUsageEntry } from "./log";
3
+ import { usageDisplayTotalTokens } from "./totals";
4
+
5
+ export type TimelineMetric = "total" | "input" | "output" | "cached";
6
+ export type TimelineAggregation = "sum" | "average" | "max";
7
+ export type TimelineGrouping = "model" | "modelAccount";
8
+ export const TIMELINE_HOURS = [6, 24, 72, 168] as const;
9
+
10
+ export interface TimelineQuery {
11
+ hours: typeof TIMELINE_HOURS[number];
12
+ bucketMinutes: number;
13
+ metric: TimelineMetric;
14
+ aggregation: TimelineAggregation;
15
+ grouping: TimelineGrouping;
16
+ models: string[] | null;
17
+ hiddenProviders: string[];
18
+ now: number;
19
+ }
20
+
21
+ export interface TimelineSeries {
22
+ id: string;
23
+ provider: string;
24
+ model: string;
25
+ accountLogLabel?: string;
26
+ total: number;
27
+ points: number[];
28
+ }
29
+
30
+ export interface UsageTimeline {
31
+ appliedFilters: { models: string[] | null; hiddenProviders: string[] };
32
+ start: number;
33
+ end: number;
34
+ bucketSeconds: number;
35
+ buckets: number;
36
+ metric: TimelineMetric;
37
+ aggregation: TimelineAggregation;
38
+ grouping: TimelineGrouping;
39
+ series: TimelineSeries[];
40
+ availableModels: string[];
41
+ missingMeasurements: number;
42
+ truncated: boolean;
43
+ }
44
+
45
+ const METRICS: readonly TimelineMetric[] = ["total", "input", "output", "cached"];
46
+ const AGGREGATIONS: readonly TimelineAggregation[] = ["sum", "average", "max"];
47
+ const GROUPINGS: readonly TimelineGrouping[] = ["model", "modelAccount"];
48
+
49
+ function enumValue<T extends string>(value: string | null, values: readonly T[], fallback: T): T | { error: string } {
50
+ if (value === null || value === "") return fallback;
51
+ return values.includes(value as T) ? value as T : { error: `invalid value for parameter: ${value}` };
52
+ }
53
+
54
+ export function isTimelineModelId(value: unknown): value is string {
55
+ return typeof value === "string" && /^[^/\s]+\/\S+$/.test(value);
56
+ }
57
+
58
+ function parseModels(raw: string | null): string[] | null | { error: string } {
59
+ if (raw === null || raw.trim() === "") return null;
60
+ const models = raw.split(",").map(model => model.trim());
61
+ if (models.length > 100) return { error: "models must contain at most 100 identifiers" };
62
+ if (models.some(model => !isTimelineModelId(model))) {
63
+ return { error: "models must contain provider/model identifiers" };
64
+ }
65
+ return [...new Set(models)];
66
+ }
67
+
68
+ export function parseTimelineQuery(params: URLSearchParams, now: number): TimelineQuery | { error: string } {
69
+ const rawHours = params.get("hours") ?? "24";
70
+ const hoursNumber = Number(rawHours);
71
+ if (!TIMELINE_HOURS.includes(hoursNumber as typeof TIMELINE_HOURS[number])) {
72
+ return { error: "hours must be one of 6, 24, 72, 168" };
73
+ }
74
+ const bucketMinutes = Number(params.get("bucketMinutes") ?? "60");
75
+ if (!Number.isInteger(bucketMinutes) || bucketMinutes < 1 || bucketMinutes > 1440) {
76
+ return { error: "bucketMinutes must be an integer from 1 through 1440" };
77
+ }
78
+ const buckets = Math.ceil(hoursNumber * 60 / bucketMinutes);
79
+ if (buckets > 2000) return { error: "timeline bucket count must not exceed 2000" };
80
+ const metric = enumValue(params.get("metric"), METRICS, "total");
81
+ if (typeof metric !== "string") return metric;
82
+ const aggregation = enumValue(params.get("aggregation"), AGGREGATIONS, "sum");
83
+ if (typeof aggregation !== "string") return aggregation;
84
+ const grouping = enumValue(params.get("grouping"), GROUPINGS, "model");
85
+ if (typeof grouping !== "string") return grouping;
86
+ const models = parseModels(params.get("models"));
87
+ if (typeof models === "object" && models !== null && "error" in models) return models;
88
+ const hiddenProviders = params.getAll("hiddenProvider");
89
+ if (hiddenProviders.length > 100 || hiddenProviders.some(value => !value || /\s/.test(value))) {
90
+ return { error: "hiddenProvider must contain at most 100 nonblank provider names" };
91
+ }
92
+ if (!Number.isFinite(now)) return { error: "now must be finite" };
93
+ return {
94
+ hours: hoursNumber as TimelineQuery["hours"],
95
+ bucketMinutes,
96
+ metric,
97
+ aggregation,
98
+ grouping,
99
+ models: models as string[] | null,
100
+ hiddenProviders: [...new Set(hiddenProviders)].sort(),
101
+ now,
102
+ };
103
+ }
104
+
105
+ interface SeriesState {
106
+ provider: string;
107
+ model: string;
108
+ accountLogLabel?: string;
109
+ points: number[];
110
+ requests: Map<number, Map<string, number>>;
111
+ }
112
+
113
+ function metricValue(metric: TimelineMetric, attribution: ReturnType<typeof usageAttributions>[number]): number | undefined {
114
+ if (metric === "total") return usageDisplayTotalTokens(attribution.usage, attribution.totalTokens);
115
+ if (metric === "input") return attribution.usage?.inputTokens;
116
+ if (metric === "output") return attribution.usage?.outputTokens;
117
+ return cacheTokensFromUsage(attribution.usage).read;
118
+ }
119
+
120
+ export function createTimelineAccumulator(query: TimelineQuery): { add(entry: PersistedUsageEntry): void; finish(): UsageTimeline } {
121
+ const bucketSeconds = query.bucketMinutes * 60;
122
+ const buckets = Math.ceil(query.hours * 60 / query.bucketMinutes);
123
+ const end = (Math.floor(query.now / 1000 / bucketSeconds) + 1) * bucketSeconds;
124
+ const start = end - buckets * bucketSeconds;
125
+ const startMs = start * 1000;
126
+ const endMs = end * 1000;
127
+ const series = new Map<string, SeriesState>();
128
+ const availableModels = new Set<string>();
129
+ const hiddenProviders = new Set(query.hiddenProviders);
130
+ let missingMeasurements = 0;
131
+
132
+ function add(entry: PersistedUsageEntry): void {
133
+ if (entry.timestamp < startMs || entry.timestamp >= endMs) return;
134
+ const bucket = Math.floor((entry.timestamp - startMs) / (bucketSeconds * 1000));
135
+ if (bucket < 0 || bucket >= buckets) return;
136
+ for (const attribution of usageAttributions(entry)) {
137
+ if (hiddenProviders.has(attribution.provider)) continue;
138
+ const modelId = `${attribution.provider}/${attribution.model}`;
139
+ availableModels.add(modelId);
140
+ if (query.models && !query.models.includes(modelId)) continue;
141
+ const id = query.grouping === "model"
142
+ ? modelId
143
+ : `${modelId} · ${attribution.accountLogLabel ?? "unknown"}`;
144
+ let state = series.get(id);
145
+ if (!state) {
146
+ state = {
147
+ provider: attribution.provider,
148
+ model: attribution.model,
149
+ ...(query.grouping === "modelAccount" ? { accountLogLabel: attribution.accountLogLabel ?? "unknown" } : {}),
150
+ points: Array<number>(buckets).fill(0),
151
+ requests: new Map(),
152
+ };
153
+ series.set(id, state);
154
+ }
155
+ const value = metricValue(query.metric, attribution);
156
+ if (value === undefined) {
157
+ missingMeasurements += 1;
158
+ continue;
159
+ }
160
+ if (query.aggregation === "sum") {
161
+ state.points[bucket] = (state.points[bucket] ?? 0) + value;
162
+ } else {
163
+ let requests = state.requests.get(bucket);
164
+ if (!requests) {
165
+ requests = new Map();
166
+ state.requests.set(bucket, requests);
167
+ }
168
+ requests.set(attribution.requestId, (requests.get(attribution.requestId) ?? 0) + value);
169
+ }
170
+ }
171
+ }
172
+
173
+ function finish(): UsageTimeline {
174
+ const rows = [...series].map(([id, state]): { row: TimelineSeries; state: SeriesState } => {
175
+ if (query.aggregation !== "sum") {
176
+ for (const [bucket, requests] of state.requests) {
177
+ const values = [...requests.values()];
178
+ state.points[bucket] = query.aggregation === "max"
179
+ ? Math.max(...values)
180
+ : values.reduce((sum, value) => sum + value, 0) / values.length;
181
+ }
182
+ }
183
+ const total = state.points.reduce((sum, value) => sum + value, 0);
184
+ return {
185
+ row: {
186
+ id,
187
+ provider: state.provider,
188
+ model: state.model,
189
+ ...(state.accountLogLabel !== undefined ? { accountLogLabel: state.accountLogLabel } : {}),
190
+ total,
191
+ points: state.points,
192
+ },
193
+ state,
194
+ };
195
+ }).sort((left, right) => right.row.total - left.row.total || left.row.id.localeCompare(right.row.id));
196
+ const kept = (rows.length > 24 ? rows.slice(0, 23) : rows).map(({ row }) => row);
197
+ if (rows.length > 24) {
198
+ const otherPoints = Array<number>(buckets).fill(0);
199
+ const folded = rows.slice(23);
200
+ if (query.aggregation === "sum") {
201
+ for (const { row } of folded) {
202
+ for (let index = 0; index < buckets; index += 1) otherPoints[index] = (otherPoints[index] ?? 0) + (row.points[index] ?? 0);
203
+ }
204
+ } else {
205
+ for (let index = 0; index < buckets; index += 1) {
206
+ const values = folded.flatMap(({ state }) => [...(state.requests.get(index)?.values() ?? [])]);
207
+ if (values.length > 0) {
208
+ otherPoints[index] = query.aggregation === "max"
209
+ ? Math.max(...values)
210
+ : values.reduce((sum, value) => sum + value, 0) / values.length;
211
+ }
212
+ }
213
+ }
214
+ kept.push({ id: "other", provider: "", model: "other", total: otherPoints.reduce((sum, value) => sum + value, 0), points: otherPoints });
215
+ }
216
+ return {
217
+ appliedFilters: {
218
+ models: query.models === null ? null : [...new Set(query.models)].sort(),
219
+ hiddenProviders: [...hiddenProviders].sort(),
220
+ },
221
+ start,
222
+ end,
223
+ bucketSeconds,
224
+ buckets,
225
+ metric: query.metric,
226
+ aggregation: query.aggregation,
227
+ grouping: query.grouping,
228
+ series: kept,
229
+ availableModels: [...availableModels].sort(),
230
+ missingMeasurements,
231
+ truncated: false,
232
+ };
233
+ }
234
+
235
+ return { add, finish };
236
+ }
@@ -14,6 +14,8 @@
14
14
  import { formatErrorResponse } from "../bridge";
15
15
  import { redactSecretString } from "../lib/redact";
16
16
  import { sidecarEnter } from "../lib/sidecar-tracker";
17
+ import { admissionScopeDenial } from "../server/admission-model-scope";
18
+ import type { DataPlaneAdmission } from "../server/auth-cors";
17
19
  import type { OcxConfig, OcxProviderConfig, OcxWebSearchSidecarConfig } from "../types";
18
20
  import { runAnthropicWebSearch } from "./anthropic-executor";
19
21
  import { runExaWebSearch } from "./exa-executor";
@@ -252,6 +254,7 @@ export async function handleAlphaSearchSidecarFallback(
252
254
  config: OcxConfig,
253
255
  signal?: AbortSignal,
254
256
  logCtx?: { provider: string },
257
+ admission?: DataPlaneAdmission,
255
258
  ): Promise<Response> {
256
259
  const resolution = resolveAlphaSearchSidecar(config);
257
260
  if (resolution.status === "missing-credential") {
@@ -266,6 +269,24 @@ export async function handleAlphaSearchSidecarFallback(
266
269
  const resolved = resolution.sidecar;
267
270
  if (logCtx) logCtx.provider = resolved.backend;
268
271
 
272
+ // This backend is a paid destination like any other, and the operator's
273
+ // configuration -- not the caller -- decides which one and which model. A key
274
+ // scoped away from it must not spend it by asking the search endpoint instead
275
+ // of the inference one. Exa has no configured provider entry, so its own
276
+ // backend name is the destination.
277
+ const settings = sidecarSettingsForAlphaSearch(resolved.backend, config);
278
+ const requestedModel = (body as { model?: unknown } | null)?.model;
279
+ const denial = admissionScopeDenial(
280
+ config,
281
+ admission,
282
+ typeof requestedModel === "string" && requestedModel.trim() ? requestedModel : undefined,
283
+ {
284
+ providerName: resolved.backend === "exa" ? resolved.backend : resolved.providerName,
285
+ modelId: settings.model,
286
+ },
287
+ );
288
+ if (denial) return denial;
289
+
269
290
  const queries = extractAlphaSearchQueries(body);
270
291
  if (queries.length === 0) {
271
292
  return formatErrorResponse(
@@ -275,7 +296,6 @@ export async function handleAlphaSearchSidecarFallback(
275
296
  );
276
297
  }
277
298
 
278
- const settings = sidecarSettingsForAlphaSearch(resolved.backend, config);
279
299
  const sidecarExit = sidecarEnter("search");
280
300
  try {
281
301
  const texts: string[] = [];