@bitkyc08/opencodex 2.58.0 → 2.60.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 (303) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-BTuCbqQd.css +1 -0
  3. package/gui/dist/assets/index-DoBVdPHP.js +134 -0
  4. package/gui/dist/index.html +2 -2
  5. package/gui/dist/provider-icons/crusoe.svg +1 -0
  6. package/gui/dist/provider-icons/opper.svg +3 -0
  7. package/package.json +4 -1
  8. package/src/adapters/anthropic-image-codec.ts +16 -2
  9. package/src/adapters/anthropic-image-normalize.ts +49 -2
  10. package/src/adapters/anthropic.ts +4 -1
  11. package/src/adapters/base.ts +34 -1
  12. package/src/adapters/coding-agent/turn.ts +22 -2
  13. package/src/adapters/command-code.ts +50 -3
  14. package/src/adapters/cursor/catalog.ts +11 -0
  15. package/src/adapters/cursor/checkpoint-store.ts +3 -0
  16. package/src/adapters/cursor/discovery.ts +11 -8
  17. package/src/adapters/cursor/effort-map.ts +16 -2
  18. package/src/adapters/cursor/envelope-echo.ts +55 -2
  19. package/src/adapters/cursor/live-transport.ts +26 -9
  20. package/src/adapters/cursor/message-mapper.ts +3 -2
  21. package/src/adapters/cursor/protobuf-request.ts +8 -5
  22. package/src/adapters/cursor/request-builder.ts +21 -4
  23. package/src/adapters/cursor/thread-continuity.ts +105 -31
  24. package/src/adapters/cursor/tool-guidance.ts +5 -4
  25. package/src/adapters/cursor/transport.ts +19 -0
  26. package/src/adapters/cursor.ts +45 -2
  27. package/src/adapters/devin/cloud-direct/chat.ts +14 -3
  28. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  29. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +140 -0
  30. package/src/adapters/devin.ts +125 -26
  31. package/src/adapters/google-antigravity-replay.ts +1 -1
  32. package/src/adapters/google-antigravity-wire.ts +55 -4
  33. package/src/adapters/google-http.ts +57 -11
  34. package/src/adapters/google-tool-schema.ts +595 -31
  35. package/src/adapters/google-wire-compiler.ts +93 -10
  36. package/src/adapters/google-wire-shape.ts +461 -0
  37. package/src/adapters/google.ts +60 -10
  38. package/src/adapters/openai-chat/response-events.ts +61 -0
  39. package/src/adapters/openai-chat-images.ts +3 -1
  40. package/src/adapters/openai-chat.ts +10 -11
  41. package/src/adapters/openai-responses/image-gen.ts +8 -6
  42. package/src/adapters/openai-responses/passthrough.ts +25 -4
  43. package/src/adapters/openai-responses/reasoning.ts +7 -0
  44. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  45. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  46. package/src/adapters/opencode-go-additional-tools.ts +12 -2
  47. package/src/adapters/responses-tool-schema.ts +76 -46
  48. package/src/adapters/run-turn-queue.ts +17 -4
  49. package/src/bridge/response-json.ts +1 -1
  50. package/src/bridge/sse.ts +28 -35
  51. package/src/claude/context-windows.ts +22 -0
  52. package/src/claude/outbound.ts +35 -4
  53. package/src/cli/account-api.ts +4 -3
  54. package/src/cli/account-extended.ts +26 -6
  55. package/src/cli/account-orca-import.ts +63 -0
  56. package/src/cli/account.ts +32 -4
  57. package/src/cli/capabilities.ts +40 -0
  58. package/src/cli/claude.ts +29 -1
  59. package/src/cli/codex-cli-update.ts +97 -2
  60. package/src/cli/dispatch.ts +57 -3
  61. package/src/cli/doctor.ts +218 -4
  62. package/src/cli/help.ts +4 -1
  63. package/src/cli/hub.ts +3 -2
  64. package/src/cli/index.ts +95 -22
  65. package/src/cli/models-runtime.ts +33 -4
  66. package/src/cli/opencode.ts +2 -2
  67. package/src/cli/provider.ts +13 -1
  68. package/src/cli/registry.ts +11 -1
  69. package/src/cli/runtime-api.ts +44 -0
  70. package/src/cli/start-args.ts +94 -0
  71. package/src/cli/system-command.ts +2 -0
  72. package/src/client/machine-api.ts +6 -5
  73. package/src/client/machine-listener.ts +16 -3
  74. package/src/client/runtime.ts +26 -2
  75. package/src/clients/config-export/constants.ts +2 -3
  76. package/src/clients/config-export.ts +5 -5
  77. package/src/codex/account-store.ts +146 -5
  78. package/src/codex/auth-api/account-list.ts +19 -11
  79. package/src/codex/auth-api/pool-quota-probe.ts +44 -10
  80. package/src/codex/auth-api/routes.ts +17 -2
  81. package/src/codex/auth-context.ts +16 -12
  82. package/src/codex/catalog/build-entries.ts +25 -4
  83. package/src/codex/catalog/derive-entry.ts +8 -1
  84. package/src/codex/catalog/effort.ts +10 -6
  85. package/src/codex/catalog/gather-capture.ts +22 -2
  86. package/src/codex/catalog/model-hints.ts +66 -33
  87. package/src/codex/catalog/parsing.ts +90 -5
  88. package/src/codex/catalog/provider-models.ts +19 -2
  89. package/src/codex/catalog/reserve-warn.ts +96 -0
  90. package/src/codex/catalog/retained-sync.ts +41 -26
  91. package/src/codex/catalog/routed-gather.ts +61 -3
  92. package/src/codex/cli-installation-identity.ts +210 -0
  93. package/src/codex/cli-installation-targets.ts +158 -0
  94. package/src/codex/context-compat.ts +5 -2
  95. package/src/codex/convergence.ts +5 -0
  96. package/src/codex/desired-state.ts +4 -1
  97. package/src/codex/history-job.ts +6 -6
  98. package/src/codex/history-provider.ts +24 -167
  99. package/src/codex/history-rollout-read.ts +174 -0
  100. package/src/codex/history-state-open.ts +105 -0
  101. package/src/codex/inject/config-toml.ts +44 -2
  102. package/src/codex/inject.ts +3 -2
  103. package/src/codex/internal/catalog-writer.ts +33 -1
  104. package/src/codex/lineage.ts +83 -32
  105. package/src/codex/loopback-target.ts +31 -0
  106. package/src/codex/main-account-hard-lock.ts +2 -1
  107. package/src/codex/main-account.ts +10 -3
  108. package/src/codex/main-device-reauth.ts +17 -9
  109. package/src/codex/model-cache.ts +47 -0
  110. package/src/codex/model-entitlements.ts +80 -2
  111. package/src/codex/observed-model-denials.ts +230 -0
  112. package/src/codex/orca-auth-source.ts +94 -0
  113. package/src/codex/orca-import.ts +219 -0
  114. package/src/codex/prompt-text-probe.ts +289 -16
  115. package/src/codex/quota-401-recovery.ts +12 -0
  116. package/src/codex/quota-types.ts +65 -0
  117. package/src/codex/quota.ts +24 -19
  118. package/src/codex/routing/cooldown-math.ts +8 -47
  119. package/src/codex/routing/pin-drain.ts +57 -0
  120. package/src/codex/routing.ts +20 -16
  121. package/src/codex/shim.ts +1 -1
  122. package/src/codex/subagent-model-fallback.ts +114 -2
  123. package/src/codex/windows-installation-files.ts +224 -0
  124. package/src/combos/failover.ts +125 -5
  125. package/src/config/admitted-identity.ts +222 -0
  126. package/src/config/diagnostics.ts +43 -1
  127. package/src/config/feature-flags.ts +5 -0
  128. package/src/config/load-degrade.ts +33 -0
  129. package/src/config/pending-teardown.ts +8 -0
  130. package/src/config/process-state.ts +36 -3
  131. package/src/config/provider-relative-send-path.ts +16 -0
  132. package/src/config/proxy-env.ts +31 -7
  133. package/src/config/schema/compaction-triggers.ts +11 -0
  134. package/src/config/schema/config-schema.ts +25 -0
  135. package/src/config/schema/leaf-validators.ts +76 -17
  136. package/src/config.ts +2 -2
  137. package/src/generated/compatibility-version.json +410 -258
  138. package/src/generated/model-metadata.ts +1 -1
  139. package/src/grok/reset-coupons.ts +38 -19
  140. package/src/images/loop.ts +6 -1
  141. package/src/integrations/aside-profile-context.ts +37 -3
  142. package/src/integrations/aside-profile-journal.ts +68 -3
  143. package/src/integrations/aside-profiles.ts +128 -3
  144. package/src/integrations/mutation-plan.ts +815 -0
  145. package/src/integrations/writer.ts +85 -99
  146. package/src/lab/live/transport.ts +4 -0
  147. package/src/lab/live/types.ts +5 -0
  148. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  149. package/src/lib/admin-secrets.ts +9 -1
  150. package/src/lib/bounded-body.ts +4 -2
  151. package/src/lib/debug-log-buffer.ts +6 -1
  152. package/src/lib/debug.ts +23 -0
  153. package/src/lib/destination-policy.ts +48 -6
  154. package/src/lib/errors.ts +82 -15
  155. package/src/lib/http-response-semantics.ts +57 -0
  156. package/src/lib/lab-live-pinned-sender.ts +26 -12
  157. package/src/lib/local-destinations.ts +32 -5
  158. package/src/lib/pinned-http.ts +142 -2
  159. package/src/lib/plain-data.ts +103 -0
  160. package/src/lib/process-control.ts +13 -5
  161. package/src/lib/provider-outbound.ts +53 -5
  162. package/src/lib/proxy-env.ts +70 -3
  163. package/src/lib/request-execution-budget.ts +11 -3
  164. package/src/lib/response-body-inactivity.ts +193 -0
  165. package/src/lib/retry-delay.ts +69 -0
  166. package/src/lib/socks5-fetch.ts +741 -0
  167. package/src/lib/spend-ledger-owner.ts +364 -0
  168. package/src/lib/spend-reservation-ledger.ts +332 -35
  169. package/src/lib/windows-system-proxy.ts +16 -11
  170. package/src/lib/workflow-budget.ts +145 -8
  171. package/src/oauth/account-quota-rank.ts +72 -15
  172. package/src/oauth/callback-server.ts +4 -3
  173. package/src/oauth/generic-account-failover.ts +41 -27
  174. package/src/oauth/health.ts +12 -1
  175. package/src/oauth/index.ts +3 -107
  176. package/src/oauth/login-flow-state.ts +127 -0
  177. package/src/oauth/orcarouter.ts +15 -2
  178. package/src/oauth/store.ts +8 -0
  179. package/src/providers/codex-capacity.ts +9 -0
  180. package/src/providers/derive.ts +34 -17
  181. package/src/providers/devin-cli-authmode-migration.ts +14 -10
  182. package/src/providers/devin-provider-merge-migration.ts +33 -12
  183. package/src/providers/free-directory.ts +20 -2
  184. package/src/providers/key-failover.ts +327 -25
  185. package/src/providers/model-rename-migration.ts +56 -1
  186. package/src/providers/model-rename-startup.ts +7 -5
  187. package/src/providers/openai-sidecar.ts +4 -0
  188. package/src/providers/openai-virtual-models.ts +42 -2
  189. package/src/providers/opencode-go-transport.ts +14 -5
  190. package/src/providers/quota/antigravity.ts +22 -2
  191. package/src/providers/quota/report-cache.ts +3 -0
  192. package/src/providers/quota/vendor-probes-key.ts +1 -1
  193. package/src/providers/registry/entries-core.ts +39 -17
  194. package/src/providers/registry/entries-extended.ts +131 -1
  195. package/src/providers/registry/model-ids.ts +168 -0
  196. package/src/providers/registry/model-seeds.ts +87 -21
  197. package/src/providers/registry/types.ts +2 -0
  198. package/src/providers/resolved-model-policy-merge.ts +167 -0
  199. package/src/providers/resolved-model-policy.ts +406 -0
  200. package/src/providers/stale-vision-classification-migration.ts +137 -0
  201. package/src/responses/apply-patch-envelope.ts +32 -11
  202. package/src/responses/bridge-search-replay-cache.ts +152 -0
  203. package/src/responses/code-mode-helper-compat.ts +26 -16
  204. package/src/responses/custom-tool-compat.ts +1 -1
  205. package/src/responses/freeform-wrapper-scan.ts +279 -0
  206. package/src/responses/hosted-tool-policy.ts +85 -2
  207. package/src/responses/legacy-dotted-tool-name-repair.ts +134 -0
  208. package/src/responses/progressive-freeform-input.ts +130 -0
  209. package/src/responses/reasoning-envelope.ts +30 -0
  210. package/src/responses/schema.ts +9 -2
  211. package/src/responses/state.ts +5 -12
  212. package/src/responses/tool-name-aliases.ts +15 -1
  213. package/src/router.ts +91 -115
  214. package/src/routing/compatibility/behavior.ts +9 -0
  215. package/src/routing/compatibility/subject.ts +16 -1
  216. package/src/server/adapter-resolve.ts +9 -0
  217. package/src/server/auth-cors.ts +29 -0
  218. package/src/server/chat-completions.ts +13 -5
  219. package/src/server/chat-native-sse.ts +26 -9
  220. package/src/server/chat-native.ts +10 -4
  221. package/src/server/claude-messages.ts +30 -5
  222. package/src/server/effort-row.ts +11 -3
  223. package/src/server/grok-responses-control-frame.ts +160 -1
  224. package/src/server/gui-static.ts +36 -2
  225. package/src/server/inbound-body-admission.ts +187 -0
  226. package/src/server/index/serve-options.ts +86 -29
  227. package/src/server/index/spend-ledger-lifecycle.ts +66 -0
  228. package/src/server/index/websocket-handler.ts +6 -1
  229. package/src/server/index.ts +24 -25
  230. package/src/server/management/api-access.ts +3 -4
  231. package/src/server/management/aside-profile-routes.ts +266 -7
  232. package/src/server/management/config-routes.ts +48 -7
  233. package/src/server/management/context.ts +3 -0
  234. package/src/server/management/integration-routes.ts +287 -5
  235. package/src/server/management/metrics-routes.ts +20 -0
  236. package/src/server/management/model-rows.ts +224 -12
  237. package/src/server/management/provider-capability-config.ts +35 -7
  238. package/src/server/management/provider-routes.ts +70 -18
  239. package/src/server/management/route-registry.ts +14 -0
  240. package/src/server/management/shared.ts +10 -3
  241. package/src/server/management/system-restart.ts +7 -2
  242. package/src/server/management/system-routes.ts +2 -0
  243. package/src/server/management/usage-aggregate-cache.ts +4 -0
  244. package/src/server/management-api.ts +2 -0
  245. package/src/server/management-auth.ts +15 -1
  246. package/src/server/proxy-liveness.ts +97 -2
  247. package/src/server/readiness.ts +29 -10
  248. package/src/server/relay-eager.ts +24 -2
  249. package/src/server/relay.ts +136 -34
  250. package/src/server/request-log.ts +80 -3
  251. package/src/server/request-metrics.ts +236 -0
  252. package/src/server/responses/adapter-continuation.ts +74 -30
  253. package/src/server/responses/adapter-delivery.ts +39 -8
  254. package/src/server/responses/adapter-dispatch.ts +63 -30
  255. package/src/server/responses/compact.ts +89 -18
  256. package/src/server/responses/compaction-routing.ts +111 -0
  257. package/src/server/responses/core-codex-account.ts +90 -24
  258. package/src/server/responses/core-combo.ts +7 -7
  259. package/src/server/responses/core-normalize.ts +16 -15
  260. package/src/server/responses/core-opaque-recovery.ts +1 -0
  261. package/src/server/responses/core-options.ts +4 -0
  262. package/src/server/responses/encrypted-payload.ts +20 -2
  263. package/src/server/responses/fetch-helpers.ts +68 -2
  264. package/src/server/responses/passthrough-delivery.ts +41 -15
  265. package/src/server/responses/passthrough-dispatch.ts +145 -53
  266. package/src/server/responses/passthrough-execution.ts +11 -1
  267. package/src/server/responses/policy-fallback.ts +5 -13
  268. package/src/server/responses/request-prepare.ts +96 -17
  269. package/src/server/responses/request-send-budget.ts +89 -8
  270. package/src/server/responses/request-sidecar-auth.ts +17 -9
  271. package/src/server/responses/request-spend.ts +38 -9
  272. package/src/server/responses/request-transport.ts +15 -12
  273. package/src/server/responses/run-turn-execution.ts +45 -8
  274. package/src/server/responses/sidecar-execution.ts +19 -2
  275. package/src/server/responses/ws-upstream.ts +16 -28
  276. package/src/server/responses-custom-tool-repair.ts +29 -56
  277. package/src/server/responses-undeclared-tool-guard.ts +31 -1
  278. package/src/server/sse-frame-buffer.ts +12 -10
  279. package/src/server/sse-payload-rewrite.ts +37 -10
  280. package/src/server/system-env-shell.ts +5 -1
  281. package/src/server/system-env.ts +7 -1
  282. package/src/server/workflow-refusal.ts +56 -2
  283. package/src/service/cli.ts +16 -6
  284. package/src/service/guards.ts +10 -0
  285. package/src/service/health.ts +43 -0
  286. package/src/service/state.ts +7 -2
  287. package/src/tray/windows-tray.ps1 +155 -3
  288. package/src/types/accounts.ts +4 -0
  289. package/src/types/config.ts +111 -6
  290. package/src/types/provider.ts +37 -0
  291. package/src/types/request.ts +9 -1
  292. package/src/types/tools.ts +14 -0
  293. package/src/types/wire.ts +9 -1
  294. package/src/types.ts +1 -0
  295. package/src/usage/expected-prices.ts +28 -0
  296. package/src/usage/log.ts +87 -4
  297. package/src/vision/eligibility.ts +88 -9
  298. package/src/vision/plan.ts +34 -10
  299. package/src/web-search/executor.ts +41 -2
  300. package/src/web-search/loop.ts +6 -1
  301. package/src/web-search/passthrough-bridge.ts +39 -5
  302. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
  303. package/gui/dist/assets/index-C5-RdDmD.css +0 -1
