mixdog 0.9.1 → 0.9.2

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 (214) hide show
  1. package/package.json +8 -1
  2. package/scripts/_bench-cwc.json +20 -0
  3. package/scripts/agent-loop-policy-test.mjs +37 -0
  4. package/scripts/agent-parallel-smoke.mjs +54 -10
  5. package/scripts/background-task-meta-smoke.mjs +1 -1
  6. package/scripts/bench-run.mjs +262 -0
  7. package/scripts/compact-smoke.mjs +12 -0
  8. package/scripts/compact-trigger-migration-smoke.mjs +67 -1
  9. package/scripts/ingest-pure-conversation-smoke.mjs +148 -0
  10. package/scripts/internal-comms-bench.mjs +727 -0
  11. package/scripts/internal-comms-smoke.mjs +75 -0
  12. package/scripts/lead-workflow-smoke.mjs +4 -4
  13. package/scripts/live-worker-smoke.mjs +9 -9
  14. package/scripts/output-style-bench.mjs +285 -0
  15. package/scripts/output-style-smoke.mjs +13 -10
  16. package/scripts/patch-replay.mjs +90 -0
  17. package/scripts/provider-stream-stall-test.mjs +276 -0
  18. package/scripts/provider-toolcall-test.mjs +599 -1
  19. package/scripts/routing-corpus.mjs +281 -0
  20. package/scripts/session-bench.mjs +1526 -0
  21. package/scripts/session-diag.mjs +595 -0
  22. package/scripts/task-bench.mjs +207 -0
  23. package/scripts/tool-failures.mjs +6 -6
  24. package/scripts/tool-smoke.mjs +306 -66
  25. package/scripts/toolcall-args-test.mjs +81 -0
  26. package/src/agents/debugger/AGENT.md +4 -4
  27. package/src/agents/heavy-worker/AGENT.md +4 -2
  28. package/src/agents/reviewer/AGENT.md +4 -4
  29. package/src/agents/worker/AGENT.md +4 -2
  30. package/src/app.mjs +10 -6
  31. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  32. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  33. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  34. package/src/headless-role.mjs +14 -14
  35. package/src/help.mjs +1 -0
  36. package/src/lib/rules-builder.cjs +32 -54
  37. package/src/mixdog-session-runtime.mjs +710 -318
  38. package/src/output-styles/default.md +12 -7
  39. package/src/output-styles/minimal.md +25 -0
  40. package/src/output-styles/oneline.md +21 -0
  41. package/src/output-styles/simple.md +10 -9
  42. package/src/repl.mjs +12 -2
  43. package/src/rules/agent/00-common.md +7 -5
  44. package/src/rules/agent/30-explorer.md +7 -8
  45. package/src/rules/lead/01-general.md +3 -1
  46. package/src/rules/lead/lead-tool.md +7 -0
  47. package/src/rules/shared/01-tool.md +17 -12
  48. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  49. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  50. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  51. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  52. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  53. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  54. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  55. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  56. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  57. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  58. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  59. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  60. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  61. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  62. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +332 -57
  63. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +59 -32
  64. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  65. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  66. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  67. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  68. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  69. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +78 -3
  70. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +202 -98
  71. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +183 -20
  72. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  73. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  74. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  75. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +15 -9
  76. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  77. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  78. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  79. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  80. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  81. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  82. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  83. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  84. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -24
  85. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  86. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  87. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  88. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  89. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  90. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  91. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  92. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  93. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  94. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  95. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  96. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  97. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  98. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  99. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  100. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  101. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  102. package/src/runtime/channels/backends/discord.mjs +99 -9
  103. package/src/runtime/channels/backends/telegram.mjs +501 -0
  104. package/src/runtime/channels/index.mjs +441 -1224
  105. package/src/runtime/channels/lib/config.mjs +54 -2
  106. package/src/runtime/channels/lib/format.mjs +4 -2
  107. package/src/runtime/channels/lib/output-forwarder.mjs +80 -67
  108. package/src/runtime/channels/lib/runtime-paths.mjs +29 -0
  109. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  110. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  111. package/src/runtime/channels/lib/tool-format.mjs +1 -1
  112. package/src/runtime/channels/lib/transcript-discovery.mjs +19 -1
  113. package/src/runtime/channels/lib/webhook.mjs +59 -31
  114. package/src/runtime/channels/tool-defs.mjs +1 -1
  115. package/src/runtime/memory/index.mjs +184 -19
  116. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  117. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  118. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  119. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  120. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  121. package/src/runtime/memory/lib/memory.mjs +101 -4
  122. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  123. package/src/runtime/memory/lib/session-ingest.mjs +107 -0
  124. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  125. package/src/runtime/memory/tool-defs.mjs +6 -3
  126. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  127. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  128. package/src/runtime/shared/config.mjs +9 -0
  129. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  130. package/src/runtime/shared/schedules-store.mjs +21 -19
  131. package/src/runtime/shared/tool-surface.mjs +98 -13
  132. package/src/runtime/shared/transcript-writer.mjs +129 -0
  133. package/src/runtime/shared/update-checker.mjs +214 -0
  134. package/src/standalone/agent-tool.mjs +255 -109
  135. package/src/standalone/channel-admin.mjs +133 -40
  136. package/src/standalone/channel-worker.mjs +8 -291
  137. package/src/standalone/explore-tool.mjs +2 -2
  138. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  139. package/src/standalone/provider-admin.mjs +11 -0
  140. package/src/standalone/usage-dashboard.mjs +1 -1
  141. package/src/tui/App.jsx +2096 -732
  142. package/src/tui/components/ConfirmBar.jsx +47 -0
  143. package/src/tui/components/ContextPanel.jsx +5 -3
  144. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  145. package/src/tui/components/Markdown.jsx +22 -98
  146. package/src/tui/components/Message.jsx +14 -35
  147. package/src/tui/components/Picker.jsx +87 -12
  148. package/src/tui/components/PromptInput.jsx +83 -7
  149. package/src/tui/components/QueuedCommands.jsx +1 -1
  150. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  151. package/src/tui/components/Spinner.jsx +7 -7
  152. package/src/tui/components/StatusLine.jsx +40 -21
  153. package/src/tui/components/TextEntryPanel.jsx +51 -7
  154. package/src/tui/components/ToolExecution.jsx +170 -98
  155. package/src/tui/components/TurnDone.jsx +4 -4
  156. package/src/tui/components/UsagePanel.jsx +1 -1
  157. package/src/tui/components/tool-output-format.mjs +159 -21
  158. package/src/tui/components/tool-output-format.test.mjs +87 -0
  159. package/src/tui/display-width.mjs +69 -0
  160. package/src/tui/display-width.test.mjs +35 -0
  161. package/src/tui/dist/index.mjs +6965 -2391
  162. package/src/tui/engine.mjs +287 -126
  163. package/src/tui/index.jsx +117 -7
  164. package/src/tui/keyboard-protocol.mjs +42 -0
  165. package/src/tui/lib/voice-recorder.mjs +453 -0
  166. package/src/tui/markdown/format-token.mjs +129 -76
  167. package/src/tui/markdown/format-token.test.mjs +61 -19
  168. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  169. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  170. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  171. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  172. package/src/tui/markdown/table-layout.mjs +9 -9
  173. package/src/tui/paste-attachments.mjs +0 -11
  174. package/src/tui/prompt-history-store.mjs +129 -0
  175. package/src/tui/prompt-history-store.test.mjs +52 -0
  176. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  177. package/src/tui/theme.mjs +41 -657
  178. package/src/tui/themes/base.mjs +86 -0
  179. package/src/tui/themes/basic.mjs +85 -0
  180. package/src/tui/themes/catppuccin.mjs +72 -0
  181. package/src/tui/themes/dracula.mjs +70 -0
  182. package/src/tui/themes/everforest.mjs +71 -0
  183. package/src/tui/themes/gruvbox.mjs +71 -0
  184. package/src/tui/themes/index.mjs +71 -0
  185. package/src/tui/themes/indigo.mjs +78 -0
  186. package/src/tui/themes/kanagawa.mjs +80 -0
  187. package/src/tui/themes/light.mjs +81 -0
  188. package/src/tui/themes/nord.mjs +72 -0
  189. package/src/tui/themes/onedark.mjs +16 -0
  190. package/src/tui/themes/rosepine.mjs +70 -0
  191. package/src/tui/themes/teal.mjs +81 -0
  192. package/src/tui/themes/tokyonight.mjs +79 -0
  193. package/src/tui/themes/utils.mjs +106 -0
  194. package/src/tui/themes/warm.mjs +79 -0
  195. package/src/tui/transcript-tool-failures.mjs +13 -2
  196. package/src/ui/markdown.mjs +1 -1
  197. package/src/ui/model-display.mjs +2 -2
  198. package/src/ui/statusline.mjs +26 -27
  199. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  200. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  201. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  202. package/src/workflows/default/WORKFLOW.md +39 -12
  203. package/src/workflows/sequential/WORKFLOW.md +46 -0
  204. package/src/workflows/solo/WORKFLOW.md +7 -0
  205. package/vendor/ink/build/display-width.js +62 -0
  206. package/vendor/ink/build/ink.js +100 -12
  207. package/vendor/ink/build/measure-text.js +4 -1
  208. package/vendor/ink/build/output.js +115 -9
  209. package/vendor/ink/build/render-node-to-output.js +4 -1
  210. package/vendor/ink/build/render.js +4 -0
  211. package/src/output-styles/extreme-simple.md +0 -20
  212. package/src/rules/lead/04-workflow.md +0 -51
  213. package/src/workflows/default/workflow.json +0 -13
  214. package/src/workflows/solo/workflow.json +0 -7
