@bitkyc08/opencodex 2.76.0 → 2.77.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 (189) hide show
  1. package/gui/dist/assets/App-COdEeqSj.css +1 -0
  2. package/gui/dist/assets/App-DEpCJEP_.js +54 -0
  3. package/gui/dist/assets/{Tray-DjNwwrWS.js → Tray-gloIYNaW.js} +1 -1
  4. package/gui/dist/assets/index-Cd7ittMv.css +1 -0
  5. package/gui/dist/assets/index-DwJKFJm5.js +94 -0
  6. package/gui/dist/assets/quota-summary-DGcEJU3h.js +1 -0
  7. package/gui/dist/index.html +4 -3
  8. package/package.json +1 -1
  9. package/src/adapters/antigravity-refusal.ts +28 -0
  10. package/src/adapters/cursor/protobuf-request.ts +6 -0
  11. package/src/adapters/cursor/request-builder.ts +3 -0
  12. package/src/adapters/cursor/structured-output.ts +13 -0
  13. package/src/adapters/cursor/types.ts +2 -0
  14. package/src/adapters/ollama-native.ts +29 -2
  15. package/src/adapters/openai-responses/passthrough.ts +133 -103
  16. package/src/adapters/openai-responses/request-strips.ts +10 -4
  17. package/src/adapters/zed.ts +26 -4
  18. package/src/bridge/response-json.ts +14 -14
  19. package/src/bridge/sse.ts +15 -5
  20. package/src/chatgpt/app-server-shim/bundle-trust.ts +40 -10
  21. package/src/chatgpt/app-server-shim/gate-rewrite.ts +3 -0
  22. package/src/claude/agents-inject.ts +4 -43
  23. package/src/claude/gateway-cache.ts +16 -13
  24. package/src/claude/inbound-model-options.ts +9 -2
  25. package/src/claude/intercept/cli-catalog.ts +27 -16
  26. package/src/claude/intercept/picker-bootstrap.ts +37 -16
  27. package/src/claude/intercept/picker-budget.ts +41 -0
  28. package/src/claude/outbound.ts +2 -0
  29. package/src/claude/subagent-force-status.ts +53 -0
  30. package/src/claude/subagent-model.ts +93 -0
  31. package/src/cli/account-api.ts +3 -0
  32. package/src/cli/account.ts +3 -1
  33. package/src/cli/agent.ts +10 -0
  34. package/src/cli/capabilities.ts +9 -0
  35. package/src/cli/chatgpt-command.ts +10 -7
  36. package/src/cli/claude.ts +22 -6
  37. package/src/cli/codex-shim-readiness.ts +89 -0
  38. package/src/cli/connect.ts +19 -0
  39. package/src/cli/dispatch.ts +3 -3
  40. package/src/cli/export-command.ts +20 -9
  41. package/src/cli/gui.ts +8 -3
  42. package/src/cli/help-catalog.ts +84 -0
  43. package/src/cli/help-models-context.ts +17 -0
  44. package/src/cli/help-navigation.ts +50 -0
  45. package/src/cli/help-recovery.ts +66 -0
  46. package/src/cli/help.ts +52 -8
  47. package/src/cli/models-runtime.ts +2 -1
  48. package/src/cli/opencode.ts +54 -10
  49. package/src/cli/provider.ts +3 -29
  50. package/src/cli/registry.ts +29 -5
  51. package/src/cli/root.ts +30 -9
  52. package/src/cli/runtime-api.ts +1 -1
  53. package/src/cli/tray-proxy.ts +3 -2
  54. package/src/clients/config-export/contracts.ts +100 -0
  55. package/src/clients/config-export/droid.ts +54 -2
  56. package/src/clients/config-export/kilo.ts +12 -2
  57. package/src/clients/config-export/model-metadata.ts +10 -4
  58. package/src/clients/config-export/reasoning-metadata.ts +47 -0
  59. package/src/clients/config-export.ts +46 -66
  60. package/src/clients/omo-role-models.ts +17 -3
  61. package/src/codex/account-credit-use.ts +4 -2
  62. package/src/codex/account-runtime-state.ts +21 -0
  63. package/src/codex/account-store.ts +11 -47
  64. package/src/codex/account-usability.ts +11 -8
  65. package/src/codex/auth-api/account-list.ts +73 -63
  66. package/src/codex/auth-api/main-account-probe.ts +25 -12
  67. package/src/codex/auth-api/pool-mode-gate.ts +6 -9
  68. package/src/codex/auth-api/pool-quota-probe.ts +2 -4
  69. package/src/codex/auth-api/reset-credit-service.ts +3 -2
  70. package/src/codex/auth-context.ts +30 -11
  71. package/src/codex/catalog/aggregation.ts +3 -0
  72. package/src/codex/catalog/model-hints.ts +2 -1
  73. package/src/codex/catalog/model-visibility.ts +6 -3
  74. package/src/codex/catalog/parsing.ts +2 -0
  75. package/src/codex/catalog/provider-models.ts +13 -4
  76. package/src/codex/catalog/retained-sync.ts +2 -1
  77. package/src/codex/chatgpt-refresh-failure.ts +56 -0
  78. package/src/codex/convergence.ts +2 -1
  79. package/src/codex/main-account.ts +144 -11
  80. package/src/codex/quota-refresh-outcome.ts +17 -2
  81. package/src/codex/quota-types.ts +33 -1
  82. package/src/codex/quota.ts +56 -15
  83. package/src/codex/routing/cooldown-math.ts +10 -4
  84. package/src/codex/routing/selection.ts +11 -4
  85. package/src/codex/routing.ts +9 -9
  86. package/src/codex/shim-path-resolution.ts +133 -0
  87. package/src/codex/shim.ts +39 -39
  88. package/src/codex/subagent-model-fallback.ts +2 -1
  89. package/src/combos/failover.ts +82 -1
  90. package/src/combos/jev.ts +13 -3
  91. package/src/config/diagnostics.ts +4 -0
  92. package/src/config/load-degrade.ts +4 -0
  93. package/src/config/provider-validation.ts +3 -0
  94. package/src/config/schema/compaction-triggers.ts +8 -0
  95. package/src/config/schema/config-schema.ts +11 -0
  96. package/src/config/schema/leaf-validators.ts +5 -1
  97. package/src/config/subagent-models.ts +12 -0
  98. package/src/generated/compatibility-version.json +245 -153
  99. package/src/grok/session-identity.ts +33 -0
  100. package/src/integrations/catalog-refresh.ts +1 -1
  101. package/src/integrations/mutation-plan.ts +4 -2
  102. package/src/integrations/state.ts +68 -6
  103. package/src/integrations/writer.ts +18 -4
  104. package/src/lib/gui-pair-capability.ts +17 -0
  105. package/src/lib/provider-client-headers.ts +62 -0
  106. package/src/oauth/anthropic-continuity.ts +65 -0
  107. package/src/oauth/anthropic-identity.ts +75 -0
  108. package/src/oauth/anthropic-routing.ts +3 -3
  109. package/src/oauth/anthropic.ts +7 -1
  110. package/src/oauth/generic-account-failover.ts +21 -0
  111. package/src/oauth/health.ts +32 -5
  112. package/src/oauth/index.ts +36 -27
  113. package/src/oauth/local-token-detect.ts +7 -0
  114. package/src/oauth/refresh-policy.ts +2 -0
  115. package/src/oauth/store.ts +55 -6
  116. package/src/oauth/types.ts +9 -0
  117. package/src/protocols/encoders/messages.ts +4 -3
  118. package/src/providers/antigravity-effort-families.ts +75 -0
  119. package/src/providers/antigravity-models.ts +54 -16
  120. package/src/providers/antigravity-wire-snapshot.ts +66 -0
  121. package/src/providers/derive.ts +2 -0
  122. package/src/providers/model-rename-fields.ts +3 -0
  123. package/src/providers/new-model-policy.ts +48 -10
  124. package/src/providers/registry/entries-extended.ts +3 -0
  125. package/src/providers/registry/model-ids.ts +2 -0
  126. package/src/providers/registry/model-seeds.ts +5 -0
  127. package/src/providers/registry/types.ts +4 -0
  128. package/src/responses/compaction.ts +19 -0
  129. package/src/router.ts +6 -0
  130. package/src/server/auth-cors.ts +9 -0
  131. package/src/server/chat-completions.ts +10 -0
  132. package/src/server/claude-messages.ts +18 -11
  133. package/src/server/droid-reasoning-default.ts +50 -0
  134. package/src/server/github-copilot-responses-repair.ts +3 -6
  135. package/src/server/grok-responses-control-frame.ts +2 -2
  136. package/src/server/grok-responses-snapshot-repair.ts +4 -5
  137. package/src/server/gui-session.ts +33 -11
  138. package/src/server/index/claude-intercept-lifecycle.ts +4 -0
  139. package/src/server/index/serve-options.ts +2 -1
  140. package/src/server/management/agent-settings-routes.ts +6 -129
  141. package/src/server/management/config-routes.ts +1 -1
  142. package/src/server/management/integration-routes.ts +64 -6
  143. package/src/server/management/link-routes.ts +20 -13
  144. package/src/server/management/model-row-export-metadata.ts +150 -0
  145. package/src/server/management/model-rows.ts +148 -9
  146. package/src/server/management/route-registry.ts +4 -3
  147. package/src/server/management/shared.ts +19 -5
  148. package/src/server/management/subagent-model-routes.ts +214 -0
  149. package/src/server/relay.ts +14 -44
  150. package/src/server/request-log-generation-window.ts +38 -0
  151. package/src/server/request-log.ts +13 -0
  152. package/src/server/responses/adapter-dispatch.ts +100 -1
  153. package/src/server/responses/codex-auth-error.ts +10 -2
  154. package/src/server/responses/combo-stream-preflight.ts +29 -3
  155. package/src/server/responses/compaction-routing.ts +10 -1
  156. package/src/server/responses/core-auth.ts +57 -26
  157. package/src/server/responses/core-codex-account.ts +6 -3
  158. package/src/server/responses/core-combo-failure.ts +5 -1
  159. package/src/server/responses/core-combo-native.ts +7 -1
  160. package/src/server/responses/core-combo.ts +18 -1
  161. package/src/server/responses/core-normalize.ts +7 -0
  162. package/src/server/responses/core-options.ts +5 -0
  163. package/src/server/responses/fetch-helpers.ts +9 -0
  164. package/src/server/responses/passthrough-delivery.ts +1 -1
  165. package/src/server/responses/policy-fallback.ts +1 -1
  166. package/src/server/responses/request-prepare.ts +5 -0
  167. package/src/server/responses/request-spend.ts +30 -30
  168. package/src/server/responses-custom-tool-repair.ts +2 -2
  169. package/src/server/responses-hosted-image-display.ts +70 -9
  170. package/src/server/responses-terminal-repair.ts +15 -2
  171. package/src/server/sse-frame-buffer.ts +38 -36
  172. package/src/server/sse-payload-rewrite.ts +78 -49
  173. package/src/server/ws-bridge.ts +1 -11
  174. package/src/service/windows-taskxml.ts +1 -2
  175. package/src/service-manager-probe.ts +36 -2
  176. package/src/types/config.ts +4 -0
  177. package/src/types/provider.ts +18 -0
  178. package/src/update/npm-invocation.mjs +35 -3
  179. package/src/update/proxy-liveness-probe.mjs +38 -8
  180. package/src/usage/expected-prices.ts +9 -0
  181. package/src/usage/log.ts +7 -0
  182. package/src/usage/summary.ts +82 -1
  183. package/src/web-search/loop.ts +11 -8
  184. package/src/web-search/run-turn-loop.ts +10 -7
  185. package/gui/dist/assets/App-CddorHdA.js +0 -51
  186. package/gui/dist/assets/App-DMudtISW.css +0 -1
  187. package/gui/dist/assets/index-Btze1I3T.css +0 -1
  188. package/gui/dist/assets/index-CvnY7E6i.js +0 -86
  189. package/gui/dist/assets/quota-summary-B_AWpHfP.js +0 -1
