mixdog 0.9.0 → 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 (240) hide show
  1. package/package.json +10 -3
  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/session-ingest-smoke.mjs +2 -2
  23. package/scripts/task-bench.mjs +207 -0
  24. package/scripts/tool-failures.mjs +6 -6
  25. package/scripts/tool-smoke.mjs +306 -66
  26. package/scripts/toolcall-args-test.mjs +81 -0
  27. package/src/agents/debugger/AGENT.md +4 -4
  28. package/src/agents/heavy-worker/AGENT.md +4 -2
  29. package/src/agents/reviewer/AGENT.md +4 -4
  30. package/src/agents/worker/AGENT.md +4 -2
  31. package/src/app.mjs +10 -6
  32. package/src/defaults/{hidden-roles.json → agents.json} +7 -7
  33. package/src/examples/schedules/SCHEDULE.example.md +32 -0
  34. package/src/examples/webhooks/WEBHOOK.example.md +40 -0
  35. package/src/headless-role.mjs +14 -14
  36. package/src/help.mjs +1 -0
  37. package/src/lib/mixdog-debug.cjs +0 -22
  38. package/src/lib/plugin-paths.cjs +1 -7
  39. package/src/lib/rules-builder.cjs +34 -56
  40. package/src/mixdog-session-runtime.mjs +710 -319
  41. package/src/output-styles/default.md +12 -7
  42. package/src/output-styles/minimal.md +25 -0
  43. package/src/output-styles/oneline.md +21 -0
  44. package/src/output-styles/simple.md +10 -9
  45. package/src/repl.mjs +12 -4
  46. package/src/rules/agent/00-common.md +7 -5
  47. package/src/rules/agent/30-explorer.md +7 -8
  48. package/src/rules/lead/01-general.md +3 -1
  49. package/src/rules/lead/lead-tool.md +7 -0
  50. package/src/rules/shared/01-tool.md +17 -12
  51. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +90 -32
  52. package/src/runtime/agent/orchestrator/agent-runtime/agent-loop-policy.mjs +32 -0
  53. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +18 -6
  54. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +23 -20
  55. package/src/runtime/agent/orchestrator/agent-runtime/session-builder.mjs +48 -14
  56. package/src/runtime/agent/orchestrator/agent-trace.mjs +87 -12
  57. package/src/runtime/agent/orchestrator/config.mjs +3 -0
  58. package/src/runtime/agent/orchestrator/context/collect.mjs +131 -67
  59. package/src/runtime/agent/orchestrator/{internal-roles.mjs → internal-agents.mjs} +72 -72
  60. package/src/runtime/agent/orchestrator/internal-tools.mjs +13 -26
  61. package/src/runtime/agent/orchestrator/mcp/client.mjs +94 -16
  62. package/src/runtime/agent/orchestrator/providers/anthropic-betas.mjs +7 -0
  63. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +188 -0
  64. package/src/runtime/agent/orchestrator/providers/anthropic-leaked-toolcall.mjs +444 -0
  65. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +359 -106
  66. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +63 -51
  67. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +27 -20
  68. package/src/runtime/agent/orchestrator/providers/gemini.mjs +184 -17
  69. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +8 -1
  70. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +18 -8
  71. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +210 -21
  72. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +86 -30
  73. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +254 -280
  74. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +191 -50
  75. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +18 -0
  76. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +11 -5
  77. package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -1
  78. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +265 -1
  79. package/src/runtime/agent/orchestrator/session/compact.mjs +560 -51
  80. package/src/runtime/agent/orchestrator/session/context-utils.mjs +250 -3
  81. package/src/runtime/agent/orchestrator/session/loop.mjs +394 -132
  82. package/src/runtime/agent/orchestrator/session/manager.mjs +217 -170
  83. package/src/runtime/agent/orchestrator/session/store.mjs +4 -4
  84. package/src/runtime/agent/orchestrator/session/tool-envelope.mjs +61 -0
  85. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +5 -0
  86. package/src/runtime/agent/orchestrator/stall-policy.mjs +63 -15
  87. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +1 -1
  88. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +194 -32
  89. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.test.mjs +143 -0
  90. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +1 -44
  91. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +34 -18
  92. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +0 -0
  93. package/src/runtime/agent/orchestrator/tools/builtin/list-formatting.mjs +10 -0
  94. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +5 -4
  95. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +15 -0
  96. package/src/runtime/agent/orchestrator/tools/builtin/read-args.mjs +9 -44
  97. package/src/runtime/agent/orchestrator/tools/builtin/read-constants.mjs +2 -1
  98. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +13 -4
  99. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +10 -17
  100. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +18 -2
  101. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -2
  102. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +10 -0
  103. package/src/runtime/agent/orchestrator/tools/builtin.mjs +59 -1
  104. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +5 -5
  105. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +4076 -3985
  106. package/src/runtime/agent/orchestrator/tools/patch.mjs +116 -2
  107. package/src/runtime/channels/backends/discord.mjs +99 -9
  108. package/src/runtime/channels/backends/telegram.mjs +501 -0
  109. package/src/runtime/channels/index.mjs +441 -1254
  110. package/src/runtime/channels/lib/cli-worker-host.mjs +1 -8
  111. package/src/runtime/channels/lib/config.mjs +54 -3
  112. package/src/runtime/channels/lib/drop-trace.mjs +1 -1
  113. package/src/runtime/channels/lib/executor.mjs +0 -3
  114. package/src/runtime/channels/lib/format.mjs +4 -2
  115. package/src/runtime/channels/lib/memory-client.mjs +0 -38
  116. package/src/runtime/channels/lib/output-forwarder.mjs +77 -71
  117. package/src/runtime/channels/lib/runtime-paths.mjs +29 -6
  118. package/src/runtime/channels/lib/scheduler.mjs +1 -1
  119. package/src/runtime/channels/lib/session-discovery.mjs +0 -4
  120. package/src/runtime/channels/lib/telegram-format.mjs +283 -0
  121. package/src/runtime/channels/lib/tool-format.mjs +1 -2
  122. package/src/runtime/channels/lib/transcript-discovery.mjs +20 -11
  123. package/src/runtime/channels/lib/webhook.mjs +59 -31
  124. package/src/runtime/channels/tool-defs.mjs +1 -1
  125. package/src/runtime/lib/keychain-cjs.cjs +0 -1
  126. package/src/runtime/memory/data/runtime-manifest.json +6 -7
  127. package/src/runtime/memory/index.mjs +187 -43
  128. package/src/runtime/memory/lib/agent-ipc.mjs +2 -2
  129. package/src/runtime/memory/lib/core-memory-store.mjs +1 -1
  130. package/src/runtime/memory/lib/llm-worker-host.mjs +0 -4
  131. package/src/runtime/memory/lib/memory-cycle1.mjs +1 -1
  132. package/src/runtime/memory/lib/memory-cycle2.mjs +9 -6
  133. package/src/runtime/memory/lib/memory-cycle3.mjs +1 -1
  134. package/src/runtime/memory/lib/memory-ops-policy.mjs +0 -1
  135. package/src/runtime/memory/lib/memory.mjs +101 -4
  136. package/src/runtime/memory/lib/pg/adapter.mjs +139 -15
  137. package/src/runtime/memory/lib/runtime-fetcher.mjs +43 -18
  138. package/src/runtime/memory/lib/session-ingest.mjs +116 -7
  139. package/src/runtime/memory/lib/trace-store.mjs +69 -22
  140. package/src/runtime/memory/tool-defs.mjs +6 -3
  141. package/src/runtime/search/index.mjs +2 -7
  142. package/src/runtime/search/lib/config.mjs +0 -4
  143. package/src/runtime/search/lib/state.mjs +1 -15
  144. package/src/runtime/search/lib/web-tools.mjs +0 -1
  145. package/src/runtime/shared/channel-notification-routing.mjs +12 -0
  146. package/src/runtime/shared/channel-notification-routing.test.mjs +45 -0
  147. package/src/runtime/shared/child-spawn-gate.mjs +0 -6
  148. package/src/runtime/shared/config.mjs +9 -0
  149. package/src/runtime/shared/llm/http-agent.mjs +12 -5
  150. package/src/runtime/shared/schedules-store.mjs +21 -19
  151. package/src/runtime/shared/tool-surface.mjs +98 -13
  152. package/src/runtime/shared/transcript-writer.mjs +129 -0
  153. package/src/runtime/shared/update-checker.mjs +214 -0
  154. package/src/standalone/agent-tool.mjs +255 -109
  155. package/src/standalone/channel-admin.mjs +133 -40
  156. package/src/standalone/channel-worker.mjs +8 -291
  157. package/src/standalone/explore-tool.mjs +2 -2
  158. package/src/standalone/memory-runtime-proxy.mjs +3 -1
  159. package/src/standalone/provider-admin.mjs +11 -0
  160. package/src/standalone/seeds.mjs +1 -11
  161. package/src/standalone/usage-dashboard.mjs +1 -1
  162. package/src/tui/App.jsx +2137 -750
  163. package/src/tui/components/ConfirmBar.jsx +47 -0
  164. package/src/tui/components/ContextPanel.jsx +5 -3
  165. package/src/tui/components/ItemRightHintOverprint.jsx +54 -0
  166. package/src/tui/components/Markdown.jsx +22 -98
  167. package/src/tui/components/Message.jsx +14 -35
  168. package/src/tui/components/Picker.jsx +87 -12
  169. package/src/tui/components/PromptInput.jsx +146 -9
  170. package/src/tui/components/QueuedCommands.jsx +1 -1
  171. package/src/tui/components/SlashCommandPalette.jsx +8 -5
  172. package/src/tui/components/Spinner.jsx +7 -7
  173. package/src/tui/components/StatusLine.jsx +40 -21
  174. package/src/tui/components/TextEntryPanel.jsx +51 -7
  175. package/src/tui/components/ToolExecution.jsx +177 -100
  176. package/src/tui/components/TurnDone.jsx +4 -4
  177. package/src/tui/components/UsagePanel.jsx +1 -1
  178. package/src/tui/components/tool-output-format.mjs +312 -40
  179. package/src/tui/components/tool-output-format.test.mjs +180 -1
  180. package/src/tui/display-width.mjs +69 -0
  181. package/src/tui/display-width.test.mjs +35 -0
  182. package/src/tui/dist/index.mjs +7324 -2393
  183. package/src/tui/engine.mjs +287 -126
  184. package/src/tui/index.jsx +117 -7
  185. package/src/tui/keyboard-protocol.mjs +42 -0
  186. package/src/tui/lib/voice-recorder.mjs +453 -0
  187. package/src/tui/markdown/format-token.mjs +354 -142
  188. package/src/tui/markdown/format-token.test.mjs +155 -17
  189. package/src/tui/markdown/measure-rendered-rows.mjs +85 -0
  190. package/src/tui/markdown/render-ansi.test.mjs +1 -1
  191. package/src/tui/markdown/streaming-markdown.mjs +167 -0
  192. package/src/tui/markdown/streaming-markdown.test.mjs +70 -0
  193. package/src/tui/markdown/table-layout.mjs +9 -9
  194. package/src/tui/paste-attachments.mjs +0 -11
  195. package/src/tui/prompt-history-store.mjs +129 -0
  196. package/src/tui/prompt-history-store.test.mjs +52 -0
  197. package/src/tui/statusline-ansi-bridge.test.mjs +3 -3
  198. package/src/tui/theme.mjs +41 -647
  199. package/src/tui/themes/base.mjs +86 -0
  200. package/src/tui/themes/basic.mjs +85 -0
  201. package/src/tui/themes/catppuccin.mjs +72 -0
  202. package/src/tui/themes/dracula.mjs +70 -0
  203. package/src/tui/themes/everforest.mjs +71 -0
  204. package/src/tui/themes/gruvbox.mjs +71 -0
  205. package/src/tui/themes/index.mjs +71 -0
  206. package/src/tui/themes/indigo.mjs +78 -0
  207. package/src/tui/themes/kanagawa.mjs +80 -0
  208. package/src/tui/themes/light.mjs +81 -0
  209. package/src/tui/themes/nord.mjs +72 -0
  210. package/src/tui/themes/onedark.mjs +16 -0
  211. package/src/tui/themes/rosepine.mjs +70 -0
  212. package/src/tui/themes/teal.mjs +81 -0
  213. package/src/tui/themes/tokyonight.mjs +79 -0
  214. package/src/tui/themes/utils.mjs +106 -0
  215. package/src/tui/themes/warm.mjs +79 -0
  216. package/src/tui/transcript-tool-failures.mjs +13 -2
  217. package/src/ui/markdown.mjs +1 -1
  218. package/src/ui/model-display.mjs +2 -2
  219. package/src/ui/statusline.mjs +26 -27
  220. package/src/vendor/statusline/bin/statusline-lib.mjs +0 -623
  221. package/src/vendor/statusline/bin/statusline-route.mjs +5 -12
  222. package/src/vendor/statusline/src/gateway/claude-current.mjs +3 -3
  223. package/src/vendor/statusline/src/gateway/route-meta.mjs +30 -16
  224. package/src/workflows/default/WORKFLOW.md +39 -12
  225. package/src/workflows/sequential/WORKFLOW.md +46 -0
  226. package/src/workflows/solo/WORKFLOW.md +7 -0
  227. package/vendor/ink/build/display-width.js +62 -0
  228. package/vendor/ink/build/ink.js +154 -20
  229. package/vendor/ink/build/measure-text.js +4 -1
  230. package/vendor/ink/build/output.js +115 -9
  231. package/vendor/ink/build/render-node-to-output.js +4 -1
  232. package/vendor/ink/build/render.js +4 -0
  233. package/src/hooks/lib/permission-rules.cjs +0 -170
  234. package/src/hooks/lib/settings-loader.cjs +0 -112
  235. package/src/lib/hook-pipe-path.cjs +0 -10
  236. package/src/output-styles/extreme-simple.md +0 -20
  237. package/src/rules/lead/04-workflow.md +0 -51
  238. package/src/runtime/channels/lib/hook-pipe-server.mjs +0 -671
  239. package/src/workflows/default/workflow.json +0 -13
  240. package/src/workflows/solo/workflow.json +0 -7