@@ -1,4 +1,4 @@
1
- import Anthropic from '@anthropic-ai/sdk';
1
+ import { createRequire } from 'node:module';
2
2
  import { loadConfig } from '../config.mjs';
3
3
  import { sanitizeToolPairs, sanitizeAnthropicContentPairs } from '../session/context-utils.mjs';
4
4
  import { classifyError, midstreamBackoffFor, sleepWithAbort, withRetry } from './retry-classifier.mjs';
@@ -15,10 +15,25 @@ import {
15
15
  _classifyMidstreamError,
16
16
  } from './anthropic-oauth.mjs';
17
17
  import { buildAnthropicBetaHeaders, supportsAnthropicFastMode } from './anthropic-betas.mjs';
18
+ import {
19
+ applyAnthropicEffortToBody,
20
+ effortValuesForModel,
21
+ shouldIncludeEffortBeta,
22
+ } from './anthropic-effort.mjs';
18
23
  import { normalizeContentForAnthropic } from './media-normalization.mjs';
19
24
  import { enrichModels } from './model-catalog.mjs';
20
25
  import { getLlmDispatcher } from '../../../shared/llm/http-agent.mjs';
21
26
 
27
+ const require = createRequire(import.meta.url);
28
+ let _Anthropic = null;
29
+ function loadAnthropic() {
30
+ if (!_Anthropic) {
31
+ const mod = require('@anthropic-ai/sdk');
32
+ _Anthropic = mod.default || mod.Anthropic || mod;
33
+ }
34
+ return _Anthropic;
35
+ }
36
+
22
37
  // Abort-aware mid-stream backoff sleep → shared sleepWithAbort
23
38
  // (retry-classifier.mjs). abortMessage preserves the prior fallback text.