@@ -29,7 +29,7 @@ import {
29
29
  } from "../claude/outbound";
30
30
  import { clearableDeadline, idleDeadline } from "../lib/abort";
31
31
  import { estimateTokens } from "../lib/token-estimate";
32
- import { NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router";
32
+ import { captureRouteStaticPolicy, NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router";
33
33
  import { evidenceFromBody } from "../routing/request-evidence";
34
34
  import { resolveWireProtocolOverride } from "./adapter-resolve";
35
35
  import type { OcxConfig } from "../types";
@@ -420,7 +420,7 @@ async function anthropicNativePassthrough(
420
420
  // count_tokens too — counts must match what the real send will contain, and the 32MB
421
421
  // body cap applies to it equally. Non-message bodies pass through untouched.
422
422
  if (Array.isArray(body.messages)) {
423
- await normalizeAnthropicImages(body.messages);
423
+ await normalizeAnthropicImages(body.messages, { abortSignal: req.signal });
424
424
  enforceAnthropicImageLimits(body.messages);
425
425
  }
426
426
  const headers = new Headers();
@@ -790,6 +790,22 @@ async function handleClaudeMessagesWithBudget(
790
790
 
791
791
  if (!requestedModel) requestedModel = (anthropicBody as Rec).model as string;
792
792
  const stream = internalBody.stream === true;
793
+ /**
794
+ * This proxy's count of the prompt it is about to forward, computed at most once.
795
+ *
796
+ * Two readers want it and they want it under different rules. The usage log takes it as a
797
+ * floor only for estimated-usage adapters, because its merge is `max(reported, estimate)` and
798
+ * would otherwise overwrite real usage. `message_start` takes it whenever the upstream sent
799
+ * no confirmed usage before the first frame, where nothing is merged and the terminal
800
+ * `message_delta` still corrects it (#4857).
801
+ */
802
+ let requestTokenFloor: number | undefined;
803
+ const claudeRequestTokenFloor = (): number => {
804
+ if (requestTokenFloor === undefined) {
805
+ requestTokenFloor = estimateClaudeRequestTokens(anthropicBody as Rec, requestedModel);
806
+ }
807
+ return requestTokenFloor;
808
+ };
793
809
  // Routed adapters only support streamed turns; always stream internally and fold
794
810
  // the translated Anthropic SSE into a message JSON for non-streaming clients.
795
811
  internalBody.stream = true;
@@ -801,7 +817,10 @@ async function handleClaudeMessagesWithBudget(
801
817
  const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody));
802
818
  // Settle the wire once so the sampling decision below reads the effective
803
819
  // adapter rather than the provider-wide default (#404).
804
- route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic");
820
+ route.staticPolicy = captureRouteStaticPolicy(
821
+ route.providerName, route.modelId, route.provider, route.staticPolicy.effectiveAlias, "anthropic",
822
+ );
823
+ route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "anthropic", route.staticPolicy);
805
824
  logCtx.routeDecision = route.routeDecision;
806
825
  if (route.provider.adapter === "openai-responses") {
807
826
  delete internalBody.max_output_tokens;
@@ -815,7 +834,7 @@ async function handleClaudeMessagesWithBudget(
815
834
  // accurate-usage adapters — the request-log merge is max(reported, estimate) and
816
835
  // would overwrite real usage (audit 133 R1#7).
817
836
  if (route.provider.adapter === "cursor" || route.provider.adapter === "kiro") {
818
- logCtx.usageLogInputTokens = estimateClaudeRequestTokens(anthropicBody as Rec, requestedModel);
837
+ logCtx.usageLogInputTokens = claudeRequestTokenFloor();
819
838
  }
820
839
  // Effort safety valve (devlog 136 B6, audit 139 R2#2): opus-shaped aliases make
821
840
  // every routed model look like a reasoning model to Claude clients, so a forced
@@ -988,7 +1007,13 @@ async function handleClaudeMessagesWithBudget(
988
1007
 
989
1008
  const contentType = response.headers.get("content-type") ?? "";
990
1009
  if (contentType.includes("text/event-stream") && response.body) {
991
- const anthropicSse = responsesSseToAnthropicSse(response.body, requestedModel, { translatorBudget });
1010
+ const anthropicSse = responsesSseToAnthropicSse(response.body, requestedModel, {
1011
+ translatorBudget,
1012
+ // Only a floor, and only for the first frame: an upstream that reports usage early wins
1013
+ // over it inside the translator, and the terminal `message_delta` carries the
1014
+ // authoritative count either way (#4857).
1015
+ inputTokenFloor: claudeRequestTokenFloor(),
1016
+ });
992
1017
  if (stream) {
993
1018
  return new Response(anthropicSse, {
994
1019
  status: 200,
@@ -30,7 +30,9 @@ export interface EffortRowOptions {
30
30
  }
31
31
 
32
32
  export function isKnownId(knownIds: EffortRowKnownIds | undefined, id: string): boolean {
33
- return typeof knownIds === "function" ? knownIds(id) : knownIds?.has(id) === true;
33
+ return typeof knownIds === "function"
34
+ ? knownIds(id)
35
+ : knownIds?.has(id) === true || knownIds?.has(id.toLowerCase()) === true;
34
36
  }
35
37
 
36
38
  export function effortRowId(baseId: string, effort: string): string {
@@ -43,6 +45,12 @@ export function effortRowId(baseId: string, effort: string): string {
43
45
  */
44
46
  export function knownEffortRowIds(config: OcxConfig): Set<string> {
45
47
  const ids = new Set<string>();
48
+ const addAlias = (id: string): void => {
49
+ ids.add(id);
50
+ // Model aliases are resolved case-insensitively. Preserve that contract here so an
51
+ // alias cannot be mistaken for a synthetic effort row before ordinary routing runs.
52
+ ids.add(id.toLowerCase());
53
+ };
46
54
  for (const [providerName, provider] of Object.entries(config.providers)) {
47
55
  const known = knownModelIdsForProvider(providerName, provider, config);
48
56
  const namespaces = [providerName, provider.alias].filter((value): value is string => (
@@ -54,8 +62,8 @@ export function knownEffortRowIds(config: OcxConfig): Set<string> {
54
62
  for (const namespace of namespaces) ids.add(`${namespace}/${id}`);
55
63
  }
56
64
  for (const alias of Object.values(provider.modelAliases ?? {})) {
57
- ids.add(alias);
58
- for (const namespace of namespaces) ids.add(`${namespace}/${alias}`);
65
+ addAlias(alias);
66
+ for (const namespace of namespaces) addAlias(`${namespace}/${alias}`);
59
67
  }
60
68
  }
61
69
  for (const [id, combo] of Object.entries(config.combos ?? {})) {
@@ -1,10 +1,137 @@
1
- import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
1
+ import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite";
2
2
 
3
3
  const GROK_CONTROL_FRAME_TYPES: Record<string, true> = {
4
4
  "codex.rate_limits": true,
5
5
  "codex.response.metadata": true,
6
6
  };
7
7
 
8
+ const GROK_RESPONSE_INTEGER_TIMESTAMP_FIELDS = ["created_at", "completed_at"] as const;
9
+
10
+ type JsonRange = { start: number; end: number };
11
+
12
+ function skipJsonWhitespace(payload: string, index: number): number {
13
+ while (index < payload.length && /[\t\n\r ]/.test(payload[index] ?? "")) index += 1;
14
+ return index;
15
+ }
16
+
17
+ function findJsonStringEnd(payload: string, start: number): number | undefined {
18
+ for (let index = start + 1; index < payload.length; index += 1) {
19
+ if (payload[index] === "\\") index += 1;
20
+ else if (payload[index] === '"') return index + 1;
21
+ }
22
+ return undefined;
23
+ }
24
+
25
+ function findJsonValueEnd(payload: string, start: number): number | undefined {
26
+ if (payload[start] === '"') return findJsonStringEnd(payload, start);
27
+ if (payload[start] !== "{" && payload[start] !== "[") {
28
+ let index = start;
29
+ while (index < payload.length && !",}]".includes(payload[index] ?? "")) index += 1;
30
+ return index;
31
+ }
32
+
33
+ let depth = 0;
34
+ let inString = false;
35
+ for (let index = start; index < payload.length; index += 1) {
36
+ const char = payload[index];
37
+ if (inString) {
38
+ if (char === "\\") index += 1;
39
+ else if (char === '"') inString = false;
40
+ continue;
41
+ }
42
+ if (char === '"') inString = true;
43
+ else if (char === "{" || char === "[") depth += 1;
44
+ else if (char === "}" || char === "]") {
45
+ depth -= 1;
46
+ if (depth === 0) return index + 1;
47
+ }
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ function findResponseObjectRange(payload: string): JsonRange | null {
53
+ let cursor = skipJsonWhitespace(payload, 0);
54
+ if (payload[cursor] !== "{") return null;
55
+ cursor += 1;
56
+ let responseRange: JsonRange | null = null;
57
+
58
+ while (cursor < payload.length) {
59
+ cursor = skipJsonWhitespace(payload, cursor);
60
+ if (payload[cursor] === "}") return responseRange;
61
+ if (payload[cursor] !== '"') return null;
62
+ const keyEnd = findJsonStringEnd(payload, cursor);
63
+ if (keyEnd === undefined) return null;
64
+ let key: unknown;
65
+ try {
66
+ key = JSON.parse(payload.slice(cursor, keyEnd));
67
+ } catch {
68
+ return null;
69
+ }
70
+
71
+ cursor = skipJsonWhitespace(payload, keyEnd);
72
+ if (payload[cursor] !== ":") return null;
73
+ const valueStart = skipJsonWhitespace(payload, cursor + 1);
74
+ const valueEnd = findJsonValueEnd(payload, valueStart);
75
+ if (valueEnd === undefined) return null;
76
+ if (key === "response" && payload[valueStart] === "{") {
77
+ responseRange = { start: valueStart, end: valueEnd };
78
+ }
79
+
80
+ cursor = skipJsonWhitespace(payload, valueEnd);
81
+ if (payload[cursor] === "}") return responseRange;
82
+ if (payload[cursor] !== ",") return null;
83
+ cursor += 1;
84
+ }
85
+ return null;
86
+ }
87
+
88
+ function rewriteIntegralTimestamp(payload: string, field: string, value: number, event: unknown): string | null {
89
+ const responseRange = findResponseObjectRange(payload);
90
+ if (responseRange === null) return null;
91
+ let cursor = responseRange.start + 1;
92
+ let candidate: { start: number; length: number } | null = null;
93
+ while (cursor < responseRange.end) {
94
+ cursor = skipJsonWhitespace(payload, cursor);
95
+ if (payload[cursor] === "}") break;
96
+ if (payload[cursor] !== '"') return null;
97
+ const keyEnd = findJsonStringEnd(payload, cursor);
98
+ if (keyEnd === undefined) return null;
99
+ let key: unknown;
100
+ try {
101
+ key = JSON.parse(payload.slice(cursor, keyEnd));
102
+ } catch {
103
+ return null;
104
+ }
105
+ cursor = skipJsonWhitespace(payload, keyEnd);
106
+ if (payload[cursor] !== ":") return null;
107
+ const valueStart = skipJsonWhitespace(payload, cursor + 1);
108
+ const valueEnd = findJsonValueEnd(payload, valueStart);
109
+ if (valueEnd === undefined || valueEnd > responseRange.end) return null;
110
+ const valueToken = payload.slice(valueStart, valueEnd).trimEnd();
111
+ if (
112
+ key === field &&
113
+ /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/.test(valueToken) &&
114
+ valueToken !== String(value) &&
115
+ Number(valueToken) === value
116
+ ) {
117
+ if (candidate !== null) return null;
118
+ candidate = { start: valueStart, length: valueToken.length };
119
+ }
120
+ cursor = skipJsonWhitespace(payload, valueEnd);
121
+ if (payload[cursor] === "}") break;
122
+ if (payload[cursor] !== ",") return null;
123
+ cursor += 1;
124
+ }
125
+ if (candidate === null) return payload;
126
+
127
+ const rewritten = `${payload.slice(0, candidate.start)}${value}${payload.slice(candidate.start + candidate.length)}`;
128
+ try {
129
+ return JSON.stringify(JSON.parse(rewritten)) === JSON.stringify(event) ? rewritten : null;
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
8
135
  /**
9
136
  * Hide Codex-only control frames from Grok's strict Responses decoder.
10
137
  *
@@ -41,3 +168,35 @@ export function createGrokResponsesControlFrameBlockRewrite(): SseBlockRewrite {
41
168
  : [block];
42
169
  };
43
170
  }
171
+
172
+ /** Normalize safe integer response timestamps for Grok's strict Responses decoder. */
173
+ export function createGrokResponsesTimestampBlockRewrite(): SseBlockRewrite {
174
+ return (block) => {
175
+ const payload = sseDataPayload(block);
176
+ if (payload === null || payload === "[DONE]") return [block];
177
+
178
+ let event: unknown;
179
+ try {
180
+ event = JSON.parse(payload);
181
+ } catch {
182
+ return [block];
183
+ }
184
+ if (!event || typeof event !== "object" || Array.isArray(event)) return [block];
185
+ const eventRecord = event as Record<string, unknown>;
186
+ if (typeof eventRecord.type !== "string" || !eventRecord.type.startsWith("response.")) return [block];
187
+ if (!eventRecord.response || typeof eventRecord.response !== "object" || Array.isArray(eventRecord.response)) {
188
+ return [block];
189
+ }
190
+ const response = eventRecord.response as Record<string, unknown>;
191
+ let rewrittenPayload = payload;
192
+ for (const field of GROK_RESPONSE_INTEGER_TIMESTAMP_FIELDS) {
193
+ const value = response[field];
194
+ if (value === undefined) continue;
195
+ if (typeof value !== "number" || value < 0 || !Number.isSafeInteger(value)) return [block];
196
+ const rewritten = rewriteIntegralTimestamp(rewrittenPayload, field, value, event);
197
+ if (rewritten === null) return [block];
198
+ rewrittenPayload = rewritten;
199
+ }
200
+ return rewrittenPayload === payload ? [block] : [replaceSseDataPayload(block, rewrittenPayload)];
201
+ };
202
+ }
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
- import { extname, isAbsolute, join, relative, resolve } from "node:path";
2
+ import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import { browserSecurityHeaders } from "./auth-cors";
4
4
  import type { GuiSessionBootstrap } from "./gui-session";
5
5
 
@@ -18,6 +18,12 @@ const MIME_TYPES: Record<string, string> = {
18
18
  ".ico": "image/x-icon",
19
19
  };
20
20
 
21
+ /**
22
+ * Matches Vite's content-hashed bundle filenames (e.g. `index-B5r7LNHN.js` or `style-D5SiRo8X.css`).
23
+ * Unhashed static assets (e.g. `runtime-config.js` or unversioned icons) must not be cached immutably.
24
+ */
25
+ const HASHED_ASSET_PATTERN = /-[a-zA-Z0-9_-]{8,}\.[a-zA-Z0-9]+$/;
26
+
21
27
  function findGuiDist(): string | null {
22
28
  const candidates = [
23
29
  join(import.meta.dir, "..", "..", "gui", "dist"),
@@ -146,6 +152,14 @@ export function serveSessionBootstrap(session: GuiSessionBootstrap): Response {
146
152
  return htmlDocumentResponse(html);
147
153
  }
148
154
 
155
+ /**
156
+ * Serves a GUI static file from the distribution directory.
157
+ * Returns a Response with MIME type and appropriate Cache-Control headers:
158
+ * - HTML files receive `no-store` to guarantee immediate bootstrap updates.
159
+ * - Content-hashed bundles under `assets/` receive 1-year `immutable` caching.
160
+ * - Non-hashed static files (e.g. favicon, unhashed assets) receive `no-cache` for prompt revalidation.
161
+ * Returns null if the file cannot be found or resolved.
162
+ */
149
163
  export function serveGuiFile(
150
164
  pathname: string,
151
165
  guiDist = findGuiDist(),
@@ -170,11 +184,31 @@ export function serveGuiFile(
170
184
  const ext = extname(filePath);
171
185
  const contentType = MIME_TYPES[ext] || "application/octet-stream";
172
186
  if (ext === ".html") return htmlResponse(filePath, session, runtimeRole, managementAuthRequired);
187
+
188
+ const root = resolve(guiDist);
189
+ // "assets/" matches Vite's default assetsDir (gui/vite.config.ts). If that value is
190
+ // ever changed, update this prefix to match. filePath is already resolve()-normalized
191
+ // by resolveGuiFilePath, so rel cannot contain ".." fragments.
192
+ //
193
+ // Immutable 1-year caching is only applied when the file is under assets/ AND its basename
194
+ // matches Vite's content-hash pattern (e.g. index-B5r7LNHN.js). Any unhashed asset under
195
+ // assets/ (e.g. runtime-config.js) or elsewhere falls back to no-cache so clients revalidate,
196
+ // whereas index.html uses no-store above to avoid storing any bootstrap state.
197
+ const rel = relative(root, filePath).replace(/\\/g, "/");
198
+ const isHashedAsset = rel.startsWith("assets/") && HASHED_ASSET_PATTERN.test(basename(filePath));
199
+ const cacheControl = isHashedAsset
200
+ ? "public, max-age=31536000, immutable"
201
+ : "no-cache";
202
+
173
203
  // Snapshot bytes before returning the response. Bun.file is lazy: if gui/dist is replaced
174
204
  // after Bun frames the response but before the stream finishes, its Content-Length can
175
205
  // describe the old file while the body comes from the new one (#2792).
176
206
  return new Response(readFileSync(filePath), {
177
- headers: { "Content-Type": contentType, ...browserSecurityHeaders() },
207
+ headers: {
208
+ "Content-Type": contentType,
209
+ "Cache-Control": cacheControl,
210
+ ...browserSecurityHeaders(),
211
+ },
178
212
  });
179
213
  }
180
214
 
@@ -0,0 +1,187 @@
1
+ import {
2
+ MAX_CONFIGURABLE_INBOUND_BODY_BYTES,
3
+ MAX_DECOMPRESSED_BODY_BYTES,
4
+ resolveInboundBodyLimitBytes,
5
+ } from "./request-decompress";
6
+ import { withCors, type RequestPolicyView } from "./auth-cors";
7
+ import { codexCompatibleUrl } from "../codex/context-compat";
8
+ import { addFinalRequestLog, markLocalRequestLogRefusal } from "./request-log";
9
+ import type { WorkflowRefusalLog } from "./workflow-refusal";
10
+
11
+ /** Admission units, not a bound on process RSS or the size of parsed object graphs. */
12
+ export const MAX_CONCURRENT_INBOUND_BODY_BYTES = MAX_CONFIGURABLE_INBOUND_BODY_BYTES;
13
+ let reservedInboundBodyBytes = 0;
14
+
15
+ // Match the HTTP routes whose JSON readers use maxInboundBodyBytes. Keep the
16
+ // current image/search/count_tokens support; management, audio, context relay,
17
+ // and WebSocket frames have separate limits and are not part of this gate.
18
+ // Exported so the contract tests classify the dispatcher's own route list
19
+ // instead of copying it: a new loopback route has to land in one of these two
20
+ // sets, or `tests/server/server-request-body-size.test.ts` fails.
21
+ export const CONFIGURABLE_JSON_BODY_ROUTES: ReadonlySet<string> = new Set([
22
+ "/v1/responses",
23
+ "/v1/responses/compact",
24
+ "/v1/chat/completions",
25
+ "/v1/messages",
26
+ "/v1/messages/count_tokens",
27
+ "/v1/images/generations",
28
+ "/v1/images/edits",
29
+ "/v1/alpha/search",
30
+ ]);
31
+
32
+ /**
33
+ * Loopback routes deliberately outside this gate. Audio uploads, realtime/live
34
+ * control and the model list do not read JSON under `maxInboundBodyBytes`, so
35
+ * reserving that allowance for them would refuse traffic the limit never covered.
36
+ */
37
+ export const UNGATED_LOOPBACK_ROUTES: ReadonlySet<string> = new Set([
38
+ "/v1/audio/transcriptions",
39
+ "/v1/audio/transcriptions/stream",
40
+ "/v1/models",
41
+ "/v1/realtime",
42
+ "/v1/realtime/calls",
43
+ "/v1/live",
44
+ ]);
45
+
46
+ export class InboundBodyCapacityError extends Error {
47
+ constructor() {
48
+ super("OpenCodex inbound request body capacity is temporarily exhausted. Retry after the current request completes.");
49
+ this.name = "InboundBodyCapacityError";
50
+ }
51
+ }
52
+
53
+ function cancelUnreadBody(req: Request, reason: unknown): void {
54
+ if (!req.body || req.body.locked) return;
55
+ try {
56
+ // A tee's cancellation can wait for the other branch. Refusal must not wait
57
+ // for a peer that has not finished uploading, or for an unconsumed clone.
58
+ void req.body.cancel(reason).catch(() => undefined);
59
+ } catch { /* Non-standard streams may throw synchronously. */ }
60
+ }
61
+
62
+ function capacityResponse(pathname: string, error: InboundBodyCapacityError): Response {
63
+ const anthropic = pathname === "/v1/messages" || pathname === "/v1/messages/count_tokens";
64
+ return Response.json({
65
+ ...(anthropic ? { type: "error" } : {}),
66
+ error: {
67
+ type: anthropic ? "overloaded_error" : "server_error",
68
+ code: "server_busy",
69
+ message: error.message,
70
+ },
71
+ }, { status: 503, headers: { "Retry-After": "1" } });
72
+ }
73
+
74
+ function retainUntilResponseSettles(response: Response, release: () => void): Response {
75
+ if (!response.body) {
76
+ release();
77
+ return response;
78
+ }
79
+ const reader = response.body.getReader();
80
+ let cancelling = false;
81
+ let finalized = false;
82
+ const finalize = (): void => {
83
+ if (finalized) return;
84
+ finalized = true;
85
+ try { reader.releaseLock(); } finally { release(); }
86
+ };
87
+ const body = new ReadableStream<Uint8Array>({
88
+ async pull(controller) {
89
+ try {
90
+ const result = await reader.read();
91
+ // cancel() can resolve a pending read as EOF before the producer's
92
+ // asynchronous cancellation completes. Only cancel owns release then.
93
+ if (cancelling) return;
94
+ if (result.done) {
95
+ finalize();
96
+ controller.close();
97
+ } else {
98
+ controller.enqueue(result.value);
99
+ }
100
+ } catch (error) {
101
+ if (cancelling) return;
102
+ finalize();
103
+ controller.error(error);
104
+ }
105
+ },
106
+ async cancel(reason) {
107
+ cancelling = true;
108
+ try { await reader.cancel(reason); } finally { finalize(); }
109
+ },
110
+ }, { highWaterMark: 0 });
111
+ // This is the outermost HTTP wrapper, after protocol conversion, relay
112
+ // preflight and request logging; no internal relay marker is consumed later.
113
+ return new Response(body, {
114
+ status: response.status,
115
+ statusText: response.statusText,
116
+ headers: response.headers,
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Own one raised-limit HTTP request from before body reading through response
122
+ * EOF/error or settled cancellation. Call only at the admitted HTTP boundary,
123
+ * after auth/origin checks, not from readers or internal combo/translation hops.
124
+ * pathname is the same canonical pathname used by the server's dispatcher.
125
+ */
126
+ export async function withRaisedInboundBodyAdmission(
127
+ req: Request,
128
+ pathname: string,
129
+ configuredLimit: number | undefined,
130
+ work: () => Promise<Response>,
131
+ onRefusal: (response: Response) => Response = response => response,
132
+ ): Promise<Response> {
133
+ const maxBytes = resolveInboundBodyLimitBytes(configuredLimit);
134
+ if (req.method !== "POST" || !CONFIGURABLE_JSON_BODY_ROUTES.has(pathname)
135
+ || maxBytes <= MAX_DECOMPRESSED_BODY_BYTES || req.signal.aborted) {
136
+ return work();
137
+ }
138
+ const declared = req.headers.get("content-length");
139
+ const declaredBytes = declared === null || declared.trim() === "" ? NaN : Number(declared);
140
+ if (Number.isFinite(declaredBytes) && declaredBytes > maxBytes) {
141
+ // The existing bounded reader rejects this before allocating. Let its
142
+ // protocol-specific 413 win over temporary capacity refusal, unchanged.
143
+ return work();
144
+ }
145
+ if (maxBytes > MAX_CONCURRENT_INBOUND_BODY_BYTES - reservedInboundBodyBytes) {
146
+ const error = new InboundBodyCapacityError();
147
+ cancelUnreadBody(req, error);
148
+ return onRefusal(capacityResponse(pathname, error));
149
+ }
150
+ reservedInboundBodyBytes += maxBytes;
151
+ let released = false;
152
+ const release = (): void => {
153
+ if (released) return;
154
+ released = true;
155
+ reservedInboundBodyBytes -= maxBytes;
156
+ };
157
+ try {
158
+ return retainUntilResponseSettles(await work(), release);
159
+ } catch (error) {
160
+ release();
161
+ throw error;
162
+ }
163
+ }
164
+
165
+ /**
166
+ * The admitted HTTP call site. Refusal is recorded as a local terminal and
167
+ * carries the receiving listener's CORS headers, so a browser dashboard can
168
+ * read it. No request content is read or logged on this path.
169
+ */
170
+ export function runAdmittedBodyWork(
171
+ req: Request,
172
+ policy: RequestPolicyView,
173
+ configuredLimit: number | undefined,
174
+ work: () => Promise<Response>,
175
+ refusalLog?: WorkflowRefusalLog,
176
+ ): Promise<Response> {
177
+ return withRaisedInboundBodyAdmission(req, codexCompatibleUrl(req.url).pathname, configuredLimit, work, refused => {
178
+ if (refusalLog) {
179
+ markLocalRequestLogRefusal(refusalLog.logCtx, "server_busy");
180
+ refusalLog.logCtx.errorCode = "server_busy";
181
+ addFinalRequestLog(refusalLog.requestId, refusalLog.start, refusalLog.logCtx, refused.status, {
182
+ closeReason: "terminal",
183
+ });
184
+ }
185
+ return withCors(refused, req, policy);
186
+ });
187
+ }