@bitkyc08/opencodex 2.88.0 → 2.90.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 (291) hide show
  1. package/README.md +11 -1
  2. package/bin/ocx.mjs +15 -4
  3. package/gui/dist/assets/App-DgxkE6Md.js +58 -0
  4. package/gui/dist/assets/App-dnkJ85te.css +1 -0
  5. package/gui/dist/assets/{Tray-B5bC7AWL.js → Tray-DbTNc_88.js} +1 -1
  6. package/gui/dist/assets/{index-B5bsvxor.js → index-TmaHa54u.js} +15 -15
  7. package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
  8. package/gui/dist/index.html +1 -1
  9. package/package.json +2 -2
  10. package/src/adapters/anthropic/cache-retention.ts +117 -0
  11. package/src/adapters/anthropic/passthrough.ts +7 -4
  12. package/src/adapters/anthropic.ts +10 -3
  13. package/src/adapters/base.ts +4 -0
  14. package/src/adapters/claude-cli/adapter.ts +13 -7
  15. package/src/adapters/claude-cli/usage-admission.ts +14 -2
  16. package/src/adapters/coding-agent/protocol.ts +6 -0
  17. package/src/adapters/coding-agent/turn.ts +14 -4
  18. package/src/adapters/cursor/protobuf-request.ts +3 -0
  19. package/src/adapters/google-http.ts +3 -2
  20. package/src/adapters/google-progress-history.ts +19 -1
  21. package/src/adapters/google-vertex-endpoint.ts +67 -0
  22. package/src/adapters/google.ts +62 -37
  23. package/src/adapters/image.ts +5 -3
  24. package/src/adapters/input-media-guard.ts +5 -2
  25. package/src/adapters/kiro-refusal.ts +41 -1
  26. package/src/adapters/ollama-native.ts +1 -0
  27. package/src/adapters/openai-chat/messages.ts +34 -4
  28. package/src/adapters/openai-chat/passthrough.ts +37 -4
  29. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  30. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  31. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  32. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  33. package/src/adapters/openai-chat.ts +7 -6
  34. package/src/adapters/openai-responses/compaction-search-history.ts +27 -0
  35. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  36. package/src/adapters/openai-responses/passthrough.ts +22 -13
  37. package/src/adapters/openai-responses/reasoning.ts +68 -1
  38. package/src/adapters/openai-responses/transport.ts +13 -1
  39. package/src/adapters/registry.ts +1 -1
  40. package/src/adapters/upstream-http-error.ts +3 -2
  41. package/src/adapters/zed.ts +7 -4
  42. package/src/bridge/internal.ts +11 -1
  43. package/src/bridge/response-json.ts +2 -2
  44. package/src/bridge/sse.ts +10 -4
  45. package/src/claude/desktop-3p-library.ts +43 -3
  46. package/src/claude/desktop-3p.ts +20 -10
  47. package/src/claude/desktop-picker-profile.ts +18 -9
  48. package/src/claude/desktop-remote-store-io.ts +2 -2
  49. package/src/claude/inbound.ts +12 -26
  50. package/src/claude/outbound.ts +5 -0
  51. package/src/cli/account-api.ts +13 -1
  52. package/src/cli/account-extended.ts +17 -5
  53. package/src/cli/account-key-quota.ts +4 -1
  54. package/src/cli/account.ts +1 -1
  55. package/src/cli/capabilities-agents-routing.ts +45 -8
  56. package/src/cli/capabilities-base.ts +5 -5
  57. package/src/cli/capabilities-provider-models.ts +5 -4
  58. package/src/cli/combo-result.ts +8 -2
  59. package/src/cli/combo.ts +17 -0
  60. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  61. package/src/cli/help.ts +1 -1
  62. package/src/cli/message-command.ts +4 -1
  63. package/src/cli/message-remote-args.ts +69 -0
  64. package/src/cli/message-remote-command.ts +77 -0
  65. package/src/cli/provider-runtime.ts +69 -1
  66. package/src/cli/registry.ts +3 -3
  67. package/src/codex/account-lifecycle.ts +19 -6
  68. package/src/codex/account-pause.ts +6 -1
  69. package/src/codex/account-runtime-state.ts +5 -0
  70. package/src/codex/account-store.ts +55 -9
  71. package/src/codex/account-usability.ts +4 -1
  72. package/src/codex/auth-api/account-list.ts +13 -5
  73. package/src/codex/auth-api/account-pause-group.ts +2 -22
  74. package/src/codex/auth-api/login-collision.ts +59 -0
  75. package/src/codex/auth-api/login-flow.ts +12 -7
  76. package/src/codex/auth-api/main-account-probe.ts +3 -2
  77. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  78. package/src/codex/auth-collision.ts +31 -4
  79. package/src/codex/auth-context.ts +57 -10
  80. package/src/codex/catalog/aggregation.ts +15 -0
  81. package/src/codex/catalog/auto-review.ts +2 -1
  82. package/src/codex/catalog/build-entries.ts +17 -7
  83. package/src/codex/catalog/derive-entry.ts +3 -1
  84. package/src/codex/catalog/metadata.ts +2 -0
  85. package/src/codex/catalog/model-visibility.ts +0 -2
  86. package/src/codex/catalog/ownership.ts +71 -0
  87. package/src/codex/catalog/parsing.ts +12 -3
  88. package/src/codex/catalog/retained-sync.ts +2 -1
  89. package/src/codex/catalog/routed-removal.ts +4 -4
  90. package/src/codex/cli-update-plan.ts +18 -3
  91. package/src/codex/collision-identity.ts +78 -0
  92. package/src/codex/convergence.ts +2 -1
  93. package/src/codex/low-quota-desktop-notifier.ts +125 -0
  94. package/src/codex/low-quota-protection.ts +57 -31
  95. package/src/codex/main-account-cache.ts +77 -3
  96. package/src/codex/main-account-hard-lock.ts +14 -0
  97. package/src/codex/main-account.ts +6 -2
  98. package/src/codex/native-residue.ts +2 -3
  99. package/src/codex/quota.ts +26 -2
  100. package/src/codex/routing/cooldown-math.ts +5 -0
  101. package/src/codex/routing/failure-window.ts +61 -9
  102. package/src/codex/routing.ts +4 -3
  103. package/src/codex/shim-diagnostics.ts +2 -1
  104. package/src/codex/shim-fingerprint.ts +11 -2
  105. package/src/codex/shim-templates.ts +16 -5
  106. package/src/codex/shim-windows-refresh.ts +224 -0
  107. package/src/codex/shim.ts +45 -21
  108. package/src/combos/failover.ts +10 -2
  109. package/src/combos/jev-model-backend.ts +30 -7
  110. package/src/combos/jev-model-request.ts +20 -0
  111. package/src/combos/jev-quota-activation.ts +153 -0
  112. package/src/combos/jev-quota-config.ts +62 -0
  113. package/src/combos/jev-quota-route.ts +26 -0
  114. package/src/combos/jev-quota.ts +88 -0
  115. package/src/combos/jev.ts +25 -12
  116. package/src/combos/types.ts +27 -0
  117. package/src/compatibility/index.ts +3 -0
  118. package/src/compatibility/openai-responses.ts +8 -1
  119. package/src/compatibility/opengateway-responses.ts +61 -0
  120. package/src/config/account-auto-pause-revisions.ts +136 -0
  121. package/src/config/diagnostics.ts +13 -0
  122. package/src/config/live-reconcile.ts +9 -0
  123. package/src/config/persist-unlocked.ts +10 -1
  124. package/src/config/provider-validation.ts +7 -2
  125. package/src/config/removed-opencode-free.ts +135 -0
  126. package/src/config/schema/account-auto-pause.ts +14 -0
  127. package/src/config/schema/config-schema.ts +15 -0
  128. package/src/config/schema/leaf-validators.ts +7 -0
  129. package/src/config.ts +10 -3
  130. package/src/generated/compatibility-version.json +476 -216
  131. package/src/generated/model-metadata.ts +1 -1
  132. package/src/images/loop.ts +34 -11
  133. package/src/lib/account-auto-pause-response.ts +16 -0
  134. package/src/lib/bun-runtime.ts +18 -1
  135. package/src/lib/bun-transpiler-cache-scope.d.mts +8 -0
  136. package/src/lib/bun-transpiler-cache-scope.mjs +82 -0
  137. package/src/lib/decision-quota-sink.ts +76 -0
  138. package/src/lib/provider-admission-wait.ts +14 -0
  139. package/src/lib/service-secrets.ts +37 -2
  140. package/src/lib/state-store-registrations.ts +25 -0
  141. package/src/lib/translator-budget.ts +1 -1
  142. package/src/lib/upstream-retry.ts +15 -0
  143. package/src/link/ssh-argv.ts +3 -0
  144. package/src/messaging/envelope.ts +6 -3
  145. package/src/messaging/remote-auth.ts +83 -0
  146. package/src/messaging/remote-bridge.ts +270 -0
  147. package/src/messaging/remote-contract.ts +108 -0
  148. package/src/messaging/remote-enrollment-recovery.ts +59 -0
  149. package/src/messaging/remote-enrollment.ts +244 -0
  150. package/src/messaging/remote-envelope.ts +40 -0
  151. package/src/messaging/remote-files.ts +117 -0
  152. package/src/messaging/remote-owner.ts +169 -0
  153. package/src/messaging/remote-ports.ts +56 -0
  154. package/src/messaging/remote-process.ts +131 -0
  155. package/src/messaging/remote-replies.ts +98 -0
  156. package/src/messaging/remote-rpc-admission.ts +32 -0
  157. package/src/messaging/remote-send.ts +72 -0
  158. package/src/messaging/remote-store.ts +111 -0
  159. package/src/messaging/remote-tunnels.ts +65 -0
  160. package/src/messaging/rpc.ts +13 -1
  161. package/src/oauth/account-auto-pause-policy.ts +66 -0
  162. package/src/oauth/account-auto-pause.ts +171 -0
  163. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  164. package/src/oauth/account-import/parser.ts +15 -2
  165. package/src/oauth/account-import/types.ts +7 -0
  166. package/src/oauth/account-quota-rank.ts +2 -1
  167. package/src/oauth/anthropic-account-refusal.ts +6 -0
  168. package/src/oauth/anthropic-model-family.ts +8 -0
  169. package/src/oauth/anthropic-model-quota.ts +2 -6
  170. package/src/oauth/api-key-pool-preserve.ts +37 -0
  171. package/src/oauth/chatgpt-shared.ts +10 -1
  172. package/src/oauth/dsh.ts +45 -2
  173. package/src/oauth/index.ts +5 -35
  174. package/src/oauth/kiro.ts +44 -1
  175. package/src/oauth/login-flow-state.ts +11 -2
  176. package/src/oauth/store.ts +35 -8
  177. package/src/oauth/types.ts +2 -0
  178. package/src/plugins/upstream-hooks.ts +14 -1
  179. package/src/providers/api-key-pause.ts +32 -0
  180. package/src/providers/api-key-resolve.ts +10 -4
  181. package/src/providers/api-key-selection.ts +6 -1
  182. package/src/providers/api-keys.ts +8 -4
  183. package/src/providers/claude-cli-usage.ts +25 -5
  184. package/src/providers/initial-model-selection.ts +2 -2
  185. package/src/providers/key-failover.ts +21 -13
  186. package/src/providers/key-store.ts +8 -3
  187. package/src/providers/mistral-transport.ts +207 -0
  188. package/src/providers/model-rename-fields.ts +1 -0
  189. package/src/providers/openai-sidecar.ts +5 -0
  190. package/src/providers/opencode-zen-rate-limit.ts +6 -72
  191. package/src/providers/quota/account-cache.ts +15 -1
  192. package/src/providers/quota/anthropic-account-quota.ts +20 -7
  193. package/src/providers/quota/anthropic-cooldown-recovery.ts +22 -4
  194. package/src/providers/quota/anthropic-decision-windows.ts +47 -0
  195. package/src/providers/quota/antigravity.ts +45 -8
  196. package/src/providers/quota/failure-codes.ts +18 -0
  197. package/src/providers/quota/report-cache.ts +14 -0
  198. package/src/providers/quota/vendor-probes-oauth.ts +15 -6
  199. package/src/providers/quota-decision-publication.ts +77 -0
  200. package/src/providers/quota-decision-snapshot.ts +145 -0
  201. package/src/providers/quota-routing-cache.ts +4 -1
  202. package/src/providers/quota-types.ts +6 -1
  203. package/src/providers/quota.ts +8 -3
  204. package/src/providers/registry/entries-extended.ts +14 -53
  205. package/src/providers/registry/model-seeds.ts +1 -1
  206. package/src/providers/registry.ts +1 -2
  207. package/src/providers/routed-config.ts +1 -1
  208. package/src/providers/stale-vision-classification-migration.ts +1 -1
  209. package/src/providers/xai-transport.ts +2 -1
  210. package/src/responses/audio.ts +46 -0
  211. package/src/responses/input-media.ts +37 -15
  212. package/src/responses/parser-content.ts +22 -27
  213. package/src/responses/parser.ts +18 -10
  214. package/src/responses/reasoning-retention.ts +218 -11
  215. package/src/responses/schema.ts +3 -4
  216. package/src/responses/web-search-replay.ts +147 -0
  217. package/src/routing/compatibility/behavior.ts +4 -1
  218. package/src/server/auth-cors.ts +7 -0
  219. package/src/server/background-lifecycle.ts +2 -1
  220. package/src/server/chat-native.ts +36 -20
  221. package/src/server/claude-messages.ts +2 -1
  222. package/src/server/index/optional-listeners.ts +6 -0
  223. package/src/server/index/serve-options.ts +8 -2
  224. package/src/server/index/websocket-handler.ts +10 -0
  225. package/src/server/lifecycle.ts +17 -1
  226. package/src/server/management/combo-routes.ts +17 -2
  227. package/src/server/management/oauth-account-routes.ts +12 -2
  228. package/src/server/management/provider-completion-test.ts +289 -0
  229. package/src/server/management/provider-routes.ts +9 -1
  230. package/src/server/management/route-registry.ts +2 -0
  231. package/src/server/messages-native.ts +29 -4
  232. package/src/server/messages-shared.ts +4 -1
  233. package/src/server/request-log.ts +23 -9
  234. package/src/server/responses/account-auto-pause-recovery.ts +26 -0
  235. package/src/server/responses/adapter-continuation.ts +2 -2
  236. package/src/server/responses/adapter-dispatch.ts +79 -30
  237. package/src/server/responses/codex-ws-exchange.ts +3 -0
  238. package/src/server/responses/codex-ws-metadata.ts +44 -0
  239. package/src/server/responses/codex-ws-session.ts +21 -1
  240. package/src/server/responses/combo-session-recall.ts +25 -0
  241. package/src/server/responses/combo-stream-preflight.ts +10 -3
  242. package/src/server/responses/combo-tool-loop-pin.ts +70 -0
  243. package/src/server/responses/compact.ts +10 -5
  244. package/src/server/responses/context-overflow.ts +141 -2
  245. package/src/server/responses/core-auth.ts +20 -0
  246. package/src/server/responses/core-codex-account.ts +28 -15
  247. package/src/server/responses/core-combo.ts +34 -5
  248. package/src/server/responses/core-opaque-recovery.ts +20 -11
  249. package/src/server/responses/core.ts +12 -9
  250. package/src/server/responses/deepseek-image-files.ts +121 -0
  251. package/src/server/responses/empty-completion-guard.ts +10 -0
  252. package/src/server/responses/fetch-helpers.ts +65 -14
  253. package/src/server/responses/input-admission.ts +6 -2
  254. package/src/server/responses/jev-model-invoke.ts +13 -14
  255. package/src/server/responses/main-hard-lock-routing.ts +33 -8
  256. package/src/server/responses/native-steering-log.ts +2 -2
  257. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  258. package/src/server/responses/passthrough-completion.ts +41 -0
  259. package/src/server/responses/passthrough-delivery.ts +5 -2
  260. package/src/server/responses/passthrough-dispatch.ts +42 -45
  261. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  262. package/src/server/responses/passthrough-observations.ts +38 -0
  263. package/src/server/responses/reasoning-archive-error.ts +21 -0
  264. package/src/server/responses/request-compression.ts +98 -0
  265. package/src/server/responses/request-prepare.ts +11 -1
  266. package/src/server/responses/request-transport.ts +92 -5
  267. package/src/server/responses/run-turn-execution.ts +2 -0
  268. package/src/server/responses/sidecar-execution.ts +16 -3
  269. package/src/server/responses/terminal-guard.ts +2 -0
  270. package/src/server/startup-action-control.ts +2 -2
  271. package/src/server/startup-health-cache.ts +2 -2
  272. package/src/server/windows-tray-control.ts +2 -2
  273. package/src/service/definition.ts +9 -1
  274. package/src/service/orchestration.ts +17 -0
  275. package/src/types/account-auto-pause.ts +15 -0
  276. package/src/types/config.ts +23 -1
  277. package/src/types/provider.ts +11 -5
  278. package/src/types/request.ts +36 -3
  279. package/src/types.ts +2 -0
  280. package/src/usage/cost.ts +17 -1
  281. package/src/usage/expected-prices.ts +3 -1
  282. package/src/usage/jev-stats.ts +4 -0
  283. package/src/usage/log.ts +2 -0
  284. package/src/usage/summary.ts +7 -0
  285. package/src/web-search/loop.ts +39 -13
  286. package/src/web-search/progress-stream.ts +4 -1
  287. package/src/web-search/run-turn-loop.ts +9 -3
  288. package/src/web-search/sidecar-search-replay.ts +96 -0
  289. package/gui/dist/assets/App-CHC89EXQ.js +0 -58
  290. package/gui/dist/assets/App-wT3mXqFz.css +0 -1
  291. package/gui/dist/assets/quota-summary-D8y2wTsB.js +0 -1
