@bitkyc08/opencodex 2.88.0 → 2.90.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 (291) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-DgxkE6Md.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-DbTNc_88.js} +1 -1
  6. package/gui/dist/assets/{index-B5bsvxor.js → index-TmaHa54u.js} +15 -15
  7. package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +2 -2
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic.ts +10 -3
  13. package/src/adapters/base.ts +4 -0
  14. package/src/adapters/claude-cli/adapter.ts +13 -7
  15. package/src/adapters/claude-cli/usage-admission.ts +14 -2
  16. package/src/adapters/coding-agent/protocol.ts +6 -0
  17. package/src/adapters/coding-agent/turn.ts +14 -4
  18. package/src/adapters/cursor/protobuf-request.ts +3 -0
  19. package/src/adapters/google-http.ts +3 -2
  20. package/src/adapters/google-progress-history.ts +19 -1
  21. package/src/adapters/google-vertex-endpoint.ts +67 -0
  22. package/src/adapters/google.ts +62 -37
  23. package/src/adapters/image.ts +5 -3
  24. package/src/adapters/input-media-guard.ts +5 -2
  25. package/src/adapters/kiro-refusal.ts +41 -1
  26. package/src/adapters/ollama-native.ts +1 -0
  27. package/src/adapters/openai-chat/messages.ts +34 -4
  28. package/src/adapters/openai-chat/passthrough.ts +37 -4
  29. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  30. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  31. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  32. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  33. package/src/adapters/openai-chat.ts +7 -6
  34. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  35. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  36. package/src/adapters/openai-responses/passthrough.ts +22 -13
  37. package/src/adapters/openai-responses/reasoning.ts +68 -1
  38. package/src/adapters/openai-responses/transport.ts +13 -1
  39. package/src/adapters/registry.ts +1 -1
  40. package/src/adapters/upstream-http-error.ts +3 -2
  41. package/src/adapters/zed.ts +7 -4
  42. package/src/bridge/internal.ts +11 -1
  43. package/src/bridge/response-json.ts +2 -2
  44. package/src/bridge/sse.ts +10 -4
  45. package/src/claude/desktop-3p-library.ts +43 -3
  46. package/src/claude/desktop-3p.ts +20 -10
  47. package/src/claude/desktop-picker-profile.ts +18 -9
  48. package/src/claude/desktop-remote-store-io.ts +2 -2
  49. package/src/claude/inbound.ts +12 -26
  50. package/src/claude/outbound.ts +5 -0
  51. package/src/cli/account-api.ts +13 -1
  52. package/src/cli/account-extended.ts +17 -5
  53. package/src/cli/account-key-quota.ts +4 -1
  54. package/src/cli/account.ts +1 -1
  55. package/src/cli/capabilities-agents-routing.ts +45 -8
  56. package/src/cli/capabilities-base.ts +5 -5
  57. package/src/cli/capabilities-provider-models.ts +5 -4
  58. package/src/cli/combo-result.ts +8 -2
  59. package/src/cli/combo.ts +17 -0
  60. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  61. package/src/cli/help.ts +1 -1
  62. package/src/cli/message-command.ts +4 -1
  63. package/src/cli/message-remote-args.ts +69 -0
  64. package/src/cli/message-remote-command.ts +77 -0
  65. package/src/cli/provider-runtime.ts +69 -1
  66. package/src/cli/registry.ts +3 -3
  67. package/src/codex/account-lifecycle.ts +19 -6
  68. package/src/codex/account-pause.ts +6 -1
  69. package/src/codex/account-runtime-state.ts +5 -0
  70. package/src/codex/account-store.ts +55 -9
  71. package/src/codex/account-usability.ts +4 -1
  72. package/src/codex/auth-api/account-list.ts +13 -5
  73. package/src/codex/auth-api/account-pause-group.ts +2 -22
  74. package/src/codex/auth-api/login-collision.ts +59 -0
  75. package/src/codex/auth-api/login-flow.ts +12 -7
  76. package/src/codex/auth-api/main-account-probe.ts +3 -2
  77. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  78. package/src/codex/auth-collision.ts +31 -4
  79. package/src/codex/auth-context.ts +57 -10
  80. package/src/codex/catalog/aggregation.ts +15 -0
  81. package/src/codex/catalog/auto-review.ts +2 -1
  82. package/src/codex/catalog/build-entries.ts +17 -7
  83. package/src/codex/catalog/derive-entry.ts +3 -1
  84. package/src/codex/catalog/metadata.ts +2 -0
  85. package/src/codex/catalog/model-visibility.ts +0 -2
  86. package/src/codex/catalog/ownership.ts +71 -0
  87. package/src/codex/catalog/parsing.ts +12 -3
  88. package/src/codex/catalog/retained-sync.ts +2 -1
  89. package/src/codex/catalog/routed-removal.ts +4 -4
  90. package/src/codex/cli-update-plan.ts +18 -3
  91. package/src/codex/collision-identity.ts +78 -0
  92. package/src/codex/convergence.ts +2 -1
  93. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  94. package/src/codex/low-quota-protection.ts +57 -31
  95. package/src/codex/main-account-cache.ts +77 -3
  96. package/src/codex/main-account-hard-lock.ts +14 -0
  97. package/src/codex/main-account.ts +6 -2
  98. package/src/codex/native-residue.ts +2 -3
  99. package/src/codex/quota.ts +26 -2
  100. package/src/codex/routing/cooldown-math.ts +5 -0
  101. package/src/codex/routing/failure-window.ts +61 -9
  102. package/src/codex/routing.ts +4 -3
  103. package/src/codex/shim-diagnostics.ts +2 -1
  104. package/src/codex/shim-fingerprint.ts +11 -2
  105. package/src/codex/shim-templates.ts +16 -5
  106. package/src/codex/shim-windows-refresh.ts +224 -0
  107. package/src/codex/shim.ts +45 -21
  108. package/src/combos/failover.ts +10 -2
  109. package/src/combos/jev-model-backend.ts +30 -7
  110. package/src/combos/jev-model-request.ts +20 -0
  111. package/src/combos/jev-quota-activation.ts +153 -0
  112. package/src/combos/jev-quota-config.ts +62 -0
  113. package/src/combos/jev-quota-route.ts +26 -0
  114. package/src/combos/jev-quota.ts +88 -0
  115. package/src/combos/jev.ts +25 -12
  116. package/src/combos/types.ts +27 -0
  117. package/src/compatibility/index.ts +3 -0
  118. package/src/compatibility/openai-responses.ts +8 -1
  119. package/src/compatibility/opengateway-responses.ts +61 -0
  120. package/src/config/account-auto-pause-revisions.ts +136 -0
  121. package/src/config/diagnostics.ts +13 -0
  122. package/src/config/live-reconcile.ts +9 -0
  123. package/src/config/persist-unlocked.ts +10 -1
  124. package/src/config/provider-validation.ts +7 -2
  125. package/src/config/removed-opencode-free.ts +135 -0
  126. package/src/config/schema/account-auto-pause.ts +14 -0
  127. package/src/config/schema/config-schema.ts +15 -0
  128. package/src/config/schema/leaf-validators.ts +7 -0
  129. package/src/config.ts +10 -3
  130. package/src/generated/compatibility-version.json +476 -216
  131. package/src/generated/model-metadata.ts +1 -1
  132. package/src/images/loop.ts +34 -11
  133. package/src/lib/account-auto-pause-response.ts +16 -0
  134. package/src/lib/bun-runtime.ts +18 -1
  135. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  136. package/src/lib/bun-transpiler-cache-scope.mjs +82 -0
  137. package/src/lib/decision-quota-sink.ts +76 -0
  138. package/src/lib/provider-admission-wait.ts +14 -0
  139. package/src/lib/service-secrets.ts +37 -2
  140. package/src/lib/state-store-registrations.ts +25 -0
  141. package/src/lib/translator-budget.ts +1 -1
  142. package/src/lib/upstream-retry.ts +15 -0
  143. package/src/link/ssh-argv.ts +3 -0
  144. package/src/messaging/envelope.ts +6 -3
  145. package/src/messaging/remote-auth.ts +83 -0
  146. package/src/messaging/remote-bridge.ts +270 -0
  147. package/src/messaging/remote-contract.ts +108 -0
  148. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  149. package/src/messaging/remote-enrollment.ts +244 -0
  150. package/src/messaging/remote-envelope.ts +40 -0
  151. package/src/messaging/remote-files.ts +117 -0
  152. package/src/messaging/remote-owner.ts +169 -0
  153. package/src/messaging/remote-ports.ts +56 -0
  154. package/src/messaging/remote-process.ts +131 -0
  155. package/src/messaging/remote-replies.ts +98 -0
  156. package/src/messaging/remote-rpc-admission.ts +32 -0
  157. package/src/messaging/remote-send.ts +72 -0
  158. package/src/messaging/remote-store.ts +111 -0
  159. package/src/messaging/remote-tunnels.ts +65 -0
  160. package/src/messaging/rpc.ts +13 -1
  161. package/src/oauth/account-auto-pause-policy.ts +66 -0
  162. package/src/oauth/account-auto-pause.ts +171 -0
  163. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  164. package/src/oauth/account-import/parser.ts +15 -2
  165. package/src/oauth/account-import/types.ts +7 -0
  166. package/src/oauth/account-quota-rank.ts +2 -1
  167. package/src/oauth/anthropic-account-refusal.ts +6 -0
  168. package/src/oauth/anthropic-model-family.ts +8 -0
  169. package/src/oauth/anthropic-model-quota.ts +2 -6
  170. package/src/oauth/api-key-pool-preserve.ts +37 -0
  171. package/src/oauth/chatgpt-shared.ts +10 -1
  172. package/src/oauth/dsh.ts +45 -2
  173. package/src/oauth/index.ts +5 -35
  174. package/src/oauth/kiro.ts +44 -1
  175. package/src/oauth/login-flow-state.ts +11 -2
  176. package/src/oauth/store.ts +35 -8
  177. package/src/oauth/types.ts +2 -0
  178. package/src/plugins/upstream-hooks.ts +14 -1
  179. package/src/providers/api-key-pause.ts +32 -0
  180. package/src/providers/api-key-resolve.ts +10 -4
  181. package/src/providers/api-key-selection.ts +6 -1
  182. package/src/providers/api-keys.ts +8 -4
  183. package/src/providers/claude-cli-usage.ts +25 -5
  184. package/src/providers/initial-model-selection.ts +2 -2
  185. package/src/providers/key-failover.ts +21 -13
  186. package/src/providers/key-store.ts +8 -3
  187. package/src/providers/mistral-transport.ts +207 -0
  188. package/src/providers/model-rename-fields.ts +1 -0
  189. package/src/providers/openai-sidecar.ts +5 -0
  190. package/src/providers/opencode-zen-rate-limit.ts +6 -72
  191. package/src/providers/quota/account-cache.ts +15 -1
  192. package/src/providers/quota/anthropic-account-quota.ts +20 -7
  193. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  194. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  195. package/src/providers/quota/antigravity.ts +45 -8
  196. package/src/providers/quota/failure-codes.ts +18 -0
  197. package/src/providers/quota/report-cache.ts +14 -0
  198. package/src/providers/quota/vendor-probes-oauth.ts +15 -6
  199. package/src/providers/quota-decision-publication.ts +77 -0
  200. package/src/providers/quota-decision-snapshot.ts +145 -0
  201. package/src/providers/quota-routing-cache.ts +4 -1
  202. package/src/providers/quota-types.ts +6 -1
  203. package/src/providers/quota.ts +8 -3
  204. package/src/providers/registry/entries-extended.ts +14 -53
  205. package/src/providers/registry/model-seeds.ts +1 -1
  206. package/src/providers/registry.ts +1 -2
  207. package/src/providers/routed-config.ts +1 -1
  208. package/src/providers/stale-vision-classification-migration.ts +1 -1
  209. package/src/providers/xai-transport.ts +2 -1
  210. package/src/responses/audio.ts +46 -0
  211. package/src/responses/input-media.ts +37 -15
  212. package/src/responses/parser-content.ts +22 -27
  213. package/src/responses/parser.ts +18 -10
  214. package/src/responses/reasoning-retention.ts +218 -11
  215. package/src/responses/schema.ts +3 -4
  216. package/src/responses/web-search-replay.ts +147 -0
  217. package/src/routing/compatibility/behavior.ts +4 -1
  218. package/src/server/auth-cors.ts +7 -0
  219. package/src/server/background-lifecycle.ts +2 -1
  220. package/src/server/chat-native.ts +36 -20
  221. package/src/server/claude-messages.ts +2 -1
  222. package/src/server/index/optional-listeners.ts +6 -0
  223. package/src/server/index/serve-options.ts +8 -2
  224. package/src/server/index/websocket-handler.ts +10 -0
  225. package/src/server/lifecycle.ts +17 -1
  226. package/src/server/management/combo-routes.ts +17 -2
  227. package/src/server/management/oauth-account-routes.ts +12 -2
  228. package/src/server/management/provider-completion-test.ts +289 -0
  229. package/src/server/management/provider-routes.ts +9 -1
  230. package/src/server/management/route-registry.ts +2 -0
  231. package/src/server/messages-native.ts +29 -4
  232. package/src/server/messages-shared.ts +4 -1
  233. package/src/server/request-log.ts +23 -9
  234. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  235. package/src/server/responses/adapter-continuation.ts +2 -2
  236. package/src/server/responses/adapter-dispatch.ts +79 -30
  237. package/src/server/responses/codex-ws-exchange.ts +3 -0
  238. package/src/server/responses/codex-ws-metadata.ts +44 -0
  239. package/src/server/responses/codex-ws-session.ts +21 -1
  240. package/src/server/responses/combo-session-recall.ts +25 -0
  241. package/src/server/responses/combo-stream-preflight.ts +10 -3
  242. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  243. package/src/server/responses/compact.ts +10 -5
  244. package/src/server/responses/context-overflow.ts +141 -2
  245. package/src/server/responses/core-auth.ts +20 -0
  246. package/src/server/responses/core-codex-account.ts +28 -15
  247. package/src/server/responses/core-combo.ts +34 -5
  248. package/src/server/responses/core-opaque-recovery.ts +20 -11
  249. package/src/server/responses/core.ts +12 -9
  250. package/src/server/responses/deepseek-image-files.ts +121 -0
  251. package/src/server/responses/empty-completion-guard.ts +10 -0
  252. package/src/server/responses/fetch-helpers.ts +65 -14
  253. package/src/server/responses/input-admission.ts +6 -2
  254. package/src/server/responses/jev-model-invoke.ts +13 -14
  255. package/src/server/responses/main-hard-lock-routing.ts +33 -8
  256. package/src/server/responses/native-steering-log.ts +2 -2
  257. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  258. package/src/server/responses/passthrough-completion.ts +41 -0
  259. package/src/server/responses/passthrough-delivery.ts +5 -2
  260. package/src/server/responses/passthrough-dispatch.ts +42 -45
  261. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  262. package/src/server/responses/passthrough-observations.ts +38 -0
  263. package/src/server/responses/reasoning-archive-error.ts +21 -0
  264. package/src/server/responses/request-compression.ts +98 -0
  265. package/src/server/responses/request-prepare.ts +11 -1
  266. package/src/server/responses/request-transport.ts +92 -5
  267. package/src/server/responses/run-turn-execution.ts +2 -0
  268. package/src/server/responses/sidecar-execution.ts +16 -3
  269. package/src/server/responses/terminal-guard.ts +2 -0
  270. package/src/server/startup-action-control.ts +2 -2
  271. package/src/server/startup-health-cache.ts +2 -2
  272. package/src/server/windows-tray-control.ts +2 -2
  273. package/src/service/definition.ts +9 -1
  274. package/src/service/orchestration.ts +17 -0
  275. package/src/types/account-auto-pause.ts +15 -0
  276. package/src/types/config.ts +23 -1
  277. package/src/types/provider.ts +11 -5
  278. package/src/types/request.ts +36 -3
  279. package/src/types.ts +2 -0
  280. package/src/usage/cost.ts +17 -1
  281. package/src/usage/expected-prices.ts +3 -1
  282. package/src/usage/jev-stats.ts +4 -0
  283. package/src/usage/log.ts +2 -0
  284. package/src/usage/summary.ts +7 -0
  285. package/src/web-search/loop.ts +39 -13
  286. package/src/web-search/progress-stream.ts +4 -1
  287. package/src/web-search/run-turn-loop.ts +9 -3
  288. package/src/web-search/sidecar-search-replay.ts +96 -0
  289. package/gui/dist/assets/App-CHC89EXQ.js +0 -58
  290. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
  291. package/gui/dist/assets/quota-summary-D8y2wTsB.js +0 -1