@@ -30,9 +30,15 @@ export function codexModelAvailabilityErrorResponse(error: CodexModelAvailabilit
30
30
  return formatErrorResponse(400, "invalid_request_error", error.message);
31
31
  }
32
32
 
33
+ /** Actionable sign-in guidance without guessing which upstream revocation cause occurred. */
34
+ export const CODEX_MAIN_SIGN_IN_REQUIRED_MESSAGE =
35
+ "Codex main account needs sign-in. Run `codex login` to sign in again.";
36
+
33
37
  export function nativeMainRefreshFailureResponse(error: unknown): Response {
34
38
  if (error instanceof MainAccountTokenRefreshError && error.reason === "reauth") {
35
- return formatErrorResponse(401, "authentication_error", "Codex main account needs reauthentication");
39
+ // Same sentence the quarantine produces on every later request, so the first refusal and the
40
+ // ones after it do not describe one dead credential two different ways.
41
+ return formatErrorResponse(401, "authentication_error", CODEX_MAIN_SIGN_IN_REQUIRED_MESSAGE);
36
42
  }
37
43
  if (error instanceof MainAccountTokenRefreshError
38
44
  || error instanceof MainAuthJsonChangedDuringRefreshError
@@ -98,7 +104,9 @@ export function mapCodexAuthContextErrorToResponse(
98
104
  return codexModelAvailabilityErrorResponse(error);
99
105
  }
100
106
  if (error instanceof CodexPoolAuthenticationError || error instanceof CodexDirectAuthenticationError) {
101
- return formatErrorResponse(401, "authentication_error", error.message);
107
+ return formatErrorResponse(401, "authentication_error",
108
+ error instanceof CodexPoolAuthenticationError && error.quarantinedMain
109
+ ? CODEX_MAIN_SIGN_IN_REQUIRED_MESSAGE : error.message);
102
110
  }
103
111
  if (error instanceof CodexMainSubstitutionUnavailableError) {
104
112
  return formatErrorResponse(
@@ -1,3 +1,4 @@
1
+ import { codexAccountModelRefusal, codexAccountModelRefusalPayload, hasConflictingCodexModelRefusalEnvelopes, type CodexAccountModelRefusal } from "../../combos/failover";
1
2
  import type { ResponsesTerminalStatus } from "../../bridge";
2
3
  import { comboFailureDecision } from "../../combos";
3
4
  import { httpStatusFromTerminalError } from "../../lib/errors";
@@ -68,6 +69,26 @@ function bareErrorStatus(payload: unknown): number | undefined {
68
69
  });
69
70
  }
70
71
 
72
+ /** Match the selected SSE error envelope before projection drops competing fields. */
73
+ function terminalModelRefusal(status: number, event: Record<string, unknown>): CodexAccountModelRefusal {
74
+ if (hasConflictingCodexModelRefusalEnvelopes(status, event)) return "ambiguous";
75
+ const outer = codexAccountModelRefusalPayload(status, event);
76
+ if (outer === "ambiguous") return outer;
77
+ const nested = event.response;
78
+ if (event.type !== "error" && nested && typeof nested === "object" && !Array.isArray(nested)) {
79
+ const nestedKind = codexAccountModelRefusalPayload(status, nested);
80
+ if (nestedKind === "ambiguous") return nestedKind;
81
+ const nestedError = (nested as Record<string, unknown>).error;
82
+ if (nestedError && typeof nestedError === "object" && !Array.isArray(nestedError)) {
83
+ return nestedKind;
84
+ }
85
+ }
86
+ if (event.type === "error" && !Object.hasOwn(event, "error") && typeof event.message === "string") {
87
+ return codexAccountModelRefusal(status, event.message, { allowNestedResponse: false });
88
+ }
89
+ return outer;
90
+ }
91
+
71
92
  function bareErrorIsRetryable(payload: unknown): boolean {
72
93
  const status = bareErrorStatus(payload);
73
94
  if (status === undefined || !payload || typeof payload !== "object" || Array.isArray(payload)) {
@@ -84,7 +105,9 @@ function bareErrorIsRetryable(payload: unknown): boolean {
84
105
  const message = typeof error.message === "string"
85
106
  ? error.message
86
107
  : typeof event.message === "string" ? event.message : "";
87
- return comboFailureDecision(status, message, { code }) === "hop";
108
+ return comboFailureDecision(status, message, {
109
+ code, codexModelRefusal: terminalModelRefusal(status, event),
110
+ }) === "hop";
88
111
  }
89
112
 
90
113
  function retryableZeroOutputTerminal(payload: unknown): boolean {
@@ -226,7 +249,7 @@ function failedTerminalResponse(
226
249
 
227
250
  export type ComboStreamPreflightResult =
228
251
  | { kind: "accepted"; response: Response }
229
- | { kind: "failed"; response: Response }
252
+ | { kind: "failed"; response: Response; codexModelRefusal?: CodexAccountModelRefusal }
230
253
  /**
231
254
  * The body errored mid-stream and `replayReadErrors` asked for the prefix back rather than
232
255
  * a rethrow. `stage` is how far the inspection actually got; whether that permits a
@@ -322,7 +345,10 @@ export async function preflightComboStreamResponse(
322
345
  || retryableTerminalPayload?.type === "error")
323
346
  && !outputCommitted && retryableTerminalPayload) {
324
347
  await reader.cancel("retrying zero-output combo stream terminal").catch(() => undefined);
325
- return { kind: "failed", response: failedTerminalResponse(response, retryableTerminalPayload, logCtx) };
348
+ const failed = failedTerminalResponse(response, retryableTerminalPayload, logCtx);
349
+ return { kind: "failed", response: failed,
350
+ ...(failed.status === 400
351
+ ? { codexModelRefusal: terminalModelRefusal(failed.status, retryableTerminalPayload) } : {}) };
326
352
  }
327
353
  if (next.done || terminalStatus !== undefined || outputCommitted
328
354
  || bufferedBytes >= COMBO_STREAM_PREFLIGHT_MAX_BYTES
@@ -1,6 +1,6 @@
1
1
  import type { OcxConfig } from "../../types";
2
2
  import { isDeclaredReasoningEffort } from "../../reasoning-effort";
3
- import { COMPACTION_TRIGGERS } from "../../config/schema/compaction-triggers";
3
+ import { COMPACTION_TRIGGERS, validCompactionSourceModels } from "../../config/schema/compaction-triggers";
4
4
  import { routeConcreteModel, type RouteResult } from "../../router";
5
5
  import { resolveComboId } from "../../combos/identifiers";
6
6
  import { resolvePolicyProfileId } from "../../routing/profile";
@@ -57,6 +57,15 @@ export function applyCompactionRoutingOverride(
57
57
  && (typeof override.reasoningEffort !== "string" || !isDeclaredReasoningEffort(override.reasoningEffort))) return null;
58
58
  const triggers = configuredTriggers(override.triggers);
59
59
  if (!triggers) return null;
60
+ if (override.sourceModels !== undefined) {
61
+ if (!validCompactionSourceModels(override.sourceModels)) return null;
62
+ // Match the selector the conversation actually routed on: a synthetic `--fast` or
63
+ // effort suffix is ingress decoration, so `cheap--fast` is still the allowlisted `cheap`.
64
+ const source = sourceSelectorOf(config, raw.model);
65
+ if (!override.sourceModels.some(selector => selector.endsWith("/*")
66
+ ? source.startsWith(selector.slice(0, -1)) && source.length > selector.length - 1
67
+ : source === selector)) return null;
68
+ }
60
69
  if (options.endpoint !== "compact"
61
70
  && !(Array.isArray(raw.input) && raw.input.some(item => record(item)?.type === "compaction_trigger"))) return null;
62
71
 
@@ -57,6 +57,9 @@ export type ResponsesAuthResolution =
57
57
  | { ok: false; response: Response };
58
58
 
59
59
 
60
+ export type CodexCredentialOwnershipOptions = Pick<HandleResponsesOptions,
61
+ "admission" | "callerDirectAuth" | "stripClaudeMainAuthForNoncanonicalForward" | "trustedClaudeMainAuth">;
62
+
60
63
  /**
61
64
  * The caller credential the final Codex auth resolution will be given, as far as the ROUTE
62
65
  * decides it: a route change that may cross a credential domain drops the raw caller credential,
@@ -67,15 +70,53 @@ export type ResponsesAuthResolution =
67
70
  * an HMAC of exactly this Authorization header. Two copies of this rule would put preview and
68
71
  * final auth in different scopes the first time one of them changed.
69
72
  */
73
+ /**
74
+ * The stored native-main credential this process injected itself, when it is the bearer the
75
+ * resolution will see.
76
+ *
77
+ * `handleClaudeMessages` reads `~/.codex/auth.json` and attaches it so OpenAI-backed sidecars stay
78
+ * reachable on a translated turn. It is OUR credential, not the caller's -- the ingress says so
79
+ * where it sets `nativeCallerAuth` and `callerDirectAuth` to null for exactly this reason -- and
80
+ * every reader of the resolution's input headers has to agree about that, or one of them conclud
81
+ * the request owns a credential that in fact belongs to the Pool.
82
+ */
83
+ export function injectedClaudeMainBearer(
84
+ route: Pick<RouteResult, "provider">,
85
+ options: CodexCredentialOwnershipOptions,
86
+ ): { authorization: string; chatgptAccountId?: string } | undefined {
87
+ return options.stripClaudeMainAuthForNoncanonicalForward === true
88
+ && isCanonicalOpenAiForwardProvider(route.provider)
89
+ ? options.trustedClaudeMainAuth : undefined;
90
+ }
91
+
92
+ function validatedCallerDirectHeaders(
93
+ config: OcxConfig,
94
+ route: Pick<RouteResult, "provider">,
95
+ options: CodexCredentialOwnershipOptions,
96
+ ): Headers | undefined {
97
+ if (!options.callerDirectAuth || !isCanonicalOpenAiForwardProvider(route.provider)) return undefined;
98
+ const headers = new Headers({ authorization: options.callerDirectAuth.authorization });
99
+ if (options.callerDirectAuth.chatgptAccountId) headers.set("chatgpt-account-id", options.callerDirectAuth.chatgptAccountId);
100
+ return captureCallerDirectAuth(headers, config) ? headers : undefined;
101
+ }
102
+
70
103
  export function codexRouteCredentialDomainHeaders(
71
104
  req: Request,
72
105
  route: RouteResult,
73
106
  options: HandleResponsesOptions,
74
107
  credentialDomainWasRewritten: boolean,
108
+ config: OcxConfig,
75
109
  ): Headers {
76
- const trustedClaudeMainForFinalRoute = options.stripClaudeMainAuthForNoncanonicalForward === true
77
- && isCanonicalOpenAiForwardProvider(route.provider)
78
- ? options.trustedClaudeMainAuth : undefined;
110
+ const callerDirect = validatedCallerDirectHeaders(config, route, options);
111
+ if (callerDirect) {
112
+ const headers = new Headers(req.headers);
113
+ headers.set("authorization", callerDirect.get("authorization")!);
114
+ const accountId = callerDirect.get("chatgpt-account-id");
115
+ if (accountId) headers.set("chatgpt-account-id", accountId);
116
+ else headers.delete("chatgpt-account-id");
117
+ return headers;
118
+ }
119
+ const trustedClaudeMainForFinalRoute = injectedClaudeMainBearer(route, options);
79
120
  if (trustedClaudeMainForFinalRoute) {
80
121
  const claudeMainHeaders = new Headers(req.headers);
81
122
  claudeMainHeaders.set("authorization", trustedClaudeMainForFinalRoute.authorization);
@@ -117,15 +158,25 @@ export function codexRouteCredentialDomainHeaders(
117
158
  export function codexRouteCredentialOwnership(
118
159
  authInputHeaders: Headers,
119
160
  config: OcxConfig,
120
- route: RouteResult,
121
- options: HandleResponsesOptions,
161
+ route: Pick<RouteResult, "provider" | "codexAccountMode">,
162
+ options: CodexCredentialOwnershipOptions,
122
163
  ): { substituteMainCredential: boolean; requestScopedMainCredential: boolean } {
123
164
  const substituteMainCredential = options.admission?.source === "bearer"
124
165
  && (route.codexAccountMode !== undefined || isCanonicalOpenAiForwardProvider(route.provider));
125
166
  return {
126
167
  substituteMainCredential,
168
+ // A request-scoped main credential is one the CALLER owns: it exists for this request only,
169
+ // owns no Pool state, and must never be quarantined or refreshed on the Pool's behalf. The
170
+ // stored credential this process injected is the opposite of that in every respect, and
171
+ // `hasForwardableCodexBearer` cannot tell them apart -- it sees a Codex JWT with an account
172
+ // id either way. Counting ours as the caller's made the native main slot caller-owned on
173
+ // every translated Claude turn: `main` instead of `main-pool`, so an upstream 401 on a
174
+ // revoked session recorded no outcome, refreshed nothing, and retired nothing, and the next
175
+ // request sent the same dead token again.
127
176
  requestScopedMainCredential: route.codexAccountMode !== undefined
128
177
  && !substituteMainCredential
178
+ && (validatedCallerDirectHeaders(config, route, options) !== undefined
179
+ || injectedClaudeMainBearer(route, options) === undefined)
129
180
  && hasForwardableCodexBearer(authInputHeaders, config),
130
181
  };
131
182
  }
@@ -149,6 +200,7 @@ export async function resolveResponsesCodexAuth(
149
200
  route,
150
201
  options,
151
202
  credentialDomainWasRewritten,
203
+ config,
152
204
  );
153
205
  // A caller-auth transport that is not canonical OpenAI (keyless Cursor) consumes the
154
206
  // caller's Authorization as its own upstream token. Keep that contract only for a clean
@@ -171,27 +223,6 @@ export async function resolveResponsesCodexAuth(
171
223
  authInputHeaders = scoped;
172
224
  }
173
225
  }
174
- // The caller's own Direct credential may cross an internal route change only to the
175
- // canonical OpenAI transport, under a predicate deliberately STRICTER than plain
176
- // unchanged-route Direct forwarding: a clean non-proxy bearer whose ChatGPT-domain
177
- // marker is valid, with any explicit account header matching that marker. Unchanged
178
- // routes keep their legacy rules; sidecar enrichment grants no primary authority.
179
- if (options.callerDirectAuth && isCanonicalOpenAiForwardProvider(route.provider)) {
180
- const directHeaders = new Headers({
181
- authorization: options.callerDirectAuth.authorization,
182
- ...(options.callerDirectAuth.chatgptAccountId
183
- ? { "chatgpt-account-id": options.callerDirectAuth.chatgptAccountId } : {}),
184
- });
185
- if (captureCallerDirectAuth(directHeaders, config)) {
186
- authInputHeaders = new Headers(authInputHeaders);
187
- authInputHeaders.set("authorization", options.callerDirectAuth.authorization);
188
- if (options.callerDirectAuth.chatgptAccountId) {
189
- authInputHeaders.set("chatgpt-account-id", options.callerDirectAuth.chatgptAccountId);
190
- } else {
191
- authInputHeaders.delete("chatgpt-account-id");
192
- }
193
- }
194
- }
195
226
  // #1686: a caller that proved admission with a BEARER presented one of our own secrets.
196
227
  // Refusing it here is what made the codex-cli `env_key` contract unusable against Direct.
197
228
  // Admitting it is only safe because the stored main credential is substituted below, so
@@ -59,7 +59,7 @@ import {
59
59
  import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models";
60
60
  import { isRequestExecutionBudget } from "../../lib/request-execution-budget";
61
61
  import type { SingleUseDispatchPermit } from "../../lib/request-execution-budget";
62
- import { hasForwardableCodexBearer } from "../auth-cors";
62
+ import { codexRouteCredentialOwnership, type CodexCredentialOwnershipOptions } from "./core-auth";
63
63
  import { bindRouteReasoningReplayScope } from "./core-replay";
64
64
  import {
65
65
  conversationStateBindingFromAuth,
@@ -360,7 +360,7 @@ export interface CodexPoolAccountRetryArgs {
360
360
  route: Pick<RouteResult, "providerName" | "modelId" | "provider" | "staticPolicy">;
361
361
  parsed: OcxParsedRequest;
362
362
  logCtx: RequestLogContext;
363
- options: {
363
+ options: CodexCredentialOwnershipOptions & {
364
364
  admission?: DataPlaneAdmission;
365
365
  codexAuthPolicy?: CodexAuthPolicyConfig;
366
366
  visionDescribeTerminal?: boolean;
@@ -641,10 +641,13 @@ export async function retryCodexPoolOnAlternateAccount(
641
641
  "pool",
642
642
  {
643
643
  excludeAccountId: firstAuthCtx.accountId,
644
+ signal: options.abortSignal,
644
645
  admission: options.admission,
645
646
  codexAuthPolicy: options.codexAuthPolicy,
646
647
  modelId: route.modelId,
647
- requestScopedMainCredential: hasForwardableCodexBearer(callerAuthHeaders, config),
648
+ requestScopedMainCredential: codexRouteCredentialOwnership(callerAuthHeaders, config, {
649
+ provider: route.provider, codexAccountMode: "pool",
650
+ }, options).requestScopedMainCredential,
648
651
  beginCodexAccountSelection: codexAccountSelectionForTurn(options.turnAdmissionLease),
649
652
  resolveCodexModelEntitlements: entitlementResolver,
650
653
  },
@@ -1,3 +1,4 @@
1
+ import { codexAccountModelRefusal, codexAccountModelRefusalHardStopCode, type CodexAccountModelRefusal } from "../../combos/failover";
1
2
  import { parseRetryAfterMs } from "../../combos";
2
3
  import type { ConsumedComboFailure, HandleResponsesOptions } from "./core-options";
3
4
  import type { OcxUsage } from "../../types";
@@ -36,6 +37,7 @@ export async function consumeComboFailure(
36
37
  const nonReplayable = isNonReplayableResponse(response);
37
38
  const fallback = `Provider error ${response.status}`;
38
39
  let classificationText = fallback;
40
+ let codexModelRefusal: CodexAccountModelRefusal | undefined;
39
41
  let usage: OcxUsage | undefined;
40
42
  let upstreamCode: string | undefined;
41
43
  let upstreamMessage: string | undefined;
@@ -60,10 +62,11 @@ export async function consumeComboFailure(
60
62
  && isRateLimitOrQuotaFailureMessage(quotaMessage);
61
63
  }
62
64
  if (body.displaySafe && !body.truncated) {
65
+ if (response.status === 400) codexModelRefusal = codexAccountModelRefusal(response.status, body.text);
63
66
  const normalized = normalizeUpstreamErrorText(body.text, fallback);
64
67
  if (utf8Trusted || isCyberPolicyCode(normalized.code) || isCyberPolicyMessage(normalized.safeText)) {
65
68
  classificationText = normalized.safeText;
66
- upstreamCode = normalized.code;
69
+ upstreamCode = codexAccountModelRefusalHardStopCode(response.status, body.text) ?? normalized.code;
67
70
  upstreamMessage = normalized.message;
68
71
  upstreamType = normalized.type;
69
72
  }
@@ -116,6 +119,7 @@ export async function consumeComboFailure(
116
119
  response: failureResponse,
117
120
  ...(nonReplayable ? { nonReplayable: true } : {}),
118
121
  classificationText,
122
+ ...(codexModelRefusal !== undefined ? { codexModelRefusal } : {}),
119
123
  ...(normalizedUpstreamCode !== undefined ? { upstreamCode: normalizedUpstreamCode } : {}),
120
124
  ...(upstreamType !== undefined ? { upstreamType } : {}),
121
125
  ...(!cyberFailure && cooldownRetryAfter !== undefined ? { retryAfter: cooldownRetryAfter } : {}),
@@ -34,6 +34,7 @@ import {
34
34
  import { captureRouteStaticPolicy, routeConcreteModel } from "../../router";
35
35
  import { comboDefaultEffort, concreteComboRequestBody, getCombo } from "../../combos";
36
36
  import { supportedLadderFor } from "../effort-policy";
37
+ import { applyDroidReasoningDefault } from "../droid-reasoning-default";
37
38
  import { resolveWireProtocolOverride } from "../adapter-resolve";
38
39
  import { resolveOpenCodeGoTransport } from "../../providers/opencode-go-transport";
39
40
  import { getOrAllocateRequestSessionLane } from "../request-log-conversation";
@@ -105,10 +106,11 @@ export function createComboProtocolLanes(input: {
105
106
  config: OcxConfig;
106
107
  logCtx: RequestLogContext;
107
108
  admission: DataPlaneAdmission | undefined;
109
+ droidDefaultEffort?: string;
108
110
  comboId: string;
109
111
  targets: readonly ComboTarget[];
110
112
  }): ComboProtocolLanes | undefined {
111
- const { source, req, config, logCtx, admission, comboId, targets } = input;
113
+ const { source, req, config, logCtx, admission, droidDefaultEffort, comboId, targets } = input;
112
114
  if (!source) return undefined;
113
115
  const envelope = source.envelope;
114
116
  const reject = resolveProtocolSettings(config).unrepresentable === "reject";
@@ -207,6 +209,10 @@ export function createComboProtocolLanes(input: {
207
209
  const route = settle(target, targetRoute);
208
210
  const body = nativeBody(target, route);
209
211
  if (!route || !body) return undefined;
212
+ applyDroidReasoningDefault(body, droidDefaultEffort, {
213
+ provider: targetRoute.provider,
214
+ modelId: targetRoute.modelId,
215
+ });
210
216
  applyComboEffort(body, config, comboId, target, targetRoute);
211
217
  return { route, body, sendBudget: targetSendBudget };
212
218
  },
@@ -1,8 +1,10 @@
1
+ import type { CodexAccountModelRefusal } from "../../combos/failover";
1
2
  import {
2
3
  isCodexReasoningEffort,
3
4
  isDeclaredReasoningEffort,
4
5
  resolveEffortAtOrBelow,
5
6
  } from "../../reasoning-effort";
7
+ import { resolveAdmissionModelScope, routeAllowedByScope } from "../admission-model-scope";
6
8
  import { recordAttemptRequestedEffort } from "../request-log";
7
9
  import {
8
10
  CODEX_TEXT_GUARDED_BUDGET_POLICY,
@@ -65,6 +67,8 @@ import {
65
67
  recoverEncryptedAgentTaskWithResult,
66
68
  } from "./agent-task-recovery";
67
69
  import { isThreadSpawnRequest, supportedLadderFor } from "../effort-policy";
70
+ import { applyDroidResponsesReasoningDefault } from "../droid-reasoning-default";
71
+ import { isPlainObject } from "../../lib/plain-data";
68
72
  import {
69
73
  clientCancelledResponse,
70
74
  comboUnavailable,
@@ -327,6 +331,7 @@ export async function executeComboResponses(
327
331
  config,
328
332
  logCtx,
329
333
  admission: options.admission,
334
+ droidDefaultEffort: options.droidDefaultEffort,
330
335
  comboId,
331
336
  targets: combo.targets,
332
337
  });
@@ -572,6 +577,9 @@ export async function executeComboResponses(
572
577
  candidates: choices.map(choice => choice.candidate),
573
578
  fallback,
574
579
  config,
580
+ isDestinationAllowed: (providerName, modelId) => routeAllowedByScope(
581
+ resolveAdmissionModelScope(config, options.admission), { providerName, modelId },
582
+ ),
575
583
  ...(combo.decisionProvider ? { decisionProvider: combo.decisionProvider } : {}),
576
584
  ...(combo.decisionModel
577
585
  ? {
@@ -709,8 +717,13 @@ export async function executeComboResponses(
709
717
  modelId: targetRoute.modelId,
710
718
  });
711
719
  const initialJevDecision = firstComboTarget ? jevDecision : undefined;
720
+ const childInput = options.droidDefaultEffort && isPlainObject(body) ? { ...body } : body;
721
+ applyDroidResponsesReasoningDefault(childInput, options.droidDefaultEffort, {
722
+ provider: targetRoute.provider,
723
+ modelId: targetRoute.modelId,
724
+ });
712
725
  const childBody = concreteComboRequestBody(
713
- body,
726
+ childInput,
714
727
  pick.target,
715
728
  initialJevDecision ? initialJevDecision.effort : comboDefaultEffort(config, comboId),
716
729
  initialJevDecision?.effort === null ? [] : targetReasoningEfforts,
@@ -773,6 +786,7 @@ export async function executeComboResponses(
773
786
  const completedTarget = { provider: pick.target.provider, model: pick.target.model };
774
787
  const writerGeneration = pick.writerGeneration;
775
788
  let consumedChildFailure: ConsumedComboFailure | undefined;
789
+ let preflightModelRefusal: CodexAccountModelRefusal | undefined;
776
790
  const callbackGate = createChildPassthroughCallbackGate({
777
791
  ...options,
778
792
  onResponseComplete: model => {
@@ -895,6 +909,7 @@ export async function executeComboResponses(
895
909
  callbackGate.discard();
896
910
  terminalRecorder?.("failed", preflight.response.status);
897
911
  response = preflight.response;
912
+ preflightModelRefusal = preflight.codexModelRefusal;
898
913
  } else {
899
914
  response = preflight.response;
900
915
  if (nativePassthrough) markNativePassthroughSseResponse(response);
@@ -937,6 +952,7 @@ export async function executeComboResponses(
937
952
  try {
938
953
  failure = consumedChildFailure
939
954
  ?? await consumeComboFailure(response, options.abortSignal);
955
+ if (preflightModelRefusal !== undefined) failure = { ...failure, codexModelRefusal: preflightModelRefusal };
940
956
  } catch (error) {
941
957
  if (options.abortSignal?.aborted) {
942
958
  retainCancelledAttempt();
@@ -981,6 +997,7 @@ export async function executeComboResponses(
981
997
  ? "stop"
982
998
  : comboFailureDecision(failure.response.status, failure.classificationText, {
983
999
  code: failure.upstreamCode,
1000
+ codexModelRefusal: failure.codexModelRefusal,
984
1001
  });
985
1002
  const wantsStream = (rawBody as { stream?: unknown } | null)?.stream === true;
986
1003
  // Local byte admission has its own diagnostic; do not relabel it as an upstream refusal.
@@ -38,6 +38,7 @@ import { isInjectionDebugEnabled } from "../../lib/debug-settings";
38
38
  import { injectionDebugLog } from "../../lib/injection-debug-log";
39
39
  import { recordAttemptRequestedEffort } from "../request-log";
40
40
  import type { ResolvedFastPolicy } from "../../providers/fastwire";
41
+ import { applyDroidResponsesReasoningDefault } from "../droid-reasoning-default";
41
42
  import { applyXaiOauthFastModel, xaiOauthFastModelForDecision } from "../../providers/xai-fast-model";
42
43
 
43
44
  /** Preview the billed xAI lane without mutating request state or resolving credentials. */
@@ -140,9 +141,15 @@ export async function applyFinalRouteRequestNormalization(args: {
140
141
  inboundWire: InboundWire;
141
142
  inboundTransport?: "websocket";
142
143
  claudeGoAffinity?: HandleResponsesOptions["claudeGoAffinity"];
144
+ droidDefaultEffort?: HandleResponsesOptions["droidDefaultEffort"];
143
145
  }): Promise<void> {
144
146
  const { parsed, route, config, req, logCtx, inboundWire, inboundTransport } = args;
145
147
  const effortSelector = prepareEffortNormalization(parsed, route);
148
+ if (applyDroidResponsesReasoningDefault(
149
+ parsed._rawBody,
150
+ args.droidDefaultEffort,
151
+ { provider: route.provider, modelId: route.modelId },
152
+ )) parsed.options.reasoning = args.droidDefaultEffort;
146
153
 
147
154
  // Only Anthropic message routes retain the Codex-facing selector. Other providers must keep
148
155
  // their existing response.model contract even when their public and wire model ids differ.
@@ -1,3 +1,4 @@
1
+ import type { CodexAccountModelRefusal } from "../../combos/failover";
1
2
  import type { NativeResponseControl } from "./native-response-control";
2
3
  import type { AdapterEvent, OcxUsage, OcxProviderContinuationState, OcxConfig } from "../../types";
3
4
  import type { RouteResult } from "../../router";
@@ -24,6 +25,8 @@ import type { PolicyRequestScope } from "./policy-request-scope";
24
25
  export interface ConsumedComboFailure {
25
26
  response: Response;
26
27
  classificationText: string;
28
+ /** Complete bounded-envelope evidence captured before display truncation; never serialized. */
29
+ codexModelRefusal?: CodexAccountModelRefusal;
27
30
  /** Structured upstream `error.code` when present in the failure body. */
28
31
  upstreamCode?: string;
29
32
  /** Complete structured provider type, retained for conservative recovery classification. */
@@ -131,6 +134,8 @@ export interface HandleResponsesOptions {
131
134
  * it. Omitted means a genuine Responses inbound.
132
135
  */
133
136
  inboundWire?: InboundWire;
137
+ /** Droid's per-request effort default; each concrete combo or policy target applies it only if its ladder allows it. */
138
+ droidDefaultEffort?: string;
134
139
  /** PF-07: the Chat source a combo child may send natively; set only by the Chat ingress. */
135
140
  protocolSource?: import("./core-combo-native").ComboProtocolSource;
136
141
  /** Internal transport identity for route-scoped upstream compatibility policy. */
@@ -192,8 +192,17 @@ export function sendWithConnectionPolicy(
192
192
  const egressInit = redirectedToLoopback
193
193
  ? { proxy: false as const }
194
194
  : decide ? providerEgressSendInit(egress, physicalFetch, input) : {};
195
+ // Bun can reset large native Codex string uploads before receiving headers while
196
+ // the identical UTF-8 buffer succeeds. Convert only at the final HTTP send so
197
+ // WebSocket selection still sees the serialized string and no retry is added.
198
+ const target = input instanceof Request ? input.url : String(input);
199
+ const body = init?.body;
200
+ const largeCodexBody = typeof body === "string"
201
+ && /^https:\/\/chatgpt\.com\/backend-api\/codex\/responses(?:\/compact)?$/.test(target)
202
+ && Buffer.byteLength(body, "utf8") >= 1024 * 1024;
195
203
  return physicalFetch(input, {
196
204
  ...init,
205
+ ...(largeCodexBody ? { body: Buffer.from(body, "utf8") } : {}),
197
206
  headers,
198
207
  redirect: "manual",
199
208
  ...(fresh ? { keepalive: false } : {}),
@@ -1329,7 +1329,7 @@ export async function deliverPassthroughResponse(
1329
1329
  clientJson = imageDisplay.json(clientJson);
1330
1330
  } catch (error) {
1331
1331
  if (error instanceof RangeError) {
1332
- return formatErrorResponse(502, "upstream_error", "hosted image result count exceeds local display limit");
1332
+ return formatErrorResponse(502, "upstream_error", "hosted image result exceeds local display limits");
1333
1333
  }
1334
1334
  throw error;
1335
1335
  } finally { imageDisplay.dispose?.(); }
@@ -111,7 +111,7 @@ async function shouldHopPolicyCandidate(response: Response, signal?: AbortSignal
111
111
  if (isPolicyCandidateRefusal(response)) return true;
112
112
  try {
113
113
  const inspected = await readBoundedResponseBody(response.clone(), { signal });
114
- const text = inspected.displaySafe ? inspected.text : "";
114
+ const text = inspected.displaySafe && !inspected.truncated ? inspected.text : "";
115
115
  return comboFailureDecision(response.status, text, { code: errorCodeFromText(text) }) === "hop";
116
116
  } catch {
117
117
  return false;
@@ -676,6 +676,7 @@ export async function prepareResponsesRequest(
676
676
  route,
677
677
  options,
678
678
  credentialDomainWasRewritten,
679
+ config,
679
680
  );
680
681
  // Does the CALLER own the credential this request will authenticate with? Validated exactly
681
682
  // the way final auth validates it: the route ownership predicate AND the caller-bearer check
@@ -736,6 +737,7 @@ export async function prepareResponsesRequest(
736
737
  // recovery or a profile drain fences the physical identity, final auth drops main and preview
737
738
  // has to drop it too. A hardcoded `true` would be wrong in the second case and `false` in
738
739
  // the first.
740
+ requestOwnedMainCredential: previewRequestScopedMainCredential,
739
741
  isMainAccountTokenLive: previewRequestScopedMainCredential
740
742
  ? () => previewRequestOwnedMainCredentialLive
741
743
  : undefined,
@@ -962,6 +964,7 @@ export async function prepareResponsesRequest(
962
964
  route,
963
965
  options,
964
966
  credentialDomainWasRewritten,
967
+ config,
965
968
  );
966
969
  const recoveryRequestScopedMainCredential = codexRouteCredentialOwnership(
967
970
  recoveryAuthHeaders,
@@ -993,6 +996,7 @@ export async function prepareResponsesRequest(
993
996
  const recoverySelectionOptions = {
994
997
  nativeMainSelectionOnly: !recoveryNativeMainBlocked
995
998
  && recoverySelectionAdmission?.mainProfileDraining === true,
999
+ requestOwnedMainCredential: recoveryRequestScopedMainCredential,
996
1000
  isMainAccountTokenLive: recoveryRequestScopedMainCredential
997
1001
  ? () => recoveryRequestOwnedMainCredentialLive
998
1002
  : undefined,
@@ -1222,6 +1226,7 @@ export async function prepareResponsesRequest(
1222
1226
  inboundWire,
1223
1227
  inboundTransport: options.inboundTransport,
1224
1228
  claudeGoAffinity: options.claudeGoAffinity,
1229
+ droidDefaultEffort: options.droidDefaultEffort,
1225
1230
  });
1226
1231
  try {
1227
1232
  assertPolicyNormalizedRoute(policyScope, admittedPolicyRoute, route);
@@ -1,9 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { RequestSendObserver } from "../../lib/request-execution-budget";
3
- import { sharedSpendLedger, type SpendReservationLedger } from "../../lib/spend-reservation-ledger";
3
+ import { sharedSpendLedger, type SpendReservationLedger, type SpendScopes } from "../../lib/spend-reservation-ledger";
4
4
  import { SpendLedgerOwnerError } from "../../lib/spend-ledger-owner";
5
5
  import { markLocalRequestLogRefusal, type RequestLogContext } from "../request-log";
6
- import { recordWorkflowRefusalEvent, workflowDenialSummary } from "../../lib/workflow-budget";
6
+ import { recordWorkflowRefusalEvent, workflowDenialSummary, type WorkflowDenial } from "../../lib/workflow-budget";
7
7
 
8
8
  /** The terminal usage a request reported, in the only two fields the ledger books. */
9
9
  export interface TerminalSpendUsage {
@@ -81,44 +81,44 @@ export function createRequestSpendTracker(
81
81
  // short of its limit forever and refuse nothing.
82
82
  const alreadySent = options?.alreadySent === true;
83
83
  const sendId = randomUUID();
84
- const decision = ledger().reserve({
84
+ const bookedLedger = ledger();
85
+ const scopes: SpendScopes = {
86
+ ...(rootId !== undefined ? { rootId } : {}),
87
+ // The existing privacy-safe log label is aliased again by the ledger.
88
+ ...(logCtx.accountLogLabel !== undefined ? { identityId: logCtx.accountLogLabel } : {}),
89
+ ...(logCtx.provider !== undefined ? { poolId: logCtx.provider } : {}),
90
+ };
91
+ const policy = bookedLedger.policy;
92
+ const enforced = (scopes.rootId !== undefined && policy.root.maxTokens !== undefined)
93
+ || (scopes.identityId !== undefined && policy.identity.maxTokens !== undefined)
94
+ || (scopes.poolId !== undefined && policy.pool.maxTokens !== undefined);
95
+ const decision = bookedLedger.reserve({
85
96
  sendId,
86
- scopes: {
87
- ...(rootId !== undefined ? { rootId } : {}),
88
- // Already the privacy-safe label the request log uses, and the ledger aliases it
89
- // again on the way to disk. A raw credential never reaches either.
90
- ...(logCtx.accountLogLabel !== undefined ? { identityId: logCtx.accountLogLabel } : {}),
91
- ...(logCtx.provider !== undefined ? { poolId: logCtx.provider } : {}),
92
- },
97
+ scopes,
93
98
  inputTokens: logCtx.spendInputEstimateTokens ?? logCtx.usageLogInputTokens ?? 0,
94
99
  outputCeilingTokens: logCtx.spendOutputCeilingTokens ?? 0,
95
100
  ...(alreadySent ? { alreadySent: true } : {}),
96
101
  });
97
102
  if (!decision.reserved) {
103
+ // An applicable ceiling cannot authorize an unbooked send, including when tracking
104
+ // capacity is full. Unconfigured/nonapplicable requests remain observe-only, and a
105
+ // physical send reported after dispatch cannot be refused retroactively.
106
+ if (alreadySent || !enforced) return true;
98
107
  refusals += 1;
99
108
  const denial = decision.denial;
100
- // A ceiling refuses, and so does a ledger that cannot make the reservation durable
101
- // under one. That second case is the whole reason this store is on disk: admitting a
102
- // send whose record a restart would forget is how an exhausted budget comes back with
103
- // a fresh allowance, and the ledger raises those two denials ONLY when a limit is
104
- // configured -- so an install that configured nothing is still never refused here.
105
- // Capacity and a duplicate send id stay permissive: they say the ledger cannot account
106
- // for this send, which is a degradation to report, not an outage to cause.
107
- if (denial.reason === "reserve-not-durable" || denial.reason === "journal-corrupt") {
108
- return alreadySent;
109
- }
110
- if (denial.reason !== "spend-limit-exceeded") return true;
111
- // This send is refused, and the dispatch path that asked will report an exhausted send
112
- // budget -- from there, that is all it can see. The row is where an operator actually
113
- // looks, so the ceiling is named on it here: a locally assigned code wins in
114
- // addFinalRequestLog, so the request that CROSSED the ceiling reads as a spend refusal
115
- // rather than as the ordinary budget exhaustion it would otherwise be indistinguishable
116
- // from. The event ring gets the same pair so /api/workflow-budget agrees with the row.
117
- const detail = { scope: denial.scope, limit: denial.limit, projected: denial.projected };
118
- const summary = workflowDenialSummary("workflow-spend-exhausted", detail);
109
+ const reason: WorkflowDenial = denial.reason === "duplicate-send-id"
110
+ ? "workflow-send-replayed"
111
+ : denial.reason === "reserve-not-durable" || denial.reason === "journal-corrupt"
112
+ ? "workflow-spend-undurable"
113
+ : denial.reason === "tracking-capacity-exhausted"
114
+ ? "workflow-tracking-exhausted"
115
+ : "workflow-spend-exhausted";
116
+ const detail = denial.reason === "spend-limit-exceeded"
117
+ ? { scope: denial.scope, limit: denial.limit, projected: denial.projected } : undefined;
118
+ const summary = workflowDenialSummary(reason, detail);
119
119
  markLocalRequestLogRefusal(logCtx, summary.code);
120
120
  logCtx.errorCode = summary.code;
121
- recordWorkflowRefusalEvent(rootId, "workflow-spend-exhausted", Date.now(), detail);
121
+ recordWorkflowRefusalEvent(rootId, reason, Date.now(), detail);
122
122
  return false;
123
123
  }
124
124
  live.push(sendId);