@yansigit/opencodex 2.32.0 → 2.33.1

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 (142) hide show
  1. package/README.md +2 -2
  2. package/gui/dist/assets/index-CIDo4y4k.js +102 -0
  3. package/gui/dist/assets/index-DrSQdTRd.css +1 -0
  4. package/gui/dist/index.html +2 -2
  5. package/package.json +8 -5
  6. package/src/adapters/anthropic.ts +20 -6
  7. package/src/adapters/azure.ts +20 -4
  8. package/src/adapters/base.ts +3 -1
  9. package/src/adapters/command-code.ts +131 -17
  10. package/src/adapters/cursor/envelope-echo.ts +162 -0
  11. package/src/adapters/cursor/live-transport.ts +4 -2
  12. package/src/adapters/cursor/native-exec-fs.ts +13 -12
  13. package/src/adapters/cursor/native-exec-network.ts +3 -5
  14. package/src/adapters/cursor/native-exec-policy.ts +47 -0
  15. package/src/adapters/cursor/native-exec-shell.ts +13 -25
  16. package/src/adapters/cursor/native-exec.ts +18 -10
  17. package/src/adapters/cursor/protobuf-events.ts +186 -9
  18. package/src/adapters/cursor/protobuf-request.ts +53 -18
  19. package/src/adapters/cursor/request-builder.ts +11 -3
  20. package/src/adapters/cursor/tool-definitions.ts +49 -2
  21. package/src/adapters/cursor/tool-result-normalize.ts +21 -8
  22. package/src/adapters/cursor/types.ts +11 -3
  23. package/src/adapters/cursor.ts +123 -0
  24. package/src/adapters/google-aistudio-parser.ts +49 -0
  25. package/src/adapters/google-antigravity-replay.ts +2 -2
  26. package/src/adapters/google-antigravity-wire.ts +7 -0
  27. package/src/adapters/google-errors.ts +6 -2
  28. package/src/adapters/google-http.ts +30 -7
  29. package/src/adapters/google-truncation.ts +5 -0
  30. package/src/adapters/google-wire-compiler.ts +38 -6
  31. package/src/adapters/google.ts +242 -28
  32. package/src/adapters/kiro-tools.ts +20 -9
  33. package/src/adapters/openai-chat.ts +9 -0
  34. package/src/adapters/openai-responses.ts +2 -1
  35. package/src/bridge.ts +112 -9
  36. package/src/chat/inbound.ts +15 -0
  37. package/src/claude/context-windows.ts +16 -9
  38. package/src/cli/doctor.ts +2 -2
  39. package/src/cli/index.ts +10 -3
  40. package/src/cli/provider.ts +6 -0
  41. package/src/cli/status.ts +23 -0
  42. package/src/codex/auth-api.ts +4 -2
  43. package/src/codex/autostart-health.ts +16 -0
  44. package/src/codex/catalog/aggregation.ts +12 -12
  45. package/src/codex/catalog/effort.ts +18 -3
  46. package/src/codex/catalog/metadata.ts +27 -1
  47. package/src/codex/catalog/model-metadata.ts +39 -12
  48. package/src/codex/catalog/parsing.ts +38 -27
  49. package/src/codex/catalog/provider-fetch.ts +232 -133
  50. package/src/codex/catalog/sync.ts +1 -1
  51. package/src/codex/convergence.ts +5 -0
  52. package/src/codex/shim.ts +56 -3
  53. package/src/config/provider-validation.ts +37 -0
  54. package/src/config.ts +78 -2
  55. package/src/generated/compatibility-version.json +187 -119
  56. package/src/generated/model-metadata.ts +3 -0
  57. package/src/images/loop.ts +37 -6
  58. package/src/lib/azure-identity.ts +154 -0
  59. package/src/lib/debug.ts +42 -0
  60. package/src/lib/errors.ts +14 -0
  61. package/src/lib/provider-outbound.ts +45 -33
  62. package/src/lib/provider-tls-profile.ts +309 -0
  63. package/src/lib/proxy-env.ts +49 -0
  64. package/src/lib/redact.ts +10 -1
  65. package/src/oauth/aistudio-native-daemon.ts +62 -0
  66. package/src/oauth/aistudio-session-sync.ts +95 -0
  67. package/src/oauth/antigravity-routing.ts +282 -236
  68. package/src/oauth/callback-server.ts +22 -2
  69. package/src/oauth/command-code.ts +5 -16
  70. package/src/oauth/google-aistudio-auth.ts +98 -0
  71. package/src/oauth/google-antigravity.ts +42 -5
  72. package/src/oauth/index.ts +15 -3
  73. package/src/oauth/key-providers.ts +8 -0
  74. package/src/oauth/kimi.ts +9 -1
  75. package/src/oauth/login-cli.ts +66 -1
  76. package/src/oauth/open-browser-choice.ts +26 -0
  77. package/src/oauth/store.ts +6 -0
  78. package/src/providers/antigravity-quota.ts +3 -1
  79. package/src/providers/api-keys.ts +2 -1
  80. package/src/providers/auto-compact-budget.ts +65 -0
  81. package/src/providers/derive.ts +5 -1
  82. package/src/providers/key-failover.ts +5 -1
  83. package/src/providers/openai-tiers.ts +5 -0
  84. package/src/providers/provider-id-rewrite.ts +1 -0
  85. package/src/providers/quota.ts +148 -50
  86. package/src/providers/registry.ts +27 -4
  87. package/src/providers/request-pacing.ts +33 -6
  88. package/src/providers/xai-transport.ts +21 -0
  89. package/src/responses/google-provider-options.ts +36 -0
  90. package/src/responses/namespace-tool-compat.ts +84 -4
  91. package/src/responses/parser.ts +11 -0
  92. package/src/responses/provider-opaque-metadata.ts +3 -3
  93. package/src/responses/schema.ts +37 -0
  94. package/src/responses/state.ts +94 -4
  95. package/src/router.ts +11 -2
  96. package/src/routing/account-pool/cooldown.ts +8 -0
  97. package/src/routing/account-pool/index.ts +1 -0
  98. package/src/server/aistudio-ws-hub.ts +295 -0
  99. package/src/server/auth-cors.ts +29 -0
  100. package/src/server/chat-completions.ts +2 -0
  101. package/src/server/images.ts +19 -35
  102. package/src/server/index.ts +94 -0
  103. package/src/server/management/agent-settings-routes.ts +205 -15
  104. package/src/server/management/combo-routes.ts +6 -0
  105. package/src/server/management/config-routes.ts +31 -5
  106. package/src/server/management/logs-usage-routes.ts +11 -5
  107. package/src/server/management/model-rows.ts +4 -0
  108. package/src/server/management/oauth-account-routes.ts +38 -7
  109. package/src/server/management/provider-routes.ts +113 -15
  110. package/src/server/management/routing-profile-routes.ts +3 -0
  111. package/src/server/port-reclaim.ts +19 -1
  112. package/src/server/request-log-conversation.ts +12 -0
  113. package/src/server/request-log.ts +23 -1
  114. package/src/server/responses/agent-task-recovery.ts +1 -1
  115. package/src/server/responses/compact.ts +30 -1
  116. package/src/server/responses/core.ts +363 -156
  117. package/src/server/responses/empty-completion-guard.ts +35 -6
  118. package/src/server/responses/fetch-helpers.ts +18 -5
  119. package/src/server/responses/policy-fallback.ts +1 -1
  120. package/src/server/responses/v2-native-parent-override.ts +59 -0
  121. package/src/server/responses/ws-upstream.ts +75 -2
  122. package/src/server/responses-undeclared-tool-guard.ts +90 -8
  123. package/src/server/ws-bridge.ts +2 -1
  124. package/src/service.ts +1 -1
  125. package/src/smoke/fingerprint-cache.ts +133 -0
  126. package/src/smoke/live-scenarios.ts +33 -0
  127. package/src/smoke/runner.ts +119 -0
  128. package/src/types/config.ts +16 -1
  129. package/src/types/provider.ts +18 -1
  130. package/src/types/request.ts +30 -0
  131. package/src/types/tools.ts +51 -0
  132. package/src/types.ts +6 -0
  133. package/src/usage/command-code-manifest.ts +116 -0
  134. package/src/usage/cost.ts +2 -2
  135. package/src/usage/expected-prices.ts +83 -0
  136. package/src/usage/log.ts +2 -2
  137. package/src/usage/summary.ts +34 -12
  138. package/src/web-search/gemini-executor.ts +6 -4
  139. package/src/web-search/index.ts +16 -8
  140. package/src/web-search/loop.ts +42 -6
  141. package/gui/dist/assets/index-BG43zwVe.js +0 -102
  142. package/gui/dist/assets/index-CiSI-jrP.css +0 -1
