@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
@@ -38,7 +38,7 @@ import { anthropicOAuthDefinition, assertAnthropicInstanceLoginConfig, Anthropic
38
38
  resumeAnthropicRefreshIntentCleanup, clearObservedAnthropicRefreshIntent } from "./anthropic-oauth-definitions";
39
39
  import { loginKimi, refreshKimiToken } from "./kimi";
40
40
  import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, RefreshIntentIOError } from "./nous";
41
- import { loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt";
41
+ import { ChatGptTokenRequestError, ChatGptTokenError, loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt";
42
42
  import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity";
43
43
  import { loginCursor, refreshCursorToken } from "./cursor";
44
44
  import { loginZed, refreshZedToken } from "./zed";
@@ -350,7 +350,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
350
350
  },
351
351
  chatgpt: {
352
352
  login: (ctrl, opts) => loginChatGPT(ctrl, { forceLogin: opts?.forceLogin, flow: opts?.flow }),
353
- refresh: (rt) => refreshChatGPTToken(rt),
353
+ refresh: (rt, signal) => refreshChatGPTToken(rt, { signal }),
354
354
  providerConfig: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const },
355
355
  defaultModel: "gpt-5.6-luna",
356
356
  },
@@ -712,10 +712,14 @@ function isTerminalRefreshError(err: unknown): boolean {
712
712
  || msg.includes("expired_token");
713
713
  }
714
714
  function terminal(error:unknown):boolean{
715
+ if (error instanceof ChatGptTokenRequestError) return false;
715
716
  if(error instanceof XaiTokenRequestError)return ["invalid_grant","refresh_token_reused","revoked_token"].includes(error.oauthError??"");
716
717
  if(error instanceof AnthropicTokenError)return (error.httpStatus===400||error.httpStatus===401)&&["invalid_grant","refresh_token_reused","revoked","revoked_token","refresh_token_revoked"].includes(error.oauthError??"");
717
718
  if(error instanceof KiroTokenRefreshError)return (error.httpStatus===400||error.httpStatus===401)&&error.oauthError!==undefined;
718
719
  if(error instanceof NousTokenError)return error.terminal===true||["invalid_grant","refresh_token_reused","revoked","revoked_token","expired_token"].includes(error.oauthError??"");
720
+ // ChatGPT carries the pool classifier's verdict on the error itself: revoked and
721
+ // expired grants are terminal; unknown/transient failures stay retryable.
722
+ if(error instanceof ChatGptTokenError)return error.terminal;
719
723
  // Local durable-write/read/cleanup failures are operational, not credential
720
724
  // death: the provider credential was never rejected or consumed. Never mark
721
725
  // the account needsReauth for broken local persistence infrastructure.
@@ -1508,7 +1508,7 @@ export async function markAccountNeedsReauth(
1508
1508
  export async function mergeAccountCredential(provider:string,accountId:string,credential:OAuthCredentials,opts:{expectedGeneration?:string;afterPrePersistRead?:()=>void|Promise<void>;assertOwnership?: (store: AuthStore) => void}={}):Promise<{superseded:false}|{superseded:true;stored:OAuthCredentials}>{const safe=normalizeCredential(credential);if(!safe)throw new Error("Refusing to persist invalid OAuth credential");if(isAnthropicInstanceId(provider))assertAnthropicCredentialSource(provider,safe);return await mutateStore(async store=>{await opts.afterPrePersistRead?.();const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account)throw new Error(`OAuth account disappeared before persist: ${provider}`);if(opts.expectedGeneration!==undefined&&credentialGeneration(account.credential)!==opts.expectedGeneration)return{superseded:true,stored:account.credential};opts.assertOwnership?.(store);account.credential=safe;delete account.refreshAttentionGeneration;if(account.needsReauthReason!=="verify_account"){delete account.needsReauth;delete account.needsReauthReason;}return{superseded:false};},[provider,accountId,safe,opts.expectedGeneration]);}
1509
1509
  // A late refresh failure must not change an operator-paused account's health. Check under
1510
1510
  // the mutation lock, not before awaiting it, so a concurrent pause cannot be overwritten.
1511
- export async function markAccountNeedsReauthIfGeneration(provider:string,accountId:string,generation:string,writerGeneration=captureConfigGeneration(),reason?:"verify_account"):Promise<boolean>{const key=oauthAccountKey(provider,accountId);if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;return await mutateStore(store=>{const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account?.credential||account.paused||credentialGeneration(account.credential)!==generation)return false;if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;account.needsReauth=true;if(reason==="verify_account")account.needsReauthReason=reason;return true;},[provider,accountId,generation]);}
1511
+ export async function markAccountNeedsReauthIfGeneration(provider:string,accountId:string,generation:string,writerGeneration=captureConfigGeneration(),reason?:"verify_account",isCurrent?:(store:AuthStore)=>boolean):Promise<boolean>{const key=oauthAccountKey(provider,accountId);if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;return await mutateStore(store=>{const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account?.credential||account.paused||credentialGeneration(account.credential)!==generation)return false;if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;if(isCurrent&&!isCurrent(store))return false;account.needsReauth=true;if(reason==="verify_account")account.needsReauthReason=reason;return true;},[provider,accountId,generation]);}
1512
1512
 
