antigravity-claude-proxy 2.9.1 → 2.9.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "antigravity-claude-proxy",
3
- "version": "2.9.1",
3
+ "version": "2.9.3",
4
4
  "description": "Proxy server to use Antigravity's Claude models with Claude Code CLI",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -38,7 +38,8 @@
38
38
  "test:sanitizer": "node tests/test-schema-sanitizer.cjs",
39
39
  "test:strategies": "node tests/test-strategies.cjs",
40
40
  "test:cache-control": "node tests/test-cache-control.cjs",
41
- "test:billing-header": "node tests/test-billing-header.cjs"
41
+ "test:billing-header": "node tests/test-billing-header.cjs",
42
+ "test:stats": "node tests/test-usage-stats.js"
42
43
  },
43
44
  "keywords": [
44
45
  "claude",
@@ -461,8 +461,18 @@ window.DashboardCharts.updateTrendChart = function (component) {
461
461
  });
462
462
  });
463
463
 
464
- // Build datasets for families
464
+ // Build datasets for families (only include families with activity > 0)
465
+ const activeFamilies = new Set();
466
+ Object.entries(dataByFamily).forEach(([family, values]) => {
467
+ if (values.some((v) => v > 0)) {
468
+ activeFamilies.add(family);
469
+ }
470
+ });
471
+ component.activeFamilies = activeFamilies;
472
+
465
473
  component.selectedFamilies.forEach((family) => {
474
+ if (!activeFamilies.has(family)) return;
475
+
466
476
  const color = window.DashboardFilters.getFamilyColor(family);
467
477
  const familyKey =
468
478
  "family" + family.charAt(0).toUpperCase() + family.slice(1);
@@ -501,10 +511,22 @@ window.DashboardCharts.updateTrendChart = function (component) {
501
511
  });
502
512
  });
503
513
 