@@ -4,14 +4,21 @@
4
4
  * Provider ciphertext and signatures are never included in the portable retained items.
5
5
  */
6
6
 
7
+ import { Database } from "bun:sqlite";
7
8
  import { expandRetainedCompactionInput } from "./retained-compaction";
8
- import { closeSync, mkdirSync, openSync, rmSync, writeFileSync } from "node:fs";
9
- import { join } from "node:path";
9
+ import { chmodSync, closeSync, lstatSync, mkdirSync, openSync, opendirSync, rmSync, writeFileSync } from "node:fs";
10
+ import { basename, dirname, join } from "node:path";
11
+ import { getConfigDir } from "../config/paths";
12
+ import { recordOwnedConfigPath } from "../lib/config-ownership";
13
+ import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
10
14
  import { estimateTokens } from "../lib/token-estimate";
11
15
  import { decodeReasoningEnvelope } from "./reasoning-envelope";
12
16
 
13
17
  export const DEFAULT_REASONING_RETENTION_PERCENT = 20;
14
18
  export const DEFAULT_MAX_REASONING_TOKENS = 100_000;
19
+ /** Compiled-in aggregate ceiling for the on-disk reasoning archive directory. */
20
+ export const DEFAULT_ARCHIVE_MAX_BYTES = 512 * 1024 * 1024;
21
+ export const DEFAULT_ARCHIVE_MAX_FILES = 256;
15
22
 