1513
1513
  /** Attention is projected only; refresh selection continues to use the stored terminal flag. */
1514
1514
  export function accountNeedsReauthForStatus(provider: string, account: ProviderAccount, now = Date.now()): boolean {
@@ -0,0 +1,115 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { atomicWriteFile } from "../config/atomic-write";
4
+ import { getConfigDir } from "../config/paths";
5
+ import { readBoundedResponseBytes } from "../lib/bounded-body";
6
+
7
+ const SOURCE_URL = "https://models.dev/api.json";
8
+ const FILENAME = "azure-model-metadata-cache.json";
9
+ const TTL_MS = 24 * 60 * 60 * 1000;
10
+
11
+ export type AzureModelMetadata = { input?: string[]; contextWindow?: number; maxTokens?: number };
12
+ type Snapshot = { version: 1; fetchedAt: number; models: Record<string, AzureModelMetadata> };
13
+ type CacheState = { snapshot?: Snapshot | null; inFlight?: Promise<void>; retryAfter: number };
14
+ const cacheByConfigDir = new Map<string, CacheState>();
15
+
16
+ function cacheState(configDir: string): CacheState {
17
+ let state = cacheByConfigDir.get(configDir);
18
+ if (!state) {
19
+ state = { retryAfter: 0 };
20
+ cacheByConfigDir.set(configDir, state);
21
+ }
22
+ return state;
23
+ }
24
+
25
+ export function isAzureModelMetadataDestination(baseUrl: string | undefined): boolean {
26
+ try {
27
+ return !!baseUrl && new URL(baseUrl).hostname.endsWith(".openai.azure.com");
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
32
+
33
+ function positiveInteger(value: unknown): number | undefined {
34
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
35
+ }
36
+
37
+ function record(value: unknown): Record<string, unknown> | undefined {
38
+ return value !== null && typeof value === "object" && !Array.isArray(value)
39
+ ? value as Record<string, unknown> : undefined;
40
+ }
41
+
42
+ function parseModels(models: unknown, published: boolean): Record<string, AzureModelMetadata> {
43
+ const out: Record<string, AzureModelMetadata> = Object.create(null);
44
+ for (const [id, value] of Object.entries(record(models) ?? {}).slice(0, 2000)) {
45
+ const row = record(value);
46
+ if (!row || id.length > 256) continue;
47
+ const limit = published ? record(row.limit) : row;
48
+ const input = published ? record(row.modalities)?.input : row.input;
49
+ const modalities = Array.isArray(input)
50
+ ? [...new Set(input.filter((item): item is string => item === "text" || item === "image" || item === "audio"))]
51
+ : [];
52
+ const contextWindow = positiveInteger(published ? limit?.context : limit?.contextWindow);
53
+ const maxTokens = positiveInteger(published ? limit?.output : limit?.maxTokens);
54
+ if (!modalities.length && contextWindow === undefined && maxTokens === undefined) continue;
55
+ out[id.toLowerCase()] = {
56
+ ...(modalities.length ? { input: modalities } : {}),
57
+ ...(contextWindow !== undefined ? { contextWindow } : {}),
58
+ ...(maxTokens !== undefined ? { maxTokens } : {}),
59
+ };
60
+ }
61
+ return out;
62
+ }
63
+
64
+ function loadSnapshot(configDir: string, state: CacheState): Snapshot | null {
65
+ if (state.snapshot !== undefined) return state.snapshot;
66
+ state.snapshot = null;
67
+ try {
68
+ const raw = record(JSON.parse(readFileSync(join(configDir, FILENAME), "utf8")));
69
+ if (raw?.version === 1 && positiveInteger(raw.fetchedAt) !== undefined && record(raw.models)) {
70
+ state.snapshot = { version: 1, fetchedAt: raw.fetchedAt as number, models: parseModels(raw.models, false) };
71
+ }
72
+ } catch { /* Missing or corrupt optional metadata leaves bundled hints available. */ }
73
+ return state.snapshot;
74
+ }
75
+
76
+ /** Synchronous catalog fallback. Generation requests never fetch this snapshot. */
77
+ export function publishedAzureModelMetadata(modelId: string, configDir = getConfigDir()): AzureModelMetadata | undefined {
78
+ return loadSnapshot(configDir, cacheState(configDir))?.models[modelId.toLowerCase()];
79
+ }
80
+
81
+ /** Refresh once per day during discovery, coalesced and bounded; retain stale data offline. */
82
+ export async function refreshAzureModelMetadata(baseUrl: string | undefined, configDir = getConfigDir()): Promise<void> {
83
+ if (!isAzureModelMetadataDestination(baseUrl)) return;
84
+ // Capture state and destination together: tests and embedders can change config roots.
85
+ const state = cacheState(configDir);
86
+ const current = loadSnapshot(configDir, state);
87
+ if ((current && Date.now() - current.fetchedAt < TTL_MS) || Date.now() < state.retryAfter) return;
88
+ if (state.inFlight) return state.inFlight;
89
+ const path = join(configDir, FILENAME);
90
+ state.inFlight = (async () => {
91
+ try {
92
+ const signal = AbortSignal.timeout(2000);
93
+ const response = await fetch(SOURCE_URL, {
94
+ headers: { accept: "application/json" },
95
+ redirect: "error",
96
+ signal,
97
+ });
98
+ if (!response.ok) return;
99
+ const body = await readBoundedResponseBytes(response, { maxBytes: 16 * 1024 * 1024, signal });
100
+ if (body.oversized) return;
101
+ const raw = record(JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(body.bytes)));
102
+ // Provider-specific rows matter: Azure may expose text only where the direct vendor has vision.
103
+ const models = parseModels(record(raw?.azure)?.models, true);
104
+ if (!Object.keys(models).length) return;
105
+ state.snapshot = { version: 1, fetchedAt: Date.now(), models };
106
+ try { atomicWriteFile(path, JSON.stringify(state.snapshot) + "\n"); } catch { /* Memory still serves discovery. */ }
107
+ } catch { /* A public metadata outage must not fail provider discovery. */ }
108
+ finally { state.retryAfter = Date.now() + 60_000; }
109
+ })().finally(() => { state.inFlight = undefined; });
110
+ return state.inFlight;
111
+ }
112
+
113
+ export function resetAzureModelMetadataForTests(): void {
114
+ cacheByConfigDir.clear();
115
+ }
@@ -1,5 +1,7 @@
1
1
  import type { CodexAccountMode, OcxProviderConfig } from "../types";
2
+ import { COST_VENDOR_PRIORITY, getModelMetadata, getModelMetadataCaseInsensitive } from "../generated/model-metadata";
2
3
  import { cloneFastWire } from "./fastwire";
4
+ import { isAzureModelMetadataDestination, publishedAzureModelMetadata, type AzureModelMetadata } from "./azure-model-metadata";
3
5
  import { resolveModelPolicy } from "./resolved-model-policy";
4
6
  import {
5
7
  PROVIDER_REGISTRY,
@@ -651,6 +653,25 @@ export function shouldCaseFoldMetadataModelId(providerId: string): boolean {
651
653
  return entry?.metadataModelIdNormalize === "case-insensitive";
652
654
  }
653
655
 
656
+ const AZURE_VENDOR_METADATA_PRIORITY = [
657
+ "azure-openai",
658
+ ...COST_VENDOR_PRIORITY.filter(provider => provider !== "azure-openai"),
659
+ ];
660
+
661
+ /**
662
+ * Fill missing Azure model metadata by destination and known model id. Custom provider
663
+ * names miss PROVIDER_ALIASES; arbitrary deployment aliases still need explicit metadata.
664
+ */
665
+ export function azureVendorModelMetadata(baseUrl: string | undefined, modelId: string, metadataConfigDir?: string): AzureModelMetadata | undefined {
666
+ if (!isAzureModelMetadataDestination(baseUrl)) return undefined;
667
+ const published = publishedAzureModelMetadata(modelId, metadataConfigDir);
668
+ for (const provider of AZURE_VENDOR_METADATA_PRIORITY) {
669
+ const meta = getModelMetadata(provider, modelId) ?? getModelMetadataCaseInsensitive(provider, modelId);
670
+ if (meta) return { ...meta, ...published };
671
+ }
672
+ return published;
673
+ }
674
+
654
675
  function entryToPreset(entry: ProviderRegistryEntry): DerivedProviderPreset {
655
676
  return {
656
677
  id: entry.id,
@@ -38,6 +38,7 @@ const DEEPSEEK_BASE_URL = "https://api.deepseek.com";
38
38
  const CLINE_BASE_URL = "https://api.cline.bot";
39
39
  const OLLAMA_CLOUD_BASE_URL = "https://ollama.com";
40
40
  const OLLAMA_CLOUD_USAGE_URL = `${OLLAMA_CLOUD_BASE_URL}/api/usage`;
41
+ const OLLAMA_CLOUD_BALANCE_URL = `${OLLAMA_CLOUD_BASE_URL}/api/balance`;
41
42
  const ZAI_BASE_URL = "https://api.z.ai";
42
43
  const ZAI_CN_BASE_URL = "https://open.bigmodel.cn";
43
44
  const MINIMAX_REMAINS_PATH = "/v1/api/openplatform/coding_plan/remains";
@@ -439,11 +440,80 @@ async function fetchClineQuota(provider: string, config: OcxProviderConfig): Pro
439
440
  }
440
441
 
441
442
  /**
442
- * Ollama Cloud `GET https://ollama.com/api/usage` — returns account usage.
443
- * Legacy plans report rolling 5-hour `limits.session.usage` and 7-day
444
- * `limits.weekly.usage`. Migrated monthly-credit plans report
445
- * `limits.monthly.usage`. `usage` values are normalized fractions (0..1).
443
+ * Ollama Cloud quota. `GET https://ollama.com/api/balance` is the live source: it
444
+ * returns the included (plan) balance plus purchased credits. Legacy plans report
445
+ * rolling 5-hour `included.session.remaining_percent` and 7-day
446
+ * `included.weekly.remaining_percent` (remaining shares, so used = 100 - remaining);
447
+ * credit plans report `included.balance_usd`/`included.allowance_usd` over a
448
+ * `period`.
449
+ *
450
+ * `GET https://ollama.com/api/usage` used to carry `limits.{session,weekly,monthly}.usage`
451
+ * as normalized fractions but now returns only a request-count timeseries, so it is
452
+ * kept as a fallback for older deployments.
446
453
  */
454
+ function parseOllamaUsedPercent(row: Record<string, unknown>): number | undefined {
455
+ if (row.remaining_percent !== undefined) {
456
+ const remaining = toFiniteNumber(row.remaining_percent);
457
+ if (remaining === undefined) return undefined;
458
+ return normalizePercent(Math.round((100 - remaining) * 100) / 100);
459
+ }
460
+ return parseOllamaPercent(row.usage);
461
+ }
462
+
463
+ export function parseOllamaCloudBalance(body: Record<string, unknown> | null): ProviderQuota | null {
464
+ if (!body) return null;
465
+ const included = asRecord(body.included);
466
+ if (!included) return null;
467
+
468
+ const quota: ProviderQuota = { updatedAt: Date.now() };
469
+ let windows = 0;
470
+
471
+ const rows: Array<[string, "fiveHour" | "weekly" | "monthly"]> = [
472
+ ["session", "fiveHour"],
473
+ ["weekly", "weekly"],
474
+ ["monthly", "monthly"],
475
+ ];
476
+ for (const [key, window] of rows) {
477
+ const row = asRecord(included[key]);
478
+ if (!row) continue;
479
+ const percent = parseOllamaUsedPercent(row);
480
+ if (percent === undefined) continue;
481
+ const resetAt = normalizeResetAt(row.resets_at);
482
+ if (window === "fiveHour") {
483
+ quota.fiveHourPercent = percent;
484
+ if (resetAt !== undefined) quota.fiveHourResetAt = resetAt;
485
+ } else if (window === "weekly") {
486
+ quota.weeklyPercent = percent;
487
+ if (resetAt !== undefined) quota.weeklyResetAt = resetAt;
488
+ } else {
489
+ quota.monthlyPercent = percent;
490
+ if (resetAt !== undefined) quota.monthlyResetAt = resetAt;
491
+ }
492
+ windows += 1;
493
+ }
494
+
495
+ const allowance = toFiniteNumber(included.allowance_usd);
496
+ const balance = toFiniteNumber(included.balance_usd);
497
+ if (allowance !== undefined && allowance > 0 && balance !== undefined) {
498
+ const used = Math.max(0, allowance - balance);
499
+ const percent = normalizePercent(Math.round((used / allowance) * 10000) / 100);
500
+ if (percent !== undefined) {
501
+ const expiresAt = normalizeResetAt(asRecord(included.period)?.until);
502
+ // creditsUsd is the included allowance only; purchased credits are ignored.
503
+ quota.creditsUsd = {
504
+ used,
505
+ limit: allowance,
506
+ remaining: balance,
507
+ percent,
508
+ ...(expiresAt !== undefined ? { expiresAt } : {}),
509
+ };
510
+ windows += 1;
511
+ }
512
+ }
513
+
514
+ return windows > 0 ? quota : null;
515
+ }
516
+
447
517
  function parseOllamaPercent(usageValue: unknown): number | undefined {
448
518
  const usage = toFiniteNumber(usageValue);
449
519
  if (usage === undefined || usage < 0) return undefined;
@@ -489,25 +559,65 @@ export function parseOllamaCloudQuota(body: Record<string, unknown> | null): Pro
489
559
  return windows > 0 ? quota : null;
490
560
  }
491
561
 
562
+ /**
563
+ * Routing projection for an `/api/balance` report. `creditsUsd` covers the included
564
+ * allowance only, so it may veto routing only when purchased credit is known to be empty;
565
+ * otherwise an exhausted allowance would block an account that still has paid balance.
566
+ */
567
+ function ollamaBalanceInferenceQuota(quota: ProviderQuota, body: Record<string, unknown> | null): ProviderQuota {
568
+ if (!quota.creditsUsd) return quota;
569
+ const purchased = toFiniteNumber(asRecord(body?.purchased)?.balance_usd);
570
+ if (purchased !== undefined && purchased <= 0) return quota;
571
+ const { creditsUsd: _includedOnly, ...inference } = quota;
572
+ return inference;
573
+ }
574
+
492
575
  async function fetchOllamaCloudQuota(provider: string, config: OcxProviderConfig): Promise<ProviderQuotaProbeResult> {
493
576
  const effectiveBaseUrl = config.baseUrl ?? getProviderRegistryEntry(provider)?.baseUrl ?? "";
494
577
  if (!isCanonicalOllamaCloudBaseUrl(effectiveBaseUrl)) return null;
495
578
  const apiKey = resolveProviderApiKey(config.apiKey)?.trim();
496
579
  if (!apiKey) return null;
497
- const response = await quotaFetch(provider, config, OLLAMA_CLOUD_USAGE_URL, {
498
- headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
499
- redirect: "error",
500
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
501
- });
502
- if (!response.ok) {
503
- if (response.status === 404) return null;
504
- return response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429
505
- ? TERMINAL_QUOTA_FAILURE
506
- : null;
580
+ const attempts: Array<{
581
+ url: string;
582
+ source: string;
583
+ parse: (body: Record<string, unknown> | null) => ProviderQuota | null;
584
+ }> = [
585
+ { url: OLLAMA_CLOUD_BALANCE_URL, source: "ollama-cloud:balance", parse: parseOllamaCloudBalance },
586
+ { url: OLLAMA_CLOUD_USAGE_URL, source: "ollama-cloud:usage", parse: parseOllamaCloudQuota },
587
+ ];
588
+ let terminalFailure = false;
589
+ for (const attempt of attempts) {
590
+ let response: Response;
591
+ try {
592
+ response = await quotaFetch(provider, config, attempt.url, {
593
+ headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
594
+ redirect: "error",
595
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
596
+ });
597
+ } catch {
598
+ continue;
599
+ }
600
+ if (!response.ok) {
601
+ if (response.status !== 404 && response.status >= 400 && response.status < 500 && response.status !== 408 && response.status !== 429) {
602
+ terminalFailure = true;
603
+ }
604
+ try {
605
+ void response.body?.cancel().catch(() => undefined);
606
+ } catch {
607
+ // Best-effort cancellation only.
608
+ }
609
+ continue;
610
+ }
611
+ const raw = await readQuotaJson(response);
612
+ if (raw === QUOTA_JSON_READ_FAILURE) return terminalFailure ? TERMINAL_QUOTA_FAILURE : null;
613
+ const body = asRecord(raw);
614
+ const quota = attempt.parse(body);
615
+ if (quota) {
616
+ const inference = attempt.url === OLLAMA_CLOUD_BALANCE_URL ? ollamaBalanceInferenceQuota(quota, body) : quota;
617
+ return keyReport(provider, attempt.source, quota, config, apiKey, inference);
618
+ }
507
619
  }
508
- const body = asRecord(await readQuotaJson(response));
509
- const quota = parseOllamaCloudQuota(body);
510
- return quota ? keyReport(provider, "ollama-cloud:usage", quota, config, apiKey, quota) : null;
620
+ return terminalFailure ? TERMINAL_QUOTA_FAILURE : null;
511
621
  }
512
622
 
513
623
  /**
@@ -97,7 +97,7 @@ export {
97
97
  type ProviderAccountQuota,
98
98
  } from "./quota/account-cache";
99
99
  export { fetchAntigravityUsageQuota, isCanonicalAntigravityQuotaUrl, setAntigravityAccountQuotaTransportForTests } from "./quota/antigravity";
100
- export { parseOllamaCloudQuota, parseZaiQuotaLimits, providerApiKeyQuotaMode } from "./quota/vendor-probes-key";
100
+ export { parseOllamaCloudBalance, parseOllamaCloudQuota, parseZaiQuotaLimits, providerApiKeyQuotaMode } from "./quota/vendor-probes-key";
101
101
  export { parseXaiCreditsResponse } from "./quota/vendor-probes-oauth";
102
102
  export async function fetchProviderApiKeyQuotas(config: OcxConfig, name: string, forceRefresh = false): Promise<ProviderApiKeyQuota[]> {
103
103
  const provider = config.providers[name];
@@ -36,6 +36,32 @@ export interface ReasoningEnvelope {
36
36
  * same way a signature does.
37
37
  */
38
38
  krc?: string;
39
+ /**
40
+ * A Responses provider's own `encrypted_content` for one reasoning item, minted while serving
41
+ * `model` (the client-facing model the Claude route was asked for). The Claude route has no
42
+ * other channel to hand a provider its reasoning back: the body is `store: false` and Claude
43
+ * Code replays only the thinking block. `src/claude/inbound.ts` restores it only for a request
44
+ * naming the same model, so a model switch cannot carry it to a provider that cannot decrypt it.
45
+ */
46
+ nat?: NativeReasoning;
47
+ }
48
+
49
+ export interface NativeReasoning {
50
+ /** The provider's opaque `encrypted_content`, verbatim. */
51
+ enc: string;
52
+ /** Client-facing model the item was produced for. */
53
+ model: string;
54
+ /** Route-bound HMAC for this exact ciphertext. */
55
+ tag: string;
56
+ /** The provider's reasoning item id, when it sent one. */
57
+ id?: string;
58
+ }
59
+
60
+ function decodeNativeReasoning(value: unknown): NativeReasoning | undefined {
61
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
62
+ const { enc, model, id, tag } = value as { enc?: unknown; model?: unknown; id?: unknown; tag?: unknown };
63
+ if (typeof enc !== "string" || enc.length === 0 || typeof model !== "string" || model.length === 0 || typeof tag !== "string" || tag.length === 0) return undefined;
64
+ return typeof id === "string" && id.length > 0 ? { enc, model, tag, id } : { enc, model, tag };
39
65
  }
40
66
 
41
67
  export function encodeReasoningEnvelope(envelope: ReasoningEnvelope, budget?: TranslatorBudget): string {
@@ -116,7 +142,9 @@ export function decodeReasoningEnvelope(encryptedContent: string, budget?: Trans
116
142
  if (hasTxt) envelope.txt = txt;
117
143
  const krc = (parsed as { krc?: unknown }).krc;
118
144
  if (typeof krc === "string" && krc.length > 0) envelope.krc = krc;
119
- return envelope.sig || envelope.red || hasTxt || envelope.krc ? envelope : null;
145
+ const nat = decodeNativeReasoning((parsed as { nat?: unknown }).nat);
146
+ if (nat) envelope.nat = nat;
147
+ return envelope.sig || envelope.red || hasTxt || envelope.krc || envelope.nat ? envelope : null;
120
148
  } catch {
121
149
  return null;
122
150
  } finally {
@@ -19,7 +19,8 @@
19
19
  * long-lived proxy cannot grow without limit.
20
20
  */
21
21
 
22
- import { createHash, createHmac, randomBytes } from "node:crypto";
22
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
23
+ import { thoughtSignatureReplaySalt } from "./thought-signature-replay";
23
24
  import type {
24
25
  OcxProviderConfig,
25
26
  OcxReasoningReplayIdentity,
@@ -27,6 +28,21 @@ import type {
27
28
  } from "../types";
28
29
 
29
30
  const MAX_ENTRIES = 64;
31
+ export interface NativeReasoningOwner { destination: string; credential: string }
32
+
33
+ /** Bind exact ciphertext to the durable route that accepted it. */
34
+ export function nativeReasoningTag(owner: NativeReasoningOwner | undefined, blob: string): string | undefined {
35
+ const salt = thoughtSignatureReplaySalt();
36
+ if (!owner || !salt || salt.length < 16 || blob.length === 0) return undefined;
37
+ return createHmac("sha256", salt).update("native-reasoning\0")
38
+ .update(JSON.stringify([owner.destination, owner.credential, createHash("sha256").update(blob).digest("hex")]))
39
+ .digest("hex");
40
+ }
41
+
42
+ export function nativeReasoningTagMatches(expected: string | undefined, presented: string | undefined): boolean {
43
+ if (!expected || !presented || !/^[a-f0-9]{64}$/.test(expected) || !/^[a-f0-9]{64}$/.test(presented)) return false;
44
+ return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(presented, "hex"));
45
+ }
30
46
  const MAX_TOTAL_BYTES = 256 * 1024;
31
47
  const TTL_MS = 60 * 60 * 1000;
32
48
  const OPAQUE_BLOB_REJECTION_TTL_MS = 5 * 60 * 1000;
@@ -765,10 +765,23 @@ export function readResponseSpill(responseId: string, ref: ResponseSpillRef): Re
765
765
  export function deleteResponseSpill(ref: ResponseSpillRef): void {
766
766
  if (!validSpillRef(ref)) return;
767
767
  const dir = responseSpillDirectory();
768
+ const path = join(dir, ref.fileName);
768
769
  try {
769
- unlink(join(dir, ref.fileName));
770
+ unlink(path);
770
771
  fsyncDirectoryBestEffort(dir);
771
- } catch { /* best effort */ }
772
+ } catch (error) {
773
+ // Still best effort, but no longer invisible: a file a failed unlink left behind occupies
774
+ // the volume, so the accounting owner keeps pricing it until the path is gone (#6747).
775
+ if (!isErrno(error, "ENOENT")) spillUnlinkFailureObserver?.(path, ref.payloadBytes);
776
+ }
777
+ }
778
+
779
+ type SpillUnlinkFailureObserver = (path: string, bytes: number) => void;
780
+ let spillUnlinkFailureObserver: SpillUnlinkFailureObserver | null = null;
781
+
782
+ /** The owner of spill accounting registers here so a failed unlink cannot under-report disk use. */
783
+ export function setResponseSpillUnlinkFailureObserver(next: SpillUnlinkFailureObserver | null): void {
784
+ spillUnlinkFailureObserver = next;
772
785
  }
773
786
 
774
787
  type SpillDirNameKind = "spill" | "temp";
@@ -18,6 +18,10 @@ export interface ResponseStateMetrics {
18
18
  spillLastWriteFailureOrigin: ResponseSpillWriteFailureOrigin | null;
19
19
  spillAclRetryReturnedTimeouts: number;
20
20
  spillAclTimeoutMemoRefusals: number;
21
+ /** Publications refused because the durable spill cap could not make room (cumulative). */
22
+ spillCapacityRefusals: number;
23
+ /** Spills evicted by admission to make room for a newer publication (cumulative). */
24
+ spillHeadroomEvictions: number;
21
25
  spillLastWriteFailureAt: number | null;
22
26
  spillLastWriteSuccessAt: number | null;
23
27
  spillReadFailures: number;
@@ -37,6 +41,8 @@ export function computeResponseStateMetrics(params: {
37
41
  spillLastSuccessAt: number | null;
38
42
  spillAclRetryReturnedTimeouts: number;
39
43
  spillAclTimeoutMemoRefusals: number;
44
+ spillCapacityRefusals: number;
45
+ spillHeadroomEvictions: number;
40
46
  spillLastFailureAt: number | null;
41
47
  spillReadFailures: number;
42
48
  replayScopeMismatchDrops: number;
@@ -79,6 +85,8 @@ export function computeResponseStateMetrics(params: {
79
85
  spillLastWriteFailureOrigin: params.spillLastFailureOrigin,
80
86
  spillAclRetryReturnedTimeouts: params.spillAclRetryReturnedTimeouts,
81
87
  spillAclTimeoutMemoRefusals: params.spillAclTimeoutMemoRefusals,
88
+ spillCapacityRefusals: params.spillCapacityRefusals,
89
+ spillHeadroomEvictions: params.spillHeadroomEvictions,
82
90
  spillLastWriteFailureAt: params.spillLastFailureAt,
83
91
  spillLastWriteSuccessAt: params.spillLastSuccessAt,
84
92
  spillReadFailures: params.spillReadFailures,
@@ -1,8 +1,14 @@
1
1
  export const spillCounters = {
2
2
  writes: 0, writeFailures: 0, readFailures: 0,
3
3
  aclRetryReturnedTimeouts: 0, aclTimeoutMemoRefusals: 0,
4
+ capacityRefusals: 0, headroomEvictions: 0,
4
5
  };
5
6
 
7
+ /** Test-only: zero every spill counter, including ones added later. */
8
+ export function resetSpillCountersForTests(): void {
9
+ for (const key of Object.keys(spillCounters) as Array<keyof typeof spillCounters>) spillCounters[key] = 0;
10
+ }
11
+
6
12
  export type ResponseSpillWriteFailureCode =
7
13
  | "EACLRETRYEXHAUSTED"
8
14
  | "ETIMEDOUT"
@@ -102,6 +108,12 @@ export function noteSpillWriteFailure(
102
108
  if (origin === "retry_returned_timeout") spillCounters.aclRetryReturnedTimeouts += 1;
103
109
  else if (origin === "timeout_memo_refusal") spillCounters.aclTimeoutMemoRefusals += 1;
104
110
  }
111
+
112
+ /** A publication refused because the durable spill cap could not make room for it (#6747). */
113
+ export function noteSpillCapacityRefusal(): void {
114
+ spillCounters.capacityRefusals += 1;
115
+ noteSpillWriteFailure(null, "ECAPACITY");
116
+ }
105
117
  /**
106
118
  * Admission-boundary observability (test-visible). directSpills: oversized
107
119
  * candidates routed straight to durable spill without a resident stay or
@@ -11,12 +11,14 @@ import {
11
11
  responseSpillNow,
12
12
  type ResponseSpillPublicationControl,
13
13
  type ResponseSpillRef,
14
+ setResponseSpillUnlinkFailureObserver,
14
15
  writeResponseSpillDurably,
15
16
  writeResponseSpillDurablyAsync,
16
17
  } from "../spill-store";
17
18
  import { enforceAppOwnedMemoryBudget } from "../../lib/app-owned-memory";
18
19
  import {
19
20
  admissionCounters,
21
+ noteSpillCapacityRefusal,
20
22
  noteSpillWriteFailure,
21
23
  noteSpillWriteSuccess,
22
24
  spillAclMemoRefusalOrigin,
@@ -42,7 +44,8 @@ export interface SpillQueueStore {
42
44
  pruneResponses(): void;
43
45
  accountedResponseSpillBytes(): number;
44
46
  spillByteCap(): number;
45
- enforceSpilledResponseBudget(): number;
47
+ /** No argument: plain cap pruning. A number (0 included): admission reclaim of that headroom. */
48
+ enforceSpilledResponseBudget(requiredHeadroomBytes?: number): number;
46
49
  terminalizationMaxPasses(): number;
47
50
  }
48
51
 
@@ -122,6 +125,8 @@ function chargeUnreclaimableSpillPath(path: string | null | undefined, bytes: nu
122
125
  if (!path || bytes <= 0) return;
123
126
  unreclaimableSpillPaths.set(path, bytes);
124
127
  }
128
+ // A failed unlink of an installed or deferred spill leaves a real file too; price it the same way.
129
+ setResponseSpillUnlinkFailureObserver(chargeUnreclaimableSpillPath);
125
130
 
126
131
  /** Drop charges for paths that have since disappeared; returns the surviving total. */
127
132
  function reconcileUnreclaimableSpillPaths(): number {
@@ -286,9 +291,10 @@ export function queuePendingResponseSpill(
286
291
  // against a total that is short by a whole envelope.
287
292
  const inheritedBytes = inheritedSpill?.payloadBytes ?? 0;
288
293
  if (requireStore().accountedResponseSpillBytes() + footprint + inheritedBytes > requireStore().spillByteCap()) {
289
- requireStore().enforceSpilledResponseBudget();
294
+ // An envelope over the replay ceiling ends in EFBIG anyway; never evict a continuation for it.
295
+ if (footprint / 2 <= responseSpillPayloadCap()) requireStore().enforceSpilledResponseBudget(footprint + inheritedBytes);
290
296
  if (requireStore().accountedResponseSpillBytes() + footprint + inheritedBytes > requireStore().spillByteCap()) {
291
- noteSpillWriteFailure(null, "ECAPACITY");
297
+ noteSpillCapacityRefusal();
292
298
  requireStore().replaceWithSpillFailure(id, candidate);
293
299
  requireStore().deferSupersededSpill(inheritedSpill);
294
300
  return;
@@ -421,10 +427,11 @@ function installShutdownFallbackSpill(
421
427
  // over budget. `replaceWithSpillFailure` is the same fail-closed ending the budget
422
428
  // exhaustion path uses, so replay reports `spill_failed` and the client resends.
423
429
  if (requireStore().accountedResponseSpillBytes() + supersededBytes > requireStore().spillByteCap()) {
424
- requireStore().enforceSpilledResponseBudget();
430
+ // The footprint is already reserved above, so only the superseded generation is extra room.
431
+ if (footprint / 2 <= responseSpillPayloadCap()) requireStore().enforceSpilledResponseBudget(supersededBytes);
425
432
  if (requireStore().accountedResponseSpillBytes() + supersededBytes > requireStore().spillByteCap()) {
426
433
  if (requireStore().currentEntry(job.id) === candidate) {
427
- noteSpillWriteFailure(null, "ECAPACITY");
434
+ noteSpillCapacityRefusal();
428
435
  requireStore().replaceWithSpillFailure(job.id, candidate);
429
436
  requireStore().deferSupersededSpill(job.supersededSpill);
430
437
  }