@bitkyc08/opencodex 2.58.0 → 2.59.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 (188) hide show
  1. package/README.md +28 -10
  2. package/gui/dist/assets/index-C5IebErG.js +136 -0
  3. package/gui/dist/assets/{index-C5-RdDmD.css → index-OESInAjC.css} +1 -1
  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 +1 -1
  8. package/src/adapters/base.ts +11 -1
  9. package/src/adapters/cursor/catalog.ts +11 -0
  10. package/src/adapters/cursor/effort-map.ts +16 -2
  11. package/src/adapters/cursor/envelope-echo.ts +55 -2
  12. package/src/adapters/cursor/message-mapper.ts +3 -2
  13. package/src/adapters/cursor/protobuf-request.ts +8 -5
  14. package/src/adapters/cursor/request-builder.ts +14 -3
  15. package/src/adapters/cursor/thread-continuity.ts +105 -31
  16. package/src/adapters/cursor/tool-guidance.ts +5 -4
  17. package/src/adapters/cursor.ts +42 -1
  18. package/src/adapters/devin/cloud-direct/chat.ts +11 -2
  19. package/src/adapters/devin/cloud-direct/index.ts +7 -0
  20. package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +103 -0
  21. package/src/adapters/devin.ts +75 -13
  22. package/src/adapters/google-antigravity-wire.ts +29 -2
  23. package/src/adapters/google-http.ts +8 -1
  24. package/src/adapters/google.ts +23 -4
  25. package/src/adapters/openai-chat/response-events.ts +61 -0
  26. package/src/adapters/openai-chat.ts +5 -10
  27. package/src/adapters/openai-responses/passthrough.ts +10 -1
  28. package/src/adapters/openai-responses/tool-output-recovery.ts +75 -0
  29. package/src/adapters/openai-responses/tool-schema.ts +19 -7
  30. package/src/adapters/responses-tool-schema.ts +76 -46
  31. package/src/adapters/run-turn-queue.ts +17 -4
  32. package/src/bridge/response-json.ts +1 -1
  33. package/src/bridge/sse.ts +165 -24
  34. package/src/claude/context-windows.ts +22 -0
  35. package/src/claude/outbound.ts +35 -4
  36. package/src/cli/account-api.ts +4 -3
  37. package/src/cli/account-extended.ts +22 -2
  38. package/src/cli/account-orca-import.ts +63 -0
  39. package/src/cli/account.ts +32 -4
  40. package/src/cli/capabilities.ts +40 -0
  41. package/src/cli/claude.ts +29 -1
  42. package/src/cli/codex-cli-update.ts +97 -2
  43. package/src/cli/dispatch.ts +54 -0
  44. package/src/cli/doctor.ts +197 -2
  45. package/src/cli/help.ts +4 -1
  46. package/src/cli/index.ts +88 -20
  47. package/src/cli/models-runtime.ts +33 -4
  48. package/src/cli/registry.ts +11 -1
  49. package/src/cli/runtime-api.ts +44 -0
  50. package/src/cli/start-args.ts +94 -0
  51. package/src/cli/system-command.ts +2 -0
  52. package/src/client/machine-api.ts +4 -3
  53. package/src/client/machine-listener.ts +14 -1
  54. package/src/clients/config-export/constants.ts +2 -3
  55. package/src/clients/config-export.ts +5 -5
  56. package/src/codex/account-store.ts +81 -5
  57. package/src/codex/auth-api/pool-quota-probe.ts +14 -3
  58. package/src/codex/auth-api/routes.ts +17 -2
  59. package/src/codex/auth-context.ts +16 -12
  60. package/src/codex/catalog/build-entries.ts +25 -4
  61. package/src/codex/catalog/derive-entry.ts +8 -1
  62. package/src/codex/catalog/effort.ts +10 -6
  63. package/src/codex/catalog/gather-capture.ts +1 -0
  64. package/src/codex/catalog/model-hints.ts +37 -5
  65. package/src/codex/catalog/parsing.ts +83 -5
  66. package/src/codex/catalog/reserve-warn.ts +96 -0
  67. package/src/codex/catalog/retained-sync.ts +19 -0
  68. package/src/codex/catalog/routed-gather.ts +42 -3
  69. package/src/codex/cli-installation-identity.ts +210 -0
  70. package/src/codex/cli-installation-targets.ts +158 -0
  71. package/src/codex/convergence.ts +5 -0
  72. package/src/codex/history-provider.ts +4 -1
  73. package/src/codex/history-state-open.ts +105 -0
  74. package/src/codex/inject/config-toml.ts +44 -2
  75. package/src/codex/inject.ts +3 -2
  76. package/src/codex/lineage.ts +83 -32
  77. package/src/codex/loopback-target.ts +31 -0
  78. package/src/codex/main-account-hard-lock.ts +2 -1
  79. package/src/codex/main-account.ts +10 -3
  80. package/src/codex/main-device-reauth.ts +17 -9
  81. package/src/codex/model-entitlements.ts +60 -1
  82. package/src/codex/observed-model-denials.ts +137 -0
  83. package/src/codex/orca-auth-source.ts +94 -0
  84. package/src/codex/orca-import.ts +219 -0
  85. package/src/codex/prompt-text-probe.ts +282 -12
  86. package/src/codex/quota-401-recovery.ts +12 -0
  87. package/src/codex/quota-types.ts +65 -0
  88. package/src/codex/quota.ts +24 -19
  89. package/src/codex/routing/cooldown-math.ts +8 -47
  90. package/src/codex/routing/pin-drain.ts +57 -0
  91. package/src/codex/routing.ts +13 -15
  92. package/src/codex/subagent-model-fallback.ts +94 -0
  93. package/src/codex/windows-installation-files.ts +224 -0
  94. package/src/combos/failover.ts +122 -5
  95. package/src/config/diagnostics.ts +21 -0
  96. package/src/config/load-degrade.ts +15 -0
  97. package/src/config/pending-teardown.ts +8 -0
  98. package/src/config/process-state.ts +36 -3
  99. package/src/config/provider-relative-send-path.ts +16 -0
  100. package/src/config/proxy-env.ts +23 -5
  101. package/src/config/schema/config-schema.ts +21 -0
  102. package/src/config/schema/leaf-validators.ts +64 -17
  103. package/src/generated/compatibility-version.json +235 -163
  104. package/src/generated/model-metadata.ts +1 -1
  105. package/src/lib/bounded-body.ts +4 -2
  106. package/src/lib/destination-policy.ts +48 -6
  107. package/src/lib/errors.ts +3 -15
  108. package/src/lib/local-destinations.ts +32 -5
  109. package/src/lib/provider-outbound.ts +3 -3
  110. package/src/lib/proxy-env.ts +70 -3
  111. package/src/lib/request-execution-budget.ts +11 -3
  112. package/src/lib/response-body-inactivity.ts +193 -0
  113. package/src/lib/retry-delay.ts +69 -0
  114. package/src/lib/socks5-fetch.ts +631 -0
  115. package/src/lib/spend-reservation-ledger.ts +115 -9
  116. package/src/lib/workflow-budget.ts +145 -8
  117. package/src/oauth/account-quota-rank.ts +72 -15
  118. package/src/oauth/generic-account-failover.ts +40 -27
  119. package/src/oauth/orcarouter.ts +15 -2
  120. package/src/oauth/store.ts +8 -0
  121. package/src/providers/codex-capacity.ts +9 -0
  122. package/src/providers/devin-provider-merge-migration.ts +33 -12
  123. package/src/providers/free-directory.ts +20 -2
  124. package/src/providers/key-failover.ts +261 -7
  125. package/src/providers/model-rename-migration.ts +1 -0
  126. package/src/providers/openai-sidecar.ts +4 -0
  127. package/src/providers/opencode-go-transport.ts +14 -5
  128. package/src/providers/quota/report-cache.ts +3 -0
  129. package/src/providers/registry/entries-extended.ts +96 -0
  130. package/src/providers/registry/model-seeds.ts +78 -21
  131. package/src/responses/apply-patch-envelope.ts +44 -11
  132. package/src/responses/bridge-search-replay-cache.ts +152 -0
  133. package/src/responses/code-mode-helper-compat.ts +26 -16
  134. package/src/responses/custom-tool-compat.ts +1 -1
  135. package/src/responses/hosted-tool-policy.ts +85 -2
  136. package/src/responses/schema.ts +9 -2
  137. package/src/server/auth-cors.ts +26 -0
  138. package/src/server/chat-completions.ts +9 -4
  139. package/src/server/chat-native-sse.ts +26 -9
  140. package/src/server/chat-native.ts +10 -4
  141. package/src/server/claude-messages.ts +24 -2
  142. package/src/server/gui-static.ts +36 -2
  143. package/src/server/inbound-body-admission.ts +187 -0
  144. package/src/server/index.ts +15 -19
  145. package/src/server/management/api-access.ts +3 -4
  146. package/src/server/management/config-routes.ts +31 -6
  147. package/src/server/management/provider-capability-config.ts +35 -7
  148. package/src/server/management/provider-routes.ts +70 -18
  149. package/src/server/proxy-liveness.ts +97 -2
  150. package/src/server/relay.ts +17 -24
  151. package/src/server/request-log.ts +25 -1
  152. package/src/server/responses/adapter-continuation.ts +71 -27
  153. package/src/server/responses/adapter-delivery.ts +39 -8
  154. package/src/server/responses/adapter-dispatch.ts +52 -24
  155. package/src/server/responses/compact.ts +60 -11
  156. package/src/server/responses/core-codex-account.ts +83 -22
  157. package/src/server/responses/core-normalize.ts +12 -5
  158. package/src/server/responses/fetch-helpers.ts +68 -2
  159. package/src/server/responses/passthrough-delivery.ts +10 -1
  160. package/src/server/responses/passthrough-dispatch.ts +113 -48
  161. package/src/server/responses/passthrough-execution.ts +11 -1
  162. package/src/server/responses/request-prepare.ts +29 -0
  163. package/src/server/responses/request-send-budget.ts +84 -7
  164. package/src/server/responses/request-sidecar-auth.ts +16 -8
  165. package/src/server/responses/request-spend.ts +38 -9
  166. package/src/server/responses/request-transport.ts +13 -10
  167. package/src/server/responses/run-turn-execution.ts +20 -5
  168. package/src/server/responses/sidecar-execution.ts +2 -0
  169. package/src/server/responses/ws-upstream.ts +2 -1
  170. package/src/server/responses-custom-tool-repair.ts +2 -2
  171. package/src/server/sse-frame-buffer.ts +12 -10
  172. package/src/server/sse-payload-rewrite.ts +36 -9
  173. package/src/server/system-env-shell.ts +5 -1
  174. package/src/server/system-env.ts +7 -1
  175. package/src/server/workflow-refusal.ts +56 -2
  176. package/src/service/cli.ts +16 -6
  177. package/src/service/guards.ts +10 -0
  178. package/src/service/health.ts +43 -0
  179. package/src/service/state.ts +7 -2
  180. package/src/types/accounts.ts +4 -0
  181. package/src/types/config.ts +100 -3
  182. package/src/types/provider.ts +19 -0
  183. package/src/types/request.ts +7 -1
  184. package/src/types/wire.ts +9 -1
  185. package/src/usage/expected-prices.ts +28 -0
  186. package/src/usage/log.ts +87 -4
  187. package/src/web-search/passthrough-bridge.ts +39 -5
  188. package/gui/dist/assets/index-BbrHOIY0.js +0 -128
