@bitkyc08/opencodex 2.81.0 → 2.82.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 (212) hide show
  1. package/bin/ocx.mjs +166 -73
  2. package/gui/dist/assets/App-Ds_qL7o8.js +54 -0
  3. package/gui/dist/assets/{Tray-EwAOsP0S.js → Tray-BqHF5Trd.js} +1 -1
  4. package/gui/dist/assets/{index-Brk6F3fn.css → index-BAbcj51J.css} +1 -1
  5. package/gui/dist/assets/{index-RsoaAxXY.js → index-D3FBCNiP.js} +13 -13
  6. package/gui/dist/assets/{quota-summary-D2lN8tDm.js → quota-summary-DTBC8n7-.js} +1 -1
  7. package/gui/dist/index.html +2 -2
  8. package/package.json +1 -1
  9. package/src/adapters/anthropic.ts +1 -1
  10. package/src/adapters/openai-responses/compaction-search-history.ts +109 -0
  11. package/src/adapters/openai-responses/passthrough.ts +9 -4
  12. package/src/adapters/openai-responses/tool-output-recovery.ts +20 -0
  13. package/src/claude/agents-inject.ts +14 -3
  14. package/src/claude/context-windows.ts +19 -17
  15. package/src/claude/desktop-3p.ts +88 -12
  16. package/src/claude/desktop-profile.ts +2 -1
  17. package/src/claude/gateway-cache.ts +18 -1
  18. package/src/claude/inbound.ts +77 -3
  19. package/src/claude/intercept/cli-catalog.ts +3 -0
  20. package/src/claude/intercept/picker-ca-persistence.ts +7 -6
  21. package/src/claude/intercept/picker-models.ts +14 -6
  22. package/src/claude/intercept/settings.ts +3 -0
  23. package/src/claude/long-context.ts +66 -0
  24. package/src/claude/model-info.ts +19 -16
  25. package/src/claude/outbound.ts +94 -9
  26. package/src/claude/subagent-model.ts +40 -11
  27. package/src/cli/agent.ts +9 -5
  28. package/src/cli/aside-profile-recovery.ts +33 -0
  29. package/src/cli/capabilities-base.ts +5 -5
  30. package/src/cli/capabilities-integrations.ts +2 -2
  31. package/src/cli/cli-path-diagnostics.ts +132 -0
  32. package/src/cli/desktop-runtime-guidance.ts +65 -0
  33. package/src/cli/dispatch.ts +5 -0
  34. package/src/cli/doctor.ts +16 -3
  35. package/src/cli/index.ts +14 -9
  36. package/src/cli/integration-aside-sync.ts +2 -1
  37. package/src/cli/integrations.ts +16 -3
  38. package/src/cli/resolve.ts +25 -2
  39. package/src/cli/root.ts +4 -2
  40. package/src/cli/status.ts +64 -12
  41. package/src/cli/update-restart-child.ts +4 -2
  42. package/src/cli/update-restart-home.ts +28 -10
  43. package/src/cli/update-restart-service-record.ts +116 -0
  44. package/src/cli/update-restart-supervision.ts +96 -0
  45. package/src/cli/update-restart.ts +59 -13
  46. package/src/cli/v2.ts +27 -20
  47. package/src/cli/version-skew-notice.ts +77 -0
  48. package/src/clients/lazycodex.ts +1 -1
  49. package/src/clients/omo-role-models.ts +32 -7
  50. package/src/codex/auth-api/account-selection.ts +107 -0
  51. package/src/codex/auth-api/routes.ts +10 -49
  52. package/src/codex/auth-context.ts +20 -1
  53. package/src/codex/autostart-health.ts +12 -3
  54. package/src/codex/catalog/build-entries.ts +18 -0
  55. package/src/codex/catalog/bundled.ts +155 -26
  56. package/src/codex/catalog/combo-member.ts +3 -2
  57. package/src/codex/catalog/effort.ts +26 -5
  58. package/src/codex/catalog/gather-capture.ts +5 -0
  59. package/src/codex/catalog/model-hints.ts +15 -8
  60. package/src/codex/catalog/model-visibility.ts +3 -1
  61. package/src/codex/catalog/operator-tiers.ts +38 -0
  62. package/src/codex/catalog/provider-models.ts +43 -35
  63. package/src/codex/catalog/retained-sync.ts +7 -5
  64. package/src/codex/catalog/routed-gather.ts +15 -1
  65. package/src/codex/catalog-auto-refresh-sources.ts +3 -4
  66. package/src/codex/catalog-auto-refresh.ts +19 -0
  67. package/src/codex/catalog.ts +1 -1
  68. package/src/codex/codex-home-owner.ts +3 -0
  69. package/src/codex/config-write-lock.ts +333 -0
  70. package/src/codex/convergence.ts +2 -1
  71. package/src/codex/features.ts +257 -200
  72. package/src/codex/inject/config-write-section.ts +34 -0
  73. package/src/codex/inject/multi-agent-v2.ts +14 -6
  74. package/src/codex/inject/remove.ts +36 -11
  75. package/src/codex/inject/restore.ts +85 -20
  76. package/src/codex/inject-coordination.ts +15 -6
  77. package/src/codex/inject.ts +62 -20
  78. package/src/codex/journal.ts +62 -17
  79. package/src/codex/main-account-cache.ts +43 -0
  80. package/src/codex/paths.ts +2 -0
  81. package/src/codex/prepared-home.ts +18 -0
  82. package/src/codex/prompt-journal.ts +25 -12
  83. package/src/codex/prompt-layers.ts +49 -26
  84. package/src/codex/prompt-lock-claim.ts +134 -0
  85. package/src/codex/prompt-lock-evidence.ts +26 -0
  86. package/src/codex/prompt-lock-io.ts +16 -0
  87. package/src/codex/prompt-lock-owner.ts +130 -0
  88. package/src/codex/prompt-lock.ts +84 -53
  89. package/src/codex/routing/failure-window.ts +146 -0
  90. package/src/codex/routing/selection.ts +4 -1
  91. package/src/codex/routing.ts +13 -17
  92. package/src/codex/runtime.ts +106 -18
  93. package/src/codex/shim.ts +45 -19
  94. package/src/combos/jev-service-exchange.ts +281 -0
  95. package/src/combos/jev.ts +34 -220
  96. package/src/config/atomic-write.ts +1 -1
  97. package/src/config/codex-ws-reuse-setting.ts +10 -0
  98. package/src/config/live-reconcile.ts +17 -0
  99. package/src/config/load-degrade.ts +8 -0
  100. package/src/config/rebase-provenance.ts +11 -0
  101. package/src/config/schema/config-schema.ts +4 -0
  102. package/src/generated/compatibility-version.json +303 -171
  103. package/src/generated/model-metadata.ts +2 -2
  104. package/src/grok/inject.ts +50 -4
  105. package/src/grok/sync.ts +2 -1
  106. package/src/images/loop.ts +29 -11
  107. package/src/integrations/aside-profiles.ts +10 -3
  108. package/src/integrations/catalog-refresh.ts +18 -2
  109. package/src/integrations/writer.ts +9 -3
  110. package/src/lib/bun-path-runtime.d.mts +32 -0
  111. package/src/lib/bun-path-runtime.mjs +95 -0
  112. package/src/lib/bun-runtime-preflight.ts +140 -0
  113. package/src/lib/desktop-cli-handoff.d.mts +20 -0
  114. package/src/lib/desktop-cli-handoff.mjs +92 -0
  115. package/src/lib/desktop-cli-record.d.mts +29 -0
  116. package/src/lib/desktop-cli-record.mjs +102 -0
  117. package/src/lib/request-execution-budget.ts +8 -0
  118. package/src/lib/synced-state-location.ts +46 -6
  119. package/src/lib/test-home-guard.ts +87 -1
  120. package/src/lib/translator-budget.ts +17 -4
  121. package/src/lib/windows-atomic-replace.ts +38 -16
  122. package/src/lib/windows-elevation.ts +6 -0
  123. package/src/lib/windows-secret-acl.ts +92 -25
  124. package/src/lib/winsw.ts +2 -3
  125. package/src/oauth/anthropic-account-refusal.ts +39 -1
  126. package/src/oauth/anthropic-routing.ts +1 -1
  127. package/src/oauth/anthropic-send-ownership.ts +3 -3
  128. package/src/oauth/chatgpt.ts +113 -41
  129. package/src/oauth/index.ts +6 -2
  130. package/src/oauth/store.ts +1 -1
  131. package/src/providers/azure-model-metadata.ts +115 -0
  132. package/src/providers/derive.ts +21 -0
  133. package/src/providers/quota/vendor-probes-key.ts +127 -17
  134. package/src/providers/quota.ts +1 -1
  135. package/src/responses/reasoning-envelope.ts +29 -1
  136. package/src/responses/reasoning-replay-cache.ts +17 -1
  137. package/src/responses/spill-store.ts +15 -2
  138. package/src/responses/state/metrics.ts +8 -0
  139. package/src/responses/state/spill-failure.ts +12 -0
  140. package/src/responses/state/spill-queue.ts +12 -5
  141. package/src/responses/state.ts +18 -10
  142. package/src/responses/task-input.ts +17 -0
  143. package/src/server/background-lifecycle.ts +27 -0
  144. package/src/server/chat-completions.ts +1 -0
  145. package/src/server/chat-native.ts +28 -12
  146. package/src/server/claude-messages.ts +43 -6
  147. package/src/server/inbound-body-admission.ts +1 -0
  148. package/src/server/index/claude-intercept-lifecycle.ts +3 -1
  149. package/src/server/index/serve-options.ts +10 -2
  150. package/src/server/index/spend-ledger-lifecycle.ts +2 -1
  151. package/src/server/index/startup-warnings.ts +19 -0
  152. package/src/server/index.ts +1 -0
  153. package/src/server/management/agent-settings-routes.ts +93 -32
  154. package/src/server/management/codex-agent-role-routes.ts +4 -2
  155. package/src/server/management/codex-prompt-routes.ts +1 -0
  156. package/src/server/management/codex-role-auto-assign.ts +13 -2
  157. package/src/server/management/config-routes.ts +59 -8
  158. package/src/server/management/context.ts +2 -2
  159. package/src/server/management/shared.ts +3 -3
  160. package/src/server/messages-native-oauth.ts +9 -8
  161. package/src/server/messages-native.ts +49 -13
  162. package/src/server/messages-response-headers.ts +14 -0
  163. package/src/server/request-log-conversation.ts +48 -5
  164. package/src/server/request-log.ts +31 -2
  165. package/src/server/responses/adapter-continuation.ts +2 -2
  166. package/src/server/responses/adapter-dispatch.ts +34 -13
  167. package/src/server/responses/codex-ws-exchange.ts +17 -2
  168. package/src/server/responses/codex-ws-pool.ts +100 -13
  169. package/src/server/responses/codex-ws-wire.ts +9 -0
  170. package/src/server/responses/combo-requested-effort.ts +3 -1
  171. package/src/server/responses/core-codex-account.ts +22 -1
  172. package/src/server/responses/core-combo-native.ts +20 -3
  173. package/src/server/responses/core-combo.ts +22 -14
  174. package/src/server/responses/core-lifetime.ts +3 -36
  175. package/src/server/responses/core-options.ts +5 -0
  176. package/src/server/responses/core-replay.ts +73 -0
  177. package/src/server/responses/core.ts +3 -3
  178. package/src/server/responses/fetch-helpers.ts +26 -3
  179. package/src/server/responses/passthrough-delivery.ts +24 -2
  180. package/src/server/responses/passthrough-dispatch.ts +19 -2
  181. package/src/server/responses/request-prepare.ts +4 -0
  182. package/src/server/responses/request-send-budget.ts +112 -49
  183. package/src/server/responses/response-effects.ts +1 -0
  184. package/src/server/responses/run-turn-execution.ts +2 -0
  185. package/src/server/responses/sidecar-execution.ts +25 -16
  186. package/src/server/responses/sidecar-send-budget.ts +58 -0
  187. package/src/server/responses/ws-upstream.ts +19 -6
  188. package/src/server/startup-health-cache.ts +2 -1
  189. package/src/server/workflow-refusal.ts +3 -0
  190. package/src/service/cli.ts +20 -4
  191. package/src/service/desktop-command-guard.ts +32 -0
  192. package/src/service/desktop-startup.ts +66 -40
  193. package/src/service/desktop-supervision.d.mts +26 -0
  194. package/src/service/desktop-supervision.mjs +140 -0
  195. package/src/service/managing-cli.ts +1 -0
  196. package/src/service/orchestration.ts +46 -20
  197. package/src/service/repair.ts +25 -9
  198. package/src/service/state.ts +4 -3
  199. package/src/service/systemd.ts +17 -8
  200. package/src/service/windows-ops.ts +61 -22
  201. package/src/storage/policy-job.ts +5 -1
  202. package/src/storage/policy-worker.ts +3 -1
  203. package/src/storage/policy.ts +9 -0
  204. package/src/types/config.ts +21 -0
  205. package/src/types/request.ts +5 -0
  206. package/src/types/wire.ts +1 -0
  207. package/src/update/index.ts +57 -9
  208. package/src/update/restart-ownership.ts +10 -3
  209. package/src/update/runtime-ownership.d.mts +6 -0
  210. package/src/update/runtime-ownership.mjs +23 -7
  211. package/src/web-search/loop.ts +5 -2
  212. package/gui/dist/assets/App-7yhoys8a.js +0 -54