16
23
  export interface ReasoningRetentionOptions {
17
24
  /** Directory that receives archived reasoning files. */
@@ -24,6 +31,10 @@ export interface ReasoningRetentionOptions {
24
31
  maxTokens?: number;
25
32
  /** Clock injection for tests. */
26
33
  now?: number;
34
+ /** Aggregate archive byte ceiling. Defaults to 512 MiB; smaller values are a test seam. */
35
+ archiveMaxBytes?: number;
36
+ /** Aggregate archive file-count ceiling. Defaults to 256; smaller values are a test seam. */
37
+ archiveMaxFiles?: number;
27
38
  }
28
39
 
29
40
  export interface ReasoningArchive {
@@ -100,6 +111,183 @@ function retentionTokenCap(value: number | undefined): number {
100
111
  : DEFAULT_MAX_REASONING_TOKENS;
101
112
  }
102
113
 
114
+ /** Aggregate-capacity refusal: the archive directory is at or over its compiled-in ceiling. */
115
+ export class ReasoningArchiveQuotaError extends Error {
116
+ constructor(message: string, options?: { cause?: unknown }) {
117
+ super(message, options);
118
+ this.name = "ReasoningArchiveQuotaError";
119
+ }
120
+ }
121
+
122
+ /** The sibling archive lock is held by another cooperating writer. */
123
+ export class ReasoningArchiveBusyError extends Error {
124
+ constructor(message: string, options?: { cause?: unknown }) {
125
+ super(message, options);
126
+ this.name = "ReasoningArchiveBusyError";
127
+ }
128
+ }
129
+
130
+ /** Unsafe or uninspectable archive state; the attempt is refused without touching anything. */
131
+ export class ReasoningArchiveError extends Error {
132
+ constructor(message: string, options?: { cause?: unknown }) {
133
+ super(message, options);
134
+ this.name = "ReasoningArchiveError";
135
+ }
136
+ }
137
+
138
+ const ARCHIVE_SEPARATOR = "\n\n---\n\n";
139
+ const ARCHIVE_SEPARATOR_BYTES = Buffer.byteLength(ARCHIVE_SEPARATOR, "utf8");
140
+ const ARCHIVE_LOCK_SIDECARS = ["-journal", "-wal", "-shm"] as const;
141
+
142
+ function archiveCap(value: number | undefined, fallback: number): number {
143
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : fallback;
144
+ }
145
+
146
+ /** lstat that treats a vanished path as absent and refuses anything it cannot classify. */
147
+ function archiveLstat(path: string): ReturnType<typeof lstatSync> | undefined {
148
+ try {
149
+ return lstatSync(path);
150
+ } catch (error) {
151
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
152
+ throw new ReasoningArchiveError(`reasoning archive path is not inspectable: ${path}`, { cause: error });
153
+ }
154
+ }
155
+
156
+ function assertRegularArchiveDirectory(archiveDir: string): void {
157
+ const entry = archiveLstat(archiveDir);
158
+ if (!entry?.isDirectory() || entry.isSymbolicLink()) {
159
+ throw new ReasoningArchiveError(`reasoning archive directory is not a regular directory: ${archiveDir}`);
160
+ }
161
+ }
162
+
163
+ function assertRegularArchiveLockPath(lockPath: string): void {
164
+ const entry = archiveLstat(lockPath);
165
+ if (entry !== undefined && (!entry.isFile() || entry.isSymbolicLink())) {
166
+ throw new ReasoningArchiveError(`reasoning archive lock path is not a regular file: ${lockPath}`);
167
+ }
168
+ }
169
+
170
+ /** Exact UTF-8 byte total of the would-be archive payload, computed before any joined string exists. */
171
+ function archivePayloadBytes(texts: string[]): number {
172
+ let bytes = texts.length > 0 ? (texts.length - 1) * ARCHIVE_SEPARATOR_BYTES : 0;
173
+ for (const text of texts) {
174
+ const next = bytes + Buffer.byteLength(text, "utf8");
175
+ if (!Number.isSafeInteger(next)) {
176
+ throw new ReasoningArchiveError("reasoning archive payload byte size is not representable");
177
+ }
178
+ bytes = next;
179
+ }
180
+ return bytes;
181
+ }
182
+
183
+ interface ArchiveUsage {
184
+ files: number;
185
+ bytes: number;
186
+ }
187
+
188
+ /**
189
+ * Non-recursive accounting of every committed, remnant and foreign file. Anything that is not a
190
+ * plain regular file refuses closed; an entry vanishing mid-scan is already gone and skipped.
191
+ */
192
+ function scanArchiveUsage(archiveDir: string, maxBytes: number, maxFiles: number): ArchiveUsage {
193
+ let directory: ReturnType<typeof opendirSync> | undefined;
194
+ let files = 0;
195
+ let bytes = 0;
196
+ let inspected = 0;
197
+ let failure: ReasoningArchiveError | undefined;
198
+ try {
199
+ // One entry per read: a legacy over-capacity directory must not first become an array
200
+ // or an unbounded read-ahead buffer on this synchronous request path.
201
+ directory = opendirSync(archiveDir, { bufferSize: 1 });
202
+ let entry;
203
+ while ((entry = directory.readSync()) !== null) {
204
+ inspected += 1;
205
+ if (!entry.isFile()) {
206
+ throw new ReasoningArchiveError(`reasoning archive entry is not a regular file: ${entry.name}`);
207
+ }
208
+ const stat = archiveLstat(join(archiveDir, entry.name));
209
+ if (stat === undefined) {
210
+ if (inspected >= maxFiles) throw new ReasoningArchiveError("reasoning archive scan changed beyond its inspection limit");
211
+ continue;
212
+ }
213
+ if (!stat.isFile() || stat.isSymbolicLink()) {
214
+ throw new ReasoningArchiveError(`reasoning archive entry is not a regular file: ${entry.name}`);
215
+ }
216
+ files += 1;
217
+ bytes += Number(stat.size);
218
+ if (files >= maxFiles || bytes > maxBytes) break; // refusal is already certain
219
+ if (inspected >= maxFiles) throw new ReasoningArchiveError("reasoning archive scan changed beyond its inspection limit");
220
+ }
221
+ } catch (error) {
222
+ failure = error instanceof ReasoningArchiveError ? error
223
+ : new ReasoningArchiveError("reasoning archive directory cannot be scanned", { cause: error });
224
+ } finally {
225
+ try { directory?.closeSync(); } catch (error) {
226
+ failure ??= new ReasoningArchiveError("reasoning archive directory could not be closed", { cause: error });
227
+ }
228
+ }
229
+ if (failure !== undefined) throw failure;
230
+ return { files, bytes };
231
+ }
232
+
233
+ function isSqliteBusy(error: unknown): boolean {
234
+ const code = error && typeof error === "object" && "code" in error
235
+ ? String((error as { code?: unknown }).code)
236
+ : "";
237
+ const message = error instanceof Error ? error.message : "";
238
+ return code === "SQLITE_BUSY" || code === "SQLITE_LOCKED" || /database (?:is|table is) locked/i.test(message);
239
+ }
240
+
241
+ /**
242
+ * Serialize usage scan, exclusive create, write and close for one physical archive directory
243
+ * across cooperating processes. The sibling SQLite write transaction is the lock: the OS
244
+ * releases it on process exit, so no stale-owner reclamation is needed. busy_timeout=0 keeps
245
+ * the synchronous request path fail-fast instead of freezing the event loop.
246
+ */
247
+ function withArchiveQuotaLockSync<T>(archiveDir: string, fn: () => T): T {
248
+ const normalized = archiveDir.replace(/[/\\]+$/, "");
249
+ const base = basename(normalized);
250
+ if (!base || base === "." || base === "..") {
251
+ throw new ReasoningArchiveError(`reasoning archive directory is not a usable path: ${archiveDir}`);
252
+ }
253
+ const parent = dirname(normalized);
254
+ assertNotRealHomeUnderTest(parent);
255
+ const lockPath = `${normalized}.lock`;
256
+ // The lock lives beside the archive inside the config home; foreign parents need no manifest entry.
257
+ if (parent === getConfigDir()) {
258
+ recordOwnedConfigPath(parent, lockPath);
259
+ for (const suffix of ARCHIVE_LOCK_SIDECARS) recordOwnedConfigPath(parent, `${lockPath}${suffix}`);
260
+ }
261
+ assertRegularArchiveLockPath(lockPath);
262
+ let database: Database;
263
+ try {
264
+ database = new Database(lockPath, { create: true });
265
+ } catch (error) {
266
+ throw new ReasoningArchiveError(`reasoning archive lock could not be opened: ${lockPath}`, { cause: error });
267
+ }
268
+ try {
269
+ try { chmodSync(lockPath, 0o600); } catch { /* platform may ignore chmod */ }
270
+ assertRegularArchiveLockPath(lockPath);
271
+ database.exec("PRAGMA locking_mode = NORMAL; PRAGMA busy_timeout = 0; BEGIN IMMEDIATE");
272
+ } catch (error) {
273
+ try { database.close(); } catch { /* acquisition already failed */ }
274
+ if (error instanceof ReasoningArchiveError) throw error;
275
+ if (isSqliteBusy(error)) {
276
+ throw new ReasoningArchiveBusyError(
277
+ `reasoning archive lock is held by another writer: ${lockPath}`,
278
+ { cause: error },
279
+ );
280
+ }
281
+ throw new ReasoningArchiveError(`reasoning archive lock could not be acquired: ${lockPath}`, { cause: error });
282
+ }
283
+ try {
284
+ return fn();
285
+ } finally {
286
+ try { database.exec("ROLLBACK"); } catch { /* close below still releases the OS lock */ }
287
+ try { database.close(); } catch { /* the OS lock is released with the handle */ }
288
+ }
289
+ }
290
+
103
291
  /** Separate reasoning locally before dispatch; unknown windows enforce the absolute cap. */
104
292
  export function applyReasoningRetention(
105
293
  input: unknown,
@@ -130,17 +318,36 @@ export function applyReasoningRetention(
130
318
  content: [{ type: "input_text", text }] })),
131
319
  };