504
- // Build datasets for models
514
+ // Track models with activity > 0 in this filtered time range
515
+ const activeModelKeys = new Set();
516
+ Object.entries(dataByModel).forEach(([key, values]) => {
517
+ if (values.some((v) => v > 0)) {
518
+ activeModelKeys.add(key);
519
+ }
520
+ });
521
+ component.activeModelKeys = activeModelKeys;
522
+
523
+ // Build datasets for models (only include models with activity > 0 in this time range)
505
524
  component.families.forEach((family) => {
506
525
  (component.selectedModels[family] || []).forEach((model, modelIndex) => {
507
526
  const key = `${family}:${model}`;
527
+ // Skip models with 0 calls in the selected period
528
+ if (!activeModelKeys.has(key)) return;
529
+
508
530
  const color = window.DashboardFilters.getModelColor(family, modelIndex);
509
531
  datasets.push(
510
532
  window.DashboardCharts.createDataset(
@@ -518,6 +540,13 @@ window.DashboardCharts.updateTrendChart = function (component) {
518
540
  });
519
541
  }
520
542
 
543
+ if (datasets.length === 0) {
544
+ if (window.UILogger) window.UILogger.debug("No active datasets for trend chart (all zero in time range)");
545
+ component.hasFilteredTrendData = false;
546
+ _trendChartUpdateLock = false;
547
+ return;
548
+ }
549
+
521
550
  try {
522
551
  const newChart = new Chart(canvas, {
523
552
  type: "line",
@@ -543,6 +572,9 @@ window.DashboardCharts.updateTrendChart = function (component) {
543
572
  borderWidth: 1,
544
573
  padding: 10,
545
574
  displayColors: true,
575
+ filter: function (tooltipItem) {
576
+ return (tooltipItem.parsed?.y || 0) > 0;
577
+ },
546
578
  callbacks: {
547
579
  label: function (context) {
548
580
  return context.dataset.label + ": " + context.parsed.y;
@@ -14,6 +14,8 @@ window.Components.dashboard = () => ({
14
14
  historyData: {},
15
15
  modelTree: {},
16
16
  families: [],
17
+ activeModelKeys: null,
18
+ activeFamilies: null,
17
19
 
18
20
  // Claude config status
19
21
  claudeConfigStatus: {
@@ -451,7 +451,8 @@
451
451
  <!-- Family Mode Legend -->
452
452
  <template x-if="displayMode === 'family'">
453
453
  <template x-for="family in selectedFamilies" :key="family">
454
- <div class="flex items-center gap-1.5 text-[10px] font-mono">
454
+ <div class="flex items-center gap-1.5 text-[10px] font-mono"
455
+ x-show="!activeFamilies || activeFamilies.has(family)">
455
456
  <div class="w-2 h-2 rounded-full" :style="'background-color:' + getFamilyColor(family)"></div>
456
457
  <span class="text-gray-400"
457
458
  x-text="$store.global.t('family' + family.charAt(0).toUpperCase() + family.slice(1))"></span>
@@ -462,7 +463,8 @@
462
463
  <template x-if="displayMode === 'model'">
463
464
  <template x-for="family in families" :key="'legend-' + family">
464
465
  <template x-for="(model, modelIndex) in (selectedModels[family] || [])" :key="family + ':' + model">
465
- <div class="flex items-center gap-1.5 text-[10px] font-mono">
466
+ <div class="flex items-center gap-1.5 text-[10px] font-mono"
467
+ x-show="!activeModelKeys || activeModelKeys.has(family + ':' + model)">
466
468
  <div class="w-2 h-2 rounded-full"
467
469
  :style="'background-color:' + getModelColor(family, modelIndex)"></div>
468
470
  <span class="text-gray-400" x-text="model"></span>
@@ -24,10 +24,23 @@ import { deriveSessionId } from './session-manager.js';
24
24
  // Defaults cover agents commonly used with this proxy; extend or override with
25
25
  // the ANTIGRAVITY_SCRUB_IDENTITY env var, formatted as a comma-separated list of
26
26
  // "Term=>Replacement" pairs, e.g. "Nous Research=>the team,Hermes=>the assistant".
27
+ //
28
+ // Claude Code and Anthropic are listed explicitly: issue #375 identified
29
+ // "You are Claude Code, Anthropic's official CLI for Claude." as a 429 trigger
30
+ // alongside the billing header, but the merged fix (2f6afae) only stripped the
31
+ // header and left both names in place.
32
+ //
33
+ // Matching is case-sensitive on purpose, so lower-case header names
34
+ // (x-anthropic-beta, anthropic-version) and file names (CLAUDE.md) are left
35
+ // alone. The bare word "Claude" is deliberately NOT scrubbed: it is legitimate
36
+ // in model self-descriptions, and requests succeed with it present once the two
37
+ // names above are neutralized.
27
38
  const DEFAULT_IDENTITY_SCRUB = [
28
39
  ['Nous Research', 'the assistant team'],
29
40
  ['Hermes Agent', 'the assistant'],
30
- ['Hermes', 'the assistant']
41
+ ['Hermes', 'the assistant'],
42
+ ['Claude Code', 'the assistant'],
43
+ ['Anthropic', 'the vendor']
31
44
  ];
32
45
 
33
46
  function parseIdentityScrubEnv(raw) {
@@ -42,16 +55,22 @@ function parseIdentityScrubEnv(raw) {
42
55
  .filter((rule) => rule && rule[0].length > 0);
43
56
  }
44
57
 
58
+ function escapeRegExp(text) {
59
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
60
+ }
61
+
62
+ // Terms match as whole words only, so identifiers that merely contain a term
63
+ // (convertAnthropicToGoogle, AnthropicRequest, HermesHandler) are left alone.
45
64
  const IDENTITY_SCRUB_RULES = [
46
65
  ...DEFAULT_IDENTITY_SCRUB,
47
66
  ...parseIdentityScrubEnv(process.env.ANTIGRAVITY_SCRUB_IDENTITY)
48
- ];
67
+ ].map(([term, replacement]) => [new RegExp(`\\b${escapeRegExp(term)}\\b`, 'g'), replacement]);
49
68
 
50
69
  function scrubClientIdentity(text) {
51
70
  if (typeof text !== 'string') return text;
52
71
  let out = text;
53
- for (const [term, replacement] of IDENTITY_SCRUB_RULES) {
54
- out = out.split(term).join(replacement);
72
+ for (const [pattern, replacement] of IDENTITY_SCRUB_RULES) {
73
+ out = out.replace(pattern, () => replacement);
55
74
  }
56
75
  return out;
57
76
  }
@@ -1,36 +1,92 @@
1
1
  /**
2
2
  * Session Management for Cloud Code
3
3
  *
4
- * Handles session ID derivation for prompt caching continuity.
5
- * Session IDs are derived from the first user message to ensure
6
- * the same conversation uses the same session across turns.
4
+ * Session IDs give the Cloud Code backend a cache-affinity namespace.
5
+ * A per-conversation ID (stable hash of the first user message) keeps every
6
+ * conversation in its own namespace so interleaved conversations on the same
7
+ * account stop evicting each other's implicit-cache KV blocks. Requests
8
+ * without any user text fall back to a per-account process-lifetime ID.
7
9
  */
8
10
 
9
11
  import crypto from 'crypto';
10
12
 
11
13
 
12
- // Runtime storage for session IDs (per account)
13
- // This mimics the behavior of the binary which generates a session ID at startup
14
- // and keeps it for the process lifetime.
14
+ // Runtime fallback storage for session IDs (per account)
15
15
  // Key: accountEmail, Value: sessionId
16
16
  const runtimeSessionStore = new Map();
17
17
 
18
18
  /**
19
- * Get or create a session ID for the given account.
20
- *
21
- * The binary generates a session ID once at startup: `p.sessionID = rs() + Date.now()`.
22
- * Since our proxy is long-running, we simulate this "per-launch" behavior by storing
23
- * a generated ID in memory for each account.
19
+ * Extract the first meaningful user text from an Anthropic request.
20
+ * Returns null when no user message carries text.
21
+ */
22
+ function extractFirstUserText(anthropicRequest) {
23
+ const messages = anthropicRequest?.messages;
24
+ if (!Array.isArray(messages)) return null;
25
+ for (const msg of messages) {
26
+ if (!msg || msg.role !== 'user') continue;
27
+ const content = msg.content;
28
+ let text = null;
29
+ if (typeof content === 'string') {
30
+ text = content;
31
+ } else if (Array.isArray(content)) {
32
+ const parts = [];
33
+ for (const block of content) {
34
+ if (block && block.type === 'text' && typeof block.text === 'string') {
35
+ parts.push(block.text);
36
+ }
37
+ }
38
+ if (parts.length > 0) text = parts.join('\n');
39
+ }
40
+ if (text && text.trim().length > 0) {
41
+ // Hash the full text, never a prefix: clients such as Claude Code
42
+ // open the first user message with a long shared preamble
43
+ // (<system-reminder>, CLAUDE.md), so a truncated prefix maps
44
+ // every conversation in a project to the same session ID.
45
+ return text;
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * Derive a deterministic per-conversation session ID shaped like the binary's
53
+ * `uuid + Date.now()` format. The same conversation (same first user text,
54
+ * same account) always maps to the same ID across turns and proxy restarts.
55
+ */
56
+ function deriveConversationSessionId(anthropicRequest, accountEmail) {
57
+ const firstUserText = extractFirstUserText(anthropicRequest);
58
+ if (!firstUserText) return null;
59
+ const digest = crypto
60
+ .createHash('sha256')
61
+ .update(`${accountEmail || 'anonymous'}\u0000${firstUserText}`)
62
+ .digest('hex');
63
+ const uuid =
64
+ `${digest.slice(0, 8)}-${digest.slice(8, 12)}-${digest.slice(12, 16)}-` +
65
+ `${digest.slice(16, 20)}-${digest.slice(20, 32)}`;
66
+ const tail = BigInt(`0x${digest.slice(32, 45)}`).toString();
67
+ return `${uuid}${tail}`;
68
+ }
69
+
70
+ /**
71
+ * Get or create a session ID for a request.
24
72
  *
25
- * - If the proxy restarts, the ID changes (matching binary/VS Code restart behavior).
26
- * - Within a running proxy instance, the ID is stable for that account.
27
- * - This enables prompt caching while using the EXACT random logic of the binary.
73
+ * Priority:
74
+ * 1. Per-conversation deterministic ID from the first user message —
75
+ * restores cache continuity per conversation and isolates concurrent
76
+ * conversations from each other's cache pressure.
77
+ * 2. Per-account process-lifetime ID when the request carries no user
78
+ * text at all (previous behavior).
28
79
  *
29
- * @param {Object} anthropicRequest - The Anthropic-format request (unused for ID generation now)
30
- * @param {string} accountEmail - The account email to scope the session ID
80
+ * @param {Object} anthropicRequest - The Anthropic-format request
81
+ * @param {string} accountEmail - The account email to scope fallback IDs
31
82
  * @returns {string} A stable session ID string matching binary format
32
83
  */
33
84
  export function deriveSessionId(anthropicRequest, accountEmail) {
85
+ const conversationId = deriveConversationSessionId(anthropicRequest, accountEmail);
86
+ if (conversationId) {
87
+ return conversationId;
88
+ }
89
+
34
90
  if (!accountEmail) {
35
91
  // Fallback for requests without an account (should differ every time)
36
92
  return generateBinaryStyleId();
@@ -55,7 +55,11 @@ export async function* streamSSEResponse(response, originalModel) {
55
55
  const usage = innerResponse.usageMetadata;
56
56
  if (usage) {
57
57
  inputTokens = usage.promptTokenCount || inputTokens;
58
- outputTokens = usage.candidatesTokenCount || outputTokens;
58
+ // Anthropic semantics: output_tokens covers ALL generated
59
+ // tokens, thinking included (thoughtsTokenCount is billed as
60
+ // output by the backend). candidatesTokenCount alone silently
61
+ // undercounts thinking-heavy responses.
62
+ outputTokens = ((usage.candidatesTokenCount || 0) + (usage.thoughtsTokenCount || 0)) || outputTokens;
59
63
  cacheReadTokens = usage.cachedContentTokenCount || cacheReadTokens;
60
64
  }
61
65
 
@@ -279,10 +283,20 @@ export async function* streamSSEResponse(response, originalModel) {
279
283
  }
280
284
 
281
285
  // Emit message_delta and message_stop
286
+ //
287
+ // Usage reporting: Google streams usageMetadata progressively; the
288
+ // early chunks carry an interim promptTokenCount and no
289
+ // cachedContentTokenCount yet. message_start therefore latches an
290
+ // inflated input_tokens (the nearly-full prompt minus zero cache) whenever
291
+ // the request is served mostly from cache. The final usageMetadata is
292
+ // authoritative, so correct input_tokens here: clients that take the last
293
+ // non-zero value (or read message_delta.usage.input_tokens) then see the
294
+ // true uncached input instead of a phantom near-100%-of-prompt figure.
282
295
  yield {
283
296
  type: 'message_delta',
284
297
  delta: { stop_reason: stopReason || 'end_turn', stop_sequence: null },
285
298
  usage: {
299
+ input_tokens: Math.max(0, inputTokens - cacheReadTokens),
286
300
  output_tokens: outputTokens,
287
301
  cache_read_input_tokens: cacheReadTokens,
288
302
  cache_creation_input_tokens: 0
package/src/constants.js CHANGED
@@ -290,6 +290,17 @@ export const MODEL_FALLBACK_MAP = {
290
290
  'claude-sonnet-4-6': 'gemini-3-flash'
291
291
  };
292
292
 
293
+ // Built-in model aliases mapped to canonical backend models
294
+ export const BUILTIN_MODEL_MAPPINGS = {
295
+ 'claude-3-5-sonnet-20241022': 'claude-sonnet-4-6',
296
+ 'claude-3-7-sonnet-20250219': 'claude-sonnet-4-6',
297
+ 'claude-opus-5-5': 'claude-opus-4-6-thinking',
298
+ 'claude-sonnet-5-5': 'claude-sonnet-4-6',
299
+ 'claude-haiku-5-5': 'claude-sonnet-4-6',
300
+ 'claude-opus-5': 'claude-opus-4-6-thinking',
301
+ 'claude-sonnet-5': 'claude-sonnet-4-6'
302
+ };
303
+
293
304
  // Default test models for each family (used by test suite)
294
305
  export const TEST_MODELS = {
295
306
  claude: 'claude-sonnet-4-6',
@@ -522,6 +533,7 @@ export default {
522
533
  OAUTH_REDIRECT_URI,
523
534
  STRATEGY_LABELS,
524
535
  MODEL_FALLBACK_MAP,
536
+ BUILTIN_MODEL_MAPPINGS,
525
537
  TEST_MODELS,
526
538
  DEFAULT_PRESETS,
527
539
  DEFAULT_SERVER_PRESETS,
@@ -112,7 +112,8 @@ export function convertGoogleToAnthropic(googleResponse, model) {
112
112
  stop_sequence: null,
113
113
  usage: {
114
114
  input_tokens: promptTokens - cachedTokens,
115
- output_tokens: usageMetadata.candidatesTokenCount || 0,
115
+ // Thinking tokens are generated (and billed) output — include them.
116
+ output_tokens: (usageMetadata.candidatesTokenCount || 0) + (usageMetadata.thoughtsTokenCount || 0),
116
117
  cache_read_input_tokens: cachedTokens,
117
118
  cache_creation_input_tokens: 0
118
119
  }
@@ -1,82 +1,270 @@
1
1
  /**
2
- * Signature Cache
3
- * In-memory cache for Gemini thoughtSignatures
2
+ * Signature Cache — persistent, LRU-bounded
4
3
  *
5
- * Gemini models require thoughtSignature on tool calls, but Claude Code
6
- * strips non-standard fields. This cache stores signatures by tool_use_id
7
- * so they can be restored in subsequent requests.
4
+ * Gemini models require thoughtSignature on tool calls and thinking blocks,
5
+ * but Anthropic-protocol clients strip non-standard fields. This cache stores
6
+ * signatures by tool_use_id so they can be restored in subsequent requests.
7
+ *
8
+ * Signatures have no fixed time-based expiry — they stay valid for the lifetime
9
+ * of active conversations, and dropping them mid-conversation silently rewrites
10
+ * conversation history on replay, which busts the upstream implicit-cache prefix
11
+ * and drops thinking blocks. The store is persisted to disk and reloaded at boot,
12
+ * bounded by an LRU cap so memory and on-disk size stay bounded over months of use.
8
13
  *
9
14
  * Also caches thinking block signatures with model family for cross-model
10
15
  * compatibility checking.
16
+ *
17
+ * Persistence: JSON file, atomic tmp+rename writes, mode 0600, debounced
18
+ * async writes (plus a periodic safety flush and synchronous flush on SIGTERM/SIGINT/
19
+ * exit). All persistence failures fail open: the in-memory cache keeps
20
+ * working and the proxy never errors because of store I/O.
11
21
  */
12
22
 
13
- import { GEMINI_SIGNATURE_CACHE_TTL_MS, MIN_SIGNATURE_LENGTH } from '../constants.js';
23
+ import fs from 'node:fs';
24
+ import os from 'node:os';
25
+ import path from 'node:path';
26
+ import { MIN_SIGNATURE_LENGTH } from '../constants.js';
27
+
28
+ const toolSignatureCache = new Map(); // toolUseId -> signature (insertion-ordered LRU)
29
+ const thinkingSignatureCache = new Map(); // signature -> modelFamily (insertion-ordered LRU)
30
+
31
+ const STORE_VERSION = 1;
32
+ const DEFAULT_MAX_TOOL_SIGNATURES = 20_000;
33
+ const DEFAULT_MAX_THINKING_SIGNATURES = 10_000;
34
+ const FLUSH_DEBOUNCE_MS = 5000;
35
+ const PERIODIC_FLUSH_MS = 30_000;
36
+ const MAX_SILENT_STORE_ERRORS = 20;
37
+
38
+ function parsePositiveIntEnv(name, fallback) {
39
+ const raw = process.env[name];
40
+ if (!raw) return fallback;
41
+ const n = Number.parseInt(raw, 10);
42
+ return Number.isFinite(n) && n > 0 ? n : fallback;
43
+ }
44
+
45
+ const maxToolSignatures = parsePositiveIntEnv('ANTIGRAVITY_SIGNATURE_CACHE_MAX_TOOL', DEFAULT_MAX_TOOL_SIGNATURES);
46
+ const maxThinkingSignatures = parsePositiveIntEnv('ANTIGRAVITY_SIGNATURE_CACHE_MAX_THINK', DEFAULT_MAX_THINKING_SIGNATURES);
47
+
48
+ function lruSet(map, key, value, maxEntries) {
49
+ if (map.has(key)) {
50
+ map.delete(key);
51
+ }
52
+ map.set(key, value);
53
+ while (map.size > maxEntries) {
54
+ const oldestKey = map.keys().next().value;
55
+ if (oldestKey === undefined) break;
56
+ map.delete(oldestKey);
57
+ }
58
+ }
59
+
60
+ function lruGet(map, key) {
61
+ if (!map.has(key)) return null;
62
+ const value = map.get(key);
63
+ map.delete(key);
64
+ map.set(key, value);
65
+ return value;
66
+ }
67
+
68
+ function resolveStorePath() {
69
+ const fromEnv = process.env.ANTIGRAVITY_SIGNATURE_STORE;
70
+ if (fromEnv && fromEnv.trim()) return fromEnv;
71
+ return path.join(os.homedir(), '.config', 'antigravity-proxy', 'signature-store.json');
72
+ }
73
+
74
+ let storePath = resolveStorePath();
75
+ let flushTimer = null;
76
+ let periodicTimer = null;
77
+ let dirty = false;
78
+ let storeErrorCount = 0;
79
+ let writeInFlight = null;
80
+ let storeGeneration = 0;
14
81
 
15
- const signatureCache = new Map();
16
- const thinkingSignatureCache = new Map();
82
+ function reportStoreError(context, error) {
83
+ if (storeErrorCount < MAX_SILENT_STORE_ERRORS) {
84
+ storeErrorCount++;
85
+ // Console only: this module is imported before the logger is configured.
86
+ console.warn(`[SignatureCache] ${context} failed: ${error?.message ?? error}`);
87
+ }
88
+ }
89
+
90
+ function loadFromDisk() {
91
+ try {
92
+ const raw = fs.readFileSync(storePath, 'utf8');
93
+ if (!raw.trim()) return;
94
+ const parsed = JSON.parse(raw);
95
+ if (parsed?.version !== STORE_VERSION) return;
96
+ let prunedOnLoad = false;
97
+ if (parsed.tool && typeof parsed.tool === 'object') {
98
+ const entries = Object.entries(parsed.tool);
99
+ if (entries.length > maxToolSignatures) prunedOnLoad = true;
100
+ for (const [id, sig] of entries) {
101
+ if (typeof id === 'string' && typeof sig === 'string') {
102
+ lruSet(toolSignatureCache, id, sig, maxToolSignatures);
103
+ }
104
+ }
105
+ }
106
+ if (parsed.think && typeof parsed.think === 'object') {
107
+ const entries = Object.entries(parsed.think);
108
+ if (entries.length > maxThinkingSignatures) prunedOnLoad = true;
109
+ for (const [sig, family] of entries) {
110
+ if (typeof sig === 'string' && typeof family === 'string') {
111
+ lruSet(thinkingSignatureCache, sig, family, maxThinkingSignatures);
112
+ }
113
+ }
114
+ }
115
+ if (prunedOnLoad) {
116
+ scheduleFlush();
117
+ }
118
+ } catch (error) {
119
+ if (error?.code !== 'ENOENT') {
120
+ reportStoreError('load', error);
121
+ }
122
+ // Corrupt or unreadable store: start empty rather than failing boot.
123
+ }
124
+ }
125
+
126
+ function serializeStore() {
127
+ return JSON.stringify({
128
+ version: STORE_VERSION,
129
+ tool: Object.fromEntries(toolSignatureCache),
130
+ think: Object.fromEntries(thinkingSignatureCache),
131
+ });
132
+ }
17
133
 
18
134
  /**
19
- * Store a signature for a tool_use_id
135
+ * Synchronous flush, used only on process exit/signals where async I/O
136
+ * would never complete. Bumps the store generation so any async write still
137
+ * in flight discards its (older) snapshot instead of renaming over this one.
138
+ */
139
+ function writeStoreSync() {
140
+ if (!dirty) return;
141
+ storeGeneration++;
142
+ try {
143
+ const data = serializeStore();
144
+ fs.mkdirSync(path.dirname(storePath), { recursive: true });
145
+ const tmp = `${storePath}.tmp-sync-${process.pid}`;
146
+ fs.writeFileSync(tmp, data, { mode: 0o600 });
147
+ fs.renameSync(tmp, storePath);
148
+ dirty = false;
149
+ } catch (error) {
150
+ reportStoreError('flush', error);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Normal-operation flush. File I/O runs asynchronously so a store near the
156
+ * LRU cap (tens of MB) never blocks the event loop on disk writes; only the
157
+ * JSON serialization stays synchronous. At most one write is in flight.
158
+ */
159
+ function writeStoreAsync() {
160
+ if (!dirty || writeInFlight) return;
161
+ let data;
162
+ try {
163
+ data = serializeStore();
164
+ } catch (error) {
165
+ reportStoreError('flush', error);
166
+ return;
167
+ }
168
+ dirty = false;
169
+ const generation = storeGeneration;
170
+ const targetPath = storePath;
171
+ const tmp = `${targetPath}.tmp-${process.pid}`;
172
+ let failed = false;
173
+ writeInFlight = (async () => {
174
+ try {
175
+ await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
176
+ await fs.promises.writeFile(tmp, data, { mode: 0o600 });
177
+ if (generation !== storeGeneration) {
178
+ // Cache was cleared or flushed synchronously meanwhile.
179
+ await fs.promises.rm(tmp, { force: true });
180
+ return;
181
+ }
182
+ await fs.promises.rename(tmp, targetPath);
183
+ } catch (error) {
184
+ failed = true;
185
+ dirty = true;
186
+ reportStoreError('flush', error);
187
+ }
188
+ })().finally(() => {
189
+ writeInFlight = null;
190
+ // Changes made during the write get flushed promptly; after a failed
191
+ // write, leave the retry to the periodic flush instead of spinning.
192
+ if (dirty && !failed) scheduleFlush();
193
+ });
194
+ }
195
+
196
+ function scheduleFlush() {
197
+ dirty = true;
198
+ if (flushTimer) return;
199
+ flushTimer = setTimeout(() => {
200
+ flushTimer = null;
201
+ writeStoreAsync();
202
+ }, FLUSH_DEBOUNCE_MS);
203
+ if (typeof flushTimer.unref === 'function') flushTimer.unref();
204
+ }
205
+
206
+ function removeStoreFile() {
207
+ try {
208
+ fs.rmSync(storePath, { force: true });
209
+ } catch (error) {
210
+ reportStoreError('remove', error);
211
+ }
212
+ }
213
+
214
+ loadFromDisk();
215
+
216
+ periodicTimer = setInterval(() => writeStoreAsync(), PERIODIC_FLUSH_MS);
217
+ if (typeof periodicTimer.unref === 'function') periodicTimer.unref();
218
+
219
+ for (const signal of ['SIGTERM', 'SIGINT', 'exit']) {
220
+ process.on(signal, () => writeStoreSync());
221
+ }
222
+
223
+ /**
224
+ * Store a signature for a tool_use_id. Bounded by an LRU cap (no fixed TTL):
225
+ * replayed signatures stay byte-stable for active conversations.
20
226
  * @param {string} toolUseId - The tool use ID
21
227
  * @param {string} signature - The thoughtSignature to cache
22
228
  */
23
229
  export function cacheSignature(toolUseId, signature) {
24
230
  if (!toolUseId || !signature) return;
25
- signatureCache.set(toolUseId, {
26
- signature,
27
- timestamp: Date.now()
28
- });
231
+ const prev = toolSignatureCache.get(toolUseId);
232
+ lruSet(toolSignatureCache, toolUseId, signature, maxToolSignatures);
233
+ if (prev === signature) return;
234
+ scheduleFlush();
29
235
  }
30
236
 
31
237
  /**
32
- * Get a cached signature for a tool_use_id
238
+ * Get a cached signature for a tool_use_id.
33
239
  * @param {string} toolUseId - The tool use ID
34
- * @returns {string|null} The cached signature or null if not found/expired
240
+ * @returns {string|null} The cached signature or null if never seen
35
241
  */
36
242
  export function getCachedSignature(toolUseId) {
37
243
  if (!toolUseId) return null;
38
- const entry = signatureCache.get(toolUseId);
39
- if (!entry) return null;
40
-
41
- // Check TTL
42
- if (Date.now() - entry.timestamp > GEMINI_SIGNATURE_CACHE_TTL_MS) {
43
- signatureCache.delete(toolUseId);
44
- return null;
45
- }
46
-
47
- return entry.signature;
244
+ return lruGet(toolSignatureCache, toolUseId);
48
245
  }
49
246
 
50
247
  /**
51
- * Cache a thinking block signature with its model family
248
+ * Cache a thinking block signature with its model family. Bounded by an LRU cap.
52
249
  * @param {string} signature - The thinking signature to cache
53
250
  * @param {string} modelFamily - The model family ('claude' or 'gemini')
54
251
  */
55
252
  export function cacheThinkingSignature(signature, modelFamily) {
56
253
  if (!signature || signature.length < MIN_SIGNATURE_LENGTH) return;
57
- thinkingSignatureCache.set(signature, {
58
- modelFamily,
59
- timestamp: Date.now()
60
- });
254
+ const prev = thinkingSignatureCache.get(signature);
255
+ lruSet(thinkingSignatureCache, signature, modelFamily, maxThinkingSignatures);
256
+ if (prev === modelFamily) return;
257
+ scheduleFlush();
61
258
  }
62
259
 
63
260
  /**
64
- * Get the cached model family for a thinking signature
261
+ * Get the cached model family for a thinking signature.
65
262
  * @param {string} signature - The signature to look up
66
- * @returns {string|null} 'claude', 'gemini', or null if not found/expired
263
+ * @returns {string|null} 'claude', 'gemini', or null if never seen
67
264
  */
68
265
  export function getCachedSignatureFamily(signature) {
69
266
  if (!signature) return null;
70
- const entry = thinkingSignatureCache.get(signature);
71
- if (!entry) return null;
72
-
73
- // Check TTL
74
- if (Date.now() - entry.timestamp > GEMINI_SIGNATURE_CACHE_TTL_MS) {
75
- thinkingSignatureCache.delete(signature);
76
- return null;
77
- }
78
-
79
- return entry.modelFamily;
267
+ return lruGet(thinkingSignatureCache, signature);
80
268
  }
81
269
 
82
270
  /**
@@ -85,4 +273,36 @@ export function getCachedSignatureFamily(signature) {
85
273
  */
86
274
  export function clearThinkingSignatureCache() {
87
275
  thinkingSignatureCache.clear();
276
+ scheduleFlush();
277
+ }
278
+
279
+ /**
280
+ * Flush pending changes to disk now and wait for the write to finish.
281
+ * The debounced flush normally does this; exported for tests and callers
282
+ * that need the store on disk at a known point.
283
+ * @returns {Promise<void>}
284
+ */
285
+ export async function flushSignatureStore() {
286
+ if (flushTimer) {
287
+ clearTimeout(flushTimer);
288
+ flushTimer = null;
289
+ }
290
+ if (writeInFlight) await writeInFlight;
291
+ writeStoreAsync();
292
+ if (writeInFlight) await writeInFlight;
293
+ }
294
+
295
+ /**
296
+ * Clear both signature caches and delete the persisted store.
297
+ */
298
+ export function clearAllSignatureCaches() {
299
+ toolSignatureCache.clear();
300
+ thinkingSignatureCache.clear();
301
+ if (flushTimer) {
302
+ clearTimeout(flushTimer);
303
+ flushTimer = null;
304
+ }
305
+ dirty = false;
306
+ storeGeneration++; // any in-flight async write must not resurrect the store
307
+ removeStoreFile();
88
308
  }
@@ -656,6 +656,21 @@ function stripInvalidThinkingBlocks(messages, targetFamily = null) {
656
656
  * @returns {Array<Object>} Modified messages with synthetic messages injected
657
657
  */
658
658
  export function closeToolLoopForThinking(messages, targetFamily = null) {
659
+ // Escape hatch (Gemini only): skip the synthetic-message injection.
660
+ // Google's backend accepts raw mid-tool-loop and interrupted-tool
661
+ // histories natively (verified 2026-09-03: identical answers with and
662
+ // without injection), so for Gemini targets the fabricated messages are
663
+ // pure downside — a fake "[Continue]" becomes the model's latest user
664
+ // instruction and the count-bearing text is unstable across requests.
665
+ // Claude targets keep the recovery: the Anthropic API hard-rejects
666
+ // unclosed tool_use ids, so the interrupted-tool synthesis stays needed.
667
+ // Set ANTIGRAVITY_DISABLE_THINKING_RECOVERY=1 to activate.
668
+ if (
669
+ process.env.ANTIGRAVITY_DISABLE_THINKING_RECOVERY === '1' &&
670
+ targetFamily === 'gemini'
671
+ ) {
672
+ return messages;
673
+ }
659
674
  const state = analyzeConversationState(messages);
660
675
 
661
676
  // Handle neither tool loop nor interrupted tool
@@ -10,11 +10,47 @@ const DATA_DIR = path.dirname(HISTORY_FILE);
10
10
  const OLD_DATA_DIR = path.join(process.cwd(), 'data');
11
11
  const OLD_HISTORY_FILE = path.join(OLD_DATA_DIR, 'usage-history.json');
12
12
 
13
+ // Legacy model aliases that should be migrated to canonical backend model names in history
14
+ export const LEGACY_MODEL_MIGRATIONS = {
15
+ 'opus-5-5': 'opus-4-6-thinking',
16
+ 'sonnet-5-5': 'sonnet-4-6',
17
+ 'haiku-5-5': 'sonnet-4-6'
18
+ };
19
+
13
20
  // In-memory storage
14
21
  // Structure: { "YYYY-MM-DDTHH:00:00.000Z": { "claude": { "model-name": count, "_subtotal": count }, "_total": count } }
15
22
  let history = {};
16
23
  let isDirty = false;
17
24
 
25
+ /**
26
+ * Migrate legacy/phantom model names in history to canonical backend model names
27
+ * @param {object} data - History data
28
+ * @returns {boolean} Whether any migration took place
29
+ */
30
+ export function normalizeHistory(data) {
31
+ if (!data || typeof data !== 'object') return false;
32
+ let modified = false;
33
+
34
+ for (const [timestamp, hourData] of Object.entries(data)) {
35
+ if (!hourData || typeof hourData !== 'object') continue;
36
+
37
+ for (const [family, familyData] of Object.entries(hourData)) {
38
+ if (!familyData || typeof familyData !== 'object' || family.startsWith('_')) continue;
39
+
40
+ for (const [legacyName, targetName] of Object.entries(LEGACY_MODEL_MIGRATIONS)) {
41
+ if (familyData[legacyName] !== undefined) {
42
+ const count = familyData[legacyName];
43
+ delete familyData[legacyName];
44
+ familyData[targetName] = (familyData[targetName] || 0) + count;
45
+ modified = true;
46
+ logger.info(`[UsageStats] Migrated legacy model ${legacyName} -> ${targetName} (${count} calls at ${timestamp})`);
47
+ }
48
+ }
49
+ }
50
+ }
51
+ return modified;
52
+ }
53
+
18
54
  /**
19
55
  * Extract model family from model ID
20
56
  * @param {string} modelId - The model identifier (e.g., "claude-opus-4-6-thinking")
@@ -62,6 +98,10 @@ function load() {
62
98
  if (fs.existsSync(HISTORY_FILE)) {
63
99
  const data = fs.readFileSync(HISTORY_FILE, 'utf8');
64
100
  history = JSON.parse(data);
101
+ if (normalizeHistory(history)) {
102
+ isDirty = true;
103
+ save();
104
+ }
65
105
  }
66
106
  } catch (err) {
67
107
  logger.error('[UsageStats] Failed to load history:', err);
@@ -143,28 +183,17 @@ function setupMiddleware(app) {
143
183
  load();
144
184
 
145
185
  // Auto-save every minute
146
- setInterval(() => {
186
+ const saveInterval = setInterval(() => {
147
187
  save();
148
188
  prune();
149
189
  }, 60 * 1000);
190
+ if (saveInterval.unref) {
191
+ saveInterval.unref();
192
+ }
150
193
 
151
194
  // Save on exit
152
195
  process.on('SIGINT', () => { save(); process.exit(); });
153
196
  process.on('SIGTERM', () => { save(); process.exit(); });
154
-
155
- // Request interceptor
156
- // Track both Anthropic (/v1/messages) and OpenAI compatible (/v1/chat/completions) endpoints
157
- const TRACKED_PATHS = ['/v1/messages', '/v1/chat/completions'];
158
-
159
- app.use((req, res, next) => {
160
- if (req.method === 'POST' && TRACKED_PATHS.includes(req.path)) {
161
- const model = req.body?.model;
162
- if (model) {
163
- track(model);
164
- }
165
- }
166
- next();
167
- });
168
197
  }
169
198
 
170
199
  /**
@@ -202,5 +231,7 @@ export default {
202
231
  track,
203
232
  getFamily,
204
233
  getShortName,
205
- getHistory
234
+ getHistory,
235
+ normalizeHistory,
236
+ LEGACY_MODEL_MIGRATIONS
206
237
  };
package/src/server.js CHANGED
@@ -15,9 +15,9 @@ import { config } from './config.js';
15
15
  const __filename = fileURLToPath(import.meta.url);
16
16
  const __dirname = path.dirname(__filename);
17
17
  import { forceRefresh } from './auth/token-extractor.js';
18
- import { REQUEST_BODY_LIMIT } from './constants.js';
18
+ import { REQUEST_BODY_LIMIT, BUILTIN_MODEL_MAPPINGS } from './constants.js';
19
19
  import { AccountManager } from './account-manager/index.js';
20
- import { clearThinkingSignatureCache } from './format/signature-cache.js';
20
+ import { clearAllSignatureCaches } from './format/signature-cache.js';
21
21
  import { formatDuration } from './utils/helpers.js';
22
22
  import { logger } from './utils/logger.js';
23
23
  import usageStats from './modules/usage-stats.js';
@@ -217,13 +217,13 @@ app.post('/', (req, res) => {
217
217
  });
218
218
 
219
219
  /**
220
- * Test endpoint - Clear thinking signature cache
220
+ * Test endpoint - Clear all signature caches (in-memory + persisted store)
221
221
  * Used for testing cold cache scenarios in cross-model tests
222
222
  */
223
223
  app.post('/test/clear-signature-cache', (req, res) => {
224
- clearThinkingSignatureCache();
225
- logger.debug('[Test] Cleared thinking signature cache');
226
- res.json({ success: true, message: 'Thinking signature cache cleared' });
224
+ clearAllSignatureCaches();
225
+ logger.debug('[Test] Cleared all signature caches and removed persisted store');
226
+ res.json({ success: true, message: 'All signature caches cleared' });
227
227
  });
228
228
 
229
229
  /**
@@ -730,6 +730,10 @@ app.post('/v1/messages', async (req, res) => {
730
730
  const targetModel = modelMapping[requestedModel].mapping;
731
731
  logger.info(`[Server] Mapping model ${requestedModel} -> ${targetModel}`);
732
732
  requestedModel = targetModel;
733
+ } else if (BUILTIN_MODEL_MAPPINGS[requestedModel]) {
734
+ const targetModel = BUILTIN_MODEL_MAPPINGS[requestedModel];
735
+ logger.info(`[Server] Built-in mapping model ${requestedModel} -> ${targetModel}`);
736
+ requestedModel = targetModel;
733
737
  }
734
738
 
735
739
  const modelId = requestedModel;
@@ -812,6 +816,7 @@ app.post('/v1/messages', async (req, res) => {
812
816
  const firstResult = await generator.next();
813
817
 
814
818
  // If we get here, the stream started successfully.
819
+ usageStats.track(modelId);
815
820
  res.status(200);
816
821
  res.setHeader('Content-Type', 'text/event-stream');
817
822
  res.setHeader('Cache-Control', 'no-cache');
@@ -863,6 +868,7 @@ app.post('/v1/messages', async (req, res) => {
863
868
  } else {
864
869
  // Handle non-streaming response
865
870
  const response = await sendMessage(request, accountManager, FALLBACK_ENABLED);
871
+ usageStats.track(response?.model || modelId);
866
872
  res.json(response);
867
873
  }
868
874