@@ -259,8 +259,15 @@ export function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean {
259
259
  }
260
260
  }
261
261
 
262
- // The flat stream-error envelope carries type/message at the top level rather than under
263
- // an error object; the same anchored identity applies there.
262
+ // Flat SSE error events carry their code/message beside type:error, without a nested
263
+ // error object. Match only the same dedicated code or exact verification identity.
264
+ if ((record.error === undefined || record.error === null)
265
+ && (record.type === "error" || record.type === "invalid_request_error")
266
+ && (record.code === "invalid_encrypted_content" || ((record.code === null || record.code === undefined)
267
+ && typeof record.message === "string" && record.message.startsWith("The encrypted content ")
268
+ && record.message.endsWith(" could not be verified. Reason: Encrypted content could not be decrypted or parsed.")))) return true;
269
+
270
+ // The caller-mismatch identity also arrives as a flat typed error.
264
271
  if (
265
272
  record.type === "invalid_request_error"
266
273
  && typeof record.message === "string"
@@ -277,14 +284,14 @@ export function isSelfIdentifiedOpaqueBlobRejection(bodyText: string): boolean {
277
284
 
278
285
 
279
286
  /**
280
- * Opted-in native reasoning retention forwards ciphertext another model or account minted. ChatGPT
281
- * can refuse it inside a 200 stream -- a Codex WebSocket `error` frame or `response.failed` --
282
- * rather than as an HTTP 4xx, so the passthrough inspects the zero-output prelude of exactly these
283
- * requests. Ordinary turns keep the function-output-only inspection.
287
+ * Opaque state can be refused inside a 200 stream rather than as an HTTP 4xx. The passthrough
288
+ * inspects its bounded zero-output prelude even when a restored thread has no serving record;
289
+ * successful native ciphertext remains unchanged and blobless turns do not gain inspection.
284
290
  */
285
291
  export function retainedReasoningStreamCandidate(parsed: OcxParsedRequest, outboundBody: string | undefined): boolean {
286
- return parsed._nativeReasoningRetainedAcrossRoute === true
287
- && parsed._stripReasoningEncryptedContent !== true
292
+ // A restored thread has no process-local serving record. Inspect its actual outbound
293
+ // opaque state too; only an explicit zero-output rejection can authorize the rebuild.
294
+ return parsed._stripReasoningEncryptedContent !== true
288
295
  && outboundResponsesBodyCarriesOpaqueBlob(outboundBody);
289
296
  }
290
297
 
@@ -310,7 +317,7 @@ export function retainedReasoningStreamRejection(
310
317
 
311
318
 
312
319
  /**
313
- * Whether an upstream Responses 4xx authoritatively rejected opaque replay state.
320
+ * Whether an upstream Responses 4xx or explicitly identified 502 rejected opaque replay state.
314
321
  *
315
322
  * The outbound-body check is intentional: the inbound transcript may contain a proxy envelope or
316
323
  * compaction blob that the adapter already lowered, in which case a replay would be byte-identical.
@@ -329,8 +336,10 @@ export function shouldAttemptOpaqueBlobRecovery(args: {
329
336
  const acceptedStatus = (args.status >= 400 && args.status < 500)
330
337
  || (
331
338
  args.status === 502
332
- && outboundResponsesBodyCarriesEncryptedFunctionOutput(args.outboundBody)
333
- && isEncryptedFunctionOutputRejection(args.errorBody)
339
+ // Gateways can project a reasoning-only rejection as 502. The existing strict
340
+ // identity classifier below still excludes generic gateway/transport failures.
341
+ && (!isEncryptedFunctionOutputRejection(args.errorBody)
342
+ || outboundResponsesBodyCarriesEncryptedFunctionOutput(args.outboundBody))
334
343
  );
335
344
  return acceptedStatus
336
345
  && adapterSpeaksResponsesWire(args.adapterName)
@@ -12,6 +12,7 @@ import { createInferenceSendBudget } from "../inference/context";
12
12
  import { finalizeOwnedTranslatorBudget, finalizeAccountLease } from "./core-lifetime";
13
13
  import type { TranslatorBudget } from "../../lib/translator-budget";
14
14
  import { executeComboResponses } from "./core-combo";
15
+ import { registerTurn, unregisterTurn } from "../lifecycle";
15
16
  import { runWithCompactionRecovery } from "./compaction-recovery";
16
17
  /**
17
18
  * Route one `/v1/responses` request through the adapter pipeline: recovery loop, passthrough
@@ -67,15 +68,17 @@ export async function handleComboResponses(
67
68
  logCtx: RequestLogContext,
68
69
  options: HandleResponsesOptions & { translatorBudget: TranslatorBudget },
69
70
  ): Promise<Response> {
70
- return executeComboResponses(
71
- req,
72
- rawBody,
73
- comboId,
74
- config,
75
- logCtx,
76
- options,
77
- requestDispatchers,
78
- );
71
+ // Each child relay binds the turn's lease and unbinds it when its stream ends; the lease is
72
+ // released once nothing is bound. Hold it for the whole ladder so a refused child's relay
73
+ // cannot settle the turn the next target still runs in ("turn already settled"). An accepted
74
+ // child's stream is bound before this returns and keeps the lease until it ends.
75
+ const ladder = new AbortController();
76
+ registerTurn(ladder, options.turnAdmissionLease);
77
+ try {
78
+ return await executeComboResponses(req, rawBody, comboId, config, logCtx, options, requestDispatchers);
79
+ } finally {
80
+ if (options.turnAdmissionLease && "bindAbortController" in options.turnAdmissionLease) unregisterTurn(ladder);
81
+ }
79
82
  }
80
83
  const requestDispatchers: ResponsesDispatchers = { handleResponses, handleComboResponses };
81
84
  export { adapterNeedsForcedContinuation } from "./core-replay";
@@ -0,0 +1,121 @@
1
+ import type { TranslatorBudget } from "../../lib/translator-budget";
2
+ import type { AdapterRequest } from "../../adapters/base";
3
+ import { readBoundedResponseBody } from "../../lib/bounded-body";
4
+
5
+ // DeepSeek's image-token accounting does not include the inline base64 wire cost.
6
+ // https://api-docs.deepseek.com/guides/vision/ documents a separate 48 MiB body cap.
7
+ const INLINE_THRESHOLD = 40 * 1024 * 1024;
8
+ const TARGET_BYTES = 32 * 1024 * 1024;
9
+ const MAX_FILE_BYTES = 64 * 1024 * 1024;
10
+ const MAX_REPAIR_BYTES = 128 * 1024 * 1024;
11
+ const MAX_CACHE_ENTRIES = 512;
12
+ const CACHE_MS = 50 * 60 * 1000;
13
+ let activeRepairs = 0;
14
+ const files = new Map<string, { id: string; until: number }>();
15
+
16
+ /** Narrow parsed JSON objects without treating arrays as image or message records. */
17
+ function record(value: unknown): value is Record<string, unknown> {
18
+ return value !== null && typeof value === "object" && !Array.isArray(value);
19
+ }
20
+
21
+ /** Only the first-party Responses image contract, never a similarly named gateway. */
22
+ export function needsDeepSeekImageFiles(request: AdapterRequest): boolean {
23
+ const url = new URL(request.url);
24
+ return url.origin === "https://api.deepseek.com"
25
+ && (url.pathname === "/responses" || url.pathname === "/v1/responses")
26
+ && request.method === "POST"
27
+ && Buffer.byteLength(request.body, "utf8") > INLINE_THRESHOLD;
28
+ }
29
+
30
+ /**
31
+ * Replace older inline images with same-account, expiring file references. The client history
32
+ * and images stay intact; provider token usage and Codex's compaction policy remain truthful.
33
+ * The caller owns the provider's transport, cancellation and credential-selection fence.
34
+ */
35
+ export async function deepSeekImageFileBody(
36
+ request: AdapterRequest,
37
+ execute: typeof globalThis.fetch,
38
+ abortSignal?: AbortSignal,
39
+ options?: { allowedModels: readonly string[]; budget?: TranslatorBudget },
40
+ ): Promise<string | undefined> {
41
+ if (!options?.allowedModels.length || !needsDeepSeekImageFiles(request)) return undefined;
42
+ const requestBytes = Buffer.byteLength(request.body, "utf8");
43
+ if (requestBytes > MAX_REPAIR_BYTES) throw new Error("DeepSeek image replay exceeds the 128 MiB repair limit; compact the request.");
44
+ if (activeRepairs >= 2) throw new Error("DeepSeek image replay preparation is busy; retry after an active request completes.");
45
+ activeRepairs++;
46
+ const releaseCopy = options.budget?.observeAcceptedRequestCopy(requestBytes * 2);
47
+ try {
48
+ abortSignal?.throwIfAborted();
49
+ const body: unknown = JSON.parse(request.body);
50
+ if (!record(body) || !options.allowedModels.includes(String(body.model))
51
+ || !Array.isArray(body.input)) return undefined;
52
+ const auth = new Headers(request.headers).get("authorization");
53
+ if (!auth) return undefined;
54
+ const scope = new Bun.CryptoHasher("sha256").update(auth).digest("hex");
55
+ const signal = AbortSignal.any([...(abortSignal ? [abortSignal] : []), AbortSignal.timeout(60_000)]);
56
+ let bytes = Buffer.byteLength(request.body, "utf8");
57
+ let changed = false;
58
+ for (const item of body.input) {
59
+ if (!record(item)) continue;
60
+ const parts = item.type === "function_call_output" || item.type === "custom_tool_call_output"
61
+ ? item.output : item.role === "user" || item.role === "developer" ? item.content : undefined; // DeepSeek rejects other roles' images.
62
+ if (!Array.isArray(parts)) continue;
63
+ for (const part of parts) {
64
+ if (bytes <= TARGET_BYTES) break;
65
+ // file_id ignores detail; keep explicit low-resolution requests inline.
66
+ if (!record(part) || part.type !== "input_image" || part.file_id !== undefined
67
+ || part.detail === "low" || typeof part.image_url !== "string") continue;
68
+ const match = /^data:(image\/(?:png|jpeg|webp|gif));base64,/.exec(part.image_url);
69
+ if (!match) continue;
70
+ signal.throwIfAborted();
71
+ const data = part.image_url.slice(match[0].length);
72
+ if (data.length > Math.ceil(MAX_FILE_BYTES / 3) * 4) continue;
73
+ const hash = new Bun.CryptoHasher("sha256").update(part.image_url).digest("hex");
74
+ const key = `${scope}:${hash}`;
75
+ let entry = files.get(key);
76
+ if (entry && entry.until <= Date.now()) { files.delete(key); entry = undefined; }
77
+ if (!entry) {
78
+ // Conservative peak: UTF-16 base64 slice/re-encoding, decoded buffer, Blob and multipart.
79
+ const releaseUpload = options.budget?.observeExternallyCapped("provider_image_upload", data.length * 4 + Math.ceil(data.length * 3 / 4) * 3 + 65_536);
80
+ try {
81
+ const image = Buffer.from(data, "base64");
82
+ if (image.length > MAX_FILE_BYTES || image.toString("base64") !== data) continue;
83
+ const form = new FormData();
84
+ form.set("purpose", "user_data");
85
+ form.set("expires_after[anchor]", "created_at");
86
+ form.set("expires_after[seconds]", "3600");
87
+ form.set("file", new Blob([image], { type: match[1] }), "image");
88
+ // Never follow a redirect with credentials or copy request-specific Content-Length.
89
+ const response = await execute("https://api.deepseek.com/files", {
90
+ method: "POST", headers: { authorization: auth }, body: form, signal, redirect: "manual",
91
+ });
92
+ if (!response.ok) {
93
+ await response.body?.cancel();
94
+ throw new Error(`DeepSeek image upload failed (HTTP ${response.status}); conversation images were preserved.`);
95
+ }
96
+ const result = await readBoundedResponseBody(response, { signal, maxBytes: 65_536 });
97
+ let uploaded: unknown;
98
+ try { uploaded = JSON.parse(result.text); } catch { /* Safe, fixed error below. */ }
99
+ if (result.truncated || !record(uploaded) || typeof uploaded.id !== "string"
100
+ || !/^file-api-[A-Za-z0-9_-]{1,200}$/.test(uploaded.id)) {
101
+ throw new Error("DeepSeek image upload returned an invalid file reference; conversation images were preserved.");
102
+ }
103
+ // Cache only opaque ids and hashes, not image bytes or plaintext credentials.
104
+ entry = { id: uploaded.id, until: Date.now() + CACHE_MS };
105
+ if (typeof uploaded.expires_at === "number") entry.until = Math.min(entry.until, uploaded.expires_at * 1000 - 60_000);
106
+ while (files.size >= MAX_CACHE_ENTRIES) files.delete(files.keys().next().value!);
107
+ files.set(key, entry);
108
+ } finally { releaseUpload?.(); }
109
+ }
110
+ const removedBytes = part.image_url.length + "image_url".length;
111
+ delete part.image_url;
112
+ part.file_id = entry.id;
113
+ bytes += entry.id.length + "file_id".length - removedBytes;
114
+ changed = true;
115
+ }
116
+ }
117
+ if (!changed) return undefined;
118
+ const releaseSerialization = options.budget?.observeExternallyCapped("passthrough_serialization", requestBytes * 2);
119
+ try { return JSON.stringify(body); } finally { releaseSerialization?.(); }
120
+ } finally { releaseCopy?.(); activeRepairs--; }
121
+ }
@@ -172,6 +172,7 @@ export function mergeUsage(
172
172
  const cachedInputTokens = sumOptional("cachedInputTokens");
173
173
  const cacheReadInputTokens = sumOptional("cacheReadInputTokens");
174
174
  const cacheCreationInputTokens = sumOptional("cacheCreationInputTokens");
175
+ const cacheCreation1hInputTokens = sumOptional("cacheCreation1hInputTokens");
175
176
  const reasoningOutputTokens = sumOptional("reasoningOutputTokens");
176
177
  const providerCredits = sumOptional("providerCredits");
177
178
  const contextTotalTokens = second.contextTotalTokens ?? first.contextTotalTokens;
@@ -188,6 +189,7 @@ export function mergeUsage(
188
189
  ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
189
190
  ...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}),
190
191
  ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}),
192
+ ...(cacheCreation1hInputTokens !== undefined ? { cacheCreation1hInputTokens } : {}),
191
193
  ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}),