@@ -36,11 +36,12 @@ import {
36
36
  buildAnthropicMessagesPassthroughRequest,
37
37
  type AnthropicMessagesPassthroughRequest,
38
38
  } from "../adapters/anthropic/passthrough";
39
- import { resolveInboundModel } from "../claude/inbound";
40
- import { anthropicErrorBody, anthropicErrorResponse, collectAnthropicMessage } from "../claude/outbound";
39
+ import { effortFromOutputConfig, resolveInboundModel } from "../claude/inbound";
40
+ import { anthropicErrorBody, anthropicErrorResponse, claudeOverflowSsePayload, claudePromptTooLongMessage, collectAnthropicMessage, isContextOverflowText, isThroughputLimitText } from "../claude/outbound";
41
41
  import type { AdmissionLease } from "../lib/admission";
42
42
  import { readBoundedResponseBody } from "../lib/bounded-body";
43
43
  import { classifyError } from "../lib/errors";
44
+ import { relaySseWithPayloadRewrite } from "./sse-payload-rewrite";
44
45
  import { redactSecretString } from "../lib/redact";
45
46
  import { resolveClientRetryAfter } from "../lib/retry-after";
46
47
  import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget";
@@ -107,6 +108,7 @@ import {
107
108
  import {
108
109
  noteProviderAttemptSend,
109
110
  recordAttemptCredentialSource,
111
+ recordAttemptRequestedEffort,
110
112
  recordFirstOutput,
111
113
  recordKeyAttemptFailure,
112
114
  recordKeyWireAttemptUsage,
@@ -119,6 +121,7 @@ import { workflowRefusalResponse } from "./workflow-refusal";
119
121
  import { sseFieldValue } from "../lib/sse-decoder";
120
122
  import { admissionModelDeniedResponse, type AdmissionModelScope } from "./admission-model-scope";
121
123
  import { nativeMessagesToolScopeDenial } from "./messages-native-scope";
124
+ import { retainUpstreamMessagesRequestId } from "./messages-response-headers";
122
125
 
123
126
  export {
124
127
  isNativeMessagesRouteEligible,
@@ -317,6 +320,15 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
317
320
  logCtx.requestedModel = requestedModel;
318
321
  if (route.routeReason === "model-alias" || route.modelId !== requestedModel) logCtx.requestedAlias = requestedModel;
319
322
  logCtx.requestedServiceTier = typeof body.service_tier === "string" ? body.service_tier : undefined;
323
+ if (logCtx.requestedEffort === undefined) {
324
+ const effort = effortFromOutputConfig(body.output_config);
325
+ const thinking = isRec(body.thinking) ? body.thinking : undefined;
326
+ const budget = thinking?.budget_tokens;
327
+ if (effort !== undefined) logCtx.requestedEffort = effort;
328
+ else if (thinking?.type === "disabled") logCtx.requestedEffort = "none";
329
+ else if (thinking?.type === "enabled" && typeof budget === "number"
330
+ && Number.isSafeInteger(budget) && budget > 0) logCtx.requestedEffort = `budget:${budget}`;
331
+ }
320
332
  // Reserve spend the way native Chat does: an input estimate that never enters usage, and the
321
333
  // caller's own output ceiling.
322
334
  if (logCtx.usageLogInputTokens === undefined) {
@@ -332,6 +344,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
332
344
  adapter: "anthropic",
333
345
  });
334
346
  attemptHandle.seal(logCtx.accountLogLabel);
347
+ recordAttemptRequestedEffort(logCtx);
335
348
  const { attempt } = attemptHandle;
336
349
  const finalLog = createFinalRequestLog(logIds, logCtx);
337
350
  const finishLog: FinishLog = (status, message, meta = { closeReason: "non_stream" }) => {
@@ -500,7 +513,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
500
513
  && nativeMessagesDeclineReason({ ...route, provider: routed }, body, config, selector) === undefined;
501
514
  };
502
515
 
503
- const send = async (recovery?: "rate-limit-429" | "oauth-account-403" | "key-429" | "key-401"): Promise<Response> => {
516
+ const send = async (recovery?: "rate-limit-429" | "oauth-401" | "oauth-account-403" | "key-429" | "key-401"): Promise<Response> => {
504
517
  const remaining = remainingTransientSends();
505
518
  if (requestTransientPolicy && remaining <= 0) {
506
519
  throw new Error("native Messages transient send budget exhausted before recovery dispatch");
@@ -672,7 +685,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
672
685
  const oauthRetryKey = {};
673
686
  const triedAccountIds = new Set<string>();
674
687
  let oauthFailovers = 0;
675
- while (oauthBinding && (response.status === 429 || response.status === 403)) {
688
+ while (oauthBinding && (response.status === 429 || response.status === 403 || response.status === 401)) {
676
689
  const sendingBinding = oauthBinding;
677
690
  const expectedRecoverySelection = sendingBinding.selection;
678
691
  triedAccountIds.add(sendingBinding.snapshot.accountId);
@@ -690,7 +703,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
690
703
  canRetry: transientSendAvailable() && oauthFailovers < ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST,
691
704
  });
692
705
  if (!nextAccountId) break;
693
- const recovery = response.status === 403 ? "oauth-account-403" : "rate-limit-429";
706
+ const recovery = response.status === 401 ? "oauth-401" : response.status === 403 ? "oauth-account-403" : "rate-limit-429";
694
707
  discard(response);
695
708
  oauthBinding = await resolveNativeOAuthBindingForInstance(nativeInstance!, config, { routeTarget,
696
709
  sessionKey: options.sessionKey, model: route.modelId, candidateAccountId: nextAccountId,
@@ -739,7 +752,7 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
739
752
  upstream.abort();
740
753
  return fail(499, "Client cancelled request", "api_error");
741
754
  }
742
- return nativeMessagesErrorResponse(response, bodyText, finishLog);
755
+ return retainUpstreamMessagesRequestId(nativeMessagesErrorResponse(response, bodyText, finishLog), response.headers);
743
756
  }
744
757
 
745
758
  const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
@@ -764,10 +777,15 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
764
777
  releaseStreamTurn();
765
778
  }
766
779
  }, bodyGuard);
767
- return new Response(relayed, {
780
+ // A configured Messages provider words an oversized-input refusal its own way; Claude Code
781
+ // compacts only on Anthropic's wording (devlog/_plan/261009_claude_1m_default/010). The
782
+ // rewrite buffers whole frames, so it sits after the tap: stall detection keeps timing raw
783
+ // upstream bytes. Real Anthropic pools refuse pre-stream in that wording and keep one relay.
784
+ const clientStream = nativeInstance ? relayed : relaySseWithPayloadRewrite(relayed, claudeOverflowSsePayload, translatorBudget);
785
+ return retainUpstreamMessagesRequestId(new Response(clientStream, {
768
786
  status: 200,
769
787
  headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache", Connection: "keep-alive" },
770
- });
788
+ }), response.headers);
771
789
  }
772
790
  // A non-streaming caller whose upstream streamed anyway: fold the stream into one message.
773
791
  const tapState: { closeReason?: FinalRequestLogMeta["closeReason"]; meta?: FinalRequestLogMeta } = {};
@@ -789,10 +807,11 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
789
807
  // as the streaming lane's row; a plain upstream error event keeps the non_stream row.
790
808
  const tapMeta = tapState.meta;
791
809
  if (tapMeta && (tapMeta.terminalStatus || tapMeta.closeReason !== "terminal")) finishLog(502, text, tapMeta);
810
+ if (isNativeOverflowError(error, text)) return fail(400, claudePromptTooLongMessage(text), "invalid_request_error");
792
811
  return fail(502, text, "api_error");
793
812
  }