@@ -1,3 +1,4 @@
1
+ import { providerRelativeSendPathConfigError } from "../config/provider-relative-send-path";
1
2
  import { modelCapabilitiesConfigError } from "../config/provider-validation";
2
3
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3
4
  import { initialModelSelection } from "../providers/initial-model-selection";
@@ -29,6 +30,7 @@ import {
29
30
  } from "../config/provider-validation";
30
31
  import { providerDestinationConfigError } from "../lib/destination-policy";
31
32
  import { redactSecretString } from "../lib/redact";
33
+ import { DECLARABLE_HOSTED_TOOL_TYPES } from "../responses/hosted-tool-policy";
32
34
  import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry";
33
35
  import { providerConfigSeed } from "../providers/derive";
34
36
  import type { OcxConfig, OcxProviderConfig } from "../types";
@@ -754,6 +756,10 @@ export function providerManagementConfigError(
754
756
  }
755
757
  const destinationError = providerDestinationConfigError(name, typed);
756
758
  if (destinationError) return `provider ${name} ${destinationError}`;
759
+ for (const field of ["responsesPath", "chatCompletionsPath"] as const) {
760
+ const sendPathError = providerRelativeSendPathConfigError(field, raw[field]);
761
+ if (sendPathError) return `provider ${JSON.stringify(redactSecretString(name))} ${sendPathError}`;
762
+ }
757
763
  const headersError = providerHeadersConfigError(typed.headers);