@@ -1,7 +1,7 @@
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
- import { classifyError, midstreamBackoffFor, withRetry } from './retry-classifier.mjs';
4
+ import { classifyError, midstreamBackoffFor, sleepWithAbort, withRetry } from './retry-classifier.mjs';
5
5
  import { traceAgentUsage } from '../agent-trace.mjs';
6
6
  import {
7
7
  PROVIDER_FIRST_BYTE_TIMEOUT_MS,
@@ -15,29 +15,29 @@ 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
 
22
- async function _midstreamSleepWithAbort(ms, signal) {
23
- if (!ms) return;
24
- if (!signal) {
25
- await new Promise((r) => setTimeout(r, ms));
26
- return;
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;
27
33
  }
28
- await new Promise((resolve, reject) => {
29
- const t = setTimeout(() => {
30
- try { signal.removeEventListener('abort', onAbort); } catch {}
31
- resolve();
32
- }, ms);
33
- const onAbort = () => {
34
- clearTimeout(t);
35
- const reason = signal.reason;
36
- reject(reason instanceof Error ? reason : new Error('Anthropic mid-stream retry backoff aborted'));
37
- };
38
- if (signal.aborted) { onAbort(); return; }
39
- signal.addEventListener('abort', onAbort, { once: true });
40
- });
34
+ return _Anthropic;
35
+ }
36
+
37
+ // Abort-aware mid-stream backoff sleep → shared sleepWithAbort
38
+ // (retry-classifier.mjs). abortMessage preserves the prior fallback text.
39
+ function _midstreamSleepWithAbort(ms, signal) {
40
+ return sleepWithAbort(ms, signal, undefined, 'Anthropic mid-stream retry backoff aborted');
41
41
  }
42
42
 
43
43
  // 4-BP cache policy aligned with anthropic-oauth — system + tier3 +
@@ -132,6 +132,10 @@ function _defaultContextForModel(id, family) {
132
132
  return 200000;
133
133
  }
134
134
 
135
+ function _capabilitySupported(capability) {
136
+ return capability === true || capability?.supported === true;
137
+ }
138
+
135
139
  function _normalizeAnthropicModel(raw, provider = 'anthropic') {
136
140
  const id = raw?.id || raw?.name || raw?.model;
137
141
  if (!id) return null;
@@ -139,15 +143,18 @@ function _normalizeAnthropicModel(raw, provider = 'anthropic') {
139
143
  const family = familyMatch ? familyMatch[1].toLowerCase() : 'other';
140
144
  const dated = /-\d{8}$/.test(String(id));
141
145
  const versioned = !dated && /^claude-[a-z]+-\d+(?:-\d+)?$/i.test(String(id));
146
+ const effortValues = effortValuesForModel(raw?.capabilities, id);
142
147
  return {
143
148
  id,
144
149
  display: raw?.display_name || raw?.displayName || raw?.display || _prettyName(id, family),
145
150
  family,
146
151
  provider,
147
- contextWindow: raw?.context_window || raw?.max_context_window || raw?.input_token_limit || raw?.inputTokenLimit || _defaultContextForModel(id, family),
148
- 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,
149
154
  tier: dated ? 'dated' : versioned ? 'version' : 'family',
150
155
  latest: false,
156
+ supportsReasoning: effortValues.length > 0 || _capabilitySupported(raw?.capabilities?.thinking),
157
+ reasoningOptions: effortValues.length ? [{ type: 'effort', values: effortValues }] : [],
151
158
  };
152
159
  }
153
160
  // Family-based heuristic so new model ids (including custom user-configured
@@ -160,15 +167,6 @@ function resolveMaxTokens(model) {
160
167
  return 8192;
161
168
  }
162
169
 
163
- // Effort → thinking budget tokens (Anthropic extended thinking)
164
- const EFFORT_BUDGET = {
165
- low: 1024,
166
- medium: 4096,
167
- high: 16384,
168
- xhigh: 32768,
169
- max: 32768,
170
- };
171
-
172
170
  const MIN_THINKING_BUDGET = 1024;
173
171
  const THINKING_OUTPUT_RESERVE = 1024;
174
172
 
@@ -404,7 +402,7 @@ export class AnthropicProvider {
404
402
  this.config = config;
405
403
  this.name = config.name || 'anthropic';
406
404
  const betaHeaders = config.disableBetaHeaders ? null : buildAnthropicBetaHeaders({ toolSearch: true });
407
- this.client = new Anthropic({
405
+ this.client = new (loadAnthropic())({
408
406
  apiKey: config.apiKey || process.env.ANTHROPIC_API_KEY,
409
407
  ...(config.baseURL ? { baseURL: config.baseURL } : {}),
410
408
  defaultHeaders: { ...(betaHeaders ? { 'anthropic-beta': betaHeaders } : {}), ...(config.extraHeaders || {}) },
@@ -418,7 +416,7 @@ export class AnthropicProvider {
418
416
  if (newKey) {
419
417
  this.config = { ...(this.config || {}), ...(cfg || {}), apiKey: newKey };
420
418
  const betaHeaders = this.config.disableBetaHeaders ? null : buildAnthropicBetaHeaders({ toolSearch: true });
421
- this.client = new Anthropic({
419
+ this.client = new (loadAnthropic())({
422
420
  apiKey: newKey,
423
421
  ...(this.config.baseURL ? { baseURL: this.config.baseURL } : {}),
424
422
  defaultHeaders: { ...(betaHeaders ? { 'anthropic-beta': betaHeaders } : {}), ...(this.config.extraHeaders || {}) },
@@ -496,16 +494,21 @@ export class AnthropicProvider {
496
494
  // Anthropic prefix semantics (order: tools → system → messages).
497
495
  params.tools = [...nativeTools, ...toAnthropicTools([...(tools || []), ...deferredAnthropicTools(tools || [], opts)])];
498
496
  }
499
- // Effort extended thinking budget. Gateway inherit mode may pass the
500
- // exact OAuth client budget from the incoming Anthropic request.
501
- const thinkingBudgetTokens = Number(opts.thinkingBudgetTokens);
502
- const requestedThinkingBudget = Number.isFinite(thinkingBudgetTokens) && thinkingBudgetTokens > 0
503
- ? thinkingBudgetTokens
504
- : (opts.effort && EFFORT_BUDGET[opts.effort] ? EFFORT_BUDGET[opts.effort] : null);
505
- const budgetTokens = clampThinkingBudgetTokens(requestedThinkingBudget, maxTokens);
506
- if (budgetTokens) {
507
- params.thinking = { type: 'enabled', budget_tokens: budgetTokens };
508
- }
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
+ });
509
512
  // Fast mode → speed: "fast" on models Anthropic marks as speed-capable.
510
513
  if (opts.fast === true && supportsAnthropicFastMode(useModel)) {
511
514
  params.speed = 'fast';
@@ -539,12 +542,20 @@ export class AnthropicProvider {
539
542
  try { totalSignal.removeEventListener('abort', handler); } catch {}
540
543
  };
541
544
 
542
- const betaHeaders = {
543
- 'anthropic-beta': buildAnthropicBetaHeaders({
544
- fastMode: this.fastModeBetaHeaderLatched,
545
- toolSearch: true,
546
- }),
547
- };
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
+ };
548
559
 
549
560
  const MAX_MIDSTREAM_RETRIES = ANTHROPIC_MAX_MIDSTREAM_RETRIES;
550
561
  let firstAttemptError = null;
@@ -638,7 +649,7 @@ export class AnthropicProvider {
638
649
  async ({ signal: attemptSignal }) => {
639
650
  const res = await this.client.messages.create(params, {
640
651
  signal: attemptSignal,
641
- headers: betaHeaders,
652
+ ...(betaHeaders ? { headers: betaHeaders } : {}),
642
653
  }).asResponse();
643
654
  if (!res.ok) {
644
655
  const text = await res.text().catch(() => '');
@@ -691,11 +702,12 @@ export class AnthropicProvider {
691
702
  const parseResult = await parseSSEStream(
692
703
  response,
693
704
  streamController.signal,
694
- () => streamController.abort(),
705
+ (reason) => streamController.abort(reason),
695
706
  onStreamDelta,
696
707
  onToolCall,
697
708
  midState,
698
709
  onTextDelta,
710
+ knownToolNames,
699
711
  );
700
712
 
701
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 },