@@ -4,6 +4,14 @@ import { collectResponsesToolGroups } from "./tool-groups";
4
4
  export interface RoutedNamespaceToolIdentity {
5
5
  namespace: string;
6
6
  name: string;
7
+ /**
8
+ * The kind the tool was DECLARED as. Restoration and `tool_choice` matching both
9
+ * need it: a wire name identifies which tool, not which kind of call may carry it,
10
+ * so without this a tool declared `function` could be selected by a `custom`
11
+ * selector and come back as a `custom_tool_call` — the same name/kind mismatch
12
+ * that motivated narrowing the alias map in the first place.
13
+ */
14
+ kind: "function" | "custom";
7
15
  }
8
16
 
9
17
  export type RoutedNamespaceToolAliases = ReadonlyMap<string, RoutedNamespaceToolIdentity>;
@@ -138,7 +146,10 @@ function buildRewritePlan(groups: readonly unknown[][]): NamespaceRewritePlan {
138
146
  addSelector(selectors, `${parsed.namespace}.${childName}`, wireName);
139
147
  addSelector(selectors, childName, wireName);
140
148
  if (parsed.namespace !== BUILTIN_FUNCTIONS_NAMESPACE) {
141
- aliases.set(wireName, { namespace: parsed.namespace, name: childName });
149
+ // A child declared `custom` stays custom; everything else lowers to a
150
+ // function, which is how `buildTools` flattens it upstream.
151
+ const kind = child.type === "custom" ? "custom" : "function";
152
+ aliases.set(wireName, { namespace: parsed.namespace, name: childName, kind });
142
153
  }
143
154
  }