794
813
  finishLog(200);
795
- return Response.json(message);
814
+ return retainUpstreamMessagesRequestId(Response.json(message), response.headers);
796
815
  } catch (error) {
797
816
  cleanupAbort();
798
817
  upstream.abort();
@@ -850,12 +869,19 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
850
869
  const serialized = JSON.stringify(message);
851
870
  finishLog(200);
852
871
  if (requestedStream) {
853
- return new Response(messageAsSse(message), {
872
+ return retainUpstreamMessagesRequestId(new Response(messageAsSse(message), {
854
873
  status: 200,
855
874
  headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache" },
856
- });
875
+ }), response.headers);
857
876
  }
858
- return new Response(serialized, { status: 200, headers: { "Content-Type": "application/json" } });
877
+ return retainUpstreamMessagesRequestId(new Response(serialized, { status: 200, headers: { "Content-Type": "application/json" } }), response.headers);
878
+ }
879
+
880
+ /** A folded stream error that refuses an oversized input (same gate as the SSE rewrite). */
881
+ function isNativeOverflowError(error: Rec, message: string): boolean {
882
+ const sized = error.type === "invalid_request_error" || error.type === "request_too_large";
883
+ return sized && !isThroughputLimitText(message)
884
+ && (error.code === "context_length_exceeded" || isContextOverflowText(message));
859
885
  }
