mixdog 0.9.52 → 0.9.53

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 (111) hide show
  1. package/package.json +1 -1
  2. package/scripts/bench-run.mjs +2 -2
  3. package/scripts/compact-pressure-test.mjs +104 -0
  4. package/scripts/desktop-session-bridge-test.mjs +704 -0
  5. package/scripts/freevar-smoke.mjs +7 -4
  6. package/scripts/lifecycle-api-test.mjs +65 -4
  7. package/scripts/max-output-recovery-test.mjs +31 -0
  8. package/scripts/memory-core-input-test.mjs +10 -0
  9. package/scripts/openai-oauth-ws-1006-retry-test.mjs +63 -3
  10. package/scripts/openai-ws-early-settle-test.mjs +40 -0
  11. package/scripts/parent-abort-link-test.mjs +24 -0
  12. package/scripts/process-lifecycle-test.mjs +80 -22
  13. package/scripts/provider-contract-test.mjs +257 -0
  14. package/scripts/provider-toolcall-test.mjs +172 -10
  15. package/scripts/session-orphan-sweep-test.mjs +27 -1
  16. package/src/lib/keychain-cjs.cjs +36 -23
  17. package/src/runtime/agent/orchestrator/agent-runtime/agent-dispatch.mjs +1 -13
  18. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +7 -8
  19. package/src/runtime/agent/orchestrator/agent-trace.mjs +33 -9
  20. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +7 -2
  21. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +14 -300
  22. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +2 -4
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +18 -266
  24. package/src/runtime/agent/orchestrator/providers/gemini-cache.mjs +18 -1
  25. package/src/runtime/agent/orchestrator/providers/gemini.mjs +15 -130
  26. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +5 -115
  27. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +224 -0
  28. package/src/runtime/agent/orchestrator/providers/lib/env-utils.mjs +6 -0
  29. package/src/runtime/agent/orchestrator/providers/lib/gemini-model-catalog.mjs +119 -0
  30. package/src/runtime/agent/orchestrator/providers/lib/grok-tool-schema.mjs +109 -0
  31. package/src/runtime/agent/orchestrator/providers/lib/openai-tool-args.mjs +70 -0
  32. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +14 -71
  33. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +47 -3
  34. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +1 -7
  35. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +72 -77
  36. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +15 -20
  37. package/src/runtime/agent/orchestrator/providers/openai-ws-delta.mjs +10 -10
  38. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +30 -3
  39. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +37 -28
  40. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +5 -7
  41. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -0
  42. package/src/runtime/agent/orchestrator/session/context-compaction-policy.mjs +170 -0
  43. package/src/runtime/agent/orchestrator/session/context-utils.mjs +20 -223
  44. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +11 -17
  45. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +12 -5
  46. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +21 -5
  47. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +3 -58
  48. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +24 -0
  49. package/src/runtime/agent/orchestrator/session/manager/usage-metrics.mjs +57 -16
  50. package/src/runtime/agent/orchestrator/session/save-session-worker.mjs +2 -2
  51. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +7 -1
  52. package/src/runtime/agent/orchestrator/session/store/paths-heartbeat.mjs +52 -0
  53. package/src/runtime/agent/orchestrator/session/store/write-guards.mjs +62 -0
  54. package/src/runtime/agent/orchestrator/session/store.mjs +305 -127
  55. package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +46 -0
  56. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-grep-chunks.mjs +173 -0
  57. package/src/runtime/agent/orchestrator/tools/builtin/lib/search-input-helpers.mjs +117 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-job-insights.mjs +199 -0
  59. package/src/runtime/agent/orchestrator/tools/builtin/lib/shell-spawn-helpers.mjs +107 -0
  60. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +1 -40
  61. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +19 -277
  62. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +22 -298
  63. package/src/runtime/agent/orchestrator/tools/lib/shell-spawn-retry.mjs +67 -0
  64. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -71
  65. package/src/runtime/channels/backends/discord-gateway.mjs +1 -1
  66. package/src/runtime/channels/backends/discord.mjs +6 -6
  67. package/src/runtime/channels/lib/config.mjs +15 -2
  68. package/src/runtime/channels/lib/memory-client.mjs +20 -3
  69. package/src/runtime/channels/lib/owned-runtime.mjs +19 -12
  70. package/src/runtime/channels/lib/status-snapshot.mjs +9 -0
  71. package/src/runtime/channels/lib/tool-dispatch.mjs +9 -5
  72. package/src/runtime/channels/lib/worker-main.mjs +16 -5
  73. package/src/runtime/memory/index.mjs +24 -198
  74. package/src/runtime/memory/lib/memory-action-handlers.mjs +2 -53
  75. package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +115 -0
  76. package/src/runtime/memory/lib/memory-port-advertiser.mjs +105 -0
  77. package/src/runtime/memory/lib/pg/supervisor.mjs +0 -4
  78. package/src/runtime/memory/lib/query-handlers.mjs +2 -122
  79. package/src/runtime/memory/lib/query-maintenance-handlers.mjs +126 -0
  80. package/src/runtime/memory/lib/tool-call-handler.mjs +57 -0
  81. package/src/runtime/search/lib/http-fetch.mjs +1 -1
  82. package/src/runtime/shared/config.mjs +58 -13
  83. package/src/runtime/shared/llm/cost.mjs +14 -4
  84. package/src/runtime/shared/memory-snapshot.mjs +236 -0
  85. package/src/runtime/shared/process-lifecycle.mjs +92 -19
  86. package/src/runtime/shared/process-shutdown.mjs +6 -0
  87. package/src/runtime/shared/resource-admission.mjs +7 -2
  88. package/src/session-runtime/channel-config-api.mjs +7 -7
  89. package/src/session-runtime/config-lifecycle.mjs +20 -17
  90. package/src/session-runtime/cwd-plugins.mjs +9 -7
  91. package/src/session-runtime/env.mjs +1 -2
  92. package/src/session-runtime/hitch-profile.mjs +45 -0
  93. package/src/session-runtime/lifecycle-api.mjs +36 -9
  94. package/src/session-runtime/mcp-glue.mjs +6 -11
  95. package/src/session-runtime/provider-init-key.mjs +17 -0
  96. package/src/session-runtime/runtime-core.mjs +44 -103
  97. package/src/session-runtime/runtime-paths.mjs +20 -0
  98. package/src/session-runtime/runtime-tool-routing.mjs +55 -0
  99. package/src/session-runtime/tool-catalog-data.mjs +51 -0
  100. package/src/session-runtime/tool-catalog.mjs +15 -89
  101. package/src/standalone/agent-tool/spawn-preset.mjs +73 -0
  102. package/src/standalone/agent-tool/worker-rows.mjs +93 -0
  103. package/src/standalone/agent-tool.mjs +12 -162
  104. package/src/standalone/channel-admin.mjs +29 -0
  105. package/src/tui/App.jsx +11 -5
  106. package/src/tui/dist/index.mjs +202 -65
  107. package/src/tui/engine/session-api-ext.mjs +37 -4
  108. package/src/tui/index.jsx +1 -0
  109. package/src/tui/lib/voice-setup.mjs +5 -5
  110. package/scripts/_devtools-stub.mjs +0 -1
  111. package/src/runtime/lib/keychain-cjs.cjs +0 -288