144
155
  }
@@ -206,18 +217,34 @@ function rewriteToolList(
206
217
  * the failure this layer exists to prevent, and this layer's own response restoration is what put
207
218
  * the key on the item.
208
219
  */
220
+ /**
221
+ * A selector's `namespace` is either absent — meaning "unqualified, resolve the bare
222
+ * name" — or a string naming the group. A present-but-non-string value is neither, and
223
+ * a malformed selector must not authorize anything: not through the unqualified
224
+ * fallback, and not by happening to carry an already-flattened wire name, which would
225
+ * otherwise match the alias map exactly and arm it anyway.
226
+ */
227
+ function hasMalformedNamespace(value: Record<string, unknown>): boolean {
228
+ return "namespace" in value && typeof value.namespace !== "string";
229
+ }
230
+
209
231
  function rewriteNamedSelector(
210
232
  value: unknown,
211
233
  plan: NamespaceRewritePlan,
212
234
  bareFallback: boolean,
213
235
  ): unknown {
214
236
  if (!isPlainObject(value) || typeof value.name !== "string") return value;
215
- if (typeof value.namespace !== "string") {
237
+ // Malformed: hand it back untouched so no rewrite occurs. Authorization rejects it
238
+ // separately — returning it unchanged is not by itself enough, because the name it
239
+ // carries may already BE a wire name.
240
+ if (hasMalformedNamespace(value)) return value;
241
+ const namespace = value.namespace;
242
+ if (typeof namespace !== "string") {
216
243
  if (!bareFallback) return value;
217
244
  const wireName = plan.selectors.get(value.name) ?? undefined;
218
245
  return wireName === undefined || wireName === value.name ? value : { ...value, name: wireName };
219
246
  }
220
- const { namespace, ...rest } = value;
247
+ const { namespace: _dropped, ...rest } = value;
221
248
  const wireName = plan.identities.get(loweredIdentity(namespace, value.name))
222
249
  ?? loweredWireName(namespace, value.name);
223
250
  return { ...rest, name: wireName };
@@ -239,6 +266,59 @@ function rewriteToolChoice(value: unknown, plan: NamespaceRewritePlan): unknown
239
266
  return changed ? { ...value, tools } : value;
240
267
  }
241
268
 
269
+ /**
270
+ * Keep response restoration inside the caller's per-turn tool authorization boundary. The
271
+ * upstream sees every flattened declaration even when `tool_choice` narrows the tools it may call,
272
+ * so its output cannot be trusted merely because a wire name appeared in that catalog.
273
+ */
274
+ function authorizedAliases(
275
+ aliases: Map<string, RoutedNamespaceToolIdentity>,
276
+ toolChoice: unknown,
277
+ ): Map<string, RoutedNamespaceToolIdentity> {
278
+ if (toolChoice === undefined || toolChoice === "auto" || toolChoice === "required") return aliases;
279
+ if (toolChoice === "none" || !isPlainObject(toolChoice)) return new Map();
280
+
281
+ // name -> the kind the selector claimed. A selector authorizes a tool only when it
282
+ // names it AND agrees about what kind of tool it is.
283
+ let authorized: Map<string, "function" | "custom">;
284
+ if (
285
+ (toolChoice.type === "function" || toolChoice.type === "custom")
286
+ && typeof toolChoice.name === "string"
287
+ ) {
288
+ // A malformed namespace makes the whole selector untrustworthy, even when its
289
+ // name is already a flattened wire name that would match the alias map exactly.
290
+ if (hasMalformedNamespace(toolChoice)) return new Map();
291
+ authorized = new Map([[toolChoice.name, toolChoice.type]]);
292
+ } else if (toolChoice.type === "allowed_tools" && Array.isArray(toolChoice.tools)) {
293
+ authorized = new Map();
294
+ for (const tool of toolChoice.tools) {
295
+ // Match the top-level branch above: only a function/custom selector can
296
+ // authorize a client namespace call. `allowed_tools` entries are typed
297
+ // `{type: string}` by the schema, so the accepted set is open-ended and
298
+ // an allowlist is the only closure that also covers kinds added later.
299
+ // Without this, `{type: "file_search", name: "<wire-name>"}` keeps the
300
+ // alias, and an upstream `function_call` carrying that name is restored
301
+ // into a namespace call the caller never permitted.
302
+ if (!isPlainObject(tool)) continue;
303
+ if (tool.type !== "function" && tool.type !== "custom") continue;
304
+ if (typeof tool.name !== "string") continue;
305
+ if (hasMalformedNamespace(tool)) continue;
306
+ authorized.set(tool.name, tool.type);
307
+ }
308
+ } else {
309
+ // An explicit selector for another tool kind does not authorize a client namespace call.
310
+ return new Map();
311
+ }
312
+
313
+ // The kind must agree too. A wire name says WHICH tool, not what kind of call may
314
+ // carry it, so a `custom` selector naming a tool declared `function` is the same
315
+ // name/kind mismatch as a `file_search` selector naming it — narrower, but the
316
+ // same class, and `allowed_tools[].type` accepts any string so both are reachable.
317
+ return new Map(
318
+ [...aliases].filter(([wireName, identity]) => authorized.get(wireName) === identity.kind),
319
+ );
320
+ }
321
+
242
322
  function rewriteInputItem(item: unknown, plan: NamespaceRewritePlan, emitted: Set<string>): unknown {
243
323
  if (!isPlainObject(item)) return item;
244
324
  if (item.type === "additional_tools" && Array.isArray(item.tools)) {
@@ -292,7 +372,7 @@ export function rewriteRoutedNamespaceToolsForUpstream(body: unknown): {
292
372
  ...(input !== body.input ? { input } : {}),
293
373
  ...(toolChoice !== body.tool_choice ? { tool_choice: toolChoice } : {}),
294
374
  },
295
- aliases: plan.aliases,
375
+ aliases: authorizedAliases(plan.aliases, toolChoice),
296
376
  };
297
377
  }
298
378
 
@@ -781,6 +781,17 @@ export function parseRequest(
781
781
  if (data.frequency_penalty !== undefined) options.frequencyPenalty = data.frequency_penalty;
782
782
  if (data.service_tier !== undefined) options.serviceTier = data.service_tier;
783
783
  if (data.prompt_cache_key !== undefined) options.promptCacheKey = data.prompt_cache_key;
784
+ if (data.provider_options?.google) {
785
+ const google = data.provider_options.google;
786
+ options.providerOptions = {
787
+ google: {
788
+ ...(google.thinking_budget !== undefined ? { thinkingBudget: google.thinking_budget } : {}),
789
+ ...(google.include_thoughts !== undefined ? { includeThoughts: google.include_thoughts } : {}),
790
+ ...(google.safety_settings !== undefined ? { safetySettings: google.safety_settings } : {}),
791
+ ...(google.cached_content !== undefined ? { cachedContent: google.cached_content } : {}),
792
+ },
793
+ };
794
+ }
784
795
 
785
796
  // Stash the hosted web_search config (if Codex enabled it) so the proxy can run searches via the
786
797
  // gpt-mini sidecar for routed providers. buildTools still drops the hosted tool; the sidecar path
@@ -24,11 +24,11 @@ function isObj(value: unknown): value is Record<string, unknown> {
24
24
  }
25
25
 
26
26
  /**
27
- * Same ceiling the Antigravity replay cache already enforces on a stored signature. An opaque
28
- * token this large is not a real signature, and accepting it would let a caller push unbounded
27
+ * Ceiling on stored thought signatures. Accommodates deep thinking models (e.g. Gemini 3.7
28
+ * Flash up to 64k tokens of reasoning, whose signatures can exceed 100 KiB) while bounding
29
29
  * state through history replay.
30
30
  */
31
- const MAX_SIGNATURE_BYTES = 64 * 1024;
31
+ const MAX_SIGNATURE_BYTES = 1024 * 1024;
32
32
 
33
33
  export function isCarryableSignature(value: unknown): value is string {
34
34
  if (typeof value !== "string" || value.length === 0) return false;
@@ -134,6 +134,42 @@ export const reasoningConfigSchema = z.object({
134
134
  summary: z.enum(["auto", "concise", "detailed", "none"]).optional(),
135
135
  });
136
136
 
137
+ const googleSafetyCategorySchema = z.enum([
138
+ "HARM_CATEGORY_HATE_SPEECH",
139
+ "HARM_CATEGORY_SEXUALLY_EXPLICIT",
140
+ "HARM_CATEGORY_DANGEROUS_CONTENT",
141
+ "HARM_CATEGORY_HARASSMENT",
142
+ "HARM_CATEGORY_CIVIC_INTEGRITY",
143
+ "HARM_CATEGORY_JAILBREAK",
144
+ ]);
145
+ const googleSafetyThresholdSchema = z.enum([
146
+ "HARM_BLOCK_THRESHOLD_UNSPECIFIED",
147
+ "BLOCK_LOW_AND_ABOVE",
148
+ "BLOCK_MEDIUM_AND_ABOVE",
149
+ "BLOCK_ONLY_HIGH",
150
+ "BLOCK_NONE",
151
+ "OFF",
152
+ ]);
153
+ const googleSafetySettingSchema = z.object({
154
+ category: googleSafetyCategorySchema,
155
+ threshold: googleSafetyThresholdSchema,
156
+ }).strict();
157
+ const googleProviderOptionsSchema = z.object({
158
+ thinking_budget: z.number().safe().int().gte(-1).optional(),
159
+ include_thoughts: z.boolean().optional(),
160
+ safety_settings: z.array(googleSafetySettingSchema).max(16).superRefine((settings, ctx) => {
161
+ const categories = new Set(settings.map(setting => setting.category));
162
+ if (categories.size !== settings.length) {
163
+ ctx.addIssue({ code: "custom", message: "safety_settings categories must be unique" });
164
+ }
165
+ }).optional(),
166
+ cached_content: z.string().regex(
167
+ /^(?:cachedContents\/[^/?#\s]+|projects\/[^/?#\s]+\/locations\/[^/?#\s]+\/cachedContents\/[^/?#\s]+)$/,
168
+ "cached_content must be a valid Google cached content resource name",
169
+ ).optional(),
170
+ }).strict();
171
+ const providerOptionsSchema = z.object({ google: googleProviderOptionsSchema.optional() }).strict();
172
+
137
173
  export const stopSchema = z.union([z.string(), z.array(z.string()), z.null()]);
138
174
 
139
175
  export const responsesRequestSchema = z.object({
@@ -162,4 +198,5 @@ export const responsesRequestSchema = z.object({
162
198
  prompt: z.unknown().optional(),
163
199
  text: z.unknown().optional(),
164
200
  truncation: z.unknown().optional(),
201
+ provider_options: providerOptionsSchema.optional(),
165
202
  });
@@ -18,6 +18,12 @@ import {
18
18
  const MAX_STORED_RESPONSES = 1_000;
19
19
  const RESPONSE_TTL_MS = 60 * 60 * 1_000;
20
20
  const SNAPSHOT_DEBOUNCE_MS = 2_000;
21
+ /** Snapshot size below which the debounce stays at its base value. */
22
+ const SNAPSHOT_DEBOUNCE_SCALE_FROM_BYTES = 1 * 1024 * 1024;
23
+ /** Ceiling for the stretched debounce. Continuation state is only read after a
24
+ * restart, and a graceful shutdown flushes, so the exposure a longer debounce adds
25
+ * is bounded by a hard kill — paid against rewriting the whole snapshot every 2 s. */
26
+ const SNAPSHOT_DEBOUNCE_MAX_MS = 30_000;
21
27
  /** In-memory high-water byte cap across all entries. Forced store:false retention (kiro/cursor
22
28
  * continuation chains) stores the full expanded input each turn — ~quadratic bytes per chain —
23
29
  * so a count cap alone cannot bound memory. Oldest-first eviction applies past this mark. */
@@ -90,6 +96,43 @@ let oldestResidentId: string | undefined;
90
96
  let oldestResidentAt: number | null = null;
91
97
  let byteCapOverride: number | null = null;
92
98
  let stateRevision = 0;
99
+ /** Byte length and digest of the last snapshot actually written, for the
100
+ * identical-payload skip and the size-scaled debounce. The payload itself is not
101
+ * retained: at the 24 MiB bound that would double the snapshot's memory cost. */
102
+ let lastSnapshotBytes = 0;
103
+ let lastSnapshotDigest: string | null = null;
104
+ // The resolved file the digest above describes. Keeping it means a config-dir
105
+ // change or a retargeted symlink is a miss rather than a false "unchanged".
106
+ let lastSnapshotTarget: string | null = null;
107
+
108
+ /**
109
+ * Is the snapshot on disk still byte-for-byte what we last wrote?
110
+ *
111
+ * The cached digest proves what this process wrote, not what is there now. Size is
112
+ * checked first so the common mismatch costs a `stat`, and the content comparison
113
+ * only runs when the size already agrees. Any read failure answers "no" and the
114
+ * caller rewrites — the safe direction.
115
+ */
116
+ async function snapshotOnDiskMatches(path: string, payload: string, payloadBytes: number): Promise<boolean> {
117
+ try {
118
+ const file = Bun.file(path);
119
+ if (file.size !== payloadBytes) return false;
120
+ if (await file.text() !== payload) return false;
121
+ // Content matching is not the whole invariant. This file holds persisted request
122
+ // and response bodies, and `atomicWriteFileAsync` writes it owner-only; the
123
+ // unconditional rewrite used to restore that on every mutation. Skipping without
124
+ // checking would let a broadened mode persist indefinitely, so treat a widened
125
+ // file as "does not match" and let the caller rewrite it through the hardening
126
+ // path. POSIX only — Windows ACLs are re-applied by that same write path.
127
+ if (process.platform !== "win32") {
128
+ const mode = statSync(path).mode & 0o777;
129
+ if (mode !== 0o600) return false;
130
+ }
131
+ return true;
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
93
136
  const spillCounters = { writes: 0, writeFailures: 0, readFailures: 0 };
94
137
  /**
95
138
  * Admission-boundary observability (test-visible). directSpills: oversized
@@ -788,9 +831,38 @@ async function writeBoundedSnapshot(path: string): Promise<SnapshotWriteOutcome>
788
831
  entries.push(persistEntry);
789
832
  }
790
833
  entries.reverse();
791
- mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
792
- try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
793
- await atomicWriteFileAsync(path, JSON.stringify({ version: 2, states: entries }));
834
+ const payload = JSON.stringify({ version: 2, states: entries });
835
+ const payloadBytes = Buffer.byteLength(payload, "utf8");
836
+ const payloadDigest = Bun.hash(payload).toString(36);
837
+ // A mutation does not always change what gets persisted: entries past the
838
+ // per-entry or total byte bound are dropped from the selection, and spill
839
+ // demotion moves bytes out of it. Re-writing a byte-identical 24 MiB file
840
+ // buys nothing, so compare first — but the cached digest describes what THIS
841
+ // process last wrote, which is not the same claim as "that is what is on disk
842
+ // now". A second proxy sharing the home, or anything that rewrites the file
843
+ // in place, leaves the digest describing bytes that are gone. Before every
844
+ // release-of-a-write, the previous behaviour rewrote unconditionally and so
845
+ // repaired that silently; skipping without checking would turn a repaired
846
+ // snapshot into a lost one at the next restart.
847
+ //
848
+ // Verify against the file itself, keyed to the resolved target so a retargeted
849
+ // symlink is also a miss. Reading back a matching-size file costs far less
850
+ // than the atomic replace it avoids, and only happens when the digest already
851
+ // matched — the amplification this fixes is the repeated WRITE, not the read.
852
+ const unchanged = lastSnapshotDigest !== null
853
+ && payloadDigest === lastSnapshotDigest
854
+ && payloadBytes === lastSnapshotBytes
855
+ && lastSnapshotTarget === resolveWriteTarget(path)
856
+ && existsSync(path)
857
+ && await snapshotOnDiskMatches(path, payload, payloadBytes);
858
+ if (!unchanged) {
859
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
860
+ try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
861
+ await atomicWriteFileAsync(path, payload);
862
+ lastSnapshotDigest = payloadDigest;
863
+ lastSnapshotBytes = payloadBytes;
864
+ lastSnapshotTarget = resolveWriteTarget(path);
865
+ }
794
866
  persistAttemptHookForTests?.();
795
867
  if (revision === stateRevision) return "stable";
796
868
  }
@@ -809,11 +881,26 @@ function drainPendingSpillUnlinks(): void {
809
881
  }
810
882
  }
811
883
 
884
+ /**
885
+ * Debounce scaled by the size of the last snapshot written.
886
+ *
887
+ * The whole snapshot is re-serialized and atomically replaced on every flush, so at
888
+ * the 24 MiB bound a fixed 2 s debounce is up to ~12 MB/s of write amplification for
889
+ * state nothing reads until the next start (#2460). Small snapshots keep the base
890
+ * cadence; the stretch is linear in size and clamped, so the write rate is roughly
891
+ * flat instead of growing with the file.
892
+ */
893
+ function snapshotDebounceMs(): number {
894
+ if (lastSnapshotBytes <= SNAPSHOT_DEBOUNCE_SCALE_FROM_BYTES) return SNAPSHOT_DEBOUNCE_MS;
895
+ const scaled = Math.round(SNAPSHOT_DEBOUNCE_MS * (lastSnapshotBytes / SNAPSHOT_DEBOUNCE_SCALE_FROM_BYTES));
896
+ return Math.min(scaled, SNAPSHOT_DEBOUNCE_MAX_MS);
897
+ }
898
+
812
899
  function schedulePersistAt(path: string, replace = false): void {
813
900
  if (persistTimer && !replace) return;
814
901
  if (persistTimer) clearTimeout(persistTimer);
815
902
  pendingPersistPath = path;
816
- persistTimer = setTimeout(() => { void persistNow(path); }, SNAPSHOT_DEBOUNCE_MS);
903
+ persistTimer = setTimeout(() => { void persistNow(path); }, snapshotDebounceMs());
817
904
  (persistTimer as { unref?: () => void }).unref?.();
818
905
  }
819
906
 
@@ -1418,6 +1505,9 @@ export function clearResponseStateMemoryForTests(): void {
1418
1505
  replayScopeMismatchDrops = 0;
1419
1506
  replayOverlapSkips = 0;
1420
1507
  persistAttemptHookForTests = null;
1508
+ lastSnapshotBytes = 0;
1509
+ lastSnapshotDigest = null;
1510
+ lastSnapshotTarget = null;
1421
1511
  loaded = false;
1422
1512
  }
1423
1513
 
package/src/router.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  } from "./combos";
10
10
  import type { NormalizedComboConfig } from "./combos/types";
11
11
  import { hasOwnProvider } from "./config/provider-name";
12
+ import { isAzureIdentityProvider } from "./config/provider-validation";
12
13
  import { resolveEnvValue } from "./config";
13
14
  import { assertProviderDestinationAllowed } from "./lib/destination-policy";
14
15
  import { redactSecretString, redactUrlForLog } from "./lib/redact";
@@ -271,10 +272,15 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
271
272
  const registryEntry = PROVIDER_REGISTRY.find(entry => entry.id === providerName);
272
273
  if (!registryEntry || !providerMatchesRegistryTransportWithStaticGuards(providerName, provider)) {
273
274
  assertProviderDestinationAllowed(providerName, provider);
274
- return { ...provider, apiKey: usableResolvedApiKey(provider.apiKey) };
275
+ return {
276
+ ...provider,
277
+ apiKey: usableResolvedApiKey(provider.apiKey),
278
+ ...(isAzureIdentityProvider(provider) ? { liveModels: false } : {}),
279
+ };
275
280
  }
276
281
  const resolvedApiKey = usableResolvedApiKey(provider.apiKey);
277
- const staticModelCatalog = !providerSupportsLiveModelDiscovery(providerName, provider);
282
+ const staticModelCatalog = isAzureIdentityProvider(provider)
283
+ || !providerSupportsLiveModelDiscovery(providerName, provider);
278
284
  const repairLegacyMimoFreeAuth = providerName === "mimo-free"
279
285
  && staticModelCatalog
280
286
  && (provider.authMode === undefined || provider.authMode === "local");
@@ -392,6 +398,9 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider
392
398
  authMode: canonicalAuthMode,
393
399
  apiKey: resolvedApiKey,
394
400
  ...(staticModelCatalog ? { liveModels: false } : {}),
401
+ ...(provider.requestPacing === undefined && registryEntry.requestPacing
402
+ ? { requestPacing: structuredClone(registryEntry.requestPacing) }
403
+ : {}),
395
404
  ...(headers ? { headers } : {}),
396
405
  // Backfill the Google wire mode + Vertex project/location from the registry when the user
397
406
  // config omits them, so a minimal `google-vertex`/`google-antigravity` entry still routes
@@ -63,6 +63,14 @@ export function clearCooldownState(poolKey?: string): void {
63
63
  registryByPool.delete(poolKey);
64
64
  }
65
65
 
66
+ export function clearPoolAccountCooldown(poolKey: string, accountId: string): boolean {
67
+ const registry = registryByPool.get(poolKey);
68
+ if (!registry) return false;
69
+ const existed = registry.get(accountId) !== null;
70
+ registry.clear(accountId);
71
+ return existed;
72
+ }
73
+
66
74
  export function parseRetryAfterMs(
67
75
  value: string | null | undefined,
68
76
  now = Date.now(),
@@ -26,6 +26,7 @@ export {
26
26
  STICK_WAIT_MAX_MS,
27
27
  classifyPoolHttpStatus,
28
28
  clearCooldownState,
29
+ clearPoolAccountCooldown,
29
30
  getPoolCooldownRegistry,
30
31
  isAccountInCooldown,
31
32
  isAccountPoolEligible,