860
886
 
861
887
  /**
@@ -865,11 +891,13 @@ export async function handleNativeMessages(options: HandleNativeMessagesOptions)
865
891
  */
866
892
  function nativeMessagesErrorResponse(response: Response, bodyText: string, finishLog: FinishLog): Response {
867
893
  let upstreamType: string | undefined;
894
+ let upstreamCode: string | undefined;
868
895
  let upstreamMessage: string | undefined;
869
896
  try {
870
897
  const parsed = JSON.parse(bodyText) as Rec;
871
898
  const details = isRec(parsed.error) ? parsed.error : parsed;
872
899
  if (typeof details.type === "string") upstreamType = details.type;
900
+ if (typeof details.code === "string") upstreamCode = details.code;
873
901
  if (typeof details.message === "string" && details.message.trim()) {
874
902
  upstreamMessage = redactSecretString(details.message.trim());
875
903
  }
@@ -898,9 +926,17 @@ function nativeMessagesErrorResponse(response: Response, bodyText: string, finis
898
926
  if (retryAfter) headers.set("Retry-After", retryAfter);
899
927
  else if (transient) headers.set("Retry-After", "2");
900
928
  if (replayRefusal) applyReplayRefusalClientHeaders(headers);
929
+ // The native envelope keeps its shape (no added code); only an overflow's wording changes, so
930
+ // Claude Code recognizes it and compacts (010).
931
+ // Only a 400/413 refusal: the shared classifier also files a 429 "too many tokens per minute"
932
+ // under context_length_exceeded, and that one must stay a rate limit.
933
+ const overflow = !replayRefusal && (response.status === 400 || response.status === 413)
934
+ && !isThroughputLimitText(safeMessage)
935
+ && (classified.code === "context_length_exceeded" || upstreamCode === "context_length_exceeded"
936
+ || isContextOverflowText(safeMessage));
901
937
  const out = new Response(JSON.stringify(anthropicErrorBody(
902
938
  status,
903
- safeMessage,
939
+ overflow ? claudePromptTooLongMessage(safeMessage) : safeMessage,
904
940
  transient ? "overloaded_error" : upstreamType,
905
941
  replayRefusal ? UPSTREAM_RESET_REPLAY_REFUSED_CODE : undefined,
906
942
  )), { status, headers });
@@ -0,0 +1,14 @@
1
+ /** Only an opaque Anthropic request id is carried; arbitrary upstream headers stay private. */
2
+ export function upstreamMessagesRequestIdHeaders(upstream: Headers): Record<string, string> {
3
+ const requestId = upstream.get("request-id");
4
+ return requestId && /^req_[A-Za-z0-9_-]{1,128}$/.test(requestId)
5
+ ? { "request-id": requestId }
6
+ : {};
7
+ }
8
+
9
+ /** The response is newly constructed by the Messages handler; preserve its body and markers. */
10
+ export function retainUpstreamMessagesRequestId(response: Response, upstream: Headers): Response {
11
+ const requestId = upstreamMessagesRequestIdHeaders(upstream)["request-id"];
12
+ if (requestId) response.headers.set("request-id", requestId);
13
+ return response;
14
+ }
@@ -37,6 +37,47 @@ export function normalizeLogConversationId(raw: string | undefined | null): stri
37
37
  return createHash("sha256").update(trimmed).digest("hex").slice(0, LOG_CONVERSATION_ID_LEN);
38
38
  }
39
39
 
40
+ const CODEX_THREAD_LINK_PREFIX = "codex://threads/";
41
+ /** Real thread links stay well under this bound; larger pastes skip unwrapping so per-row matching stays cheap. */
42
+ const LOG_CONVERSATION_LINK_MAX = 512;
43
+
44
+ /**
45
+ * Unwrap a pasted `codex://threads/<id>` deep link to the bare thread id. Codex's
46
+ * "copy session link" clipboard carries that URI form while clients send the bare id
47
+ * on the wire, so Logs search accepts either paste without changing what is persisted.
48
+ * Query/fragment metadata (e.g. `?hostId=…`) belongs to the link, not the id, and is
49
+ * dropped. The scan is a bounded linear walk — no ambiguous regex — because this runs
50
+ * per log row on every filter evaluation.
51
+ */
52
+ export function unwrapLogConversationQuery(query: string): string {
53
+ const trimmed = query.trim();
54
+ if (
55
+ trimmed.length > LOG_CONVERSATION_LINK_MAX ||
56
+ trimmed.length <= CODEX_THREAD_LINK_PREFIX.length ||
57
+ !trimmed.toLowerCase().startsWith(CODEX_THREAD_LINK_PREFIX)
58
+ ) {
59
+ return trimmed;
60
+ }
61
+ const rest = trimmed.slice(CODEX_THREAD_LINK_PREFIX.length);
62
+ const metaIndex = rest.search(/[?#]/);
63
+ const segment = (metaIndex === -1 ? rest : rest.slice(0, metaIndex)).replace(/\/+$/, "");
64
+ // A thread id is a single path segment; deeper paths or embedded whitespace keep the
65
+ // whole paste as the query, so foreign `codex://` URIs still fail to match.
66
+ return segment !== "" && !/[/\s]/.test(segment) ? segment : trimmed;
67
+ }
68
+
69
+ /**
70
+ * The candidate ids a conversation query may legitimately match. Unwrapping a pasted
71
+ * `codex://threads/<id>` link must add a match path, not replace one: a client can
72
+ * literally send a `codex://threads/…` session id, which is hashed whole for storage,
73
+ * so the untouched paste stays a candidate too.
74
+ */
75
+ export function logConversationQueryCandidates(query: string): string[] {
76
+ const trimmed = query.trim();
77
+ const unwrapped = unwrapLogConversationQuery(trimmed);
78
+ return unwrapped === trimmed ? [unwrapped] : [unwrapped, trimmed];
79
+ }
80
+
40
81
  /**
41
82
  * Filter match: accept either the persisted digest or the original preimage
42
83
  * (so pasting from Logs detail or the client-facing session id both work).
@@ -46,11 +87,13 @@ export function matchesLogConversationId(
46
87
  query: string | undefined | null,
47
88
  ): boolean {
48
89
  if (!stored) return false;
49
- const trimmed = typeof query === "string" ? query.trim() : "";
50
- if (!trimmed) return false;
51
- if (stored === trimmed) return true;
52
- const hashed = normalizeLogConversationId(trimmed);
53
- return hashed !== undefined && stored === hashed;
90
+ for (const candidate of logConversationQueryCandidates(typeof query === "string" ? query : "")) {
91
+ if (!candidate) continue;
92
+ if (stored === candidate) return true;
93
+ const hashed = normalizeLogConversationId(candidate);
94
+ if (hashed !== undefined && stored === hashed) return true;
95
+ }
96
+ return false;
54
97
  }
55
98
 
56
99
  /**
@@ -232,6 +232,8 @@ export interface RequestLogContext {
232
232
  * message) extracted from a `response.failed` SSE payload or non-streaming error body, so the
233
233
  * request log / GUI shows the actual upstream failure rather than only the HTTP-mapped code. */
234
234
  upstreamError?: string;
235
+ upstreamErrorCode?: string;
236
+ upstreamRequestId?: string;
235
237
  /** HTTP status derived from a terminal `response.failed` SSE payload (429/401/503/etc.). */
236
238
  terminalHttpStatus?: number;
237
239
  /** Recognized structured terminal code whose exact identity must survive status mapping. */
@@ -342,6 +344,8 @@ export interface RequestLogEntry {
342
344
  closeReason?: "terminal" | "client_cancel" | "non_stream" | "body_stall" | "body_overflow";
343
345
  /** Secret-redacted upstream error reason, surfaced in /api/logs and the GUI detail modal. */
344
346
  upstreamError?: string;
347
+ upstreamErrorCode?: string;
348
+ upstreamRequestId?: string;
345
349
  usageStatus: UsageStatus;
346
350
  usage?: OcxUsage;
347
351
  totalTokens?: number;
@@ -1095,6 +1099,24 @@ export function inspectResponseLogSsePayloadParsed(
1095
1099
  * a non-streaming JSON error body. We keep the FIRST non-empty reason (the original failure) and
1096
1100
  * run it through redactSecretString so secrets never reach /api/logs. Pure; safe on any text.
1097
1101
  */
1102
+ const UPSTREAM_DIAGNOSTIC_TOKEN = /^[A-Za-z0-9_.:-]{1,128}$/;
1103
+
1104
+ function noteBoundedDiagnostic(
1105
+ logCtx: RequestLogContext,
1106
+ field: "upstreamErrorCode" | "upstreamRequestId",
1107
+ value: unknown,
1108
+ ): void {
1109
+ if (logCtx[field] !== undefined) return;
1110
+ if (typeof value !== "string" || !UPSTREAM_DIAGNOSTIC_TOKEN.test(value)) return;
1111
+ logCtx[field] = value;
1112
+ const status = logCtx.terminalHttpStatus;
1113
+ if (status === undefined || status >= 500) console.warn(`[opencodex] upstream failure${status ? ` status=${status}` : ""}${logCtx.upstreamErrorCode ? ` code=${logCtx.upstreamErrorCode}` : ""}${logCtx.upstreamRequestId ? ` request_id=${logCtx.upstreamRequestId}` : ""}`);
1114
+ }
1115
+
1116
+ export function noteUpstreamRequestId(logCtx: RequestLogContext, headers: Headers): void {
1117
+ noteBoundedDiagnostic(logCtx, "upstreamRequestId", headers.get("openai-request-id") ?? headers.get("x-request-id"));
1118
+ }
1119
+
1098
1120
  function captureUpstreamError(logCtx: RequestLogContext, text: string | null): void {
1099
1121
  if (!text) return;
1100
1122
  let parsed: unknown | undefined;
@@ -1114,14 +1136,19 @@ function captureUpstreamErrorParsed(
1114
1136
  if (parsed !== undefined && parsed !== null) {
1115
1137
  const json = parsed as {
1116
1138
  type?: unknown;
1117
- error?: { message?: unknown };
1118
- last_error?: { message?: unknown };
1139
+ error?: { message?: unknown; code?: unknown };
1140
+ last_error?: { message?: unknown; code?: unknown };
1119
1141
  response?: {
1120
1142
  error?: { type?: unknown; code?: unknown; message?: unknown };
1121
1143
  incomplete_details?: { reason?: unknown; message?: unknown };
1122
1144
  };
1123
1145
  };
1124
1146
  captureTerminalHttpStatus(logCtx, json);
1147
+ noteBoundedDiagnostic(
1148
+ logCtx,
1149
+ "upstreamErrorCode",
1150
+ json.error?.code ?? json.response?.error?.code ?? json.last_error?.code,
1151
+ );
1125
1152
  const reason = json?.response?.incomplete_details?.reason;
1126
1153
  if (json.type === "response.incomplete"
1127
1154
  && logCtx.terminalIncompleteReason === undefined
@@ -1593,6 +1620,8 @@ export function addFinalRequestLog(
1593
1620
  ...(meta?.terminalStatus ? { terminalStatus: meta.terminalStatus } : {}),
1594
1621
  ...(closeReason ? { closeReason } : {}),
1595
1622
  ...(logCtx.upstreamError ? { upstreamError: logCtx.upstreamError } : {}),
1623
+ ...(logCtx.upstreamErrorCode ? { upstreamErrorCode: logCtx.upstreamErrorCode } : {}),
1624
+ ...(logCtx.upstreamRequestId ? { upstreamRequestId: logCtx.upstreamRequestId } : {}),
1596
1625
  usageStatus,
1597
1626
  ...(loggedUsage ? { usage: loggedUsage } : {}),
1598
1627
  ...(totalTokens !== undefined ? { totalTokens } : {}),
@@ -401,7 +401,7 @@ export function createAdapterContinuations(
401
401
  }
402
402
  }
403
403
  if (
404
- (response.status === 429 || response.status === 403)
404
+ (response.status === 429 || response.status === 403 || response.status === 401)
405
405
  && anthropicInstance
406
406
  && transportState.anthropicPoolAccountId
407
407
  && !isNonReplayableResponse(response)
@@ -428,7 +428,7 @@ export function createAdapterContinuations(
428
428
  );
429
429
  sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
430
430
  recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name);
431
- nextContinuationRecoveryKind = "anthropic-oauth-429";
431
+ nextContinuationRecoveryKind = response.status === 401 ? "oauth-401" : "anthropic-oauth-429";
432
432
  continue;
433
433
  } catch {
434
434
  // fall through to emit continuation error below
@@ -170,6 +170,9 @@ export async function prepareAdapterExchange(
170
170
  | "workflowRootId"
171
171
  | "claimAmbiguousResend"
172
172
  | "sendsUsed"
173
+ | "targetSendsUsed"
174
+ | "initialSendAllowance"
175
+ | "noteInitialDispatch"
173
176
  >,
174
177
  ) {
175
178
  const { options, config, logCtx, req } = requestContext;
@@ -374,24 +377,33 @@ export async function prepareAdapterExchange(
374
377
  const compactPrepaid = options.compactionRecoveryAttempted ? sendBudgetState.pendingHopPermit : undefined;
375
378
  if (compactPrepaid) sendBudgetState.pendingHopPermit = undefined;
376
379
  let compactPrepaidUsed = false;
380
+ // A combo-owned or compaction-prepaid send is settled by the physical-dispatch receipt
381
+ // when spend enforcement is off. Enforced spend and direct requests keep the shared
382
+ // physical-send reporter, which already owns the permit lifecycle there.
383
+ // Stable per request: a Combo or compaction permit comes from `reserveDispatch`, which starts
384
+ // (and freezes) the spend policy first, so `spendEnforced` cannot change before dispatch.
385
+ const receiptMode = !sendBudgetState.adapterSendBudget?.spendEnforced
386
+ && Boolean(options.comboInitialSend || compactPrepaid);
377
387
  // Combo and emergency compaction admission already book this target's first send.
378
388
  // Its configured initial ceiling is target-local; the shared remainder below still
379
389
  // accounts for earlier targets without deducting their sends from this target twice.
380
390
  const initialSendCap = transientPolicy || resetPolicy
391
+ || (options.comboAttempt && route.provider.adapter === "google")
381
392
  ? transientSendCapFor(transientPolicy?.attempts,
382
393
  (options.comboAttempt || compactPrepaid) ? 0 : sendBudgetState.sendsUsed)
383
394
  : 1;
384
395
  const fetchWithRetryPolicy = (route.provider.adapter === "google" || transientPolicy)
385
396
  ? fetchWithTransientRetry
386
397
  : fetchWithResetRetry;
387
- upstreamResponse = await fetchWithRetryPolicy(
398
+ try { upstreamResponse = await fetchWithRetryPolicy(
388
399
  recovery => {
389
400
  // Unconfigured generic sends may omit the counting reporter. Capture policy
390
401
  // independently so a later recovery still belongs to this request's start.
391
402
  sendBudgetState.adapterSendBudget?.startRequest?.({
392
403
  poolId: logCtx.spendPoolId ?? route.providerName, identityId: logCtx.accountLogLabel,
393
404
  });
394
- if (compactPrepaid && !compactPrepaidUsed) {
405
+ // Receipt mode books the physical send at executor admission instead of here.
406
+ if (!receiptMode && compactPrepaid && !compactPrepaidUsed) {
395
407
  if (!compactPrepaid.use()) throw new SendBudgetExhaustedError(safeHostLabel(builtInitialRequest.url));
396
408
  compactPrepaidUsed = true;
397
409
  }
@@ -405,6 +417,14 @@ export async function prepareAdapterExchange(
405
417
  dispatchOverride: oauthDispatch(builtInitialRequest),
406
418
  providerName: route.providerName,
407
419
  modelId: route.modelId,
420
+ onPhysicalDispatch: () => {
421
+ // Preserve the unconfigured direct-adapter accounting contract; Combo admission
422
+ // and explicit retry/compaction budgets require a receipt for their booked send.
423
+ if (receiptMode) {
424
+ sendBudgetState.noteInitialDispatch(compactPrepaid && !compactPrepaidUsed ? compactPrepaid : undefined);
425
+ compactPrepaidUsed = true;
426
+ }
427
+ },
408
428
  beforeDispatch: isCanonicalOpenAiForwardProvider(route.provider)
409
429
  ? createCodexAuthDispatchGuard(admissionState.authCtx, options.codexAuthPolicy ?? config, route.modelId, options.admission, options.visionDescribeTerminal === true) : undefined,
410
430
  }));
@@ -413,22 +433,24 @@ export async function prepareAdapterExchange(
413
433
  abortSignal: upstream.signal,
414
434
  label: safeHostLabel(builtInitialRequest.url),
415
435
  claimAmbiguousResend: claimPreHeaderResend,
416
- ...(sendBudgetState.adapterSendBudget?.spendEnforced || options.comboDispatchPermit || transientPolicy || resetPolicy || compactPrepaid
436
+ ...(sendBudgetState.adapterSendBudget?.spendEnforced || options.comboDispatchPermit || transientPolicy || resetPolicy || compactPrepaid || options.comboAttempt
417
437
  ? {
418
438
  // A pending compaction permit already booked this leg's first physical send.
419
- ...(transientPolicy || resetPolicy || compactPrepaid
439
+ ...(transientPolicy || resetPolicy || compactPrepaid || options.comboAttempt
420
440
  ? {
421
441
  attempts: Math.min(initialSendCap,
422
- remainingTransientSendBudget(initialSendCap) + (compactPrepaid ? 1 : 0)),
442
+ sendBudgetState.initialSendAllowance(initialSendCap) + (compactPrepaid ? 1 : 0)),
423
443
  }
424
444
  : {}),
425
- // Keep the exact combo or compaction receipt on the helper callback. The reporter
426
- // also settles the request and workflow counters for reset-only retries.
427
- onSendsConsumed: transientSendReporter(compactPrepaid ?? options.comboDispatchPermit),
445
+ // Reporter path: direct requests and enforced spend. Receipt-mode sends are
446
+ // settled by `onPhysicalDispatch`, so a reporter here would count them twice.
447
+ ...(receiptMode
448
+ ? {}
449
+ : { onSendsConsumed: transientSendReporter(compactPrepaid ?? options.comboDispatchPermit) }),
428
450
  }
429
451
  : {}),
430
452
  },
431
- );
453
+ ); } finally { compactPrepaid?.release(); }
432
454
  }
433
455
  } catch (err) {
434
456
  cleanupUpstreamAbort();
@@ -609,8 +631,7 @@ export async function prepareAdapterExchange(
609
631
  const helperCountsSends = sendBudgetState.adapterSendBudget?.spendEnforced === true || refetchTransientPolicy !== null || resetReplayPolicyFor(route.provider) !== null;
610
632
  const prepaid = sendBudgetState.pendingHopPermit;
611
633
  const configuredTotal = refetchTransientPolicy?.attempts;
612
- const refetchCap = transientSendCapFor(configuredTotal,
613
- sendBudgetState.sendsUsed - (prepaid ? 1 : 0));
634
+ const refetchCap = transientSendCapFor(configuredTotal, sendBudgetState.targetSendsUsed);
614
635
  // An exact total includes a booked hop, even when it consumed the last base slot.
615
636
  // Keep that funded send while forbidding the final reserve from widening the total.
616
637
  const prepaidLastSlot = helperCountsSends && configuredTotal !== undefined && prepaid
@@ -1076,7 +1097,7 @@ export async function prepareAdapterExchange(
1076
1097
  // Anthropic OAuth: recover a rate limit or proven account entitlement refusal
1077
1098
  // before output, within the shared request and account rotation limits.
1078
1099
  while (
1079
- (upstreamResponse.status === 429 || upstreamResponse.status === 403)
1100
+ (upstreamResponse.status === 429 || upstreamResponse.status === 403 || upstreamResponse.status === 401)
1080
1101
  && anthropicInstance
1081
1102
  && transportState.anthropicPoolAccountId
1082
1103
  ) {
@@ -1101,7 +1122,7 @@ export async function prepareAdapterExchange(
1101
1122
  );
1102
1123
  sealRequestAttemptIdentity(logCtx.activeAttempt, logCtx.provider, transportState.activeAdapter.name, logCtx.accountLogLabel);
1103
1124
  recordAttemptCredentialSource(logCtx.activeAttempt, route.providerName, route.provider, transportState.activeAdapter.name);
1104
- const result = await rebuildAndRefetch("anthropic-oauth-429");
1125
+ const result = await rebuildAndRefetch(upstreamResponse.status === 401 ? "oauth-401" : "anthropic-oauth-429");
1105
1126
  if ("failed" in result) return result.failed;
1106
1127
  upstreamResponse = result;
1107
1128
  if (isNonReplayableResponse(upstreamResponse)) continue recovery;
@@ -9,7 +9,7 @@ import { CODEX_RESPONSES_HTTP_URL, type PreparedCodexWsRequest } from "./codex-w
9
9
  import { CodexWsCorrelation } from "./codex-ws-correlation";
10
10
  import type { CodexWsSession } from "./codex-ws-session";
11
11
  import { UPGRADE_DEADLINE_MS, CODEX_WS_LIVENESS_PING_INTERVAL_MS, CODEX_WS_RESPONSE_PRELUDE_TIMEOUT_MS, MAX_CODEX_WS_FRAME_BYTES,
12
- MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage,
12
+ MAX_CODEX_WS_QUEUE_BYTES, markCodexWsResponse, markCodexWsPreludeProjection, normalizeResponsesWsRelayEvent, closedBeforeTerminalMessage,
13
13
  codexWsCreateFrameExceedsLimit, codexWsFailureDetail, codexWsPreResponseFailure, markCodexWsStage, codexWsOcxVersion,
14
14
  markCodexWsSocketDeath, type CodexWsFailureStage, type CodexWsStageRecord } from "./codex-ws-wire";
15
15
 
@@ -23,6 +23,7 @@ interface ExchangeOptions {
23
23
  sseFallback: typeof globalThis.fetch;
24
24
  onQuota?: CodexWsQuotaObserver;
25
25
  beforeDispatch?: (headers: Headers) => void;
26
+ onPhysicalDispatch?: () => void;
26
27
  /** Bun version string the caller gated on; stamped onto the stage record. */
27
28
  bunVersion?: string;
28
29
  }
@@ -44,7 +45,8 @@ function rejectionHeaders(source: Record<string, unknown>, prelude: Headers): He
44
45
  }
45
46
  }
46
47
  // Reuse the metadata owner's count/value/family budgets and window freshness
47
- // rules, without publishing quota twice. The unmarked HTTP response owns it.
48
+ // rules, without publishing quota twice. Pool bookkeeping consumes the HTTP
49
+ // projection; plain-main publication belongs only to the WS observer.
48
50
  const projected = new CodexWsMetadata();
49
51
  try {
50
52
  for (const values of [Object.fromEntries(prelude), source]) {
@@ -240,6 +242,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
240
242
  // are left out: their channel may already have sent continuation frames on this socket, so
241
243
  // the create frame alone no longer describes the turn.
242
244
  if (socketDied && !nativeControl) markCodexWsSocketDeath(failureResponse, stage);
245
+ markCodexWsPreludeProjection(failureResponse);
243
246
  resolve(failureResponse);
244
247
  return;
245
248
  }
@@ -411,6 +414,17 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
411
414
  failStream(error);
412
415
  return;
413
416
  }
417
+ try { options.onPhysicalDispatch?.(); } catch (error) {
418
+ // Admission refused before the frame left. This is a pre-open rejection,
419
+ // not a transport failure and not an authorization to replay over SSE.
420
+ settledPreOpen = true;
421
+ sent = false;
422
+ terminal = true;
423
+ cleanup();
424
+ session.dispose();
425
+ reject(error);
426
+ return;
427
+ }
414
428
  try {
415
429
  ws.send(frameText);
416
430
  sentAt = Date.now();
@@ -501,6 +515,7 @@ export function codexWsExchange(options: ExchangeOptions): Promise<Response> {
501
515
  cleanup();
502
516
  try { controller.close(); } catch { /* unused stream already closed */ }
503
517
  session.dispose();
518
+ markCodexWsPreludeProjection(rejection);
504
519
  resolve(rejection);
505
520
  return;
506
521
  }