132
320
 
133
- mkdirSync(options.archiveDir, { recursive: true, mode: 0o700 });
321
+ const archiveBytes = archiveCap(options.archiveMaxBytes, DEFAULT_ARCHIVE_MAX_BYTES);
322
+ const archiveFiles = archiveCap(options.archiveMaxFiles, DEFAULT_ARCHIVE_MAX_FILES);
323
+ const incomingBytes = archivePayloadBytes(texts);
134
324
  const stamp = new Date(options.now ?? Date.now()).toISOString().replace(/[:.]/g, "-");
135
325
  const path = join(options.archiveDir, "reasoning-" + stamp + "-" + Math.random().toString(36).slice(2, 8) + ".md");
136
- // Only a successful exclusive open grants ownership; an occupied path is never removed.
137
- const fd = openSync(path, "wx", 0o600);
138
- let failure: unknown;
139
- try { writeFileSync(fd, texts.join("\n\n---\n\n"), "utf-8"); } catch (error) { failure = error; }
140
- try { closeSync(fd); } catch (error) { failure ??= error; }
141
- if (failure !== undefined) {
142
- try { rmSync(path); } catch { /* preserve the original I/O error; cleanup is best-effort */ }
143
- throw failure;
326
+ try {
327
+ mkdirSync(options.archiveDir, { recursive: true, mode: 0o700 });
328
+ assertRegularArchiveDirectory(options.archiveDir);
329
+ withArchiveQuotaLockSync(options.archiveDir, () => {
330
+ assertRegularArchiveDirectory(options.archiveDir);
331
+ const usage = scanArchiveUsage(options.archiveDir, archiveBytes, archiveFiles);
332
+ if (usage.files >= archiveFiles || incomingBytes > archiveBytes - usage.bytes) {
333
+ throw new ReasoningArchiveQuotaError(
334
+ `reasoning archive is at capacity: ${usage.files} file(s), ${usage.bytes} byte(s) under ${options.archiveDir}; delete older archives to archive more`,
335
+ );
336
+ }
337
+ // Only a successful exclusive open grants ownership; an occupied path is never removed.
338
+ const fd = openSync(path, "wx", 0o600);
339
+ let failure: unknown;
340
+ try { writeFileSync(fd, texts.join(ARCHIVE_SEPARATOR), "utf-8"); } catch (error) { failure = error; }
341
+ try { closeSync(fd); } catch (error) { failure ??= error; }
342
+ if (failure !== undefined) {
343
+ try { rmSync(path); } catch { /* preserve the original I/O error; cleanup is best-effort */ }
344
+ throw failure;
345
+ }
346
+ });
347
+ } catch (error) {
348
+ if (error instanceof ReasoningArchiveQuotaError || error instanceof ReasoningArchiveBusyError
349
+ || error instanceof ReasoningArchiveError) throw error;
350
+ throw new ReasoningArchiveError("reasoning archive could not be written", { cause: error });
144
351
  }
145
352
 
146
353
  const limitDescription = windowKnown
@@ -27,10 +27,9 @@ const inputFileBlockSchema = z.object({
27
27
  file_data: z.string().optional(),
28
28
  });
29
29
  // codex-rs protocol/src/models.rs sends audio as input_audio with an audio_url, in
30
- // both user content and tool output. Accepting the block keeps a legitimate audio turn
31
- // out of the malformed-item catch-all. The translated IR records only its PRESENCE —
32
- // there is no audio carrier and no adapter-level refusal; a typed unsupported-modality
33
- // signal reaching final adapter dispatch remains a recorded residual.
30
+ // both user content and tool output. The translated IR preserves supported inline
31
+ // WAV/MP3 bytes and the Chat adapter sends them as input_audio; other wires and
32
+ // unsupported formats are refused at the final translated-adapter boundary.
34
33
  const inputAudioBlockSchema = z.object({
35
34
  type: z.literal("input_audio"),
36
35
  audio_url: z.string().min(1),
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Parse-time rebuild of replayed hosted `web_search_call` cells for routed providers (#7269).
3
+ *
4
+ * Codex replays the cell it was shown, which carries the query and at most a source list, never the
5
+ * result text. Dropping the cell made a routed model forget it had searched: after a client tool
6
+ * call ended the turn, the next request carried no trace of the search, so the model searched and
7
+ * ran its tools again in a loop.
8
+ *
9
+ * When search is declared, the parser rewrites each cell into the `web_search` function call and
10
+ * output the routed model made, at the raw-input level, so the ordinary call/output handling owns
11
+ * turn batching and reasoning attachment. The call takes the cell's place; its output is deferred to
12
+ * the end of the contiguous run of call/output items that follows, so a cell inside a parallel
13
+ * client batch never splits it. The output is a fallback built from the cell alone; the web-search
14
+ * sidecar swaps in the result it recorded for the cell, and removes the pairs again when it does not
15
+ * run, because the synthetic tool is then undeclared (src/web-search/sidecar-search-replay.ts).
16
+ */
17
+ import { isObj } from "./parser-content";
18
+ import { WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool";
19
+ import type { OcxReplayedWebSearchCell } from "../types";
20
+
21
+ const MAX_FIELD_CHARS = 2048;
22
+ const MAX_LIST_ENTRIES = 20;
23
+ const CELL_ID = /^[A-Za-z0-9_-]{1,128}$/;
24
+
25
+ /** Call items of a batch; a call after the batch's first output starts the next assistant turn. */
26
+ const CALL_TYPES = new Set(["function_call", "custom_tool_call", "local_shell_call", "tool_search_call"]);
27
+ /** Output items of a batch. Anything that is neither a call nor an output ends the batch. */
28
+ const OUTPUT_TYPES = new Set(["function_call_output", "custom_tool_call_output", "local_shell_call_output", "tool_search_output"]);
29
+
30
+ function capped(value: string): string {
31
+ return value.length <= MAX_FIELD_CHARS ? value : `${value.slice(0, MAX_FIELD_CHARS)}…`;
32
+ }
33
+
34
+ function queriesOf(action: Record<string, unknown>): string[] {
35
+ const queries = Array.isArray(action.queries)
36
+ ? action.queries.filter((query): query is string => typeof query === "string" && query.trim() !== "")
37
+ : [];
38
+ if (queries.length === 0 && typeof action.query === "string" && action.query.trim() !== "") queries.push(action.query);
39
+ return queries.slice(0, MAX_LIST_ENTRIES).map(capped);
40
+ }
41
+
42
+ function sourcesOf(item: Record<string, unknown>, action: Record<string, unknown>): string[] {
43
+ const lines: string[] = [];
44
+ const seen = new Set<string>();
45
+ for (const list of [item.sources, action.sources]) {
46
+ if (!Array.isArray(list)) continue;
47
+ for (const source of list) {
48
+ if (lines.length >= MAX_LIST_ENTRIES) return lines;
49
+ if (!isObj(source) || typeof source.url !== "string" || seen.has(source.url)) continue;
50
+ seen.add(source.url);
51
+ lines.push(typeof source.title === "string" && source.title.trim() !== ""
52
+ ? `- ${capped(source.title)} <${capped(source.url)}>`
53
+ : `- <${capped(source.url)}>`);
54
+ }
55
+ }
56
+ return lines;
57
+ }
58
+
59
+ /** The call arguments and fallback result text for one replayed cell. */
60
+ export function replayedWebSearchContent(item: Record<string, unknown>): { arguments: Record<string, unknown>; result: string; failed: boolean } {
61
+ const action = isObj(item.action) ? item.action : {};
62
+ const failed = item.status === "failed";
63
+ const queries = queriesOf(action);
64
+ const lines: string[] = [];
65
+ if (action.type === "open_page" && typeof action.url === "string") {
66
+ lines.push(`Earlier page open: <${capped(action.url)}>.`);
67
+ } else if (action.type === "find_in_page" && typeof action.url === "string") {
68
+ lines.push(`Earlier find in page <${capped(action.url)}>${typeof action.pattern === "string" ? ` for "${capped(action.pattern)}"` : ""}.`);
69
+ } else {
70
+ lines.push(queries.length > 0 ? `Earlier web search for: ${queries.map(query => `"${query}"`).join(", ")}.` : "Earlier web search.");
71
+ }
72
+ if (failed) {
73
+ lines.push("This search failed and returned no results.");
74
+ } else {
75
+ lines.push("The result text of this search is not available in the replayed conversation history.");
76
+ const sources = sourcesOf(item, action);
77
+ if (sources.length > 0) lines.push("Sources it returned (titles and URLs only, untrusted):", ...sources);
78
+ }
79
+ return { arguments: queries.length > 1 ? { queries } : { query: queries[0] ?? "" }, result: lines.join("\n"), failed };
80
+ }
81
+
82
+ export interface RewrittenWebSearchInput {
83
+ items: unknown[];
84
+ /** Original input index per rewritten item, for boundaries measured on the caller's input. */
85
+ sourceIndexes: number[];
86
+ cells: OcxReplayedWebSearchCell[];
87
+ }
88
+
89
+ /** Rewrite replayed cells into web_search call/output pairs; undefined when the input has none. */
90
+ export function rewriteReplayedWebSearchCells(input: readonly unknown[]): RewrittenWebSearchInput | undefined {
91
+ if (!input.some(item => isObj(item) && item.type === "web_search_call")) return undefined;
92
+ // A synthetic call id must not collide with any client call id, nor with another cell's.
93
+ const taken = new Set(input.flatMap(item => isObj(item) && typeof item.call_id === "string" ? [item.call_id] : []));
94
+ // First suffix each base has not settled yet; taken stays the collision check on every candidate,
95
+ // so seeded ids and ids handed out under another base still block the smallest free suffix.
96
+ const nextSuffix = new Map<string, number>();
97
+ const allocate = (base: string): string => {
98
+ if (!taken.has(base)) {
99
+ taken.add(base);
100
+ return base;
101
+ }
102
+ let n = nextSuffix.get(base) ?? 1;
103
+ let id = `${base}_${n}`;
104
+ while (taken.has(id)) id = `${base}_${++n}`;
105
+ nextSuffix.set(base, n + 1);
106
+ taken.add(id);
107
+ return id;
108
+ };
109
+ const items: unknown[] = [];
110
+ const sourceIndexes: number[] = [];
111
+ const cells: OcxReplayedWebSearchCell[] = [];
112
+ const seenCells = new Set<string>();
113
+ let pending: Array<{ item: unknown; sourceIndex: number }> = [];
114
+ // True once the current batch reached its outputs: the next call opens a new assistant turn.
115
+ let outputPhase = false;
116
+ const flush = () => {
117
+ for (const entry of pending) { items.push(entry.item); sourceIndexes.push(entry.sourceIndex); }
118
+ pending = [];
119
+ outputPhase = false;
120
+ };
121
+ input.forEach((item, sourceIndex) => {
122
+ const type = isObj(item) ? item.type : undefined;
123
+ if (isObj(item) && type === "web_search_call") {
124
+ const cellId = typeof item.id === "string" && CELL_ID.test(item.id) ? item.id : undefined;
125
+ // The same cell replayed twice is one search.
126
+ if (cellId && seenCells.has(cellId)) return;
127
+ if (cellId) seenCells.add(cellId);
128
+ if (outputPhase) flush();
129
+ const content = replayedWebSearchContent(item);
130
+ const callId = allocate(cellId ?? "ws_replay");
131
+ cells.push({ ...(cellId ? { cellId } : {}), callId, failed: content.failed });
132
+ items.push({ type: "function_call", call_id: callId, name: WEB_SEARCH_TOOL_NAME, arguments: JSON.stringify(content.arguments) });
133
+ sourceIndexes.push(sourceIndex);
134
+ pending.push({ item: { type: "function_call_output", call_id: callId, output: content.result }, sourceIndex });
135
+ return;
136
+ }
137
+ if (typeof type === "string" && OUTPUT_TYPES.has(type)) {
138
+ outputPhase = true;
139
+ } else if (!(typeof type === "string" && CALL_TYPES.has(type)) || outputPhase) {
140
+ flush();
141
+ }
142
+ items.push(item);
143
+ sourceIndexes.push(sourceIndex);
144
+ });
145
+ flush();
146
+ return { items, sourceIndexes, cells };
147
+ }
@@ -4,6 +4,7 @@ import type { OcxConfig, OcxProviderConfig } from "../../types";
4
4
  import { PROVIDER_REGISTRY } from "../../providers/registry";
5
5
  import { fastPolicyForModel, serviceTierSupportFromPolicy } from "../../providers/service-tier";
6
6
  import { resolveProviderAuthTransport } from "../../providers/fastwire";
7
+ import { defaultAnthropicCacheRetention } from "../../adapters/anthropic/cache-retention";
7
8
  import { localFingerprint } from "../../lab/digest";
8
9
  import type { LabBehaviorSource, LabBehaviorValues } from "../../lab/live/types";
9
10
 
@@ -237,7 +238,9 @@ export function resolveProductionBehaviorValues(
237
238
  }),
238
239
  "tools.builtinNameEscaping": behaviorRow("provider_config", effective.escapeBuiltinToolNames === true),
239
240
  "cache.forwarding": behaviorRow("provider_config", effective.promptCacheKey === true),
240
- "cache.retention": behaviorRow("global_config", config.cacheRetention ?? "short"),
241
+ // The wire TTL, not just the setting: an omitted value means 1h on first-party Anthropic.
242
+ "cache.retention": behaviorRow("global_config", config.cacheRetention
243
+ ?? (adapter === "anthropic" ? defaultAnthropicCacheRetention(effective) : "short")),
241
244
  "anthropic.eofPolicy": behaviorRow("provider_config", effective.anthropicEofTolerance === true ? "tolerant" : "strict"),
242
245
  "openai-chat.eofPolicy": behaviorRow("provider_config", effective.openaiChatEofTolerance === true ? "tolerant" : "strict"),
243
246
  "google.mode": behaviorRow("provider_config", effective.googleMode ?? null),
@@ -40,6 +40,7 @@ import { DECLARABLE_HOSTED_TOOL_TYPES } from "../responses/hosted-tool-policy";
40
40
  import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry";
41
41
  import { providerConfigSeed } from "../providers/derive";
42
42
  import { anthropicOAuthInstanceConfigError } from "../config/schema/anthropic-account-pool";
43
+ import { accountAutoPausePolicySchema } from "../config/schema/account-auto-pause";
43
44
  import type { OcxConfig, OcxProviderConfig } from "../types";
44
45
  import { openRouterRoutingConfigError } from "../providers/openrouter-routing";
45
46
  import { modelAutoCompactTokenLimitsConfigError } from "../providers/auto-compact-budget";
@@ -740,6 +741,9 @@ export function providerManagementConfigError(
740
741
  return "provider must be a plain object";
741
742
  }
742
743
  const raw = provider as Record<string, unknown>;
744
+ if (raw.accountAutoPause !== undefined && !accountAutoPausePolicySchema.safeParse(raw.accountAutoPause).success) {
745
+ return "accountAutoPause must be an object with optional boolean enabled and integer consecutiveFailures (1-100)";
746
+ }
743
747
  const contextTiersError = contextTierRecordConfigError(raw.modelContextTiers);
744
748
  if (contextTiersError) return contextTiersError;
745
749
  const capabilitiesError = modelCapabilitiesConfigError(raw.modelCapabilities);
@@ -796,6 +800,8 @@ export function providerManagementConfigError(
796
800
  // sends recover, not what the canonical forward seed is. Validated below
797
801
  // (retryOnResetPolicyConfigError).
798
802
  delete canonicalCandidate.retryOnReset;
803
+ // Validated account eligibility policy is independent of the canonical transport.
804
+ delete canonicalCandidate.accountAutoPause;
799
805
  // Context windows are the same kind of user-owned overlay as requestPacing: the operator
800
806
  // narrowing what their own native rows advertise. They can only ever LOWER the measured
801
807
  // window (see nativeOpenAiContextWindow), so admitting them cannot widen what the proxy
@@ -1087,6 +1093,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
1087
1093
  apiKeyPool: "redacted",
1088
1094
  // Ordering preference only; it names no key material, so an editor may read and set it.
1089
1095
  apiKeyPoolStrategy: "editor",
1096
+ accountAutoPause: "editor",
1090
1097
  apiKeySelectionRevision: "runtime",
1091
1098
  _apiKeyAttempt: "runtime",
1092
1099
  defaultModel: "editor",
@@ -1,5 +1,6 @@
1
1
  import type { OcxConfig, StorageCleanupPolicy } from "../types";
2
2
  import { registerCodexLowQuotaProtection, type LowQuotaRegistration } from "../codex/low-quota-protection";
3
+ import { resolveLowQuotaDesktopSink } from "../codex/low-quota-desktop-notifier";
3
4
  import type { LowQuotaEvent } from "../codex/low-quota-events";
4
5
  import { startStateStoreSweeper } from "../lib/state-store-sweeper";
5
6
  import {
@@ -209,7 +210,7 @@ export function acquireServerBackgroundLifecycle(
209
210
  fanoutRevoked: false,
210
211
  applyPolicy,
211
212
  resources: acquireServerResourceOwner(),
212
- lowQuota: registerCodexLowQuotaProtection(config),
213
+ lowQuota: registerCodexLowQuotaProtection(config, { desktopSink: () => resolveLowQuotaDesktopSink() }),
213
214
  };
214
215
  try {
215
216
  if (!processLoops) {
@@ -1,3 +1,4 @@
1
+ import { captureAccountAutoPauseAttempt, wasAccountAutoPaused } from "../oauth/account-auto-pause";
1
2
  import { createPhysicalSendReporter } from "../lib/request-execution-budget";
2
3
  import { createInferenceSendBudget } from "./inference/context";
3
4
  import { observeAdvisorNativeReasoning } from "./inference/advisor-diagnostics";
@@ -30,7 +31,7 @@ import {
30
31
  applyUpstreamRecoveryInit,
31
32
  fetchWithResetRetry,
32
33
  fetchWithTransientRetry,
33
- isNonReplayableResponse,
34
+ isNonReplayableResponse, isSameTargetRetryHold,
34
35
  isReplayRefusalCode,
35
36
  isReplayRefusalResponse,
36
37
  prepareSameTarget429Wait,
@@ -44,10 +45,7 @@ import {
44
45
  UPSTREAM_RESET_REPLAY_REFUSED_CODE,
45
46
  wrapWithZeroOutputRefetch,
46
47
  } from "../lib/upstream-retry";
47
- import {
48
- isTranslatorBudgetExceededError,
49
- type TranslatorBudget,
50
- } from "../lib/translator-budget";
48
+ import { isTranslatorBudgetExceededError, type TranslatorBudget } from "../lib/translator-budget";
51
49
  import { authorizeResendForRecovery } from "../lib/request-resend-gate";
52
50
  import { ambiguousResendAllowanceFor, selfContainedChatBody } from "./responses/reset-replay";
53
51
  import {
@@ -64,11 +62,10 @@ import { RequestPacingQueueOverloadError } from "../providers/request-pacing";
64
62
  import { stampApiKeyAccountLabel } from "../providers/label";
65
63
  import { distributedKeyPhysicalFetch, distributedKeyResponseOwner } from "../providers/api-key-response-ownership";
66
64
  import { providerApiKeySelectionIsCurrent, resolveCurrentProviderApiKeyTransport } from "../providers/api-key-selection";
67
- import { enrichOpenCodeZenFreeTierMessage } from "../providers/opencode-zen-rate-limit";
68
65
  import type { OcxProviderTransport } from "../providers/xai-transport";
69
66
  import type { RouteResult } from "../router";
70
67
  import type { OcxConfig, OcxProviderConfig } from "../types";
71
- import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, sendWithConnectionPolicy } from "./responses/fetch-helpers";
68
+ import { fetchWithHeaderTimeout, physicalSendObserverInit, providerFetch, safeHostLabel, sendWithConnectionPolicy } from "./responses/fetch-helpers";
72
69
  import { isLocalUpstream } from "../lib/local-upstream";
73
70
  import { linkAbortSignal } from "./responses";
74
71
  import {
@@ -88,6 +85,7 @@ import { workflowRefusalResponse } from "./workflow-refusal";
88
85
  import type { ComboProtocolSource } from "./responses/core-combo-native";
89
86
  import type { ProtocolEnvelope } from "../protocols/envelope";
90
87
  import { nativeChatReplayScope, bindNativeChatReplayScope, nativeChatReasoningCapture } from "./chat-native-reasoning";
88
+ import { resolveMistralTransport } from "../providers/mistral-transport";
91
89
 
92
90
  export { isNativeChatRouteEligible, nativeChatDeclineReason } from "./chat-native-eligibility";
93
91
 
@@ -335,7 +333,8 @@ export async function runNativeChatAttempt(
335
333
  refusal.headers.set("Retry-After", String(error.retryAfterSeconds));
336
334
  return refusal;
337
335
  }
338
- let activeProvider: OcxProviderConfig = route.provider;
336
+ // Key selection re-resolves the transport; a single-key route still needs canonical Mistral pacing.
337
+ let activeProvider: OcxProviderConfig = resolveMistralTransport(route.provider);
339
338
  stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider);
340
339
  const spendTracker = sendBudget ? undefined : attachRequestSpendTracker(req, logCtx);
341
340
  const physicalBudget = sendBudget ?? createInferenceSendBudget(req, logCtx);
@@ -365,6 +364,8 @@ export async function runNativeChatAttempt(
365
364
  config.fastMode,
366
365
  route.providerName,
367
366
  replayScope,
367
+ undefined,
368
+ translatorBudget,
368
369
  );
369
370
  observeAdvisorNativeReasoning(req.headers, logCtx, request.body);
370
371
  return request;
@@ -449,7 +450,7 @@ export async function runNativeChatAttempt(
449
450
  let responseOwnsDistributedKey = true;
450
451
  const send = async (
451
452
  request: AdapterRequest,
452
- recovery?: "rate-limit-429" | "key-429" | "key-401" | UpstreamSendRecovery,
453
+ recovery?: "rate-limit-429" | "key-401" | "key-429" | UpstreamSendRecovery,
453
454
  singleShot = false,
454
455
  ): Promise<Response> => {
455
456
  try {
@@ -516,6 +517,7 @@ export async function runNativeChatAttempt(
516
517
  const spendReport = createPhysicalSendReporter(physicalBudget, () => ({
517
518
  poolId: logCtx.spendPoolId ?? route.providerName, identityId: logCtx.accountLogLabel,
518
519
  }), execution.comboDispatchPermit);
520
+ const observeAutoPause = captureAccountAutoPauseAttempt(route.providerName, activeProvider, { config, oauth: undefined }, headers);
519
521
  let dispatched: Response;
520
522
  try {
521
523
  // Capture this request's spend policy at its first physical boundary, including
@@ -538,6 +540,7 @@ export async function runNativeChatAttempt(
538
540
  request.url,
539
541
  applyUpstreamRecoveryInit({
540
542
  ...init, method: request.method, headers, body: request.body,
543
+ ...physicalSendObserverInit(observeAutoPause, request.url),
541
544
  }, transportRecovery),
542
545
  // Reselection can replace the provider transport and the wire shape, so the
543
546
  // egress route is bound to the provider this send actually uses. Omitting it
@@ -552,7 +555,7 @@ export async function runNativeChatAttempt(
552
555
  responseOwnsDistributedKey = !activeProvider._apiKeyAttempt?.distributed
553
556
  || distributedKeyResponseOwner(dispatched, config, route.providerName) !== null;
554
557
  if (!dispatched.ok) await recordKeyAttemptFailure(logCtx, dispatched, init.signal ?? upstream.signal);
555
- return dispatched;
558
+ return observeAutoPause ? observeAutoPause(dispatched) : dispatched;
556
559
  },
557
560
  }),
558
561
  );
@@ -585,13 +588,30 @@ export async function runNativeChatAttempt(
585
588
  response = await send(activeRequest);
586
589
  const retryPolicy = rateLimitRetryPolicyFor(activeProvider);
587
590
  let retries = 0;
591
+ let autoPauseMoves = 0;
592
+ for (;;) {
593
+ const priorResponse = response;
594
+ for (; wasAccountAutoPaused(response) && config.providers[route.providerName]?.apiKey && autoPauseMoves < 3 && transientSendAvailable(); autoPauseMoves++) {
595
+ const fresh = resolveCurrentProviderApiKeyTransport(config, route.providerName, { ...activeProvider, _apiKeyAttempt: undefined });
596
+ const next = fresh && (selectProactiveApiKeyTransport(config, route.providerName, fresh) ?? fresh);
597
+ if (!next) break;
598
+ try { void response.body?.cancel().catch(() => {}); } catch { /* closed */ }
599
+ activeProvider = next;
600
+ stampApiKeyAccountLabel(logCtx, route.providerName, activeProvider);
601
+ activeAdapter = createOpenAIChatAdapter(activeProvider);
602
+ releaseRetainedRequest();
603
+ activeRequest = buildActiveRequest();
604
+ retainRequest(activeRequest);
605
+ response = await send(activeRequest, "key-401");
606
+ }
607
+
588
608
  while (
589
609
  response.status === 429
590
610
  // A 429 this proxy synthesized for a refused reset replay is not a provider rate
591
611
  // limit: waiting and re-sending here is exactly the duplicate inference the refusal
592
612
  // exists to stop. It kept the same shape under the old 502 only because 502 never
593
613
  // matched this branch.
594
- && !isNonReplayableResponse(response)
614
+ && !isNonReplayableResponse(response) && !isSameTargetRetryHold(response)
595
615
  && retryPolicy
596
616
  && retries < retryPolicy.attempts
597
617
  && transientSendAvailable()
@@ -632,6 +652,10 @@ export async function runNativeChatAttempt(
632
652
  retainRequest(activeRequest);
633
653
  response = await send(activeRequest, failureStatus === 401 ? "key-401" : "key-429");
634
654
  }
655
+ if (response !== priorResponse && wasAccountAutoPaused(response)) continue;
656
+ break;
657
+ }
658
+
635
659
  } catch (error) {
636
660
  releaseRetainedRequest();
637
661
  cleanupAbort();
@@ -694,20 +718,12 @@ export async function runNativeChatAttempt(
694
718
  && (isCyberPolicyCode(upstreamCode) || isCyberPolicyMessage(upstreamMessage))
695
719
  ? upstreamMessage
696
720
  : detail ? `Provider error ${response.status}: ${detail}` : `Provider error ${response.status}`;
697
- // Zen's keyless free tier refuses the request outright rather than rate-limiting it, and
698
- // the raw `MissingSessionID` tells a user nothing about why or what to do (#4121).
699
- const clientMessage = enrichOpenCodeZenFreeTierMessage(message, {
700
- providerName: route.providerName,
701
- baseUrl: route.provider.baseUrl,
702
- adapter: route.provider.adapter,
703
- upstreamErrorType: upstreamType,
704
- });
705
721
  const classified = classifyError(
706
722
  response.status,
707
723
  upstreamType ?? (response.status === 401 ? "authentication_error"
708
724
  : response.status === 429 ? "rate_limit_error"
709
725
  : response.status >= 500 ? "server_error" : "invalid_request_error"),
710
- clientMessage,
726
+ message,
711
727
  );
712
728
  // The verdict is read once, from the response that carries it and from the code a
713
729
  // re-wrapped body kept -- never from the status, which a real rate limit shares.