192
194
  ...(providerCredits !== undefined ? { providerCredits } : {}),
193
195
  ...(first.estimated || second.estimated ? { estimated: true } : {}),
@@ -241,6 +243,11 @@ export async function* guardEmptyCompletionEventStream(
241
243
  heldBytes = 0;
242
244
  return released;
243
245
  };
246
+ // Held events from a discarded attempt are released with the retry's output, so a boundary
247
+ // keeps the retry's reasoning from being appended to the discarded attempt's reasoning item.
248
+ const markRetryBoundary = () => {
249
+ if (held.length > 0 && held.at(-1)?.type !== "assistant_boundary") held.push({ type: "assistant_boundary" });
250
+ };
244
251
 
245
252
  while (true) {
246
253
  let terminalSeen = false;
@@ -277,6 +284,7 @@ export async function* guardEmptyCompletionEventStream(
277
284
  if (retries < maxRetries) {
278
285
  // Suppress the terminal: the client must never see a completed event
279
286
  // for a turn that produced nothing. Retry the identical turn.
287
+ markRetryBoundary();
280
288
  retries += 1;
281
289
  try {
282
290
  source = await options.continuation();
@@ -298,6 +306,7 @@ export async function* guardEmptyCompletionEventStream(
298
306
  // malformed function call). Nothing actionable reached the client, so this is the same
299
307
  // replay-safe shape as an empty completion: retry the identical turn once.
300
308
  usage = mergeUsage(usage, event.usage);
309
+ markRetryBoundary();
301
310
  retries += 1;
302
311
  try {
303
312
  source = await options.continuation();
@@ -349,6 +358,7 @@ export async function* guardEmptyCompletionEventStream(
349
358
  // actionable reached the client. Retry once, then surface a stated error
350
359
  // instead of letting the bridge reduce the turn to adapter_eof.
351
360
  if (!sawContent && !passthrough && retries < maxRetries) {
361
+ markRetryBoundary();
352
362
  retries += 1;
353
363
  try {
354
364
  source = await options.continuation();
@@ -1,4 +1,5 @@
1
1
  import type { NativeResponseControl } from "./native-response-control";
2
+ import { PROVIDER_HEADER_DEADLINE as HEADER_DEADLINE, type ProviderHeaderDeadline as HeaderDeadline } from "../../lib/provider-admission-wait";
2
3
  import type { CodexWsPreOutputRefusalHold } from "./codex-ws-wire";
3
4
  import type { Server } from "bun";
4
5
  import {
@@ -31,6 +32,7 @@ import {
31
32
  } from "../../lib/provider-egress";
32
33
  import { redactSecretString } from "../../lib/redact";
33
34
  import { outboundCredentials } from "../../lib/outbound-credential-registry";
35
+ import { compressUpstreamRequestBody, isCanonicalCodexResponsesUrl } from "./request-compression";
34
36
 
35
37
  export { withUpstreamHttpVersion };
36
38
 
@@ -48,11 +50,19 @@ const EGRESS_DECIDED = Symbol.for("opencodex.provider-egress.decided");
48
50
  const UPSTREAM_REWRITTEN = Symbol.for("opencodex.plugins.upstream-rewritten");
49
51
  // Survives queued credential rebuilds and executor replacement without importing budget owners.
50
52
  const PHYSICAL_DISPATCH = Symbol.for("opencodex.physical-dispatch");
53
+ // Final destination and headers after plugin rewrites and queued rebuilds, for observers that
54
+ // must attribute a response to the credential that actually reached the wire.
55
+ const PHYSICAL_SEND_OBSERVER = Symbol.for("opencodex.physical-send-observer");
56
+ export type AccountAutoPauseObserver = ((response: Response) => Promise<Response>) & {
57
+ physical?: (sameDestination: boolean, headers: Headers) => void;
58
+ };
59
+ /** Init fragment that reports the final send of the observer's attempt; empty when nothing observes. */
60
+ export function physicalSendObserverInit(observer: AccountAutoPauseObserver | undefined, target: string): Record<symbol, unknown> {
61
+ const physical = observer?.physical;
62
+ return physical ? { [PHYSICAL_SEND_OBSERVER]: (destination: string, headers: Headers) => physical(destination === target, headers) } : {};
63
+ }
51
64
  // One WS attempt and its HTTP fallback are one logical physical-send receipt.
52
65
  const WS_FALLBACK_RECEIPT = Symbol.for("opencodex.ws-fallback-receipt");
53
- // Replacement admission waits on the client signal, outside the response-header deadline.
54
- const HEADER_DEADLINE = Symbol("header-deadline");
55
- type HeaderDeadline = { clientSignal: AbortSignal; pause(): void; resume(): void };
56
66
 
57
67
  /**
58
68
  * Announce once, per provider, that an explicit egress route moved this provider off the
@@ -206,18 +216,27 @@ export function sendWithConnectionPolicy(
206
216
  // Bun can reset large native Codex string uploads before receiving headers while
207
217
  // the identical UTF-8 buffer succeeds. Convert only at the final HTTP send so
208
218
  // WebSocket selection still sees the serialized string and no retry is added.
219
+ // Opt-in compression (#7247) takes precedence at the same boundary and also yields bytes. Every
220
+ // retry and HTTP fallback re-enters here with the caller's original string, so each send derives
221
+ // its own coded bytes; `headers` is already this send's private copy.
209
222
  const target = input instanceof Request ? input.url : String(input);
210
223
  const body = init?.body;
211
- const largeCodexBody = typeof body === "string"
212
- && /^https:\/\/chatgpt\.com\/backend-api\/codex\/responses(?:\/compact)?$/.test(target)
224
+ const compressed = compressUpstreamRequestBody(target, body, headers);
225
+ if (compressed) {
226
+ headers.set("Content-Encoding", compressed.encoding);
227
+ headers.delete("Content-Length");
228
+ }
229
+ const largeCodexBody = !compressed && typeof body === "string"
230
+ && isCanonicalCodexResponsesUrl(target)
213
231
  && Buffer.byteLength(body, "utf8") >= 1024 * 1024;
214
232
  (init as Record<symbol, (() => void) | undefined> | undefined)?.[PHYSICAL_DISPATCH]?.();
233
+ (init as Record<symbol, ((destination: string, headers: Headers) => void) | undefined> | undefined)?.[PHYSICAL_SEND_OBSERVER]?.(target, headers);
215
234
  // Final headers, after plugin rewrites: an upstream can only echo what it received here, so the
216
235
  // request log refuses any diagnostic that repeats one of these values (see request-log-terminal-status).
217
236
  outboundCredentials().remember(headers);
218
237
  return physicalFetch(input, {
219
238
  ...init,
220
- ...(largeCodexBody ? { body: Buffer.from(body, "utf8") } : {}),
239
+ ...(compressed ? { body: compressed.body } : largeCodexBody ? { body: Buffer.from(body, "utf8") } : {}),
221
240
  headers,
222
241
  redirect: "manual",
223
242
  ...(fresh ? { keepalive: false } : {}),
@@ -227,7 +246,22 @@ export function sendWithConnectionPolicy(
227
246
  });
228
247
  }
229
248
 
249
+ interface CodexWsExecutor { provider: OcxProviderConfig; http: typeof globalThis.fetch }
250
+ const codexWsExecutors = new WeakMap<object, CodexWsExecutor>();
251
+ const codexWsExecutor = (send: (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => Promise<Response>, binding: CodexWsExecutor) => {
252
+ codexWsExecutors.set(send, binding);
253
+ return send as typeof globalThis.fetch;
254
+ };
255
+ /**
256
+ * Set for the executor a `dispatchOverride` receives on the WS branch: it must not get HTTP-only
257
+ * send policy. `provider` is the one WS eligibility was checked against; a dispatch-time
258
+ * replacement was not, so it sends through `http` (the same fallback) instead.
259
+ */
260
+ export const codexWsExecutorBinding = (execute: unknown): CodexWsExecutor | undefined =>
261
+ typeof execute === "function" ? codexWsExecutors.get(execute) : undefined;
262
+
230
263
  export interface ProviderFetchOptions {
264
+ accountAutoPause?: (name: string, provider: OcxProviderConfig, headers: Headers) => AccountAutoPauseObserver | undefined;
231
265
  /**
232
266
  * Keep this send on HTTP even where the WebSocket upstream would normally be selected.
233
267
  *
@@ -308,7 +342,9 @@ export function providerFetch(
308
342
  ) as typeof globalThis.fetch);
309
343
  const httpFetch = Object.assign(
310
344
  async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit,
311
- reacquirePacing?: (provider: OcxProviderConfig, modelId?: string) => Promise<void>) => {
345
+ reacquirePacing?: (provider: OcxProviderConfig, modelId?: string) => Promise<void>,
346
+ // False only for the HTTP fallback of a WS send the override already wrapped: one logical send.
347
+ override: ProviderFetchOptions["dispatchOverride"] | null = options.dispatchOverride) => {
312
348
  // Refuse before any dispatch side effect where that is sound. `beforeDispatch` commits
313
349
  // attempt accounting and consumes admission state, so a refusal firing after it would
314
350
  // charge an attempt for a send that never happens, and a throwing hook would mask the
@@ -318,7 +354,7 @@ export function providerFetch(
318
354
  // full decision can be made now. With an override, only the configured value is checked:
319
355
  // the override may rebuild against a different host and select a different transport, and
320
356
  // refusing on this destination would reject a request whose real route is fine.
321
- if (options.dispatchOverride) egressFor(input);
357
+ if (override) egressFor(input);
322
358
  else providerEgressSendInit(egressBinding, transport, input);
323
359
  // The hook inspects the outgoing headers and refuses the send by throwing; it is not a
324
360
  // mutator, and the copy it receives is deliberately not threaded onward. `Connection`
@@ -335,8 +371,8 @@ export function providerFetch(
335
371
  else if (!notified) { options.onPhysicalDispatch!(); notified = true; }
336
372
  } } : {}),
337
373
  };
338
- return options.dispatchOverride
339
- ? options.dispatchOverride(input, dispatchInit, dispatch, reacquirePacing)
374
+ return override
375
+ ? override(input, dispatchInit, dispatch, reacquirePacing)
340
376
  : dispatch(input, dispatchInit);
341
377
  },
342
378
  { preconnect },
@@ -344,7 +380,7 @@ export function providerFetch(
344
380
  // ChatGPT Codex backend: streaming turns ride the responses_websockets
345
381
  // transport (measured ~3s faster TTFT than the SSE POST queue); everything
346
382
  // else keeps the provider's HTTP fetch. See ws-upstream.ts for the details.
347
- const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit,
383
+ const unobserved = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit,
348
384
  reacquirePacing?: (provider: OcxProviderConfig, modelId?: string) => Promise<void>) => {
349
385
  const upstreamWebsocket = provider.upstreamWebsocket;
350
386
  if (!options.httpOnly && typeof input === "string" && init
@@ -375,12 +411,27 @@ export function providerFetch(
375
411
  options.onPhysicalDispatch?.();
376
412
  };
377
413
  const fallback = ((url: Parameters<typeof globalThis.fetch>[0], fallbackInit?: RequestInit) =>
378
- httpFetch(url, { ...fallbackInit, [WS_FALLBACK_RECEIPT]: notifyOnce } as RequestInit, reacquirePacing)) as typeof globalThis.fetch;
379
- return codexWsUpstreamFetch(input, init, fallback, runtime, options.onCodexWsQuota, options.beforeDispatch, options.nativeControl,
380
- async () => { (await waitForPacing(init.signal ?? undefined))?.release(); }, notifyOnce, options.preOutputRefusalHold);
414
+ httpFetch(url, { ...fallbackInit, [WS_FALLBACK_RECEIPT]: notifyOnce } as RequestInit, reacquirePacing, null)) as typeof globalThis.fetch;
415
+ const wsSend = codexWsExecutor((url: Parameters<typeof globalThis.fetch>[0], wsInit?: RequestInit) => codexWsUpstreamFetch(String(url), wsInit ?? init,
416
+ fallback, runtime, options.onCodexWsQuota, options.beforeDispatch, options.nativeControl,
417
+ async () => { (await waitForPacing(init.signal ?? undefined))?.release(); }, notifyOnce, options.preOutputRefusalHold),
418
+ { provider, http: markEgressTransparentExecutor(fallback) });
419
+ // A WS frame is a physical send like any other, so it runs the same override: that is where a
420
+ // key-auth send is committed to its attempt row and the send budget is rebound to the
421
+ // credential that left. Without it a key-auth WS turn logged `sendCount: 0`.
422
+ return options.dispatchOverride ? options.dispatchOverride(input, init, wsSend, reacquirePacing) : wsSend(input, init);
381
423
  }
382
424
  return httpFetch(input, init, reacquirePacing);
383
425
  };
426
+ const unpaced = async (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit,
427
+ reacquirePacing?: (provider: OcxProviderConfig, modelId?: string) => Promise<void>) => {
428
+ const observe = options.accountAutoPause?.(providerName, provider,
429
+ new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined)));
430
+ const observedInit = observe?.physical
431
+ ? { ...init, ...physicalSendObserverInit(observe, input instanceof Request ? input.url : String(input)) } : init;
432
+ const response = await unobserved(input, observedInit, reacquirePacing);
433
+ return observe ? observe(response) : response;
434
+ };
384
435
  let pacingSlotAcquired = options.pacingSlotAcquired === true;
385
436
  const waitForPacing = async (signal?: AbortSignal): Promise<ProviderRequestSlot | undefined> => {
386
437
  if (pacingSlotAcquired) {
@@ -55,6 +55,9 @@ const REMOTE_IMAGE_TOKENS = 850;
55
55
  /** Decoded image bytes per token. Coarse tile-count proxy, not a provider formula. */
56
56
  const IMAGE_BYTES_PER_TOKEN = 750;
57
57
 
58
+ /** Compressed-audio bytes per token. More conservative than the image proxy. */
59
+ const AUDIO_BYTES_PER_TOKEN = 256;
60
+
58
61
  export interface InputAdmissionResult {
59
62
  admitted: boolean;
60
63
  estimatedTokens: number;
@@ -85,6 +88,7 @@ function imageTokens(imageUrl: string): number {
85
88
  function contentPartTokens(part: OcxContentPart, modelId: string): number {
86
89
  if (part.type === "image") return imageTokens(part.imageUrl);
87
90
  if (part.type === "video") return imageTokens(part.videoUrl);
91
+ if (part.type === "audio") return base64PayloadTokens(part.data, AUDIO_BYTES_PER_TOKEN);
88
92
  // An inline document is a base64 payload, not a sentence: estimating it from its marker would
89
93
  // admit a request whose real input is orders of magnitude larger. Counted arithmetically —
90
94
  // rebuilding the data URL here would materialize a second request-sized string just to measure it.
@@ -92,10 +96,10 @@ function contentPartTokens(part: OcxContentPart, modelId: string): number {
92
96
  return estimateTokens(part.text, modelId);
93
97
  }
94
98
 
95
- function base64PayloadTokens(base64: string): number {
99
+ function base64PayloadTokens(base64: string, bytesPerToken = IMAGE_BYTES_PER_TOKEN): number {
96
100
  if (base64.length === 0) return 0;
97
101
  const decoded = Math.floor((base64.length * 3) / 4);
98
- return Math.max(1, Math.ceil(decoded / IMAGE_BYTES_PER_TOKEN));
102
+ return Math.max(1, Math.ceil(decoded / bytesPerToken));
99
103
  }
100
104
 
101
105
  function contentTokens(content: string | readonly OcxContentPart[], modelId: string): number {
@@ -1,6 +1,7 @@
1
1
  import { readBoundedResponseBytes } from "../../lib/bounded-body";
2
2
  import { JevModelInvokeError, type JevModelInvoke, type JevModelInvokeResult } from "../../combos/jev-model-backend";
3
3
  import { JEV_MAX_REQUEST_BYTES, JEV_MAX_RESPONSE_BYTES } from "../../combos/jev";
4
+ import { serializeJevModelRequest } from "../../combos/jev-model-request";
4
5
  import type { OcxConfig } from "../../types";
5
6
  import { codexEffortRank, isCodexReasoningEffort } from "../../reasoning-effort";
6
7
  import { routeConcreteModel } from "../../router";
@@ -92,7 +93,7 @@ function decodeSse(bytes: Uint8Array): JevModelInvokeResult {
92
93
  * bounds billed output (including reasoning) and gives the spend reservation a nonzero ceiling;
93
94
  * a truncated answer is malformed and fails open like any other bad reply.
94
95
  */
95
- export const JEV_MODEL_MAX_OUTPUT_TOKENS = 1024;
96
+ export { JEV_MODEL_MAX_OUTPUT_TOKENS } from "../../combos/jev-model-request";
96
97
 
97
98
  /**
98
99
  * The cheapest reasoning effort the decision model declares, so a reasoning model cannot spend
@@ -121,19 +122,17 @@ export function jevDecisionReasoningEffort(config: OcxConfig, model: string): st
121
122
  * or conversation header. It therefore runs on credentials configured on provider rows only.
122
123
  */
123
124
  export function createJevModelInvoker(context: JevModelInvokerContext): JevModelInvoke {
124
- return async ({ model, instructions, input, signal }) => {
125
+ return async ({ model, instructions, input, signal, withoutQuota }) => {
125
126
  const effort = jevDecisionReasoningEffort(context.config, model);
126
- const body = JSON.stringify({
127
- model,
128
- stream: true,
129
- store: false,
130
- instructions,
131
- input: [{ role: "user", content: [{ type: "input_text", text: input }] }],
132
- tools: [],
133
- max_output_tokens: JEV_MODEL_MAX_OUTPUT_TOKENS,
134
- ...(effort ? { reasoning: { effort } } : {}),
135
- });
136
- if (new TextEncoder().encode(body).byteLength > JEV_MAX_REQUEST_BYTES) {
127
+ let body = serializeJevModelRequest({ model, instructions, input }, effort);
128
+ /** True when the serialized decision request exceeds the request byte limit. */
129
+ const oversized = () => new TextEncoder().encode(body).byteLength > JEV_MAX_REQUEST_BYTES;
130
+ let quotaOmitted = false;
131
+ if (oversized() && withoutQuota) {
132
+ body = serializeJevModelRequest({ model, ...withoutQuota }, effort);
133
+ quotaOmitted = true;
134
+ }
135
+ if (oversized()) {
137
136
  throw new JevModelInvokeError("malformed", "decision request too large");
138
137
  }
139
138
  const lease = tryAdmitTurn(undefined, signal);
@@ -178,7 +177,7 @@ export function createJevModelInvoker(context: JevModelInvokerContext): JevModel
178
177
  return terminalResult(parsed, "");
179
178
  })();
180
179
  usage = result.usage;
181
- return result;
180
+ return quotaOmitted ? { ...result, quotaOmitted: true } : result;
182
181
  } finally {
183
182
  // No final log row is written for this detached context, so settle its spend here.
184
183
  childLog.spendTracker?.settle(childLog.usage ?? (usage
@@ -1,6 +1,8 @@
1
1
  import type { OcxConfig } from "../../types";
2
2
  import { resolveComboId } from "../../combos";
3
- import { isBareOpenAiFamilyModel } from "../../router";
3
+ import { isBareOpenAiFamilyModel, previewRouteModel } from "../../router";
4
+ import { resolveChosenTarget } from "./shadow-target-availability";
5
+ import { getRoutingProfile, resolvePolicyProfileId } from "../../routing/profile";
4
6
  import { NATIVE_RESERVE_MODEL } from "../../codex/catalog/native-models";
5
7
  import {
6
8
  getMainAccountHardLockStatus,
@@ -10,6 +12,8 @@ import { codexQuotaScopeForModel } from "../../codex/routing/health-store";
10
12
  import { getEligiblePoolAccounts } from "../../codex/routing/selection";
11
13
  import { OPENAI_CODEX_PROVIDER_ID, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers";
12
14
  import { providerCodexAccountMode } from "../../providers/registry";
15
+ /** Resolves to a synchronous ownership proof, or null when the caller does not own the locked main. */
16
+ export type MainHardLockCallerCheck = (sourceModel: string) => Promise<(() => boolean) | null>;
13
17
 
14
18
  function isConfiguredComboSelector(config: OcxConfig, modelId: string): boolean {
15
19
  const comboId = resolveComboId(config, modelId);
@@ -72,11 +76,12 @@ function rawModelOf(body: unknown): { raw: Record<string, unknown>; model: strin
72
76
  * - Luna Reserve keeps its own admission policy (`assertReserveAdmission`); its helper
73
77
  * traffic is not operator chat and must not silently change models.
74
78
  */
75
- export function applyMainHardLockFallbackOverride(
79
+ export async function applyMainHardLockFallbackOverride(
76
80
  body: unknown,
77
81
  config: OcxConfig,
78
- now = Date.now(),
79
- ): MainHardLockFallbackOverride | null {
82
+ now?: number,
83
+ callerCheck?: MainHardLockCallerCheck,
84
+ ): Promise<MainHardLockFallbackOverride | null> {
80
85
  const parsed = rawModelOf(body);
81
86
  if (!parsed) return null;
82
87
  const fallback = resolveMainAccountHardLockFallback(config);
@@ -85,14 +90,34 @@ export function applyMainHardLockFallbackOverride(
85
90
  if (!isBareOpenAiFamilyModel(parsed.model)
86
91
  || parsed.model === NATIVE_RESERVE_MODEL
87
92
  || isConfiguredComboSelector(config, parsed.model)) return null;
88
- if (getMainAccountHardLockStatus(config, now).state !== "blocked") return null;
89
93
  const accountMode = providerCodexAccountMode(
90
94
  OPENAI_CODEX_PROVIDER_ID,
91
95
  config.providers?.[OPENAI_CODEX_PROVIDER_ID],
92
96
  );
93
- if (accountMode !== "direct"
94
- && getEligiblePoolAccounts(config, undefined, now, codexQuotaScopeForModel(parsed.model)).length > 0) {
95
- return null;
97
+ if (accountMode === "direct") {
98
+ // Direct may forward a different caller's healthy credential. Only stored-main substitution
99
+ // or an observed-main match owes this lock. Recheck usage after the startup binding wait.
100
+ const stillOwnsLockedMain = callerCheck ? await callerCheck(parsed.model) : null;
101
+ // Everything from here to the rewrite is synchronous, so this proof cannot go stale before it.
102
+ if (!stillOwnsLockedMain?.()) return null;
103
+ if (getMainAccountHardLockStatus(config, now ?? Date.now()).state !== "blocked") return null;
104
+ } else {
105
+ if (getMainAccountHardLockStatus(config, now ?? Date.now()).state !== "blocked") return null;
106
+ if (getEligiblePoolAccounts(config, undefined, now ?? Date.now(), codexQuotaScopeForModel(parsed.model)).length > 0) return null;
107
+ }
108
+
109
+ // Combos and policies keep their own dispatch admission and declared failover/evidence.
110
+ // Other chosen targets must resolve now; a removed qualifier cannot become a default send.
111
+ const policyId = resolvePolicyProfileId(config, fallback.model);
112
+ if (!isConfiguredComboSelector(config, fallback.model)
113
+ && !(policyId && getRoutingProfile(config, policyId))) {
114
+ try {
115
+ const target = resolveChosenTarget(fallback.model, model => previewRouteModel(config, model));
116
+ if ("unavailable" in target) return null;
117
+ } catch {
118
+ // An unavailable target keeps the source lock's refusal without touching the body.
119
+ return null;
120
+ }
96
121
  }
97
122
 
98
123
  parsed.raw.model = fallback.model;
@@ -24,7 +24,7 @@ export function createNativeSteeringLogObserver(logCtx: RequestLogContext, onFir
24
24
  if (usage && (usages.has(event.response.id) || usages.size < MAX_NATIVE_STEERING_RESPONSES)) {
25
25
  // Retain numeric counters only, never arbitrary rawUsage metadata.
26
26
  const counters: OcxUsage = { inputTokens: usage.inputTokens, outputTokens: usage.outputTokens };
27
- for (const key of ["totalTokens", "cachedInputTokens", "cacheReadInputTokens", "cacheCreationInputTokens", "reasoningOutputTokens"] as const) {
27
+ for (const key of ["totalTokens", "cachedInputTokens", "cacheReadInputTokens", "cacheCreationInputTokens", "cacheCreation1hInputTokens", "reasoningOutputTokens"] as const) {
28
28
  if (typeof usage[key] === "number") counters[key] = usage[key];
29
29
  }
30
30
  usages.set(event.response.id, counters);
@@ -33,7 +33,7 @@ export function createNativeSteeringLogObserver(logCtx: RequestLogContext, onFir
33
33
  if (!usages.size) return;
34
34
  const total: OcxUsage = { inputTokens: 0, outputTokens: 0 };
35
35
  for (const usage of usages.values()) {
36
- for (const key of ["inputTokens", "outputTokens", "totalTokens", "cachedInputTokens", "cacheReadInputTokens", "cacheCreationInputTokens", "reasoningOutputTokens"] as const) {
36
+ for (const key of ["inputTokens", "outputTokens", "totalTokens", "cachedInputTokens", "cacheReadInputTokens", "cacheCreationInputTokens", "cacheCreation1hInputTokens", "reasoningOutputTokens"] as const) {
37
37
  const value = usage[key];
38
38
  if (typeof value === "number" && Number.isFinite(value)) total[key] = (total[key] ?? 0) + value;
39
39
  }