24
39
  function _midstreamSleepWithAbort(ms, signal) {
@@ -117,6 +132,10 @@ function _defaultContextForModel(id, family) {
117
132
  return 200000;
118
133
  }
119
134
 
135
+ function _capabilitySupported(capability) {
136
+ return capability === true || capability?.supported === true;
137
+ }
138
+
120
139
  function _normalizeAnthropicModel(raw, provider = 'anthropic') {
121
140
  const id = raw?.id || raw?.name || raw?.model;
122
141
  if (!id) return null;
@@ -124,15 +143,18 @@ function _normalizeAnthropicModel(raw, provider = 'anthropic') {
124
143
  const family = familyMatch ? familyMatch[1].toLowerCase() : 'other';
125
144
  const dated = /-\d{8}$/.test(String(id));
126
145
  const versioned = !dated && /^claude-[a-z]+-\d+(?:-\d+)?$/i.test(String(id));
146
+ const effortValues = effortValuesForModel(raw?.capabilities, id);
127
147
  return {
128
148
  id,
129
149
  display: raw?.display_name || raw?.displayName || raw?.display || _prettyName(id, family),
130
150
  family,
131
151
  provider,
132
- contextWindow: raw?.context_window || raw?.max_context_window || raw?.input_token_limit || raw?.inputTokenLimit || _defaultContextForModel(id, family),
133
- outputTokens: raw?.max_output_tokens || raw?.output_token_limit || raw?.outputTokenLimit || null,
152
+ contextWindow: raw?.context_window || raw?.max_context_window || raw?.max_input_tokens || raw?.input_token_limit || raw?.inputTokenLimit || _defaultContextForModel(id, family),
153
+ outputTokens: raw?.max_tokens || raw?.max_output_tokens || raw?.output_token_limit || raw?.outputTokenLimit || null,
134
154
  tier: dated ? 'dated' : versioned ? 'version' : 'family',
135
155
  latest: false,
156
+ supportsReasoning: effortValues.length > 0 || _capabilitySupported(raw?.capabilities?.thinking),
157
+ reasoningOptions: effortValues.length ? [{ type: 'effort', values: effortValues }] : [],
136
158
  };
137
159
  }
138
160
  // Family-based heuristic so new model ids (including custom user-configured
@@ -145,15 +167,6 @@ function resolveMaxTokens(model) {
145
167
  return 8192;
146
168
  }
147
169
 
148
- // Effort → thinking budget tokens (Anthropic extended thinking)
149
- const EFFORT_BUDGET = {
150
- low: 1024,
151
- medium: 4096,
152
- high: 16384,
153
- xhigh: 32768,
154
- max: 32768,
155
- };
156
-
157
170
  const MIN_THINKING_BUDGET = 1024;
158
171
  const THINKING_OUTPUT_RESERVE = 1024;
159
172
 
@@ -389,7 +402,7 @@ export class AnthropicProvider {
389
402
  this.config = config;
390
403
  this.name = config.name || 'anthropic';
391
404
  const betaHeaders = config.disableBetaHeaders ? null : buildAnthropicBetaHeaders({ toolSearch: true });
392
- this.client = new Anthropic({
405
+ this.client = new (loadAnthropic())({
393
406
  apiKey: config.apiKey || process.env.ANTHROPIC_API_KEY,
394
407
  ...(config.baseURL ? { baseURL: config.baseURL } : {}),
395
408
  defaultHeaders: { ...(betaHeaders ? { 'anthropic-beta': betaHeaders } : {}), ...(config.extraHeaders || {}) },
@@ -403,7 +416,7 @@ export class AnthropicProvider {
403
416
  if (newKey) {
404
417
  this.config = { ...(this.config || {}), ...(cfg || {}), apiKey: newKey };
405
418
  const betaHeaders = this.config.disableBetaHeaders ? null : buildAnthropicBetaHeaders({ toolSearch: true });
406
- this.client = new Anthropic({
419
+ this.client = new (loadAnthropic())({
407
420
  apiKey: newKey,
408
421
  ...(this.config.baseURL ? { baseURL: this.config.baseURL } : {}),
409
422
  defaultHeaders: { ...(betaHeaders ? { 'anthropic-beta': betaHeaders } : {}), ...(this.config.extraHeaders || {}) },
@@ -481,16 +494,21 @@ export class AnthropicProvider {
481
494
  // Anthropic prefix semantics (order: tools → system → messages).
482
495
  params.tools = [...nativeTools, ...toAnthropicTools([...(tools || []), ...deferredAnthropicTools(tools || [], opts)])];
483
496
  }
484
- // Effort extended thinking budget. Gateway inherit mode may pass the
485
- // exact OAuth client budget from the incoming Anthropic request.
486
- const thinkingBudgetTokens = Number(opts.thinkingBudgetTokens);
487
- const requestedThinkingBudget = Number.isFinite(thinkingBudgetTokens) && thinkingBudgetTokens > 0
488
- ? thinkingBudgetTokens
489
- : (opts.effort && EFFORT_BUDGET[opts.effort] ? EFFORT_BUDGET[opts.effort] : null);
490
- const budgetTokens = clampThinkingBudgetTokens(requestedThinkingBudget, maxTokens);
491
- if (budgetTokens) {
492
- params.thinking = { type: 'enabled', budget_tokens: budgetTokens };
493
- }
497
+ // Known tool names for the shared parseSSEStream leaked-tool-call guard
498
+ // (same guard fixes both Anthropic providers). Recovered leaked calls
499
+ // are only synthesized when they name a tool actually offered here.
500
+ const knownToolNames = new Set(
501
+ (Array.isArray(params.tools) ? params.tools : [])
502
+ .map((t) => (t && typeof t.name === 'string' ? t.name : null))
503
+ .filter(Boolean),
504
+ );
505
+ applyAnthropicEffortToBody(params, {
506
+ model: useModel,
507
+ opts,
508
+ maxTokens,
509
+ clampThinkingBudgetTokens,
510
+ logTag: this.name,
511
+ });
494
512
  // Fast mode → speed: "fast" on models Anthropic marks as speed-capable.
495
513
  if (opts.fast === true && supportsAnthropicFastMode(useModel)) {
496
514
  params.speed = 'fast';
@@ -524,12 +542,20 @@ export class AnthropicProvider {
524
542
  try { totalSignal.removeEventListener('abort', handler); } catch {}
525
543
  };
526
544
 
527
- const betaHeaders = {
528
- 'anthropic-beta': buildAnthropicBetaHeaders({
529
- fastMode: this.fastModeBetaHeaderLatched,
530
- toolSearch: true,
531
- }),
532
- };
545
+ // Per-call headers override the client defaultHeaders, so the
546
+ // constructor-level disableBetaHeaders opt-out must be honoured here
547
+ // too — otherwise opencode-go's anthropic-compatible routing
548
+ // (disableBetaHeaders:true) would still send beta strings that a
549
+ // third-party endpoint may reject.
550
+ const betaHeaders = this.config?.disableBetaHeaders
551
+ ? null
552
+ : {
553
+ 'anthropic-beta': buildAnthropicBetaHeaders({
554
+ fastMode: this.fastModeBetaHeaderLatched,
555
+ toolSearch: true,
556
+ effort: shouldIncludeEffortBeta(useModel, opts),
557
+ }),
558
+ };
533
559
 
534
560
  const MAX_MIDSTREAM_RETRIES = ANTHROPIC_MAX_MIDSTREAM_RETRIES;
535
561
  let firstAttemptError = null;
@@ -623,7 +649,7 @@ export class AnthropicProvider {
623
649
  async ({ signal: attemptSignal }) => {
624
650
  const res = await this.client.messages.create(params, {
625
651
  signal: attemptSignal,
626
- headers: betaHeaders,
652
+ ...(betaHeaders ? { headers: betaHeaders } : {}),
627
653
  }).asResponse();
628
654
  if (!res.ok) {
629
655
  const text = await res.text().catch(() => '');
@@ -676,11 +702,12 @@ export class AnthropicProvider {
676
702
  const parseResult = await parseSSEStream(
677
703
  response,
678
704
  streamController.signal,
679
- () => streamController.abort(),
705
+ (reason) => streamController.abort(reason),
680
706
  onStreamDelta,
681
707
  onToolCall,
682
708
  midState,
683
709
  onTextDelta,
710
+ knownToolNames,
684
711
  );
685
712
 
686
713
  if (firstBytePoll) {
@@ -1,6 +1,6 @@
1
- import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
- import * as fsp from 'node:fs/promises';
1
+ import { existsSync, readFileSync } from 'node:fs';
3
2
  import { join } from 'node:path';
3
+ import { updateJsonAtomicSync } from '../../../shared/atomic-file.mjs';
4
4
  import { resolvePluginData } from '../../../shared/plugin-paths.mjs';
5
5
  import { getAgentApiKey, getOpenAIUsageSessionKey } from '../../../shared/config.mjs';
6
6
 
@@ -41,14 +41,6 @@ function readJson(file) {
41
41
  }
42
42
  }
43
43
 
44
- function writeJson(file, value) {
45
- diskJsonCache = { at: Date.now(), file, value };
46
- try {
47
- mkdirSync(resolvePluginData(), { recursive: true });
48
- void fsp.writeFile(file, JSON.stringify(value, null, 2), 'utf8').catch(() => {});
49
- } catch {}
50
- }
51
-
52
44
  function cacheKey(provider) {
53
45
  return String(provider || '').trim().toLowerCase();
54
46
  }
@@ -68,16 +60,31 @@ export function readCachedApiUsageSnapshot(provider, { allowStale = true } = {})
68
60
 
69
61
  function writeCachedApiUsageSnapshot(provider, snapshot) {
70
62
  const file = cachePath();
71
- const raw = readJson(file) || {};
72
- const snapshots = raw.snapshots && typeof raw.snapshots === 'object' ? raw.snapshots : {};
73
- writeJson(file, {
74
- version: 1,
75
- updatedAt: Date.now(),
76
- snapshots: {
77
- ...snapshots,
78
- [cacheKey(provider)]: snapshot,
79
- },
80
- });
63
+ // Synchronous atomic+lock write (updateJsonAtomicSync) rather than the
64
+ // prior fire-and-forget fsp.writeFile: the read-modify-write of
65
+ // `snapshots` must happen inside the same file lock as the write, or two
66
+ // concurrent orchestrator processes updating different providers can
67
+ // clobber each other's snapshot merge (last writer wins, losing the
68
+ // other's entry). Usage snapshot writes are infrequent (once per
69
+ // provider per TTL window, not per-request), so trading the async/
70
+ // non-blocking write for lock+fsync-dir safety has no meaningful
71
+ // latency impact on the request path.
72
+ let next = null;
73
+ try {
74
+ next = updateJsonAtomicSync(file, (curRaw) => {
75
+ const cur = curRaw && typeof curRaw === 'object' ? curRaw : {};
76
+ const snapshots = cur.snapshots && typeof cur.snapshots === 'object' ? cur.snapshots : {};
77
+ return {
78
+ version: 1,
79
+ updatedAt: Date.now(),
80
+ snapshots: {
81
+ ...snapshots,
82
+ [cacheKey(provider)]: snapshot,
83
+ },
84
+ };
85
+ }, { lock: true, fsyncDir: true, timeoutMs: 1000 }); // best-effort cache write: short lock timeout, don't block on contention
86
+ } catch {}
87
+ if (next) diskJsonCache = { at: Date.now(), file, value: next };
81
88
  }
82
89
 
83
90
  function authHeaders(key, extra = {}) {
@@ -16,6 +16,7 @@ import {
16
16
  import { getLlmDispatcher, preconnect } from '../../../shared/llm/http-agent.mjs';
17
17
  import { traceHash, stableTraceStringify, summarizeTraceTools, traceTextShape } from './trace-utils.mjs';
18
18
  import { normalizeContentForGeminiParts, splitToolContentForGemini } from './media-normalization.mjs';
19
+ import { scanLeakedToolCalls } from './anthropic-leaked-toolcall.mjs';
19
20
 
20
21
  const MODELS = [
21
22
  { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash Preview', provider: 'gemini', contextWindow: 1048576 },
@@ -31,12 +32,19 @@ const DEFAULT_MODEL = MODELS[0].id;
31
32
  // Gemini's /models has no `created` timestamp, so latest-resolution is
32
33
  // VERSION-based (parse gemini-X.Y) rather than release-date based.
33
34
  const MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
35
+ // Bump when the on-disk cache shape changes so stale-shape entries are
36
+ // discarded instead of misread (mirrors openai-oauth's schema-version gate).
37
+ const GEMINI_MODEL_CACHE_SCHEMA_VERSION = 1;
34
38
 
35
39
  // De-dupes concurrent force-refreshes so they share one HTTP round-trip,
36
40
  // mirroring anthropic-oauth's _modelRefreshInFlight.
37
41
  let _modelRefreshInFlight = null;
38
42
 
39
- const _modelCache = makeModelCache({ fileName: 'gemini-models.json', ttlMs: MODEL_CACHE_TTL_MS });
43
+ const _modelCache = makeModelCache({
44
+ fileName: 'gemini-models.json',
45
+ ttlMs: MODEL_CACHE_TTL_MS,
46
+ version: GEMINI_MODEL_CACHE_SCHEMA_VERSION,
47
+ });
40
48
 
41
49
  // Mirror of anthropic-oauth.mjs _compareVersion: compare two gemini ids by the
42
50
  // X.Y version embedded in the id (gemini-3.5-flash -> [3, 5]). Falls back to a
@@ -182,6 +190,10 @@ function _geminiCachePrefixHash({ model, systemInstruction, geminiTools, content
182
190
 
183
191
  const GEMINI_GLOBAL_CACHE_MIN_LIVE_MS = 6 * 60 * 1000;
184
192
  const GEMINI_GLOBAL_CACHE_MAX_ENTRIES = 128;
193
+ // Grace window before deleting a superseded cachedContents name (see the
194
+ // cross-session race note at the L1341-1372 call site). Long enough that a
195
+ // concurrent session still mid-flight on the old name has time to finish.
196
+ const GEMINI_GLOBAL_CACHE_DELETE_GRACE_MS = 2 * 60 * 1000;
185
197
  const geminiGlobalCaches = new Map();
186
198
  const geminiGlobalCacheCreates = new Map();
187
199
 
@@ -294,7 +306,7 @@ function writeGeminiCacheTrace({ opts, model, systemInstruction, tools, contents
294
306
  provider: 'gemini',
295
307
  model,
296
308
  owner: session.owner || null,
297
- role: session.role || null,
309
+ agent: session.agent || null,
298
310
  permission: session.permission || null,
299
311
  toolPermission: session.toolPermission || null,
300
312
  profileId: session.profileId || null,
@@ -412,7 +424,104 @@ function geminiChunkText(chunk) {
412
424
  return text;
413
425
  }
414
426
 
415
- async function consumeGeminiRestStreamResponse(response, { signal, onStreamDelta, onTextDelta, label }) {
427
+ function relayGeminiStreamText(t, { onTextDelta, textLeakGuard }) {
428
+ if (!t) return;
429
+ if (textLeakGuard) textLeakGuard.feedText(t);
430
+ else if (onTextDelta) { try { onTextDelta(t); } catch {} }
431
+ }
432
+
433
+ /**
434
+ * Rolling scanner for tool calls leaked as plain XML/antml tags inside Gemini
435
+ * `part.text` streams. Mirrors the Anthropic OAuth guard: suppress tags from
436
+ * visible text, synthesize known-tool calls, dispatch via onToolCall.
437
+ */
438
+ export function createGeminiTextLeakGuard({ knownToolNames, onTextDelta, onToolCall, onStreamDelta }) {
439
+ const _knownTools = knownToolNames instanceof Set
440
+ ? knownToolNames
441
+ : new Set(Array.isArray(knownToolNames) ? knownToolNames : []);
442
+ const _enabled = _knownTools.size > 0;
443
+ const _isKnownTool = (name) => _knownTools.has(name);
444
+ let leakBuffer = '';
445
+ const leakedCalls = [];
446
+ const dispatchedFingerprints = new Set();
447
+
448
+ const toolCallFingerprint = (name, args) => {
449
+ let a = args;
450
+ if (a === null || typeof a !== 'object' || Array.isArray(a)) a = {};
451
+ return traceHash(stableTraceStringify({ name: name || '', args: a }));
452
+ };
453
+
454
+ const dispatchLeakedCall = (recovered) => {
455
+ let args = recovered?.arguments;
456
+ if (args === null || typeof args !== 'object' || Array.isArray(args)) args = {};
457
+ const fp = toolCallFingerprint(recovered.name, args);
458
+ if (dispatchedFingerprints.has(fp)) return;
459
+ dispatchedFingerprints.add(fp);
460
+ const idHash = traceHash(stableTraceStringify({
461
+ name: recovered.name,
462
+ args,
463
+ leak: true,
464
+ })).slice(0, 16);
465
+ const call = {
466
+ id: `gemini_leaked_${idHash}`,
467
+ name: recovered.name,
468
+ arguments: args,
469
+ };
470
+ leakedCalls.push(call);
471
+ try { onToolCall?.(call); } catch {}
472
+ try { onStreamDelta?.(); } catch {}
473
+ };
474
+
475
+ const pumpLeakBuffer = (final) => {
476
+ if (!_enabled) return;
477
+ if (!leakBuffer && !final) return;
478
+ const { emit, calls, rest } = scanLeakedToolCalls(leakBuffer, { isKnownTool: _isKnownTool, final });
479
+ leakBuffer = rest;
480
+ if (emit && onTextDelta) {
481
+ try { onTextDelta(emit); } catch {}
482
+ }
483
+ for (const c of calls) dispatchLeakedCall(c);
484
+ };
485
+
486
+ return {
487
+ get enabled() { return _enabled; },
488
+ feedText(text) {
489
+ if (!text) return;
490
+ if (!_enabled) {
491
+ try { onTextDelta?.(text); } catch {}
492
+ return;
493
+ }
494
+ leakBuffer += text;
495
+ pumpLeakBuffer(false);
496
+ },
497
+ finalize() {
498
+ pumpLeakBuffer(true);
499
+ },
500
+ scrubAssistantText(raw) {
501
+ if (!raw) return '';
502
+ if (!_enabled) return raw;
503
+ const { emit, calls, rest } = scanLeakedToolCalls(raw, { isKnownTool: _isKnownTool, final: true });
504
+ for (const c of calls) dispatchLeakedCall(c);
505
+ return emit + rest;
506
+ },
507
+ filterNativeToolCalls(nativeCalls) {
508
+ if (!_enabled || !nativeCalls?.length) return nativeCalls;
509
+ const kept = [];
510
+ for (const call of nativeCalls) {
511
+ const fp = toolCallFingerprint(call?.name, call?.arguments);
512
+ if (dispatchedFingerprints.has(fp)) continue;
513
+ dispatchedFingerprints.add(fp);
514
+ kept.push(call);
515
+ }
516
+ return kept.length ? kept : undefined;
517
+ },
518
+ getLeakedToolCalls() {
519
+ return leakedCalls.length ? [...leakedCalls] : [];
520
+ },
521
+ };
522
+ }
523
+
524
+ async function consumeGeminiRestStreamResponse(response, { signal, onStreamDelta, onTextDelta, textLeakGuard, label }) {
416
525
  if (!response?.body) throw new Error(`${label}: missing response body`);
417
526
  const reader = response.body.getReader();
418
527
  const decoder = new TextDecoder();
@@ -509,9 +618,9 @@ async function consumeGeminiRestStreamResponse(response, { signal, onStreamDelta
509
618
  }
510
619
  allChunks.push(parsed);
511
620
  try { onStreamDelta?.(); } catch {}
512
- if (onTextDelta) {
621
+ if (onTextDelta || textLeakGuard) {
513
622
  const t = geminiChunkText(parsed);
514
- if (t) { try { onTextDelta(t); } catch {} }
623
+ relayGeminiStreamText(t, { onTextDelta, textLeakGuard });
515
624
  }
516
625
  }
517
626
  }
@@ -528,9 +637,9 @@ async function consumeGeminiRestStreamResponse(response, { signal, onStreamDelta
528
637
  }
529
638
  allChunks.push(parsed);
530
639
  try { onStreamDelta?.(); } catch {}
531
- if (onTextDelta) {
640
+ if (onTextDelta || textLeakGuard) {
532
641
  const t = geminiChunkText(parsed);
533
- if (t) { try { onTextDelta(t); } catch {} }
642
+ relayGeminiStreamText(t, { onTextDelta, textLeakGuard });
534
643
  }
535
644
  } catch { /* skip malformed tail */ }
536
645
  }
@@ -541,6 +650,7 @@ async function consumeGeminiRestStreamResponse(response, { signal, onStreamDelta
541
650
  if (idleTimer) clearTimeout(idleTimer);
542
651
  if (signal) signal.removeEventListener('abort', onAbort);
543
652
  try { reader.releaseLock(); } catch {}
653
+ try { textLeakGuard?.finalize(); } catch {}
544
654
  }
545
655
 
546
656
  const aggregated = aggregateGeminiStreamChunks(allChunks);
@@ -549,7 +659,7 @@ async function consumeGeminiRestStreamResponse(response, { signal, onStreamDelta
549
659
  return aggregated;
550
660
  }
551
661
 
552
- async function consumeGeminiSdkStream(streamResult, { signal, onStreamDelta, onTextDelta, label }) {
662
+ async function consumeGeminiSdkStream(streamResult, { signal, onStreamDelta, onTextDelta, textLeakGuard, label }) {
553
663
  let sawStreamChunk = false;
554
664
  let idleTimedOut = false;
555
665
  let idleTimer = null;
@@ -665,9 +775,9 @@ async function consumeGeminiSdkStream(streamResult, { signal, onStreamDelta, onT
665
775
  }
666
776
  resetIdleTimer();
667
777
  try { onStreamDelta?.(); } catch {}
668
- if (onTextDelta) {
778
+ if (onTextDelta || textLeakGuard) {
669
779
  const t = geminiChunkText(step.value);
670
- if (t) { try { onTextDelta(t); } catch {} }
780
+ relayGeminiStreamText(t, { onTextDelta, textLeakGuard });
671
781
  }
672
782
  }
673
783
  if (idleTimedOut) {
@@ -686,6 +796,7 @@ async function consumeGeminiSdkStream(streamResult, { signal, onStreamDelta, onT
686
796
  if (signal && onSignalAbort) {
687
797
  try { signal.removeEventListener('abort', onSignalAbort); } catch {}
688
798
  }
799
+ try { textLeakGuard?.finalize(); } catch {}
689
800
  }
690
801
 
691
802
  let response;
@@ -1241,11 +1352,44 @@ export class GeminiProvider {
1241
1352
  // Best-effort cleanup of the previous cache so storage cost only
1242
1353
  // accrues on the live revision. Fire-and-forget; TTL expiry covers
1243
1354
  // any delete failures.
1355
+ //
1356
+ // Cross-session race: `_geminiGlobalCacheNameIsLive` only checks
1357
+ // whether `priorCacheName` still appears as *some* entry's live
1358
+ // cacheName in `geminiGlobalCaches`. If another session sharing the
1359
+ // same globalCacheKey already overwrote that map slot with a newer
1360
+ // cache (via `_setGeminiGlobalCache`), the check sees "not live" for
1361
+ // a name that a *different* in-flight session still holds in its own
1362
+ // `providerState.gemini.cacheName` (captured earlier via
1363
+ // `_attachGeminiCacheState` and possibly already in-flight inside a
1364
+ // `generateContent`/`streamGenerateContent` call at L1470-1473).
1365
+ // Deleting immediately can 404 that concurrent request server-side.
1366
+ //
1367
+ // Fix chosen: delay the DELETE by a grace period instead of adding
1368
+ // refcounting/last-used-session tracking. Rationale (minimal-change,
1369
+ // matches the module's existing "best-effort, TTL is the backstop"
1370
+ // posture at L1342-1343):
1371
+ // - Any session that captured `priorCacheName` did so before this
1372
+ // create finished, so its in-flight (or next) turn using that
1373
+ // name almost certainly completes within a couple of minutes;
1374
+ // a short grace window is enough for it to either finish or move
1375
+ // on to a fresh cache attach.
1376
+ // - The server-side cache TTL (1h) already reclaims any cache we
1377
+ // fail to delete, so skipping/delaying deletion is safe — it
1378
+ // only costs a little extra storage for at most the grace
1379
+ // window, never correctness.
1380
+ // - Refcounting/session tracking would need to plumb per-session
1381
+ // liveness into a shared map across concurrent providers, which
1382
+ // is a much larger change for a purely cosmetic cost saving.
1383
+ // Re-check liveness right before firing the DELETE too, in case the
1384
+ // name became live again (e.g. re-attached) during the wait.
1244
1385
  const priorCacheName = state?.cacheName || null;
1245
- if (priorCacheName && priorCacheName !== cacheName && !_geminiGlobalCacheNameIsLive(priorCacheName)) {
1246
- const delUrl = `https://generativelanguage.googleapis.com/v1beta/${priorCacheName}?key=${encodeURIComponent(apiKey)}`;
1247
- fetch(delUrl, { method: 'DELETE', signal: AbortSignal.timeout(10_000), dispatcher: getLlmDispatcher() })
1248
- .catch(() => { /* TTL expiry will reclaim it */ });
1386
+ if (priorCacheName && priorCacheName !== cacheName) {
1387
+ setTimeout(() => {
1388
+ if (_geminiGlobalCacheNameIsLive(priorCacheName)) return;
1389
+ const delUrl = `https://generativelanguage.googleapis.com/v1beta/${priorCacheName}?key=${encodeURIComponent(apiKey)}`;
1390
+ fetch(delUrl, { method: 'DELETE', signal: AbortSignal.timeout(10_000), dispatcher: getLlmDispatcher() })
1391
+ .catch(() => { /* TTL expiry will reclaim it */ });
1392
+ }, GEMINI_GLOBAL_CACHE_DELETE_GRACE_MS).unref?.();
1249
1393
  }
1250
1394
  const createdAt = Date.now();
1251
1395
  const entry = {
@@ -1322,6 +1466,14 @@ export class GeminiProvider {
1322
1466
  const toolConfig = functionGeminiTools.length ? toGeminiToolConfig(opts.toolChoice) : undefined;
1323
1467
  try { opts.onStageChange?.('requesting'); } catch {}
1324
1468
 
1469
+ const buildTextLeakGuard = () => createGeminiTextLeakGuard({
1470
+ knownToolNames: tools?.map((t) => t.name).filter(Boolean) ?? [],
1471
+ onTextDelta,
1472
+ onToolCall,
1473
+ onStreamDelta,
1474
+ });
1475
+ let textLeakGuard = null;
1476
+
1325
1477
  // Explicit cachedContents (system + tools + prior-turn transcript).
1326
1478
  // Per Google docs, `tools` must be supplied on BOTH the cache create
1327
1479
  // call AND every subsequent generate_content call — the cache stores
@@ -1401,10 +1553,12 @@ export class GeminiProvider {
1401
1553
  err.status = res.status;
1402
1554
  throw err;
1403
1555
  }
1556
+ textLeakGuard = buildTextLeakGuard();
1404
1557
  return await consumeGeminiRestStreamResponse(res, {
1405
1558
  signal: attemptSignal,
1406
1559
  onStreamDelta,
1407
1560
  onTextDelta,
1561
+ textLeakGuard,
1408
1562
  label: 'Gemini REST streamGenerateContent',
1409
1563
  });
1410
1564
  },
@@ -1485,10 +1639,12 @@ export class GeminiProvider {
1485
1639
  // timer but KEEP the parent link attached so a later
1486
1640
  // abort during streaming still reaches the request.
1487
1641
  clearConnectTimer();
1642
+ textLeakGuard = buildTextLeakGuard();
1488
1643
  return await consumeGeminiSdkStream(streamResult, {
1489
1644
  signal: attemptSignal,
1490
1645
  onStreamDelta,
1491
1646
  onTextDelta,
1647
+ textLeakGuard,
1492
1648
  label: 'Gemini SDK streamGenerateContent',
1493
1649
  });
1494
1650
  } finally {
@@ -1519,10 +1675,21 @@ export class GeminiProvider {
1519
1675
  });
1520
1676
  const candidate = response.candidates?.[0] || null;
1521
1677
  const textParts = candidate?.content?.parts?.filter(p => 'text' in p) ?? [];
1522
- const content = textParts.map(p => 'text' in p ? p.text : '').join('');
1523
- const toolCalls = parseToolCalls(candidate?.content?.parts ?? []);
1678
+ const rawContent = textParts.map(p => 'text' in p ? p.text : '').join('');
1679
+ const content = textLeakGuard?.enabled
1680
+ ? textLeakGuard.scrubAssistantText(rawContent)
1681
+ : rawContent;
1682
+ const leakedToolCalls = textLeakGuard?.getLeakedToolCalls() ?? [];
1683
+ let nativeToolCalls = parseToolCalls(candidate?.content?.parts ?? []);
1684
+ if (textLeakGuard?.enabled) {
1685
+ nativeToolCalls = textLeakGuard.filterNativeToolCalls(nativeToolCalls);
1686
+ }
1687
+ let toolCalls = nativeToolCalls;
1688
+ if (leakedToolCalls.length) {
1689
+ toolCalls = toolCalls?.length ? [...toolCalls, ...leakedToolCalls] : leakedToolCalls;
1690
+ }
1524
1691
  const citations = collectGeminiGroundingSources(candidate);
1525
- emitGeminiToolCalls(toolCalls, onToolCall);
1692
+ emitGeminiToolCalls(nativeToolCalls, onToolCall);
1526
1693
  // Inspect candidate.finishReason — Gemini reports terminal status here.
1527
1694
  // Only STOP (and the legacy "FINISH_REASON_STOP") plus tool/function-
1528
1695
  // call paths represent a fully delivered turn. MAX_TOKENS / SAFETY /
@@ -113,6 +113,9 @@ export function normalizeGrokModelId(id) {
113
113
  return (id && RETIRED_MODEL_ALIASES[id]) || id;
114
114
  }
115
115
  const MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
116
+ // Bump when the on-disk cache shape changes so stale-shape entries are
117
+ // discarded instead of misread (mirrors openai-oauth's schema-version gate).
118
+ const GROK_MODEL_CACHE_SCHEMA_VERSION = 1;
116
119
  const DISCOVERY_TIMEOUT_MS = 15_000;
117
120
  const TOKEN_TIMEOUT_MS = 30_000;
118
121
  const LOGIN_TIMEOUT_MS = 5 * 60_000;
@@ -362,7 +365,11 @@ async function refreshTokens(tokens) {
362
365
  }
363
366
 
364
367
  // --- Model catalog cache (24h disk TTL) ---
365
- const _modelCache = makeModelCache({ fileName: 'grok-oauth-models.json', ttlMs: MODEL_CACHE_TTL_MS });
368
+ const _modelCache = makeModelCache({
369
+ fileName: 'grok-oauth-models.json',
370
+ ttlMs: MODEL_CACHE_TTL_MS,
371
+ version: GROK_MODEL_CACHE_SCHEMA_VERSION,
372
+ });
366
373
  const PROXY_MODEL_METADATA = {
367
374
  'grok-build': { display: 'Grok Build', contextWindow: 512000 },
368
375
  'grok-composer-2.5-fast': { display: 'Composer 2.5 Fast', contextWindow: 200000 },
@@ -14,9 +14,10 @@
14
14
  * a stale number.
15
15
  */
16
16
 
17
- import { existsSync, readFileSync, writeFileSync } from 'fs';
17
+ import { existsSync, readFileSync } from 'fs';
18
18
  import { join } from 'path';
19
19
  import { getPluginData } from '../config.mjs';
20
+ import { writeJsonAtomicSync } from '../../../shared/atomic-file.mjs';
20
21
 
21
22
  const CATALOG_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
22
23
  const CATALOG_CACHE_FILE = 'litellm-catalog.json';
@@ -187,7 +188,7 @@ async function _loadCatalogImpl() {
187
188
  if (!res.ok) throw new Error('HTTP ' + res.status);
188
189
  const data = await res.json();
189
190
  try {
190
- writeFileSync(cachePath(), JSON.stringify({ fetchedAt: Date.now(), data }));
191
+ writeJsonAtomicSync(cachePath(), { fetchedAt: Date.now(), data }, { lock: true, compact: true, fsyncDir: true, timeoutMs: 1000 });
191
192
  } catch { /* cache is best-effort */ }
192
193
  _memCache = data;
193
194
  _memCacheAt = Date.now();
@@ -239,7 +240,7 @@ async function _loadModelsDevImpl() {
239
240
  if (!res.ok) throw new Error('HTTP ' + res.status);
240
241
  const data = await res.json();
241
242
  try {
242
- writeFileSync(mdCachePath(), JSON.stringify({ fetchedAt: Date.now(), data }));
243
+ writeJsonAtomicSync(mdCachePath(), { fetchedAt: Date.now(), data }, { lock: true, compact: true, fsyncDir: true, timeoutMs: 1000 });
243
244
  } catch { /* cache is best-effort */ }
244
245
  _mdCache = data;
245
246
  _mdCacheAt = Date.now();
@@ -430,12 +431,12 @@ export async function enrichModels(models) {
430
431
  outputCostPerM: meta.outputCostPerM,
431
432
  cacheReadCostPerM: meta.cacheReadCostPerM,
432
433
  cacheWriteCostPerM: meta.cacheWriteCostPerM,
433
- supportsVision: meta.supportsVision,
434
- supportsFunctionCalling: meta.supportsFunctionCalling,
434
+ supportsVision: m.supportsVision === true || meta.supportsVision,
435
+ supportsFunctionCalling: m.supportsFunctionCalling === true || meta.supportsFunctionCalling,
435
436
  supportsWebSearch: meta.supportsWebSearch || m.supportsWebSearch === true,
436
- supportsPromptCaching: meta.supportsPromptCaching,
437
- supportsReasoning: meta.supportsReasoning,
438
- reasoningOptions: meta.reasoningOptions || m.reasoningOptions || [],
437
+ supportsPromptCaching: m.supportsPromptCaching === true || meta.supportsPromptCaching,
438
+ supportsReasoning: m.supportsReasoning === true || meta.supportsReasoning,
439
+ reasoningOptions: m.reasoningOptions?.length ? m.reasoningOptions : (meta.reasoningOptions || []),
439
440
  reasoningContentField: meta.reasoningContentField || m.reasoningContentField || null,
440
441
  mode: meta.mode || m.mode || null,
441
442
  };
@@ -472,3 +473,12 @@ export async function warmModelMetadataCatalogs() {
472
473
  const [litellm] = await Promise.all([loadCatalog(), loadModelsDevCatalog()]);
473
474
  return litellm;
474
475
  }
476
+
477
+ /** Fire-and-forget warm of both in-memory catalog caches (disk-first, then remote). */
478
+ export async function warmCatalogsInBackground() {
479
+ try {
480
+ await Promise.all([loadCatalog(), loadModelsDevCatalog()]);
481
+ } catch {
482
+ /* never throw — boot/statusline must not fail on catalog warm */
483
+ }
484
+ }