@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,7 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { RequestSendObserver } from "../../lib/request-execution-budget";
3
3
  import { sharedSpendLedger, type SpendReservationLedger } from "../../lib/spend-reservation-ledger";
4
- import type { RequestLogContext } from "../request-log";
4
+ import { markLocalRequestLogRefusal, type RequestLogContext } from "../request-log";
5
+ import { recordWorkflowRefusalEvent, workflowDenialSummary } from "../../lib/workflow-budget";
5
6
 
6
7
  /** The terminal usage a request reported, in the only two fields the ledger books. */
7
8
  export interface TerminalSpendUsage {
@@ -42,7 +43,7 @@ export function createRequestSpendTracker(
42
43
  logCtx: Pick<
43
44
  RequestLogContext,
44
45
  "provider" | "accountLogLabel" | "usageLogInputTokens" | "spendOutputCeilingTokens"
45
- >,
46
+ > & Partial<Pick<RequestLogContext, "localTerminalReason" | "terminalSource" | "errorCode">>,
46
47
  rootId: string | undefined,
47
48
  injected?: SpendReservationLedger,
48
49
  ): RequestSpendTracker {
@@ -70,7 +71,13 @@ export function createRequestSpendTracker(
70
71
  for (let index = 0; index < live.length - 1; index += 1) ledger().markDispatched(live[index] as string);
71
72
  };
72
73
  return {
73
- charge(): boolean {
74
+ charge(options?: { alreadySent?: boolean }): boolean {
75
+ // A send that has already left is RECORDED, never refused: the tokens are spent, and a
76
+ // booking the ledger drops is a booking the ceiling can never see. This is the reporting
77
+ // transports' path -- the passthrough ladder reports through `onSendsConsumed` after the
78
+ // fetch -- so without it a root ceiling on the canonical Codex path would sit one send
79
+ // short of its limit forever and refuse nothing.
80
+ const alreadySent = options?.alreadySent === true;
74
81
  const sendId = randomUUID();
75
82
  const decision = ledger().reserve({
76
83
  sendId,
@@ -83,18 +90,40 @@ export function createRequestSpendTracker(
83
90
  },
84
91
  inputTokens: logCtx.usageLogInputTokens ?? 0,
85
92
  outputCeilingTokens: logCtx.spendOutputCeilingTokens ?? 0,
93
+ ...(alreadySent ? { alreadySent: true } : {}),
86
94
  });
87
95
  if (!decision.reserved) {
88
96
  refusals += 1;
89
- // Only an operator's configured ceiling refuses a dispatch. Every other denial --
90
- // capacity, durability, a journal this process could not prove complete -- means the
91
- // ledger cannot ACCOUNT for this send, which is not a reason to refuse one. An
92
- // unconfigured install keeps the count caps it already had and is not newly refused,
93
- // and a degraded ledger must not become an outage.
94
- return decision.denial.reason !== "spend-limit-exceeded";
97
+ const denial = decision.denial;
98
+ // A ceiling refuses, and so does a ledger that cannot make the reservation durable
99
+ // under one. That second case is the whole reason this store is on disk: admitting a
100
+ // send whose record a restart would forget is how an exhausted budget comes back with
101
+ // a fresh allowance, and the ledger raises those two denials ONLY when a limit is
102
+ // configured -- so an install that configured nothing is still never refused here.
103
+ // Capacity and a duplicate send id stay permissive: they say the ledger cannot account
104
+ // for this send, which is a degradation to report, not an outage to cause.
105
+ if (denial.reason === "reserve-not-durable" || denial.reason === "journal-corrupt") {
106
+ return alreadySent;
107
+ }
108
+ if (denial.reason !== "spend-limit-exceeded") return true;
109
+ // This send is refused, and the dispatch path that asked will report an exhausted send
110
+ // budget -- from there, that is all it can see. The row is where an operator actually
111
+ // looks, so the ceiling is named on it here: a locally assigned code wins in
112
+ // addFinalRequestLog, so the request that CROSSED the ceiling reads as a spend refusal
113
+ // rather than as the ordinary budget exhaustion it would otherwise be indistinguishable
114
+ // from. The event ring gets the same pair so /api/workflow-budget agrees with the row.
115
+ const detail = { scope: denial.scope, limit: denial.limit, projected: denial.projected };
116
+ const summary = workflowDenialSummary("workflow-spend-exhausted", detail);
117
+ markLocalRequestLogRefusal(logCtx, summary.code);
118
+ logCtx.errorCode = summary.code;
119
+ recordWorkflowRefusalEvent(rootId, "workflow-spend-exhausted", Date.now(), detail);
120
+ return false;
95
121
  }
96
122
  live.push(sendId);
97
123
  confirmOlderSends();
124
+ // It has already left, so the reservation cannot be handed back for free: from here only
125
+ // a settlement or unresolved spend is honest about it.
126
+ if (alreadySent) ledger().markDispatched(sendId);
98
127
  return true;
99
128
  },
100
129
  refund(): void {
@@ -41,7 +41,7 @@ import {
41
41
  resolveCurrentProviderApiKeyTransport,
42
42
  } from "../../providers/api-key-selection";
43
43
  import { resolveAdapter, resolveWireProtocolOverride } from "../adapter-resolve";
44
- import { providerFetch } from "./fetch-helpers";
44
+ import { providerFetch, sendWithConnectionPolicy } from "./fetch-helpers";
45
45
  import type { ProviderFetchOptions } from "./fetch-helpers";
46
46
  import { captureConfigGeneration } from "../../lib/state-store-sweeper";
47
47
  import { recordAnthropicAccountQuotaFromHeaders, hasPassiveAccountQuota } from "../../providers/quota";
@@ -190,8 +190,9 @@ export async function prepareResponsesTransport(
190
190
  * lines, and the divergence that produced was the bug: `apiKey` was swapped while the routing
191
191
  * metadata paired with it stayed behind.
192
192
  *
193
- * Returns false when the snapshot cannot be used safely, and the caller must then abandon the
194
- * rotation rather than send a half-applied identity:
193
+ * Returns the admitted snapshot, which can differ when a newer manual selection wins the
194
+ * proposal race. Returns null when the snapshot cannot be used safely, and the caller must then
195
+ * abandon the rotation rather than send a half-applied identity:
195
196
  *
196
197
  * - Copilot pins its bearer to an account-scoped regional origin, so transport is re-resolved
197
198
  * with the new account's `apiBaseUrl` instead of inheriting the previous account's host. The
@@ -207,10 +208,10 @@ export async function prepareResponsesTransport(
207
208
  const applyFailoverSnapshot = async (
208
209
  snapshot: OAuthAccessSnapshot,
209
210
  retryParsed: OcxParsedRequest = parsed,
210
- ): Promise<boolean> => {
211
- if (route.provider.googleMode === "cloud-code-assist" && !snapshot.projectId) return false;
211
+ ): Promise<OAuthAccessSnapshot | null> => {
212
+ if (route.provider.googleMode === "cloud-code-assist" && !snapshot.projectId) return null;
212
213
  const committed = await commitResolvedOAuthSelection(snapshot);
213
- if (!committed) return false;
214
+ if (!committed) return null;
214
215
  snapshot = committed;
215
216
  let rotatedProvider: OcxProviderConfig = { ...route.provider, apiKey: snapshot.accessToken };
216
217
  if (route.providerName === "github-copilot") {
@@ -243,7 +244,7 @@ export async function prepareResponsesTransport(
243
244
  }
244
245
  sentOAuthSnapshot = snapshot;
245
246
  replayOAuthCredentialSnapshot = { accountId: snapshot.accountId, generation: snapshot.generation };
246
- return true;
247
+ return snapshot;
247
248
  };
248
249
  // Key sends may be rebuilt while queued. Keep metadata pending until the guarded
249
250
  // physical dispatch binds it to the selection that actually reaches the upstream.
@@ -405,8 +406,10 @@ export async function prepareResponsesTransport(
405
406
  && sentHeaders?.get("authorization") === `Bearer ${snapshot.accessToken}`
406
407
  && !sentHeaders?.has("x-api-key");
407
408
  // Reselection can choose a provider override instead of the supplied executor.
409
+ // Either way the send crosses the physical boundary, so the connection policy is
410
+ // applied around whichever implementation was just selected (#4992).
408
411
  commitKeyAttemptSend();
409
- const response = await fetchImpl(destination, { ...dispatchInit, redirect: "manual" });
412
+ const response = await sendWithConnectionPolicy(fetchImpl, destination, { ...dispatchInit, redirect: "manual" });
410
413
  if (!response.ok) await recordKeyAttemptFailure(logCtx, response, dispatchInit.signal ?? options.abortSignal);
411
414
  // Observe each physical response before retries replace it. The binding belongs to
412
415
  // this dispatch, so a manual switch cannot file A's headers against B. Header
@@ -487,7 +490,7 @@ export async function prepareResponsesTransport(
487
490
  // measured as spent. A null answer means "use the active account", so every provider
488
491
  // without quota evidence keeps the resolution it has today.
489
492
  const preferredAccountId = isGenericFailoverProvider(route.providerName, route.provider)
490
- ? preferredInitialAccount(config, route.providerName)
493
+ ? preferredInitialAccount(config, route.providerName, Date.now(), route.modelId)
491
494
  : null;
492
495
  // Resolved account-scoped, NOT through failoverAccountSnapshot: that helper marks a
493
496
  // rotation site, and rotation sites must apply their credential through
@@ -550,7 +553,7 @@ export async function prepareResponsesTransport(
550
553
  // Advance the pool cursor only now that this account is actually admitted. The
551
554
  // helper returns immediately unless the kernel is on AND the strategy is
552
555
  // round-robin, so quota and fill-first pools reach it without being touched.
553
- noteGenericPoolSelection(config, route.providerName, resolved.accountId);
556
+ noteGenericPoolSelection(config, route.providerName, resolved.accountId, route.modelId);
554
557
  }
555
558
  // Anthropic is excluded from isGenericFailoverProvider -- its own pool owns affinity and
556
559
  // a fail-closed local-cli credential rule -- so without this stamp its identity is
@@ -12,7 +12,7 @@ import {
12
12
  adapterNeedsForcedContinuation,
13
13
  adapterResponseReachedServingTerminal,
14
14
  } from "./core-replay";
15
- import { sealRequestAttemptIdentity, recordAttemptCredentialSource } from "../request-log";
15
+ import { noteAttemptRecoveryWithheld, sealRequestAttemptIdentity, recordAttemptCredentialSource } from "../request-log";
16
16
  import { waitForProviderRequestSlot, RequestPacingQueueOverloadError } from "../../providers/request-pacing";
17
17
  import type { AdapterEventQueue } from "../../adapters/run-turn-queue";
18
18
  import type { AttemptRecoveryKind } from "../../usage/log";
@@ -231,12 +231,21 @@ export async function executeResponsesRunTurn(
231
231
  "auth-recovery",
232
232
  `${route.providerName}|${route.modelId}|runturn-oauth-429`,
233
233
  );
234
- if (!hop.allowed) return false;
234
+ if (!hop.allowed) {
235
+ // The roster bound above already said this credential set may rotate again; the shared
236
+ // request budget is what refused. Returning false lets the preflight 429 reach the
237
+ // client unchanged, which is right, but it used to leave a log indistinguishable from
238
+ // a request where no rotation was ever available (#5044).
239
+ noteAttemptRecoveryWithheld(logCtx.activeAttempt, "rotation-send-budget");
240
+ return false;
241
+ }
235
242
  const nextAccountId = rotateGenericOAuthAccountOn429(
236
243
  config,
237
244
  route.providerName,
238
245
  transportState.genericFailoverAccountId,
239
246
  null,
247
+ Date.now(),
248
+ route.modelId,
240
249
  );
241
250
  if (!nextAccountId) {
242
251
  hop.permit?.release();
@@ -245,7 +254,8 @@ export async function executeResponsesRunTurn(
245
254
  try {
246
255
  const snapshot = await failoverAccountSnapshot(route.providerName, nextAccountId);
247
256
  transportState.genericFailovers += 1;
248
- if (!await applyFailoverSnapshot(snapshot)) {
257
+ const admittedSnapshot = await applyFailoverSnapshot(snapshot);
258
+ if (!admittedSnapshot) {
249
259
  hop.permit?.release();
250
260
  return false;
251
261
  }
@@ -275,7 +285,10 @@ export async function executeResponsesRunTurn(
275
285
  providerName: route.providerName,
276
286
  provider: rotatedProvider,
277
287
  adapterName: rotatedAdapter.name,
278
- oauthCredentialSnapshot: { accountId: snapshot.accountId, generation: snapshot.generation },
288
+ oauthCredentialSnapshot: {
289
+ accountId: admittedSnapshot.accountId,
290
+ generation: admittedSnapshot.generation,
291
+ },
279
292
  codexAuthContext: admissionState.authCtx,
280
293
  forwardHeaders: requestState.selectedForwardHeaders,
281
294
  });
@@ -299,7 +312,9 @@ export async function executeResponsesRunTurn(
299
312
  try {
300
313
  while (true) {
301
314
  const preflight = await preflightAdapterEvents(source);
302
- if (!preflight.error || !(await rotateRunTurnAdapterOnPreflight429(preflight.error))) {
315
+ if (preflight.replayUnsafe
316
+ || !preflight.error
317
+ || !(await rotateRunTurnAdapterOnPreflight429(preflight.error))) {
303
318
  return preflight.stream;
304
319
  }
305
320
  const retryQueue = createAdapterEventQueue({
@@ -186,6 +186,8 @@ export async function executeResponsesSidecars(
186
186
  route.providerName,
187
187
  transportState.genericFailoverAccountId,
188
188
  retryAfter,
189
+ Date.now(),
190
+ route.modelId,
189
191
  );
190
192
  if (!nextAccountId) {
191
193
  hop.permit?.release();
@@ -16,7 +16,7 @@ import type { NativeResponseControl } from "./native-response-control";
16
16
  // (passthrough relay, adapter parsers, usage sniffing) is unchanged.
17
17
 
18
18
  import { compareBunVersions } from "../../lib/bun-stream-caps";
19
- import { resolveProxyRoute } from "../../lib/proxy-env";
19
+ import { resolveProxyRoute, socks5ProxyFromEnv } from "../../lib/proxy-env";
20
20
  import type { CodexWsQuotaObserver } from "./codex-ws-metadata";
21
21
  import { CODEX_RESPONSES_HTTP_URL, CODEX_RESPONSES_WS_URL, prepareCodexHttpInit, prepareCodexWsRequest } from "./codex-ws-request";
22
22
  import { codexWsExchange } from "./codex-ws-exchange";
@@ -107,6 +107,7 @@ export function shouldUseCodexWsUpstream(
107
107
  upstreamWebsocketConfigured = false,
108
108
  ): boolean {
109
109
  if (!bunSupportsBoundedCodexWsRelay(runtime)) return false;
110
+ if (socks5ProxyFromEnv()) return false;
110
111
  if (url !== CODEX_RESPONSES_HTTP_URL && !upstreamWebsocketConfigured) return false;
111
112
  if (upstreamWebsocketConfigured && !isResponsesWebsocketEligibleUrl(url)) return false;
112
113
  if ((init?.method ?? "GET").toUpperCase() !== "POST") return false;
@@ -298,7 +298,7 @@ export function createRoutedCustomToolRestoreBlockRewrite(
298
298
  releaseCall(upstreamItemId);
299
299
  return [replaceSseDataPayload(block, JSON.stringify({
300
300
  ...parsed,
301
- input: helper ? compileCodeModeHelperInput(source, helper) : source,
301
+ input: helper ? compileCodeModeHelperInput(source, helper, name) : source,
302
302
  }))];
303
303
  }
304
304
  if (
@@ -381,7 +381,7 @@ export function createRoutedCustomToolRestoreBlockRewrite(
381
381
  type: nextType,
382
382
  item_id: customToolItemId(upstreamItemId),
383
383
  input: helper
384
- ? compileCodeModeHelperInput(source, helper)
384
+ ? compileCodeModeHelperInput(source, helper, itemName?.name ?? "")
385
385
  : unwrapRoutedCustomToolArguments(source, itemName?.name ?? "", itemName?.namespace),
386
386
  };
387
387
  return [replaceSseDataPayload(replaceSseEventName(block, nextType), JSON.stringify(next))];
@@ -2,6 +2,8 @@ import { isCyberPolicyCode, isCyberPolicyMessage } from "../lib/errors";
2
2
 
3
3
  export const MAX_CLIENT_SSE_FRAME_BYTES = 4 * 1024 * 1024;
4
4
 
5
+ export const EMPTY_BYTES = new Uint8Array(0);
6
+
5
7
  const LF_LF = Uint8Array.of(10, 10);
6
8
  const LF_CR_LF = Uint8Array.of(10, 13, 10);
7
9
  const CR_LF_LF = Uint8Array.of(13, 10, 10);
@@ -155,8 +157,8 @@ function isResponsesTerminalFrame(block: Uint8Array): boolean {
155
157
  export class BoundedSseFrameBuffer {
156
158
  private readonly maxFrameBytes: number;
157
159
  private readonly maxFramesPerFeed: number;
158
- private delimiterTail: Uint8Array = new Uint8Array(0);
159
- private candidate: Uint8Array = new Uint8Array(0);
160
+ private delimiterTail: Uint8Array = EMPTY_BYTES;
161
+ private candidate: Uint8Array = EMPTY_BYTES;
160
162
  private candidateBytes = 0;
161
163
  private disposed = false;
162
164
 
@@ -172,8 +174,8 @@ export class BoundedSseFrameBuffer {
172
174
  }
173
175
 
174
176
  private clear(): void {
175
- this.delimiterTail = new Uint8Array(0);
176
- this.candidate = new Uint8Array(0);
177
+ this.delimiterTail = EMPTY_BYTES;
178
+ this.candidate = EMPTY_BYTES;
177
179
  this.candidateBytes = 0;
178
180
  }
179
181
 
@@ -209,12 +211,12 @@ export class BoundedSseFrameBuffer {
209
211
  }
210
212
 
211
213
  private takeCandidate(): Uint8Array {
212
- if (this.candidateBytes === 0) return new Uint8Array(0);
214
+ if (this.candidateBytes === 0) return EMPTY_BYTES;
213
215
  const block = this.candidate.slice(0, this.candidateBytes);
214
216
  // Release the working allocation after each complete frame. This avoids
215
217
  // retaining a rare multi-MiB frame allocation for the rest of a long-lived
216
218
  // stream; normal small-frame allocation remains bounded by feed's frame cap.
217
- this.candidate = new Uint8Array(0);
219
+ this.candidate = EMPTY_BYTES;
218
220
  this.candidateBytes = 0;
219
221
  return block;
220
222
  }
@@ -225,7 +227,7 @@ export class BoundedSseFrameBuffer {
225
227
 
226
228
  const frames: BoundedSseFrame[] = [];
227
229
  const previousTail = this.delimiterTail;
228
- this.delimiterTail = new Uint8Array(0);
230
+ this.delimiterTail = EMPTY_BYTES;
229
231
  const tailLength = previousTail.byteLength;
230
232
  const totalLength = tailLength + chunk.byteLength;
231
233
  const byteAt = (index: number): number => index < tailLength
@@ -286,10 +288,10 @@ export class BoundedSseFrameBuffer {
286
288
 
287
289
  /** Return the final unterminated block bytes and release all retained state. */
288
290
  finish(): Uint8Array {
289
- if (this.disposed) return new Uint8Array(0);
291
+ if (this.disposed) return EMPTY_BYTES;
290
292
  try {
291
293
  this.retain(this.delimiterTail);
292
- this.delimiterTail = new Uint8Array(0);
294
+ this.delimiterTail = EMPTY_BYTES;
293
295
  return this.takeCandidate();
294
296
  } finally {
295
297
  this.clear();
@@ -307,7 +309,7 @@ export class BoundedSseFrameBuffer {
307
309
  export function joinSseFrameBytes(parts: readonly Uint8Array[]): Uint8Array {
308
310
  let byteLength = 0;
309
311
  for (const part of parts) byteLength += part.byteLength;
310
- if (byteLength === 0) return new Uint8Array(0);
312
+ if (byteLength === 0) return EMPTY_BYTES;
311
313
  if (parts.length === 1 && parts[0]!.byteLength === byteLength) return parts[0]!;
312
314
  const joined = new Uint8Array(byteLength);
313
315
  let offset = 0;
@@ -178,17 +178,44 @@ export function createSseBlockBuffer(
178
178
 
179
179
  /** Join all data lines from one SSE event according to the event-stream field rules. */
180
180
  export function sseDataPayload(block: string): string | null {
181
- const data: string[] = [];
182
- for (const line of block.split(/\r?\n/)) {
183
- if (line === "data") {
184
- data.push("");
185
- continue;
181
+ let result = "";
182
+ let found = false;
183
+ let lineStart = 0;
184
+ const len = block.length;
185
+
186
+ while (lineStart < len) {
187
+ const nextNewline = block.indexOf("\n", lineStart);
188
+ let lineEnd = nextNewline === -1 ? len : nextNewline;
189
+ const nextStart = nextNewline === -1 ? len : nextNewline + 1;
190
+ if (lineEnd > lineStart && block.charCodeAt(lineEnd - 1) === 13) {
191
+ lineEnd -= 1;
186
192
  }
187
- if (!line.startsWith("data:")) continue;
188
- const value = line.slice(5);
189
- data.push(value.startsWith(" ") ? value.slice(1) : value);
193
+
194
+ const lineLen = lineEnd - lineStart;
195
+ if (lineLen === 4 && block.startsWith("data", lineStart)) {
196
+ if (found) {
197
+ result += "\n";
198
+ } else {
199
+ found = true;
200
+ }
201
+ } else if (lineLen >= 5 && block.startsWith("data:", lineStart)) {
202
+ let valueStart = lineStart + 5;
203
+ if (valueStart < lineEnd && block.charCodeAt(valueStart) === 32) {
204
+ valueStart += 1;
205
+ }
206
+ const value = block.slice(valueStart, lineEnd);
207
+ if (found) {
208
+ result += "\n" + value;
209
+ } else {
210
+ result = value;
211
+ found = true;
212
+ }
213
+ }
214
+
215
+ lineStart = nextStart;
190
216
  }
191
- return data.length > 0 ? data.join("\n") : null;
217
+
218
+ return found ? result : null;
192
219
  }
193
220
 
194
221
  /** Replace an SSE event's data field while preserving non-data fields and newline style. */
@@ -1,7 +1,7 @@
1
1
  import { accessSync, constants, readFileSync, writeFileSync, unlinkSync, mkdirSync, statSync } from "node:fs";
2
2
  import { delimiter, join } from "node:path";
3
3
  import { getConfigDir } from "../config";
4
- import { resolveAutoContext, type AutoContextMode } from "../claude/context-windows";
4
+ import { claudeToolSearchEnv, resolveAutoContext, type AutoContextMode } from "../claude/context-windows";
5
5
  import { PROXY_MARKER, defaultAuthDetectDeps, detectClaudeAuth, ownAdmissionTokens, type AuthDetectDeps } from "../claude/auth-detect";
6
6
  import { resolveClaudeAuthMode } from "../claude/auth-mode";
7
7
  import { ANTHROPIC_PARENT_ENV_SLOTS, trustedNodeLauncherContext, type AnthropicParentEnvSlot } from "../cli/launcher-context";
@@ -128,6 +128,10 @@ export function writeShellEnvFile(
128
128
  if (config.claudeCode?.alwaysEnableEffort === true) {
129
129
  lines.push(conditional("CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", "1"));
130
130
  }
131
+ // Tool-search deferral (#4838): opt-in, conditional like every other lever so a
132
+ // value already exported in the user's shell wins.
133
+ const toolSearch = claudeToolSearchEnv(config.claudeCode?.toolSearch);
134
+ if (toolSearch !== undefined) lines.push(conditional("ENABLE_TOOL_SEARCH", toolSearch));
131
135
  const shellEnvPath = getShellEnvFilePath();
132
136
  recordOwnedConfigPath(getConfigDir(), shellEnvPath);
133
137
  mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
@@ -2,7 +2,7 @@ import { execFileSync } from "node:child_process";
2
2
  import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { getConfigDir } from "../config";
5
- import { resolveAutoContext, type AutoContextMode } from "../claude/context-windows";
5
+ import { claudeToolSearchEnv, resolveAutoContext, type AutoContextMode } from "../claude/context-windows";
6
6
  import { PROXY_MARKER } from "../claude/auth-detect";
7
7
  import { isProxyAdmissionSecret } from "./auth-cors";
8
8
  import type { OcxConfig } from "../types";
@@ -33,6 +33,7 @@ const MANAGED_SYSTEM_ENV_NAMES = new Set<string>([
33
33
  "DISABLE_COMPACT",
34
34
  "CLAUDE_CODE_AUTO_COMPACT_WINDOW",
35
35
  "CLAUDE_CODE_ALWAYS_ENABLE_EFFORT",
36
+ "ENABLE_TOOL_SEARCH",
36
37
  ]);
37
38
 
38
39
  interface SystemEnvTracking {
@@ -309,6 +310,11 @@ export async function injectSystemEnv(
309
310
  if (config.claudeCode?.alwaysEnableEffort === true) {
310
311
  injectLever("CLAUDE_CODE_ALWAYS_ENABLE_EFFORT", "1");
311
312
  }
313
+ // Tool-search deferral (#4838): same opt-in contract as `ocx claude`, so a machine
314
+ // whose Claude Code is launched from Dock/launchd gets the setting it configured
315
+ // instead of only terminal sessions. injectLever keeps a user-owned launchd value.
316
+ const toolSearch = claudeToolSearchEnv(config.claudeCode?.toolSearch);
317
+ if (toolSearch !== undefined) injectLever("ENABLE_TOOL_SEARCH", toolSearch);
312
318
 
313
319
  // Shell-hook env file: works for new shells in already-running Terminal.app.
314
320
  writeShellEnvFile(port, config, modelEnv, auto, deps);
@@ -5,6 +5,10 @@
5
5
  * check in `src/server/index.ts` and the pre-dispatch ceiling check in
6
6
  * `src/server/responses/core.ts` -- had drifted into saying different things about the same
7
7
  * refusal, and because the non-obvious part below has to be stated once and not twice.
8
+ *
9
+ * The HTTP admission DECISION lives here too, for the same reason: the lane derivation and the
10
+ * refusal it produces are two halves of one answer, and keeping them in one file is what stops
11
+ * a denial from losing the detail that makes it legible on the way out.
8
12
  */
9
13
  import { formatErrorResponse } from "../bridge";
10
14
  import {
@@ -14,9 +18,13 @@ import {
14
18
  } from "./request-log";
15
19
  import {
16
20
  WORKFLOW_LOCAL_REFUSAL_HEADER,
21
+ admitWorkflowTurn,
17
22
  workflowDenialSummary,
18
23
  recordWorkflowRefusalEvent,
19
24
  type WorkflowDenial,
25
+ type WorkflowDecision,
26
+ type WorkflowLane,
27
+ type WorkflowSpendDenialDetail,
20
28
  } from "../lib/workflow-budget";
21
29
 
22
30
  /**
@@ -54,11 +62,16 @@ export function workflowRefusalResponse(
54
62
  logCtx?: RequestLogContext,
55
63
  refusalLog?: WorkflowRefusalLog,
56
64
  rootId?: string,
65
+ spend?: WorkflowSpendDenialDetail,
57
66
  ): Response {
58
- const summary = workflowDenialSummary(reason);
67
+ // The denial detail, when the caller has it, is what turns "a token ceiling refused this"
68
+ // into "the account ceiling of 100,000 is spent". It reaches the message, the event ring and
69
+ // the log row through this one function, so the three cannot drift into saying different
70
+ // things about the same refusal -- which is what put this module here in the first place.
71
+ const summary = workflowDenialSummary(reason, spend);
59
72
  // Only a caller that decided the refusal ITSELF passes a root id. admitWorkflowTurn already
60
73
  // records its own denials, so passing one there would double-count them.
61
- if (rootId) recordWorkflowRefusalEvent(rootId, reason);
74
+ if (rootId) recordWorkflowRefusalEvent(rootId, reason, Date.now(), spend);
62
75
  const recordOn = logCtx ?? refusalLog?.logCtx;
63
76
  if (recordOn) {
64
77
  markLocalRequestLogRefusal(recordOn, summary.code);
@@ -82,3 +95,44 @@ export function workflowRefusalResponse(
82
95
  refusal.headers.set("Access-Control-Expose-Headers", WORKFLOW_LOCAL_REFUSAL_HEADER);
83
96
  return refusal;
84
97
  }
98
+
99
+ /**
100
+ * Admit one HTTP turn against its root workflow budget.
101
+ *
102
+ * A fan-out shares the conversation it serves. Without a reserve, a worker burst takes every
103
+ * slot under its own root and the interactive turn that started it waits behind its own
104
+ * children. A request that names a parent is treated as that fan-out; a top-level request is
105
+ * the conversation and may use the reserved slots.
106
+ */
107
+ export function admitHttpWorkflowTurn(headers: Headers): WorkflowDecision | undefined {
108
+ const rootId = headers.get("x-codex-parent-thread-id")?.trim() || undefined;
109
+ const threadId = headers.get("thread-id")?.trim() || undefined;
110
+ const lane: WorkflowLane = rootId !== undefined && threadId !== undefined && threadId !== rootId
111
+ ? "worker"
112
+ : "interactive";
113
+ return admitWorkflowTurn(rootId, lane, undefined, threadId);
114
+ }
115
+
116
+ /**
117
+ * The refusal for a decision {@link admitHttpWorkflowTurn} declined.
118
+ *
119
+ * It exists so the denial's own detail survives the trip to the wire. A token-ceiling denial
120
+ * arrives carrying the scope that refused and the number it refused against, and a caller that
121
+ * forwarded only `reason` would answer with a 429 that names no ceiling -- which is the
122
+ * illegible refusal this whole path is built to avoid. No root id is passed on: the admission
123
+ * check already recorded its own event, and passing one would count the refusal twice.
124
+ */
125
+ export function workflowDecisionRefusalResponse(
126
+ decision: Extract<WorkflowDecision, { admitted: false }>,
127
+ logCtx?: RequestLogContext,
128
+ refusalLog?: WorkflowRefusalLog,
129
+ ): Response {
130
+ const spend = decision.spendScope !== undefined && decision.spendLimit !== undefined
131
+ ? {
132
+ scope: decision.spendScope,
133
+ limit: decision.spendLimit,
134
+ ...(decision.spendProjected !== undefined ? { projected: decision.spendProjected } : {}),
135
+ }
136
+ : undefined;
137
+ return workflowRefusalResponse(decision.reason, logCtx, refusalLog, undefined, spend);
138
+ }
@@ -197,23 +197,28 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
197
197
  assertServiceAuthEnvironment();
198
198
  // A throw used to escape straight to the top level, so the one command that can
199
199
  // leave a macOS hub evicted never reached its own serving check (#4236, defect 1f).
200
- // Report the failure, then still ask whether anything is listening: on darwin the
201
- // rollback inside installLaunchd may have brought the previous job back, and on
202
- // Windows the preserve/restart protocol may have done the same. The operator needs
203
- // both halves of that answer, and the exit code stays non-zero either way.
200
+ // Still ask whether anything is listening: on darwin the rollback inside installLaunchd
201
+ // may have brought the previous job back, and on Windows the preserve/restart protocol
202
+ // may have done the same. The operator needs both halves of that answer, and the exit
203
+ // code stays non-zero either way.
204
+ //
205
+ // The failure text travels INTO that report rather than being printed here. Printing it
206
+ // here and then letting the report reach its success line stated both outcomes for one
207
+ // run — "❌ Service repair failed: ... exit code 199" beside "✅ opencodex service
208
+ // repaired and serving on port 10100" — and the checkmark was the false half: the
209
+ // existing registration had been restarted, not repaired (#4914).
204
210
  let repairError: unknown;
205
211
  try {
206
212
  await repairService({ verb });
207
213
  } catch (error) {
208
214
  repairError = error;
209
- console.error(`❌ Service ${verb} failed: ${error instanceof Error ? error.message : String(error)}`);
210
215
  process.exitCode = 1;
211
216
  }
212
217
  // All three platforms: a repair that reports success while nothing serves is the
213
218
  // defect class this unit exists to close. Windows bakes its port into the
214
219
  // scheduler wrapper or the WinSW XML, both of which installedServiceListenPort()
215
220
  // now reads.
216
- await reportServiceServing(verb === "restart" ? "restarted" : "repaired");
221
+ await reportServiceServing(verb === "restart" ? "restarted" : "repaired", {}, repairError);
217
222
  if (repairError !== undefined) process.exitCode = 1;
218
223
  return;
219
224
  }
@@ -265,6 +270,11 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
265
270
  await maybeShowStarPrompt();
266
271
  break;
267
272
  case "start":
273
+ // The installed launcher preserves the recorded CODEX_SQLITE_HOME: a
274
+ // changed sqlite_home/CODEX_SQLITE_HOME/CODEX_HOME would start the service
275
+ // on the recorded database while this shell resolves another, splitting
276
+ // native Codex history between databases. Same guard `stop` already runs.
277
+ assertServiceEnvironmentMatchesInstall();
268
278
  ops.start();
269
279
  await reportServiceServing("started");
270
280
  break;
@@ -10,6 +10,7 @@ import { isTestHomeGuardArmed } from "../lib/test-home-guard";
10
10
  import { diagnoseService } from "./diagnostics";
11
11
  import type { ServiceDiagnostic } from "./diagnostics";
12
12
  import { currentCodexHome, currentOpenCodexHome, normalizePathForCompare, readServiceInstallState } from "./state";
13
+ import { resolveCodexSqliteHome } from "../codex/paths";
13
14
  import { win32 } from "node:path";
14
15
 
15
16
  /**
@@ -61,6 +62,15 @@ export function assertServiceEnvironmentMatchesInstall(): void {
61
62
  "Run the service command from the same OpenCodex home so service state and secrets match.",
62
63
  );
63
64
  }
65
+ if (state.codexSqliteHome !== undefined) {
66
+ const actualCodexSqliteHome = resolveCodexSqliteHome({ codexHome: actualCodexHome });
67
+ if (normalizePathForCompare(state.codexSqliteHome) !== normalizePathForCompare(actualCodexSqliteHome)) {
68
+ throw new ServiceOwnershipError(
69
+ `Service was installed with Codex SQLite home=${state.codexSqliteHome}, but the current Codex SQLite home=${actualCodexSqliteHome}. ` +
70
+ "Run the service command with the same sqlite_home configuration and CODEX_SQLITE_HOME so native Codex history restore updates the correct database.",
71
+ );
72
+ }
73
+ }
64
74
  }
65
75
 
66
76
  function isLoopbackHostname(hostname: string | undefined): boolean {