@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
@@ -1,13 +1,40 @@
1
1
  import type { ResponsesTerminalStatus } from "../bridge";
2
2
  import type { AttemptRecoveryKind } from "../usage/log";
3
+ import {
4
+ REQUEST_FAILURE_CAUSES,
5
+ type RequestFailureCause,
6
+ causeForRecoveryKind,
7
+ } from "../lib/request-failure-model";
8
+ import {
9
+ REQUEST_OUTCOME_CLASSES,
10
+ classifyRequestOutcome,
11
+ type RequestOutcomeClass,
12
+ } from "../usage/request-outcome";
3
13
 
4
14
  export const REQUEST_METRICS_PROTOCOLS = Object.freeze(["responses", "chat", "messages", "unknown"] as const);
5
- export const REQUEST_METRICS_RESULTS = Object.freeze(["completed", "failed", "incomplete", "aborted"] as const);
15
+ /**
16
+ * The exporter's result label set IS the shared outcome vocabulary, not a copy of it. Restating
17
+ * these four strings here is what let the exporter and the dashboard drift into disagreeing about
18
+ * the same request.
19
+ */
20
+ export const REQUEST_METRICS_RESULTS = REQUEST_OUTCOME_CLASSES;
21
+ /**
22
+ * Closed recovery classes exported as Prometheus label values.
23
+ *
24
+ * Bounded by construction: the label can only ever take one of these strings, so no user, model,
25
+ * account or request identifier can reach a series name. `quota`, `policy` and `ciphertext` are
26
+ * separate members because an operator seeing a spike needs to know which one it is -- waiting
27
+ * out a rate limit, changing accounts, changing the prompt and dropping stale ciphertext are
28
+ * four different responses, and collapsing them is what made the existing counter unactionable.
29
+ */
6
30
  export const REQUEST_METRICS_RECOVERY_CLASSES = Object.freeze([
7
31
  "transient",
8
32
  "connection",
9
33
  "credential",
10
34
  "rate_limit",
35
+ "quota",
36
+ "policy",
37
+ "ciphertext",
11
38
  "payload",
12
39
  "empty_completion",
13
40
  "effort_downgrade",
@@ -17,8 +44,20 @@ export const REQUEST_METRICS_RECOVERY_CLASSES = Object.freeze([
17
44
  export const REQUEST_DURATION_BUCKETS_SECONDS = Object.freeze([0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60] as const);
18
45
  export const REQUEST_TTFT_BUCKETS_SECONDS = Object.freeze([0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30] as const);
19
46
 
47
+ /**
48
+ * The failure-cause label set IS the shared dictionary, for the same reason the result label set
49
+ * is the shared outcome vocabulary: a restated copy is what let two surfaces drift into
50
+ * disagreeing about the same request.
51
+ *
52
+ * It labels a COUNTER and never a histogram. Fifteen causes across four protocols is sixty
53
+ * series, fixed for the lifetime of the roster, and every value comes from a frozen list, so no
54
+ * user, model, account or request identifier can reach a series name. A histogram labelled by
55
+ * cause would multiply that by its bucket count for no question anyone asks.
56
+ */
57
+ export const REQUEST_METRICS_FAILURE_CAUSES = REQUEST_FAILURE_CAUSES;
58
+
20
59
  export type RequestMetricsProtocol = typeof REQUEST_METRICS_PROTOCOLS[number];
21
- export type RequestMetricsResult = typeof REQUEST_METRICS_RESULTS[number];
60
+ export type RequestMetricsResult = RequestOutcomeClass;
22
61
  export type RequestMetricsRecoveryClass = typeof REQUEST_METRICS_RECOVERY_CLASSES[number];
23
62
 
24
63
  export interface RequestMetricFinalFact {
@@ -33,6 +72,11 @@ export interface RequestMetricFinalFact {
33
72
  recoveryKinds: readonly AttemptRecoveryKind[];
34
73
  }>;
35
74
  spendSends?: number;
75
+ /**
76
+ * Why this request failed, as the recorder derived it. Absent when it did not fail, which is
77
+ * why the counter below cannot be reconstructed by subtracting completions from totals.
78
+ */
79
+ failureCause?: RequestFailureCause;
36
80
  }
37
81
 
38
82
  export interface RequestMetricsRecorder {
@@ -56,6 +100,7 @@ interface HistogramCell {
56
100
  const protocolCell = (value: RequestMetricsProtocol): number => REQUEST_METRICS_PROTOCOLS.indexOf(value);
57
101
  const resultCell = (value: RequestMetricsResult): number => REQUEST_METRICS_RESULTS.indexOf(value);
58
102
  const recoveryCell = (value: RequestMetricsRecoveryClass): number => REQUEST_METRICS_RECOVERY_CLASSES.indexOf(value);
103
+ const failureCauseCell = (value: RequestFailureCause): number => REQUEST_METRICS_FAILURE_CAUSES.indexOf(value);
59
104
 
60
105
  function matrix(rows: number, columns: number): number[][] {
61
106
  return Array.from({ length: rows }, () => Array.from({ length: columns }, () => 0));
@@ -71,35 +116,34 @@ function histograms(bounds: readonly number[]): HistogramCell[][] {
71
116
  ));
72
117
  }
73
118
 
74
- function classifyResult(fact: RequestMetricFinalFact): RequestMetricsResult {
75
- if (fact.closeReason === "client_cancel" || fact.status === 499) return "aborted";
76
- if (fact.terminalStatus === "failed") return "failed";
77
- if (fact.terminalStatus === "incomplete"
78
- || fact.closeReason === "body_stall"
79
- || fact.closeReason === "body_overflow") return "incomplete";
80
- if (fact.terminalStatus === "completed") return "completed";
81
- if (fact.terminalStatus === undefined
82
- && (fact.status === 101 || (fact.status >= 200 && fact.status < 400))) return "completed";
83
- return "failed";
84
- }
119
+ /**
120
+ * Metrics class for each shared failure cause.
121
+ *
122
+ * Keyed on the cause rather than on the recovery kind so this projection and the durable log
123
+ * speak one vocabulary. Total by construction: the previous switch ended in `default: "other"`,
124
+ * which meant a recovery kind added later compiled cleanly and then disappeared into an
125
+ * unactionable bucket. A missing member is now a typecheck failure.
126
+ */
127
+ const CAUSE_METRICS_CLASS = {
128
+ "transport-unsent": "connection",
129
+ "transport-ambiguous": "connection",
130
+ "upstream-declined": "transient",
131
+ "rate-limit": "rate_limit",
132
+ "quota-exhausted": "quota",
133
+ "credential-rejected": "credential",
134
+ "policy-refusal": "policy",
135
+ "parameter-rejected": "effort_downgrade",
136
+ "ciphertext-refusal": "ciphertext",
137
+ "payload-too-large": "payload",
138
+ "payload-rejected": "payload",
139
+ "upstream-fault": "transient",
140
+ "empty-output": "empty_completion",
141
+ "client-cancelled": "other",
142
+ "local-refusal": "other",
143
+ } as const satisfies Record<RequestFailureCause, RequestMetricsRecoveryClass>;
85
144
 
86
145
  function recoveryClass(kind: AttemptRecoveryKind): RequestMetricsRecoveryClass {
87
- switch (kind) {
88
- case "transient-5xx": return "transient";
89
- case "connection-reset": return "connection";
90
- case "oauth-401":
91
- case "key-401": return "credential";
92
- case "key-429":
93
- case "rate-limit-429":
94
- case "anthropic-oauth-429":
95
- case "oauth-account-429": return "rate_limit";
96
- case "image-413":
97
- case "console-go-upload-retry":
98
- case "opaque-blob-rejection": return "payload";
99
- case "empty-completion": return "empty_completion";
100
- case "reasoning-effort-downgrade": return "effort_downgrade";
101
- default: return "other";
102
- }
146
+ return CAUSE_METRICS_CLASS[causeForRecoveryKind(kind)];
103
147
  }
104
148
 
105
149
  function observeHistogram(cell: HistogramCell, bounds: readonly number[], value: number): void {
@@ -145,6 +189,7 @@ export function createRequestMetricsOwner(
145
189
  let logicalRequests = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RESULTS.length);
146
190
  let physicalSends = Array.from({ length: REQUEST_METRICS_PROTOCOLS.length }, () => 0);
147
191
  let recoveries = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RECOVERY_CLASSES.length);
192
+ let failureCauses = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_FAILURE_CAUSES.length);
148
193
  let durations = histograms(REQUEST_DURATION_BUCKETS_SECONDS);
149
194
  let ttft = histograms(REQUEST_TTFT_BUCKETS_SECONDS);
150
195
  let missingTtft = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RESULTS.length);
@@ -152,7 +197,7 @@ export function createRequestMetricsOwner(
152
197
  return {
153
198
  recordFinalRequest(fact): void {
154
199
  const protocol: RequestMetricsProtocol = fact.protocol ?? "unknown";
155
- const result = classifyResult(fact);
200
+ const result = classifyRequestOutcome(fact);
156
201
  const protocolIndex = protocolCell(protocol);
157
202
  const resultIndex = resultCell(result);
158
203
  logicalRequests[protocolIndex]![resultIndex]! += 1;
@@ -165,6 +210,13 @@ export function createRequestMetricsOwner(
165
210
  ), 0);
166
211
  physicalSends[protocolIndex]! += sends;
167
212
 
213
+ // Counted from the cause the recorder derived, not re-derived here. Two derivations of one
214
+ // answer is the disagreement this batch exists to remove, and the recorder is the only
215
+ // place that sees the transport facts a cause needs.
216
+ if (fact.failureCause !== undefined) {
217
+ failureCauses[protocolIndex]![failureCauseCell(fact.failureCause)]! += 1;
218
+ }
219
+
168
220
  for (const attempt of attempts ?? []) {
169
221
  for (const kind of new Set(attempt.recoveryKinds)) {
170
222
  recoveries[protocolIndex]![recoveryCell(recoveryClass(kind))]! += 1;
@@ -205,6 +257,15 @@ export function createRequestMetricsOwner(
205
257
  lines.push(`opencodex_recoveries_total{protocol="${protocol}",recovery="${recovery}"} ${recoveries[protocolCell(protocol)]![recoveryCell(recovery)]}`);
206
258
  }
207
259
  }
260
+ lines.push(
261
+ "# HELP opencodex_request_failures_total Finalized logical requests that did not deliver an answer, by derived cause.",
262
+ "# TYPE opencodex_request_failures_total counter",
263
+ );
264
+ for (const protocol of REQUEST_METRICS_PROTOCOLS) {
265
+ for (const cause of REQUEST_METRICS_FAILURE_CAUSES) {
266
+ lines.push(`opencodex_request_failures_total{protocol="${protocol}",cause="${cause}"} ${failureCauses[protocolCell(protocol)]![failureCauseCell(cause)]}`);
267
+ }
268
+ }
208
269
  appendHistogram(lines, "opencodex_request_duration_seconds", "Finalized logical request duration in seconds.", durations, REQUEST_DURATION_BUCKETS_SECONDS);
209
270
  appendHistogram(lines, "opencodex_ttft_seconds", "Observed time to first output in seconds.", ttft, REQUEST_TTFT_BUCKETS_SECONDS);
210
271
  lines.push(
@@ -228,6 +289,7 @@ export function createRequestMetricsOwner(
228
289
  logicalRequests = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RESULTS.length);
229
290
  physicalSends = Array.from({ length: REQUEST_METRICS_PROTOCOLS.length }, () => 0);
230
291
  recoveries = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RECOVERY_CLASSES.length);
292
+ failureCauses = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_FAILURE_CAUSES.length);
231
293
  durations = histograms(REQUEST_DURATION_BUCKETS_SECONDS);
232
294
  ttft = histograms(REQUEST_TTFT_BUCKETS_SECONDS);
233
295
  missingTtft = matrix(REQUEST_METRICS_PROTOCOLS.length, REQUEST_METRICS_RESULTS.length);
@@ -4,7 +4,8 @@ import {
4
4
  UPSTREAM_CLOSED_BEFORE_RESPONSE_CODE,
5
5
  UPSTREAM_NO_RESPONSE_CODE,
6
6
  } from "../../lib/upstream-retry";
7
- import { readFileSync } from "node:fs";
7
+ import type { RequestFailureCause, RequestFailureStage } from "../../lib/request-failure-model";
8
+ import { packageVersion } from "../../lib/package-version";
8
9
  // If the 101 never arrives (network black hole), give SSE a chance well before
9
10
  // the caller's connect timeout (default 200s) would fire.
10
11
  export const UPGRADE_DEADLINE_MS = 10_000;
@@ -72,13 +73,7 @@ export function markCodexWsResponse(response: Response, observed: boolean): void
72
73
  * importing management-api from the transport layer would invert the
73
74
  * layering and pull the management surface into every WS exchange.
74
75
  */
75
- const OCX_VERSION = (() => {
76
- try {
77
- return JSON.parse(readFileSync(new URL("../../../package.json", import.meta.url), "utf8")).version as string;
78
- } catch {
79
- return "0.0.0";
80
- }
81
- })();
76
+ const OCX_VERSION = packageVersion("0.0.0");
82
77
 
83
78
  /**
84
79
  * The durable form of the stage counters, carried out of the exchange on the
@@ -210,6 +205,37 @@ export function classifyCodexWsFailure(stage: CodexWsFailureStage): CodexWsFailu
210
205
  return "no-response-event";
211
206
  }
212
207
 
208
+ /**
209
+ * The same four outcomes said in the shared stage-and-cause vocabulary (#4191).
210
+ *
211
+ * A projection, not a second classifier: {@link classifyCodexWsFailure} stays the one place that
212
+ * reads the counters, and this only restates its answer in the words the durable log, the metrics
213
+ * projection and the HTTP path already use. Without it the WebSocket transport is the one surface
214
+ * whose failures cannot be compared with anything else, which is the reported symptom -- every
215
+ * such failure reached the user as one of two bare sentences.
216
+ *
217
+ * It does not relax the transport's own rule. The no-replay-after-send contract in
218
+ * `codex-ws-exchange.ts` holds regardless of what this returns, and the stage below is
219
+ * deliberately not consulted as a fallback-eligibility signal; it reports where the exchange got
220
+ * to, and `resendPermission` happens to agree that everything past `before-send` is refused.
221
+ */
222
+ export const CODEX_WS_FAILURE_PROJECTION = {
223
+ /** The create frame never left, so the origin provably never saw this turn. */
224
+ "before-send": { stage: "pre-header", cause: "transport-unsent" },
225
+ /** The frame left and the socket said nothing at all. The turn may be running upstream. */
226
+ "no-upstream-frame": { stage: "pre-header", cause: "transport-ambiguous" },
227
+ /** Control frames only: the peer is alive and answered, but no Responses event arrived. */
228
+ "no-response-event": { stage: "protocol-prelude", cause: "transport-ambiguous" },
229
+ /** Events already reached the caller, so a resend would duplicate output they have seen. */
230
+ "after-response-started": { stage: "semantic-output", cause: "transport-ambiguous" },
231
+ } as const satisfies Record<CodexWsFailureCause, { stage: RequestFailureStage; cause: RequestFailureCause }>;
232
+
233
+ export function projectCodexWsFailure(
234
+ stage: CodexWsFailureStage,
235
+ ): { stage: RequestFailureStage; cause: RequestFailureCause } {
236
+ return CODEX_WS_FAILURE_PROJECTION[classifyCodexWsFailure(stage)];
237
+ }
238
+
213
239
  /**
214
240
  * Render the stage as a suffix appended to an existing failure message.
215
241
  *
@@ -1,6 +1,7 @@
1
1
  import type { ResponsesTerminalStatus } from "../../bridge";
2
2
  import { comboFailureDecision } from "../../combos";
3
3
  import { httpStatusFromTerminalError } from "../../lib/errors";
4
+ import type { RequestFailureStage } from "../../lib/request-failure-model";
4
5
  import type { RequestLogContext } from "../request-log";
5
6
  import { createSseInspector } from "../relay";
6
7
  import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
@@ -108,9 +109,48 @@ export function comboStreamPayloadCommitsOutput(payload: unknown): boolean {
108
109
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return true;
109
110
  const type = (payload as { type?: unknown }).type;
110
111
  if (typeof type !== "string") return true;
112
+ if (type === "response.created") {
113
+ // A created event is a control frame only while its snapshot is empty. An origin that
114
+ // resumes a turn can put completed items in it, and treating that as a prelude would let
115
+ // a replacement re-emit output the caller already received.
116
+ const response = (payload as { response?: unknown }).response;
117
+ if (response && typeof response === "object" && !Array.isArray(response)) {
118
+ const output = (response as { output?: unknown }).output;
119
+ if (Array.isArray(output) && output.length > 0) return true;
120
+ }
121
+ }
111
122
  return !PRE_OUTPUT_CONTROL_EVENTS.has(type) && !TERMINAL_EVENTS.has(type);
112
123
  }
113
124
 
125
+ /**
126
+ * How far this SSE body got, in the vocabulary of src/lib/request-failure-model.ts.
127
+ *
128
+ * The preflight cannot separate `semantic-output` from `side-effect`: it classifies any event
129
+ * that is not a lifecycle control frame as committing, without reading item types. Both stages
130
+ * refuse a resend, so the distinction would change no decision -- it is named here so a later
131
+ * reader does not mistake the collapse for an omission.
132
+ *
133
+ * A terminal that settled carrying no output is `protocol-prelude`, not `terminal`. That is
134
+ * the failure model's own rule: `terminal` means the answer was delivered, and an empty
135
+ * completion delivered none.
136
+ *
137
+ * Only the two nothing-observed stages actually reach a read error today: the loop below hands
138
+ * the body back as `accepted` the moment output commits or a terminal arrives, so a stream
139
+ * that committed anything never reports a stage at all. The committed branches stay because
140
+ * this has to be total for any other caller, and because a later change to that loop must not
141
+ * be able to promote a committed stream into a replaceable one by omission.
142
+ */
143
+ function observedResponsesStage(state: {
144
+ readonly outputCommitted: boolean;
145
+ readonly terminalStatus: ResponsesTerminalStatus | undefined;
146
+ readonly responseCreated: boolean;
147
+ }): RequestFailureStage {
148
+ if (state.outputCommitted) return "semantic-output";
149
+ if (state.terminalStatus === "completed") return "terminal";
150
+ if (state.responseCreated || state.terminalStatus !== undefined) return "protocol-prelude";
151
+ return "headers-only";
152
+ }
153
+
114
154
  function replayBufferedResponse(
115
155
  response: Response,
116
156
  reader: ReadableStreamDefaultReader<Uint8Array>,
@@ -186,13 +226,22 @@ function failedTerminalResponse(
186
226
 
187
227
  export type ComboStreamPreflightResult =
188
228
  | { kind: "accepted"; response: Response }
189
- | { kind: "failed"; response: Response };
229
+ | { kind: "failed"; response: Response }
230
+ /**
231
+ * The body errored mid-stream and `replayReadErrors` asked for the prefix back rather than
232
+ * a rethrow. `stage` is how far the inspection actually got; whether that permits a
233
+ * replacement is the resend gate's decision, not this function's. Callers that only act on
234
+ * a projected terminal can treat this exactly as `accepted`, which is what it was before
235
+ * the stage became observable.
236
+ */
237
+ | { kind: "read-error"; response: Response; error: unknown; stage: RequestFailureStage };
190
238
 
191
239
  /**
192
- * Buffer a combo child's downstream SSE only until the request becomes unsafe to
193
- * replay or reaches a terminal. This owns exactly one body reader. The aggregate
194
- * buffer is capped by bytes and retained chunks; hitting either cap commits the
195
- * current target instead of growing memory or guessing that replay is safe.
240
+ * Buffer a Responses SSE only until the request becomes unsafe to replay or reaches a
241
+ * terminal. Combo failover and native post-header reset recovery share this protocol
242
+ * boundary, because they are asking the same question about the same bytes. This owns exactly
243
+ * one body reader. The aggregate buffer is capped by bytes and retained chunks; hitting either
244
+ * cap commits the current target instead of growing memory or guessing that replay is safe.
196
245
  */
197
246
  export async function preflightComboStreamResponse(
198
247
  response: Response,
@@ -211,12 +260,18 @@ export async function preflightComboStreamResponse(
211
260
  const buffered: Uint8Array[] = [];
212
261
  let bufferedBytes = 0;
213
262
  let outputCommitted = false;
263
+ let responseCreated = false;
214
264
  let terminalStatus: ResponsesTerminalStatus | undefined;
215
265
  let retryableTerminalPayload: Record<string, unknown> | undefined;
216
266
  const inspector = createSseInspector({
217
267
  logCtx,
268
+ // A payload the inspector could not parse still reached this proxy, and it may be output.
269
+ // Committing on it is what keeps an unreadable frame from reading as an empty prelude.
270
+ onOpaquePayload: () => { outputCommitted = true; },
218
271
  onParsedPayload: payload => {
219
272
  if (terminalStatus !== undefined || outputCommitted || retryableTerminalPayload) return;
273
+ if (payload !== null && typeof payload === "object" && !Array.isArray(payload)
274
+ && (payload as { type?: unknown }).type === "response.created") responseCreated = true;
220
275
  const retryable = retryableTerminal(payload);
221
276
  const matchedBareError = retryable && payload !== null && typeof payload === "object"
222
277
  && !Array.isArray(payload) && (payload as { type?: unknown }).type === "error";
@@ -239,7 +294,9 @@ export async function preflightComboStreamResponse(
239
294
  // The native relay still owns post-header transport failures. Preserve
240
295
  // the bounded prefix and the errored reader; cancelling it here would
241
296
  // erase the failure before either client relay or inspection sees it.
242
- return { kind: "accepted", response: replayBufferedResponse(response, reader, buffered) };
297
+ const replay = replayBufferedResponse(response, reader, buffered);
298
+ const stage = observedResponsesStage({ outputCommitted, terminalStatus, responseCreated });
299
+ return { kind: "read-error", response: replay, error, stage };
243
300
  }
244
301
  if (next.done) {
245
302
  inspector.finish();
@@ -277,3 +334,108 @@ export async function preflightComboStreamResponse(
277
334
  inspector.dispose();
278
335
  }
279
336
  }
337
+
338
+ /** Produce a replacement body for a mid-stream failure at `stage`, or null to keep the error. */
339
+ export type ProtocolSafeResetRecovery = (
340
+ error: unknown,
341
+ stage: RequestFailureStage,
342
+ ) => Promise<Response | null>;
343
+
344
+ /**
345
+ * Defer protocol inspection until the downstream actually pulls the body.
346
+ *
347
+ * Direct passthrough must return response headers before the first SSE event arrives, so the
348
+ * inspection cannot be awaited at the dispatch site the way combo routing awaits it. Wrapping
349
+ * the body moves it to the first pull, which is the earliest moment the client is willing to
350
+ * wait anyway.
351
+ */
352
+ export function deferProtocolSafeResetRecovery(
353
+ response: Response,
354
+ logCtx: RequestLogContext,
355
+ recover: ProtocolSafeResetRecovery,
356
+ options?: { allowMissingContentType?: boolean },
357
+ ): Response {
358
+ if (!response.body) return response;
359
+
360
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
361
+ let initialization: Promise<void> | undefined;
362
+ let closed = false;
363
+
364
+ const cancelBody = (body: ReadableStream<Uint8Array> | null, reason?: unknown): void => {
365
+ try { void body?.cancel(reason).catch(() => {}); } catch { /* already locked or closed */ }
366
+ };
367
+ const initialize = async (): Promise<void> => {
368
+ const preflight = await preflightComboStreamResponse(
369
+ response,
370
+ logCtx,
371
+ () => false,
372
+ { allowMissingContentType: options?.allowMissingContentType === true, replayReadErrors: true },
373
+ );
374
+ let selected = preflight.response;
375
+ if (preflight.kind === "read-error") {
376
+ const replacement = await recover(preflight.error, preflight.stage);
377
+ if (replacement) {
378
+ cancelBody(selected.body, "using protocol-safe replacement stream");
379
+ selected = replacement;
380
+ }
381
+ }
382
+ if (closed) {
383
+ cancelBody(selected.body, "downstream cancelled before protocol preflight completed");
384
+ return;
385
+ }
386
+ reader = selected.body?.getReader();
387
+ };
388
+
389
+ const body = new ReadableStream<Uint8Array>({
390
+ async pull(controller) {
391
+ try {
392
+ initialization ??= initialize();
393
+ await initialization;
394
+ if (closed) return;
395
+ if (!reader) {
396
+ closed = true;
397
+ controller.close();
398
+ return;
399
+ }
400
+ const next = await reader.read();
401
+ if (closed) return;
402
+ if (next.done) {
403
+ closed = true;
404
+ try { reader.releaseLock(); } catch { /* already released */ }
405
+ reader = undefined;
406
+ controller.close();
407
+ return;
408
+ }
409
+ controller.enqueue(next.value);
410
+ } catch (error) {
411
+ if (closed) return;
412
+ closed = true;
413
+ try { reader?.releaseLock(); } catch { /* errored reader */ }
414
+ reader = undefined;
415
+ controller.error(error);
416
+ }
417
+ },
418
+ cancel(reason) {
419
+ if (closed) return;
420
+ closed = true;
421
+ if (reader) {
422
+ try { void reader.cancel(reason).catch(() => {}); } catch { /* already closed */ }
423
+ try { reader.releaseLock(); } catch { /* already released */ }
424
+ reader = undefined;
425
+ } else if (initialization === undefined) {
426
+ // Nothing has read the upstream yet, so this body is still ours to cancel.
427
+ cancelBody(response.body, reason);
428
+ }
429
+ // A cancel while the preflight is mid-flight falls through deliberately. That body is
430
+ // locked by the preflight's own reader, so cancelling it here would reject and be
431
+ // swallowed; `initialize` sees `closed` when it settles and releases whichever body it
432
+ // ended up selecting, which is the one that actually has to be let go.
433
+ },
434
+ }, { highWaterMark: 0 });
435
+
436
+ return new Response(body, {
437
+ status: response.status,
438
+ statusText: response.statusText,
439
+ headers: response.headers,
440
+ });
441
+ }
@@ -1,4 +1,10 @@
1
1
  import { capturePoolQuotaWriter } from "../../codex/account-store";
2
+ import {
3
+ admissionModelDeniedResponse,
4
+ AdmissionModelDeniedError,
5
+ assertRouteAllowedByScope,
6
+ resolveAdmissionModelScope,
7
+ } from "../admission-model-scope";
2
8
  import type { Server } from "bun";
3
9
  import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge";
4
10
  import {
@@ -655,7 +661,12 @@ export async function handleResponsesCompact(
655
661
  // routes ordinary turns elsewhere (#2901); the compaction-scoped router
656
662
  // may land that on the configured default provider instead of 404.
657
663
  route = routeCompactionModel(config, compactModel, evidenceFromBody(raw));
664
+ // A compaction override picks the model, not the caller, so the key's scope
665
+ // is applied to what the override resolved to rather than to the selector
666
+ // the client sent.
667
+ assertRouteAllowedByScope(resolveAdmissionModelScope(config, admission), compactRequestedModel, route);
658
668
  } catch (err) {
669
+ if (err instanceof AdmissionModelDeniedError) return admissionModelDeniedResponse(err);
659
670
  if (err instanceof NoEligiblePolicyCandidateError) {
660
671
  // Persist the evaluation trace (per-candidate exclusions + the
661
672
  // no-eligible reason) so a failed compact policy request stays
@@ -116,6 +116,88 @@ export function isReasoningBlobCallerMismatchMessage(message: string): boolean {
116
116
  }
117
117
 
118
118
 
119
+ /**
120
+ * Longest embedded payload this will parse. The wrapper is a short error envelope; anything
121
+ * larger is not the shape being matched, and refusing to walk it keeps an upstream-controlled
122
+ * string from deciding how much work the classifier does.
123
+ */
124
+ const LITELLM_EMBEDDED_PAYLOAD_LIMIT = 16_384;
125
+ const LITELLM_WRAPPER_PREFIX = "litellm.BadRequestError:";
126
+ const LITELLM_WRAPPER_MARKER = "OpenAIException - ";
127
+
128
+ /**
129
+ * The JSON an OpenAI-compatible gateway embeds in its own error message, or undefined.
130
+ *
131
+ * Brace-aware rather than a regex because the embedded object legitimately contains braces and
132
+ * escaped quotes inside its message, and the gateway appends its own prose after the closing
133
+ * brace. Counting depth outside string literals is the only way to find the real end.
134
+ */
135
+ function liteLlmEmbeddedErrorPayload(message: string): unknown {
136
+ if (!message.startsWith(LITELLM_WRAPPER_PREFIX)) return undefined;
137
+ const markerIndex = message.indexOf(LITELLM_WRAPPER_MARKER);
138
+ if (markerIndex < 0) return undefined;
139
+ const start = message.indexOf("{", markerIndex + LITELLM_WRAPPER_MARKER.length);
140
+ if (start < 0) return undefined;
141
+ const end = Math.min(message.length, start + LITELLM_EMBEDDED_PAYLOAD_LIMIT);
142
+
143
+ let depth = 0;
144
+ let inString = false;
145
+ let escaped = false;
146
+ for (let index = start; index < end; index += 1) {
147
+ const character = message[index]!;
148
+ if (inString) {
149
+ if (escaped) escaped = false;
150
+ else if (character === "\\") escaped = true;
151
+ else if (character === '"') inString = false;
152
+ continue;
153
+ }
154
+ if (character === '"') { inString = true; continue; }
155
+ if (character === "{") depth += 1;
156
+ else if (character === "}") {
157
+ depth -= 1;
158
+ if (depth === 0) {
159
+ try { return JSON.parse(message.slice(start, index + 1)) as unknown; } catch { return undefined; }
160
+ }
161
+ }
162
+ }
163
+ return undefined;
164
+ }
165
+
166
+
167
+ /** True for an error message that is a gateway envelope rather than an upstream's own wording. */
168
+ function isLiteLlmEnvelopeMessage(message: string): boolean {
169
+ return message.startsWith(LITELLM_WRAPPER_PREFIX) && message.includes(LITELLM_WRAPPER_MARKER);
170
+ }
171
+
172
+
173
+ /**
174
+ * An OpenAI-compatible gateway relaying the one authoritative ciphertext rejection inside its
175
+ * own error string.
176
+ *
177
+ * Deliberately narrower than {@link isSelfIdentifiedOpaqueBlobRejection}. The embedded payload is
178
+ * matched against exactly one identity -- `invalid_request_error` carrying
179
+ * `invalid_encrypted_content` -- and the generic classifier is NOT re-run against it. Re-running
180
+ * it would let every other opaque identity arrive through the wrapper as well: the code-less
181
+ * unverifiable-ciphertext wording, the #4469 caller mismatch, and the two xAI decoder strings.
182
+ * Each of those was admitted on evidence from a specific upstream about how that upstream words
183
+ * its own rejection, and a gateway in between is not that evidence. Only the coded identity is
184
+ * unambiguous enough to survive relaying.
185
+ */
186
+ export function isLiteLlmWrappedCiphertextRejection(payload: unknown): boolean {
187
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false;
188
+ const outer = (payload as { error?: unknown }).error;
189
+ if (!outer || typeof outer !== "object" || Array.isArray(outer)) return false;
190
+ const message = (outer as { message?: unknown }).message;
191
+ if (typeof message !== "string") return false;
192
+ const embedded = liteLlmEmbeddedErrorPayload(message);
193
+ if (!embedded || typeof embedded !== "object" || Array.isArray(embedded)) return false;
194
+ const inner = (embedded as { error?: unknown }).error;
195
+ if (!inner || typeof inner !== "object" || Array.isArray(inner)) return false;
196
+ const { type, code } = inner as { type?: unknown; code?: unknown };
197
+ return type === "invalid_request_error" && code === "invalid_encrypted_content";
198
+ }
199
+
200
+
119
201
  export function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean {
120
202
  if (isEncryptedFunctionOutputRejection(bodyText)) return true;
121
203
  try {
@@ -132,6 +214,14 @@ export function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean {
132
214
 
133
215
  if (record.error && typeof record.error === "object" && !Array.isArray(record.error)) {
134
216
  const error = record.error as { type?: unknown; code?: unknown; message?: unknown };
217
+ // A gateway envelope is decided ONLY by its embedded payload, before any wording check
218
+ // below runs. Those checks match anchored phrases anywhere in the message, and a gateway
219
+ // quotes the upstream's message inside its own -- so without this the relayed text would
220
+ // satisfy the caller-mismatch identity and gain a resend the strict wrapper check exists to
221
+ // withhold. Returning here rather than falling through is the point.
222
+ if (typeof error.message === "string" && isLiteLlmEnvelopeMessage(error.message)) {
223
+ return isLiteLlmWrappedCiphertextRejection(payload);
224
+ }
135
225
  if (error.type === "invalid_request_error") {
136
226
  if (error.code === "invalid_encrypted_content") return true;
137
227
  if (