758
764
  if (headersError) return `provider ${name} ${headersError}`;
759
765
  const retryOn429Error = retryOn429PolicyConfigError(raw.retryOn429);
@@ -793,6 +799,8 @@ export function providerManagementConfigError(
793
799
  }
794
800
  const reasoningSummariesError = booleanRecordConfigError(raw.modelSupportsReasoningSummaries, "modelSupportsReasoningSummaries");
795
801
  if (reasoningSummariesError) return `provider ${name} ${reasoningSummariesError}`;
802
+ const suppressSyntheticMaxError = booleanRecordConfigError(raw.modelSuppressSyntheticMax, "modelSuppressSyntheticMax");
803
+ if (suppressSyntheticMaxError) return `provider ${name} ${suppressSyntheticMaxError}`;
796
804
  const reasoningSummaryDeliveryError = reasoningSummaryDeliveryRecordConfigError(
797
805
  raw.modelReasoningSummaryDelivery,
798
806
  raw.modelSupportsReasoningSummaries,
@@ -834,6 +842,22 @@ export function providerManagementConfigError(
834
842
  "omitReasoningEffortWithToolsModels",
835
843
  );
836
844
  if (toolReasoningOptOutError) return `provider ${name} ${toolReasoningOptOutError}`;
845
+ const unsupportedHostedToolsError = nonBlankStringArrayConfigError(
846
+ raw.unsupportedHostedTools,
847
+ "unsupportedHostedTools",
848
+ );
849
+ if (unsupportedHostedToolsError) return `provider ${name} ${unsupportedHostedToolsError}`;
850
+ if (Array.isArray(raw.unsupportedHostedTools)) {
851
+ // Closed vocabulary, same reason as the config schema: an unrecognized name would be
852
+ // stored and then strip nothing, so the operator would keep getting the upstream 400
853
+ // this field exists to prevent.
854
+ const unknownTool = (raw.unsupportedHostedTools as unknown[])
855
+ .find(tool => typeof tool === "string" && !DECLARABLE_HOSTED_TOOL_TYPES.has(tool.trim()));
856
+ if (unknownTool !== undefined) {
857
+ return `provider ${name} unsupportedHostedTools must name only hosted tool types: `
858
+ + `${[...DECLARABLE_HOSTED_TOOL_TYPES].join(", ")}`;
859
+ }
860
+ }
837
861
  const openRouterError = openRouterRoutingConfigError(typed);
838
862
  if (openRouterError) return `provider ${name} ${openRouterError}`;
839
863
  const vercelError = vercelGatewayRoutingConfigError(typed);
@@ -964,6 +988,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
964
988
  refreshPolicy: "editor",
965
989
  reasoningEfforts: "editor",
966
990
  modelReasoningEfforts: "editor",
991
+ modelSuppressSyntheticMax: "editor",
967
992
  modelDefaultReasoningEfforts: "editor",
968
993
  pinnedReasoningEffort: "editor",
969
994
  modelPinnedReasoningEfforts: "editor",
@@ -979,6 +1004,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
979
1004
  xaiResponsesDefaultVersion: "runtime",
980
1005
  zaiResponsesDefaultVersion: "runtime",
981
1006
  supportsResponsesCustomTools: "editor",
1007
+ unsupportedHostedTools: "editor",
982
1008
  responsesSnapshotRepair: "editor",
983
1009
  webSearchBridge: "editor",
984
1010
  reasoningEffortMap: "editor",
@@ -150,10 +150,15 @@ async function handleChatCompletionsWithBudget(
150
150
  let chatNativeRoute: ReturnType<typeof routeModel> | null = null;
151
151
  try {
152
152
  const route = routeModel(config, chatBody.model as string, evidenceFromBody(chatBody));
153
- route.provider = resolveOpenCodeGoTransport(route.provider, getOrAllocateRequestSessionLane(req));
154
- // Settle the wire once so every branch below reads the adapter this model will
155
- // actually use, not the provider-wide default (#404).
156
- route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "chat");
153
+ // Preserve the routed destination for Go recognition, then settle the wire before
154
+ // deriving protocol-scoped affinity. Recognition must not inspect the flipped adapter.
155
+ const routedProvider = route.provider;
156
+ const wireProvider = resolveWireProtocolOverride(route.providerName, route.modelId, routedProvider, "chat");
157
+ route.provider = resolveOpenCodeGoTransport(
158
+ wireProvider,
159
+ getOrAllocateRequestSessionLane(req),
160
+ routedProvider,
161
+ );
157
162
  logCtx.model = route.modelId;
158
163
  logCtx.providerAdapter = route.provider.adapter;
159
164
  logCtx.requestedModel = requestedModel;
@@ -152,6 +152,8 @@ export function nativeChatSse(
152
152
  let sawFinish = false;
153
153
  let sawDone = false;
154
154
  let firstOutput = false;
155
+ let sawTextOutput = false;
156
+ let sawToolCalls = false;
155
157
  let settled = false;
156
158
  let cancelled = false;
157
159
  let cancelledBySignal = false;
@@ -264,7 +266,7 @@ export function nativeChatSse(
264
266
  const choices = Array.isArray(parsed.choices) ? parsed.choices : [];
265
267
  const finished = choices.some(choice => isRec(choice) && typeof choice.finish_reason === "string" && choice.finish_reason.length > 0);
266
268
  if (finished) sawFinish = true;
267
- const hasProgress = choices.some(choice => {
269
+ const hasText = choices.some(choice => {
268
270
  if (!isRec(choice) || !isRec(choice.delta)) return false;
269
271
  const delta = choice.delta;
270
272
  return (typeof delta.content === "string" && delta.content.length > 0)
@@ -272,15 +274,22 @@ export function nativeChatSse(
272
274
  || (typeof delta.reasoning === "string" && delta.reasoning.length > 0)
273
275
  || (Array.isArray(delta.reasoning_details) && delta.reasoning_details.some(detail =>
274
276
  isRec(detail) && typeof detail.text === "string" && detail.text.length > 0))
275
- || (typeof delta.refusal === "string" && delta.refusal.length > 0)
276
- || (Array.isArray(delta.tool_calls) && delta.tool_calls.some(tool => {
277
- if (!isRec(tool)) return false;
278
- const fn = isRec(tool.function) ? tool.function : {};
279
- return (typeof tool.id === "string" && tool.id.length > 0)
280
- || (typeof fn.name === "string" && fn.name.length > 0)
281
- || (typeof fn.arguments === "string" && fn.arguments.length > 0);
282
- }));
277
+ || (typeof delta.refusal === "string" && delta.refusal.length > 0);
283
278
  });
279
+ const hasTool = choices.some(choice => {
280
+ if (!isRec(choice) || !isRec(choice.delta)) return false;
281
+ const delta = choice.delta;
282
+ return Array.isArray(delta.tool_calls) && delta.tool_calls.some(tool => {
283
+ if (!isRec(tool)) return false;
284
+ const fn = isRec(tool.function) ? tool.function : {};
285
+ return (typeof tool.id === "string" && tool.id.length > 0)
286
+ || (typeof fn.name === "string" && fn.name.length > 0)
287
+ || (typeof fn.arguments === "string" && fn.arguments.length > 0);
288
+ });
289
+ });
290
+ if (hasText) sawTextOutput = true;
291
+ if (hasTool) sawToolCalls = true;
292
+ const hasProgress = hasText || hasTool;
284
293
  if (hasProgress || finished) pullDeadline = performance.now() + stallMs;
285
294
  else if (performance.now() >= pullDeadline) throw stalled;
286
295
  if (!firstOutput && hasProgress) {
@@ -358,6 +367,14 @@ export function nativeChatSse(
358
367
  if (!sawDone) enqueue(controller, "data: [DONE]\n\n");
359
368
  settle(200);
360
369
  controller.close();
370
+ } else if (sawToolCalls) {
371
+ // Mid tool-call truncation must fail closed to prevent downstream executing partial arguments.
372
+ fail(controller, "upstream SSE ended mid tool call without a terminal signal — possible truncation", "upstream_sse_truncated");
373
+ } else if (sawTextOutput) {
374
+ // Upstream produced text or reasoning output but missed the terminal frame; recover cleanly with [DONE].
375
+ enqueue(controller, "data: [DONE]\n\n");
376
+ settle(200);
377
+ controller.close();
361
378
  } else {
362
379
  fail(controller, "upstream SSE ended before a terminal event", "upstream_sse_truncated");
363
380
  }
@@ -50,7 +50,7 @@ import { enrichOpenCodeZenFreeTierMessage } from "../providers/opencode-zen-rate
50
50
  import type { OcxProviderTransport } from "../providers/xai-transport";
51
51
  import type { RouteResult } from "../router";
52
52
  import type { OcxConfig, OcxProviderConfig } from "../types";
53
- import { fetchWithHeaderTimeout, providerFetch, safeHostLabel } from "./responses/fetch-helpers";
53
+ import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, sendWithConnectionPolicy } from "./responses/fetch-helpers";
54
54
  import { linkAbortSignal } from "./responses";
55
55
  import {
56
56
  addFinalRequestLog,
@@ -351,9 +351,15 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
351
351
  if (!headers.has("accept-encoding") && encoding) headers.set("accept-encoding", encoding);
352
352
  if (init.signal?.aborted) throw init.signal.reason;
353
353
  noteProviderAttemptSend(logCtx, route.providerName, activeProvider, logCtx.usageLogInputTokens, transportRecovery ?? recovery);
354
- const dispatched = await ((activeProvider as OcxProviderTransport).fetch ?? execute)(request.url, applyUpstreamRecoveryInit({
355
- ...init, method: request.method, headers, body: request.body,
356
- }, transportRecovery));
354
+ // A reselected provider transport is still a physical send: the connection policy
355
+ // and manual-redirect ownership wrap the selected implementation (#4992).
356
+ const dispatched = await sendWithConnectionPolicy(
357
+ (activeProvider as OcxProviderTransport).fetch ?? execute,
358
+ request.url,
359
+ applyUpstreamRecoveryInit({
360
+ ...init, method: request.method, headers, body: request.body,
361
+ }, transportRecovery),
362
+ );
357
363
  if (!dispatched.ok) await recordKeyAttemptFailure(logCtx, dispatched, init.signal ?? upstream.signal);
358
364
  return dispatched;
359
365
  },
@@ -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;
@@ -815,7 +831,7 @@ async function handleClaudeMessagesWithBudget(
815
831
  // accurate-usage adapters — the request-log merge is max(reported, estimate) and
816
832
  // would overwrite real usage (audit 133 R1#7).
817
833
  if (route.provider.adapter === "cursor" || route.provider.adapter === "kiro") {
818
- logCtx.usageLogInputTokens = estimateClaudeRequestTokens(anthropicBody as Rec, requestedModel);
834
+ logCtx.usageLogInputTokens = claudeRequestTokenFloor();
819
835
  }
820
836
  // Effort safety valve (devlog 136 B6, audit 139 R2#2): opus-shaped aliases make
821
837
  // every routed model look like a reasoning model to Claude clients, so a forced
@@ -988,7 +1004,13 @@ async function handleClaudeMessagesWithBudget(
988
1004
 
989
1005
  const contentType = response.headers.get("content-type") ?? "";
990
1006
  if (contentType.includes("text/event-stream") && response.body) {
991
- const anthropicSse = responsesSseToAnthropicSse(response.body, requestedModel, { translatorBudget });
1007
+ const anthropicSse = responsesSseToAnthropicSse(response.body, requestedModel, {
1008
+ translatorBudget,
1009
+ // Only a floor, and only for the first frame: an upstream that reports usage early wins
1010
+ // over it inside the translator, and the terminal `message_delta` carries the
1011
+ // authoritative count either way (#4857).
1012
+ inputTokenFloor: claudeRequestTokenFloor(),
1013
+ });
992
1014
  if (stream) {
993
1015
  return new Response(anthropicSse, {
994
1016
  status: 200,
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  import { remoteWorkspaceEnabled } from "../remote-control/workspace-activation";
2
2
  import { AuxiliaryListenerBindError } from "./ports";
3
+ import { runAdmittedBodyWork } from "./inbound-body-admission";
3
4
  import {
4
5
  buildWarmupCompletionFrames,
5
6
  buildWsErrorFrame,
@@ -106,7 +107,6 @@ export {
106
107
  unregisterTurn,
107
108
  } from "./lifecycle";
108
109
  import {
109
- addFinalRequestLog,
110
110
  hydrateRequestLogsFromDisk,
111
111
  httpStatusForRequestLogTerminal,
112
112
  inspectResponseLogSsePayload,
@@ -116,8 +116,8 @@ import {
116
116
  type RequestLogEntry,
117
117
  } from "./request-log";
118
118
  import { sessionLaneIdFromRequest } from "./request-log-conversation";
119
- import { admitWorkflowTurn, type WorkflowLane } from "../lib/workflow-budget";
120
- import { workflowRefusalResponse, type WorkflowRefusalLog } from "./workflow-refusal";
119
+ import { configureSharedSpendLedger, spendPolicyFromConfig } from "../lib/spend-reservation-ledger";
120
+ import { admitHttpWorkflowTurn, workflowDecisionRefusalResponse, type WorkflowRefusalLog } from "./workflow-refusal";
121
121
  export {
122
122
  addFinalRequestLog,
123
123
  filterRequestLogs,
@@ -181,7 +181,7 @@ import {
181
181
  type NativeMainStartupLifecycle,
182
182
  } from "../codex/native-profile-startup";
183
183
  import { EXTERNAL_CALL_PREFIX, LiveCallBindings } from "./live-call-bindings";
184
- import { codexCompatibleUrl, contextEndpoint, contextRelayActivated } from "../codex/context-compat";
184
+ import { contextEndpoint, contextRelayActivated } from "../codex/context-compat";
185
185
  import { fetchAllModels, handleManagementAPI, VERSION, type ManagementApiDeps } from "./management-api";
186
186
  import {
187
187
  createManagementSessionControl,
@@ -225,7 +225,7 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
225
225
  warnPlaintextV2AgentMessagesStartup(config);
226
226
  warnAgentTaskRecoveryStartup(config);
227
227
  setLiveStateStoreConfig(config);
228
- applyProxyEnv(config);
228
+ applyProxyEnv(config, true);
229
229
  assertServerAuthConfig(config);
230
230
  const managementAuth = deps.managementAuthState ?? initializeManagementAuthState(config);
231
231
  const managementSessionControl = createManagementSessionControl(managementAuth);
@@ -299,6 +299,12 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
299
299
  registerAppOwnedMemorySweepFallback();
300
300
  configureAppOwnedMemoryBudget(resolveAppOwnedMemoryBudgetBytes(config.appOwnedMemoryBudgetMb));
301
301
  enforceAppOwnedMemoryBudget();
302
+ // Operator token ceilings (#4546). Applied before the listener binds: the shared ledger is
303
+ // built on the first reservation, and a request arriving before this ran would build it with
304
+ // no policy and enforce nothing. An absent `spend` section resolves to the unconfigured
305
+ // default, which refuses nothing and opens no journal -- so on an install that never wrote
306
+ // the key this line changes no behaviour at all.
307
+ configureSharedSpendLedger(spendPolicyFromConfig(config.spend));
302
308
  registerCodexCooldownRecoveryProbeWorker(config);
303
309
  // Issue #42 Phase 3: opt-in archived auto-cleanup (default OFF). Unref'd hourly
304
310
  // tick for daily/weekly; startup evaluation is fire-and-forget after listen.
@@ -498,28 +504,18 @@ export function startServer(port?: number, deps: StartServerDeps = {}): Server<W
498
504
  ): Promise<Response> {
499
505
  const lease = tryAdmitTurn(sessionLaneIdFromRequest(req.headers));
500
506
  if (!lease) return serverBusyResponse(req, "active turns", policy);
501
- // A fan-out shares the conversation it serves. Without a reserve, a worker burst takes every
502
- // slot under its own root and the interactive turn that started it waits behind its own
503
- // children. A request that names a parent is treated as that fan-out; a top-level request is
504
- // the conversation and may use the reserved slots.
505
- const workflowRootId = req.headers.get("x-codex-parent-thread-id")?.trim() || undefined;
506
- const workflowThreadId = req.headers.get("thread-id")?.trim() || undefined;
507
- const workflowLane: WorkflowLane = workflowRootId !== undefined
508
- && workflowThreadId !== undefined
509
- && workflowThreadId !== workflowRootId
510
- ? "worker"
511
- : "interactive";
512
- const workflow = admitWorkflowTurn(workflowRootId, workflowLane, undefined, workflowThreadId);
507
+ // Root, lane, and the refusal that follows from them, all live in ./workflow-refusal.
508
+ const workflow = admitHttpWorkflowTurn(req.headers);
513
509
  if (workflow && !workflow.admitted) {
514
510
  lease.release();
515
511
  // withCors, because without Access-Control-Allow-Origin the exposed refusal header is
516
512
  // still unreadable to a browser dashboard -- which made exposing it pointless.
517
- return withCors(workflowRefusalResponse(workflow.reason, undefined, refusalLog), req, policy);
513
+ return withCors(workflowDecisionRefusalResponse(workflow, undefined, refusalLog), req, policy);
518
514
  }
519
515
  const releaseWorkflow = (): void => { if (workflow?.admitted) workflow.lease.release(); };
520
516
  let response: Response;
521
517
  try {
522
- response = await work(lease);
518
+ response = await runAdmittedBodyWork(req, policy, config.maxInboundBodyBytes, () => work(lease), refusalLog);
523
519
  } catch (error) {
524
520
  releaseWorkflow();
525
521
  lease.release();
@@ -1,7 +1,6 @@
1
1
  import type { OcxConfig } from "../../types";
2
2
  import { isWildcardHostname } from "../../codex/loopback-target";
3
- import { localInferenceDestination } from "../../lib/local-destinations";
4
- import { probeHostname } from "../proxy-liveness";
3
+ import { localCredentialDestinationHostname, localInferenceDestination } from "../../lib/local-destinations";
5
4
  import { isCanonicalOpenAiForwardProvider, OPENAI_API_PROVIDER_ID, OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers-destination";
6
5
  import { LIVE_AUDIO_MODEL, TRANSCRIPTION_MODEL } from "../audio-upstream";
7
6
 
@@ -95,7 +94,7 @@ export function resolveApiAccessBaseUrl(
95
94
  const port = config.port ?? 10100;
96
95
 
97
96
  if (!isWildcardBindHost(config.hostname)) {
98
- return `http://${probeHostname(config.hostname)}:${port}/v1`;
97
+ return `http://${localCredentialDestinationHostname(config.hostname)}:${port}/v1`;
99
98
  }
100
99
 
101
100
  const fromOrigin = opts.requestOrigin ? originBaseUrl(opts.requestOrigin) : null;
@@ -140,7 +139,7 @@ export function resolveApiAccessDisplayHost(
140
139
  opts: BuildApiAccessEndpointsOptions = {},
141
140
  ): string {
142
141
  if (!isWildcardBindHost(configHostname)) {
143
- return probeHostname(configHostname);
142
+ return localCredentialDestinationHostname(configHostname);
144
143
  }
145
144
  try {
146
145
  return new URL(resolveApiAccessBaseUrl({ hostname: configHostname, port: 10100 }, opts)).hostname