@@ -24,7 +24,7 @@ import {
24
24
  ANTHROPIC_MAX_MIDSTREAM_RETRIES,
25
25
  parseSSEStream,
26
26
  _classifyMidstreamError,
27
- } from './anthropic-oauth.mjs';
27
+ } from './anthropic-sse.mjs';
28
28
  import { buildAnthropicBetaHeaders, supportsAnthropicFastMode } from './anthropic-betas.mjs';
29
29
  import {
30
30
  applyAnthropicEffortToBody,
@@ -34,11 +34,21 @@ import {
34
34
  import { normalizeContentForAnthropic } from './media-normalization.mjs';
35
35
  import { enrichModels } from './model-catalog.mjs';
36
36
  import { sanitizeModelList } from './model-list-sanitize.mjs';
37
- import { providerNativeToolPrefixCount } from '../../../../session-runtime/provider-request-tools.mjs';
38
37
  import { makeModelCache } from './model-cache.mjs';
39
38
  import { resolveAnthropicMaxTokens } from './anthropic-max-tokens.mjs';
40
39
  import { getLlmDispatcher } from '../../../shared/llm/http-agent.mjs';
41
40
  import { notifyCurrentAnthropicRateLimit } from './admission-scheduler.mjs';
41
+ import {
42
+ ANTHROPIC_CACHE_TTL_STABLE as CACHE_TTL_STABLE,
43
+ ANTHROPIC_CACHE_TTL_VOLATILE as CACHE_TTL_VOLATILE,
44
+ applyAnthropicCacheMarkers,
45
+ clampAnthropicThinkingBudget as clampThinkingBudgetTokens,
46
+ deferredAnthropicTools as sharedDeferredAnthropicTools,
47
+ requestAnthropicTools as sharedRequestAnthropicTools,
48
+ resolveAnthropicCacheTtls as resolveCacheTtls,
49
+ sanitizeAnthropicInputSchema,
50
+ toAnthropicToolChoice,
51
+ } from './lib/anthropic-request-utils.mjs';
42
52
 
43
53
  const require = createRequire(import.meta.url);
44
54
  let _Anthropic = null;
@@ -61,66 +71,11 @@ function _midstreamSleepWithAbort(ms, signal) {
61
71
  // breakpoint, so they do not spend a separate cache_control slot. 1h TTL
62
72
  // requires the extended-cache-ttl beta header, which we set on the client via
63
73
  // defaultHeaders below.
64
- const CACHE_TTL_STABLE = { type: 'ephemeral', ttl: '1h' };
65
- const CACHE_TTL_VOLATILE = { type: 'ephemeral' };
66
-
67
- function withCacheControl(block, ttl = CACHE_TTL_VOLATILE) {
68
- if (!block || typeof block !== 'object' || block.cache_control) return block;
69
- return { ...block, cache_control: ttl };
70
- }
71
-
72
- function appendCacheControl(content, ttl = CACHE_TTL_VOLATILE) {
73
- if (Array.isArray(content)) {
74
- if (content.length === 0) return content;
75
- const next = [...content];
76
- next[next.length - 1] = withCacheControl(next[next.length - 1], ttl);
77
- return next;
78
- }
79
- if (typeof content === 'string') {
80
- return [withCacheControl({ type: 'text', text: content }, ttl)];
81
- }
82
- return content;
83
- }
84
74
 
85
75
  // BP3 (tier3) rides its own `system` role block (the 3rd system block, tagged
86
76
  // cacheTier:'tier3'). buildSystemBlocks applies the tier3 1h cache_control to
87
77
  // that block; BP1/BP2 take the system TTL. Mirrors anthropic-oauth.mjs.
88
78
 
89
- function resolveCacheTtls(opts) {
90
- const strategy = opts?.cacheStrategy || {};
91
- const pick = (layer, fallback) => {
92
- const v = strategy[layer];
93
- if (v === '1h') return CACHE_TTL_STABLE;
94
- if (v === '5m') return CACHE_TTL_VOLATILE;
95
- if (v === 'none') return null;
96
- return fallback;
97
- };
98
- const resolved = {
99
- tools: pick('tools', null),
100
- system: pick('system', CACHE_TTL_STABLE),
101
- tier3: pick('tier3', CACHE_TTL_STABLE),
102
- messages: pick('messages', CACHE_TTL_STABLE),
103
- };
104
- // A partial cacheStrategy override (e.g. {system:'5m'} while tier3/
105
- // messages default to '1h') can put a longer TTL after a shorter one in
106
- // request order, which Anthropic rejects: 1h breakpoints must all appear
107
- // before any 5m breakpoint. Normalize left-to-right in wire order
108
- // (system -> tier3 -> messages; tools is emitted before system and is
109
- // excluded from the run) so a later layer is downgraded to the earliest
110
- // shorter TTL seen so far — never re-promoted. Layers set to null ('none')
111
- // emit no breakpoint at all, so they neither violate nor constrain
112
- // ordering and are skipped. Mirrors anthropic-oauth.mjs.
113
- const ttlRank = (ttl) => (ttl === CACHE_TTL_STABLE ? 2 : 1); // 1h=2, 5m=1
114
- let minRank = Infinity;
115
- for (const layer of ['system', 'tier3', 'messages']) {
116
- if (!resolved[layer]) continue;
117
- const rank = ttlRank(resolved[layer]);
118
- if (rank > minRank) resolved[layer] = CACHE_TTL_VOLATILE;
119
- else minRank = rank;
120
- }
121
- return resolved;
122
- }
123
-
124
79
  function buildSystemBlocks(systemMsgs, systemTtl, tier3Ttl) {
125
80
  // systemMsgs is an array of { content, cacheTier }. Each non-empty element
126
81
  // becomes its own content block: cacheTier:'tier3' (BP3 sessionMarker) gets
@@ -248,84 +203,11 @@ export const _test = {
248
203
  resolveMaxTokens,
249
204
  deferredAnthropicTools,
250
205
  requestAnthropicTools,
251
- sanitizeInputSchema: _sanitizeInputSchema,
206
+ sanitizeInputSchema: (schema, toolName) => sanitizeAnthropicInputSchema(schema, toolName, 'anthropic'),
252
207
  };
253
208
 
254
- const MIN_THINKING_BUDGET = 1024;
255
- const THINKING_OUTPUT_RESERVE = 1024;
256
-
257
- function clampThinkingBudgetTokens(value, maxTokens) {
258
- const desired = Math.floor(Number(value));
259
- const max = Math.floor(Number(maxTokens));
260
- if (!Number.isFinite(desired) || desired <= 0 || !Number.isFinite(max)) return null;
261
- const ceiling = max - THINKING_OUTPUT_RESERVE;
262
- if (ceiling < MIN_THINKING_BUDGET) return null;
263
- return Math.max(MIN_THINKING_BUDGET, Math.min(desired, ceiling));
264
- }
265
209
  // Anthropic forbids oneOf / allOf / anyOf at the TOP level of input_schema.
266
210
  // Mirror the same sanitizer as anthropic-oauth.mjs so both providers are safe.
267
- function _sanitizeInputSchema(schema, toolName) {
268
- if (!schema || typeof schema !== 'object') {
269
- return { type: 'object', properties: {} };
270
- }
271
- const compound = schema.oneOf || schema.anyOf || schema.allOf;
272
- if (!compound) return structuredClone(schema);
273
- const mergedProps = { ...(schema.properties && typeof schema.properties === 'object' ? schema.properties : {}) };
274
- const branchDescs = [];
275
- for (const branch of Array.isArray(compound) ? compound : []) {
276
- if (branch && typeof branch === 'object' && branch.properties) {
277
- Object.assign(mergedProps, branch.properties);
278
- }
279
- if (branch && typeof branch === 'object') {
280
- const parts = [];
281
- if (branch.description) parts.push(branch.description);
282
- else if (branch.type) parts.push(`type:${branch.type}`);
283
- if (parts.length) branchDescs.push(parts.join(' '));
284
- }
285
- }
286
- const compoundKey = schema.oneOf ? 'oneOf' : schema.anyOf ? 'anyOf' : 'allOf';
287
- let description = schema.description || '';
288
- if (branchDescs.length) {
289
- const parts = [];
290
- let used = 0;
291
- for (let i = 0; i < branchDescs.length; i++) {
292
- const v = `(variant ${i + 1}: ${branchDescs[i]})`;
293
- if (used + v.length + (parts.length ? 1 : 0) > 500) break;
294
- parts.push(v);
295
- used += v.length + (parts.length > 1 ? 1 : 0);
296
- }
297
- const addition = parts.join(' ');
298
- if (addition) description = description ? `${description} ${addition}` : addition;
299
- }
300
- const mergedPropsCount = Object.keys(mergedProps).length;
301
- if (process.env.MIXDOG_DEBUG_SESSION_LOG) {
302
- process.stderr.write(
303
- `[anthropic-sanitizer] tool="${toolName ?? ''}" compound="${compoundKey}" branches=${Array.isArray(compound) ? compound.length : 0} mergedProps=${mergedPropsCount}\n`
304
- );
305
- }
306
- return {
307
- type: 'object',
308
- ...(description ? { description } : {}),
309
- properties: mergedProps,
310
- };
311
- }
312
-
313
- function toAnthropicTools(tools) {
314
- return tools.map((t) => {
315
- const out = {
316
- name: t.name,
317
- description: t.description,
318
- input_schema: _sanitizeInputSchema(t.inputSchema, t.name),
319
- };
320
- if (t.deferLoading === true || t.defer_loading === true) out.defer_loading = true;
321
- return out;
322
- });
323
- }
324
- function nativeAnthropicTools(opts) {
325
- return Array.isArray(opts?.nativeTools)
326
- ? opts.nativeTools.filter(t => t && typeof t === 'object')
327
- : [];
328
- }
329
211
  // Map the orchestrator-level opts.toolChoice into Anthropic's tool_choice.
330
212
  // Only 'none' is activated: it lets the hard-cap final turn keep the tool
331
213
  // DEFINITIONS in-request (so the tools->system->messages prefix — and its
@@ -339,58 +221,11 @@ function nativeAnthropicTools(opts) {
339
221
  // previously-harmless no-op into a hard 400 on exactly that turn. Attached
340
222
  // only when the request actually carries tools (see _doSend). Mirrors
341
223
  // anthropic-oauth.mjs.
342
- function toAnthropicToolChoice(toolChoice) {
343
- return toolChoice === 'none' ? { type: 'none' } : undefined;
344
- }
345
- function discoveredAnthropicToolNames(messages, opts, provider) {
346
- const anthropicNative = new Set(['anthropic', 'anthropic-oauth']);
347
- const discovered = new Set(
348
- Array.isArray(opts?.session?.deferredDiscoveredTools)
349
- ? opts.session.deferredDiscoveredTools.map((name) => String(name || '').trim()).filter(Boolean)
350
- : [],
351
- );
352
- for (const message of Array.isArray(messages) ? messages : []) {
353
- const native = message?.nativeToolSearch;
354
- const source = String(native?.provider || '').toLowerCase();
355
- if (source && source !== provider
356
- && !(anthropicNative.has(source) && anthropicNative.has(provider))) continue;
357
- for (const name of Array.isArray(native?.toolReferences) ? native.toolReferences : []) {
358
- const key = String(name || '').trim();
359
- if (key) discovered.add(key);
360
- }
361
- }
362
- return discovered;
363
- }
364
224
  function deferredAnthropicTools(activeTools, messages, opts) {
365
- if (opts?.session?.deferredNativeTools !== true) return [];
366
- // Guard against an all-deferred tools array — the API rejects it with
367
- // `400: At least one tool must have defer_loading=false` (iteration-cap
368
- // final turn sends tools: []). See anthropic-oauth.mjs counterpart.
369
- if (!Array.isArray(activeTools) || activeTools.length === 0) return [];
370
- const active = new Set((activeTools || []).map((tool) => String(tool?.name || '').trim()).filter(Boolean));
371
- const discovered = discoveredAnthropicToolNames(messages, opts, 'anthropic');
372
- const catalog = Array.isArray(opts.session.deferredToolCatalog) ? opts.session.deferredToolCatalog : [];
373
- return catalog
374
- .filter((tool) => tool?.name && discovered.has(String(tool.name)) && !active.has(String(tool.name)))
375
- .map((tool) => ({ ...tool, deferLoading: true }));
225
+ return sharedDeferredAnthropicTools(activeTools, messages, opts, 'anthropic');
376
226
  }
377
227
  function requestAnthropicTools(tools, messages, opts) {
378
- const activeTools = Array.isArray(tools) ? tools : [];
379
- if (opts?.providerToolSnapshotAuthoritative === true) {
380
- const nativePrefixCount = providerNativeToolPrefixCount(
381
- activeTools,
382
- opts.providerNativeToolPrefixCount,
383
- );
384
- return [
385
- ...activeTools.slice(0, nativePrefixCount),
386
- ...toAnthropicTools(activeTools.slice(nativePrefixCount)),
387
- ];
388
- }
389
- const deferredTools = deferredAnthropicTools(activeTools, messages, opts);
390
- return [
391
- ...nativeAnthropicTools(opts),
392
- ...toAnthropicTools([...activeTools, ...deferredTools]),
393
- ];
228
+ return sharedRequestAnthropicTools(tools, messages, opts, 'anthropic');
394
229
  }
395
230
  function toAnthropicMessages(messages) {
396
231
  // Marker-free lowering. cache_control is applied AFTER sanitization by
@@ -492,92 +327,6 @@ export function _toAnthropicMessagesForTest(messages) {
492
327
  // messageTtl === null disables the tail. BP3 (tier3) now rides a system block,
493
328
  // so it is no longer marked here.
494
329
  // ANTHROPIC_MSG_SLOTS=0 is honoured upstream by passing messageTtl = null.
495
- function applyAnthropicCacheMarkers(sanitizedMessages, { messageTtl = CACHE_TTL_VOLATILE, messageSlots = 1 } = {}) {
496
- if (!Array.isArray(sanitizedMessages) || sanitizedMessages.length === 0) {
497
- return sanitizedMessages;
498
- }
499
-
500
- const firstText = (content) => {
501
- if (typeof content === 'string') return content;
502
- if (Array.isArray(content)) {
503
- const first = content.find((b) => b?.type === 'text');
504
- return first && typeof first.text === 'string' ? first.text : '';
505
- }
506
- return '';
507
- };
508
- const isSystemReminder = (content) => firstText(content).startsWith('<system-reminder>');
509
-
510
- const markLast = (msg, ttl) => {
511
- if (!msg) return;
512
- msg.content = appendCacheControl(msg.content, ttl);
513
- };
514
- const ttlRank = (ttl) => ttl?.ttl === '1h' ? 2 : 1;
515
- const canMarkMessageIdx = (idx) => {
516
- // System-reminder messages (volatileTail / roleSpecific BP4) vary
517
- // per-call, so never pin them with a 1h marker. The 1h system blocks
518
- // (BP1/BP2/BP3) already satisfy Anthropic's "1h before 5m" ordering.
519
- if (idx < 0) return false;
520
- const msg = sanitizedMessages[idx];
521
- if (ttlRank(messageTtl) > ttlRank(CACHE_TTL_VOLATILE)
522
- && isSystemReminder(msg?.content)) {
523
- return false;
524
- }
525
- return true;
526
- };
527
- const hasUserText = (msg) => {
528
- if (msg?.role !== 'user') return false;
529
- if (isSystemReminder(msg.content)) return false;
530
- if (typeof msg.content === 'string') return msg.content.trim().length > 0;
531
- if (!Array.isArray(msg.content)) return false;
532
- return msg.content.some(b => b?.type === 'text' && typeof b.text === 'string' && b.text.trim().length > 0);
533
- };
534
- const previousUserTextAnchorIdx = () => {
535
- const tailIdx = sanitizedMessages.length - 1;
536
- for (let i = tailIdx - 1; i >= 0; i--) {
537
- if (hasUserText(sanitizedMessages[i])) return i;
538
- }
539
- return -1;
540
- };
541
- const latestToolResultTailIdx = () => {
542
- for (let i = sanitizedMessages.length - 1; i >= 0; i--) {
543
- const msg = sanitizedMessages[i];
544
- if (msg?.role !== 'user' || !Array.isArray(msg.content) || msg.content.length === 0) continue;
545
- const lastBlock = msg.content[msg.content.length - 1];
546
- if (lastBlock?.type === 'tool_result') return i;
547
- }
548
- return -1;
549
- };
550
-
551
- const firstRequestUserPromptIdx = () => {
552
- // Iteration-1 fallback: on the very first request a session has only the
553
- // current user prompt — no tool_result tail and no earlier user turn, so
554
- // both anchors above return -1 and NO message breakpoint is placed. That
555
- // left the whole tools+system prefix (~4.2k) uncached on iter1: nothing
556
- // was written, so iter2 re-sent it as a fresh full write instead of a
557
- // read hit. Anchor the current prompt's tail so the stable prefix is
558
- // cache-written on the first ask and read back on the next. Only used
559
- // when neither real anchor exists, so later turns still prefer the
560
- // tool_result / previous-user-text anchors (never the volatile new
561
- // prompt). Synthetic <system-reminder> turns are excluded by hasUserText.
562
- if (latestToolResultTailIdx() !== -1 || previousUserTextAnchorIdx() !== -1) return -1;
563
- const tailIdx = sanitizedMessages.length - 1;
564
- return hasUserText(sanitizedMessages[tailIdx]) ? tailIdx : -1;
565
- };
566
- if (messageTtl !== null) {
567
- const slots = Math.max(0, Math.min(4, Number(messageSlots) || 0));
568
- const marked = new Set();
569
- const candidates = [latestToolResultTailIdx(), previousUserTextAnchorIdx(), firstRequestUserPromptIdx()];
570
- for (const idx of candidates) {
571
- if (slots <= 0) break;
572
- if (idx < 0 || marked.has(idx) || !canMarkMessageIdx(idx)) continue;
573
- markLast(sanitizedMessages[idx], messageTtl);
574
- marked.add(idx);
575
- if (marked.size >= slots) break;
576
- }
577
- }
578
-
579
- return sanitizedMessages;
580
- }
581
330
  export class AnthropicProvider {
582
331
  // Anthropic reports usage.input_tokens EXCLUDING cache_read/cache_creation
583
332
  // (those are separate fields), so the live context-window footprint must
@@ -799,6 +548,9 @@ export class AnthropicProvider {
799
548
  rawUsage: usageRaw,
800
549
  provider: this.name,
801
550
  requestKind: opts.requestKind || null,
551
+ // Anthropic usage is additive at this inner transport
552
+ // boundary, even when OpenCode Go supplies the provider id.
553
+ inputTokensInclusive: false,
802
554
  });
803
555
  }
804
556
 
@@ -204,12 +204,29 @@ export function _resolveGeminiCacheUsage({ usageMetadata, cachedContent, provide
204
204
  }
205
205
  return 0;
206
206
  };
207
- const inputTokens = firstFinite(
207
+ const promptTokens = firstFinite(
208
208
  usageMetadata?.promptTokenCount,
209
209
  usageMetadata?.prompt_token_count,
210
+ );
211
+ const totalTokens = firstFinite(
210
212
  usageMetadata?.totalTokenCount,
211
213
  usageMetadata?.total_token_count,
212
214
  );
215
+ // Gemini totalTokenCount includes candidate and thought output tokens.
216
+ // When promptTokenCount is omitted, derive the prompt portion so callers
217
+ // that separately record output tokens do not count them twice.
218
+ const outputTokens = firstFinite(
219
+ usageMetadata?.candidatesTokenCount,
220
+ usageMetadata?.candidates_token_count,
221
+ ) + firstFinite(
222
+ usageMetadata?.thoughtsTokenCount,
223
+ usageMetadata?.thoughts_token_count,
224
+ );
225
+ const hasExplicitPromptTokens = Object.prototype.hasOwnProperty.call(usageMetadata || {}, 'promptTokenCount')
226
+ || Object.prototype.hasOwnProperty.call(usageMetadata || {}, 'prompt_token_count');
227
+ const inputTokens = hasExplicitPromptTokens
228
+ ? promptTokens
229
+ : Math.max(0, totalTokens - outputTokens);
213
230
  const reportedCachedTokens = firstFinite(
214
231
  // generateContent UsageMetadata field.
215
232
  usageMetadata?.cachedContentTokenCount,
@@ -1,6 +1,5 @@
1
1
  import { GoogleGenerativeAI } from '@google/generative-ai';
2
2
  import { getAgentApiKey } from '../../../shared/provider-api-key.mjs';
3
- import { makeModelCache } from './model-cache.mjs';
4
3
  import { withRetry } from './retry-classifier.mjs';
5
4
  import { traceAgentUsage, appendAgentTrace } from '../agent-trace.mjs';
6
5
  import {
@@ -50,107 +49,26 @@ import {
50
49
  _geminiCredentialFingerprint,
51
50
  _invalidateGeminiCachesForCredentialFingerprint,
52
51
  } from './gemini-cache.mjs';
52
+ import {
53
+ GEMINI_MODELS as MODELS,
54
+ DEFAULT_GEMINI_MODEL as DEFAULT_MODEL,
55
+ geminiModelCache as _modelCache,
56
+ fetchGeminiModelPages,
57
+ resolveLatestGeminiModel,
58
+ ensureLatestGeminiModel,
59
+ fetchAndCacheGeminiModels,
60
+ } from './lib/gemini-model-catalog.mjs';
53
61
 
54
62
  // Legacy import path: tests + gemini-stream import these from gemini.mjs.
55
63
  // Re-export the extracted symbols so existing importers resolve unchanged.
56
64
  export { createGeminiTextLeakGuard };
57
65
  export { parseToolCalls, emitGeminiToolCalls, collectGeminiGroundingSources, parseGeminiTextPartMetadata };
58
-
59
- const MODELS = [
60
- { id: 'gemini-3-flash-preview', name: 'Gemini 3 Flash Preview', provider: 'gemini', contextWindow: 1048576 },
61
- { id: 'gemini-3.1-pro-preview', name: 'Gemini 3.1 Pro Preview', provider: 'gemini', contextWindow: 1048576 },
62
- { id: 'gemini-3-pro-preview', name: 'Gemini 3 Pro Preview', provider: 'gemini', contextWindow: 1048576 },
63
- { id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash', provider: 'gemini', contextWindow: 1048576 },
64
- { id: 'gemini-2.5-pro', name: 'Gemini 2.5 Pro', provider: 'gemini', contextWindow: 1048576 },
65
- ];
66
-
67
- const DEFAULT_MODEL = MODELS[0].id;
68
-
69
- // --- Model catalog cache (24h disk TTL) ---
70
- // Gemini's /models has no `created` timestamp, so latest-resolution is
71
- // VERSION-based (parse gemini-X.Y) rather than release-date based.
72
- const MODEL_CACHE_TTL_MS = 24 * 60 * 60_000;
73
- // Bump when the on-disk cache shape changes so stale-shape entries are
74
- // discarded instead of misread.
75
- const GEMINI_MODEL_CACHE_SCHEMA_VERSION = 2;
66
+ export { fetchGeminiModelPages, resolveLatestGeminiModel, ensureLatestGeminiModel };
76
67
 
77
68
  // De-dupes concurrent force-refreshes so they share one HTTP round-trip,
78
69
  // mirroring anthropic-oauth's _modelRefreshInFlight.
79
70
  let _modelRefreshInFlight = null;
80
71
 
81
- export async function fetchGeminiModelPages(apiKey, fetchFn = fetch) {
82
- const items = [];
83
- let pageToken = '';
84
- do {
85
- const params = new URLSearchParams({ key: apiKey, pageSize: '1000' });
86
- if (pageToken) params.set('pageToken', pageToken);
87
- const url = `https://generativelanguage.googleapis.com/v1beta/models?${params}`;
88
- const res = await fetchFn(url, {
89
- signal: AbortSignal.timeout(60_000),
90
- dispatcher: getLlmDispatcher(),
91
- });
92
- if (!res.ok) throw new Error(`gemini list_models ${res.status}`);
93
- const data = await res.json();
94
- if (Array.isArray(data?.models)) items.push(...data.models);
95
- pageToken = typeof data?.nextPageToken === 'string' ? data.nextPageToken : '';
96
- } while (pageToken);
97
- return items;
98
- }
99
-
100
- const _modelCache = makeModelCache({
101
- fileName: 'gemini-models.json',
102
- ttlMs: MODEL_CACHE_TTL_MS,
103
- version: GEMINI_MODEL_CACHE_SCHEMA_VERSION,
104
- });
105
-
106
- // Mirror of anthropic-oauth.mjs _compareVersion: compare two gemini ids by the
107
- // X.Y version embedded in the id (gemini-3.5-flash -> [3, 5]). Falls back to a
108
- // lexicographic tiebreak so ordering is total.
109
- function _compareVersion(a, b) {
110
- const na = (a.match(/gemini-(\d+)(?:\.(\d+))?/) || []).slice(1).map(Number);
111
- const nb = (b.match(/gemini-(\d+)(?:\.(\d+))?/) || []).slice(1).map(Number);
112
- for (let i = 0; i < Math.max(na.length, nb.length); i++) {
113
- if ((na[i] || 0) !== (nb[i] || 0)) return (na[i] || 0) - (nb[i] || 0);
114
- }
115
- return a.localeCompare(b);
116
- }
117
-
118
- // Per family, mark the highest-version model as latest:true.
119
- function _markLatestGemini(models) {
120
- const byFamily = new Map();
121
- for (const m of models) {
122
- if (!m?.id) continue;
123
- const cur = byFamily.get(m.family);
124
- if (!cur || _compareVersion(m.id, cur.id) > 0) {
125
- byFamily.set(m.family, m);
126
- }
127
- }
128
- for (const m of byFamily.values()) m.latest = true;
129
- }
130
-
131
- // Newest chat model by VERSION in the 'gemini-flash' family, read from the
132
- // on-disk catalog cache. Returns null until cached; callers warm via
133
- // ensureLatestGeminiModel when null.
134
- export function resolveLatestGeminiModel() {
135
- const cached = _modelCache.loadSync();
136
- if (!Array.isArray(cached)) return null;
137
- let best = null;
138
- for (const m of cached) {
139
- if (!m?.id || m.family !== 'gemini-flash') continue;
140
- if (!best || _compareVersion(m.id, best.id) > 0) best = m;
141
- }
142
- return best?.id || null;
143
- }
144
-
145
- export async function ensureLatestGeminiModel(provider) {
146
- let m = resolveLatestGeminiModel();
147
- if (m) return m;
148
- await provider._refreshModelCache();
149
- m = resolveLatestGeminiModel();
150
- if (m) return m;
151
- throw new Error('[gemini] model catalog unavailable after warmup — cannot resolve default model');
152
- }
153
-
154
72
  // --- Cache accounting/trace: extracted to gemini-cache.mjs ---
155
73
  // --- Stream consumption/guards: extracted to gemini-stream.mjs ---
156
74
  // --- Schema/content/tool-call mapping: extracted to gemini-schema.mjs ---
@@ -918,45 +836,12 @@ export class GeminiProvider {
918
836
  // TTL check) and _refreshModelCache() (bypassing it). Throws on failure so
919
837
  // each caller applies its own fallback/logging.
920
838
  async _fetchAndCacheModels(apiKey) {
921
- const items = await fetchGeminiModelPages(apiKey, this._fetch);
922
- // Filter to Gemini family; skip embedding/imagen and non-coding SKUs
923
- // (robotics, computer-use) that the chat picker should never show.
924
- const normalized = items
925
- .filter(m => (m?.name || '').includes('gemini'))
926
- .filter(m => !Array.isArray(m?.supportedGenerationMethods)
927
- || m.supportedGenerationMethods.includes('generateContent'))
928
- .filter(m => !/embedding|aqa|imagen|robotics|computer-use/.test(m?.name || ''))
929
- .map(m => {
930
- const id = (m.name || '').replace(/^models\//, '');
931
- const family = /flash-lite/.test(id) ? 'gemini-flash-lite'
932
- : /flash/.test(id) ? 'gemini-flash'
933
- : /pro/.test(id) ? 'gemini-pro'
934
- : 'gemini';
935
- return {
936
- id,
937
- display: m.displayName || id,
938
- family,
939
- provider: 'gemini',
940
- // No fabricated fallbacks: when the API omits limits leave
941
- // them null so enrichModels can fill from the catalog and
942
- // the picker shows no context label instead of a fake 1M.
943
- contextWindow: m.inputTokenLimit || null,
944
- outputTokens: m.outputTokenLimit || null,
945
- tier: 'version',
946
- latest: false,
947
- description: m.description || '',
948
- };
949
- });
950
- _markLatestGemini(normalized);
951
- // LiteLLM catalog overlays pricing and updated metadata.
952
- const { enrichModels } = await import('./model-catalog.mjs');
953
- const { sanitizeModelList } = await import('./model-list-sanitize.mjs');
954
- const enriched = sanitizeModelList(await enrichModels(normalized, {
839
+ return fetchAndCacheGeminiModels({
840
+ apiKey,
955
841
  fetchFn: this._fetch,
956
- force: this.config.catalogForceRefresh === true,
957
- }), { provider: 'gemini' });
958
- this._modelCache.save(enriched);
959
- return enriched;
842
+ modelCache: this._modelCache,
843
+ catalogForceRefresh: this.config.catalogForceRefresh,
844
+ });
960
845
  }
961
846
 
962
847
  // Force a catalog refresh (ignores the 24h disk TTL). De-duped via