[Bug]: Usage counts most Antigravity calls three times (step, generation and retry copies are all added)#17071

openedOct 8, 2026byCcornishandy

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. Have some Antigravity history: conversations under ~/.gemini/antigravity/conversations (the Antigravity app) and/or T3 Code's Antigravity profile, <state dir>/providers/antigravity/<sha256 of instance id>/antigravity-acp/conversations.
  2. Open Usage and compare Antigravity's requests and tokens with the number of model calls actually made. Or run main's reader directly from the repo root (Node ≥ 23.6; prints aggregates only):
ts
1// repro.mts: node repro.mts <conversations dir>...
2import { readAntigravityUsage } from "./apps/server/src/usage/antigravityUsageReader.ts";
3
4const { files, errors } = await readAntigravityUsage(process.argv.slice(2), 0);
5const records = files.flatMap((file) => file.records);
6const tokens = (rs) => rs.reduce((n, { totals: t }) => n + t.uncachedInputTokens + t.cachedInputTokens + t.cacheCreationTokens + t.outputTokens, 0);
7// Identical counters within one conversation = one call stored several times.
8const calls = new Map(records.map((r) => [JSON.stringify([r.sessionId, r.totals]), r]));
9console.log({ files: files.length, errors: errors.length, records: records.length, dedupeKeys: new Set(records.map((r) => r.dedupeKey)).size, tokens: tokens(records), distinctCalls: calls.size, distinctTokens: tokens([...calls.values()]) });

Expected behavior

Each Antigravity model call is counted once.

Actual behavior

Most Antigravity calls are counted three times, so Antigravity's requests, tokens and API-equivalent cost on the Usage page are about 3× too high.

Antigravity writes the same usage message to up to three places in a conversation database. Counts of non-zero usage messages across 34 conversation files on one Mac:

WhereField pathUsages
steps.metadata93,598
gen_metadata.data1 → 43,506
gen_metadata.data retry list1 → 17 → 23,506, each with the same counters as its generation's main usage
steps.metadata retry list28 → 292, each with the same counters as its step's main usage

All 3,506 generation usages have exactly the same counters (fields 2, 3, 4, 5, 9 and 10) as a step usage in the same conversation; the other 92 step usages have no generation entry. Only 165 of the 10,702 usages carry a response id (fields 11/12/7): 55 calls whose three copies share their ids, so only those are merged today.

Where the copies get through:

  • metadata() returns an entry's main usage plus every retry entry (L131–L136).
  • readDatabase makes a record for every usage of every step and every generation (L202–L244). Without a response id, the fallback dedupeKey (…:${source}:${index}:${usageIndex}, L244) is different for each copy.
  • readAntigravityUsage merges only records that share a response id (L325–L331), and UsageAggregator drops only repeated dedupe keys (usageAggregation.ts L176–L177), so every id-less copy is counted.

The script above on those 34 files (9 from the Antigravity app, 25 from T3 Code's Antigravity profile):

1{ files: 34, errors: 0, records: 10592, dedupeKeys: 10592, tokens: 924376492, distinctCalls: 3598, distinctTokens: 312264011 }

That's 10,592 records for 3,598 calls (2.94×), and 924M tokens for 312M.

Impact

Minor bug or occasional failure

Nothing breaks, but every Antigravity figure on the Usage page (requests, tokens, API-equivalent dollars) is about 3× too high.

Version or commit

T3 Code Nightly 0.0.46-nightly.20261008.2801; the reader is unchanged on main @ b24f0fb

Environment

macOS 27.2, desktop app (Nightly). Antigravity provider (gemini-3.8-flash-high, gemini-pro-agent) plus the Antigravity app's own history (app 2.21.1).

Workaround

None in the app.

Possible fix

Within one conversation, count each call once:

  • a retry entry with the same counters as its entry's main usage (and no different response id) is the same usage;
  • a step usage and a generation usage with identical counters are the same call. Pair them nth with nth, so a conversation that really made two identical calls keeps both, each with its own time and model;
  • keep merging by response id across files, as now.

Reading only generations wouldn't be enough: 92 step usages here have no generation entry.

Note

Grok responding on behalf of Julius.

Triage (main @ b24f0fbb)

Confirmed in code. The reader has no step to drop identical copies of the same call. Response ids are the only thing it merges on:

  • metadata() returns the entry's main usage plus one usage for every retry entry: field 9 and the field 28 list for steps, field 4 and the field 17 list for generations (L131-L137).
  • readDatabase makes a record for every usage of every step and then every generation (L202-L253). Ids come only from fields 11/12/7 (L227-L230). With no id, the key falls back to antigravity:${sessionId}:${source}:${index}:${usageIndex} (L244), so every copy gets its own key.
  • readAntigravityUsage merges candidates only when they share a key from that id list (L325-L333). UsageAggregator.add drops only repeated dedupeKeys (usageAggregation.ts L176-L180). Copies without an id never collide.

I checked this locally against main's reader with a made-up DB. One call with no id is stored as a step usage, a generation usage, and a generation retry entry, all with the same counters. The reader returns 3 records (step:0:0, generation:0:0, generation:0:1).

Tests. Every Antigravity fixture that has both a step and a generation copy gives them a shared field-11/12 id (usageTranscriptReader.test.ts L531-L590, L615-L658, L696-L739). No fixture covers id-less copies, which is where the duplicates come from. In the one retry fixture, the retry has different counters and its own id (retry-1), and the test expects it to stay a separate record.

Origin. Not a later regression. The multi-source reading, the retry lists and the per-source fallback key have been there since the reader landed in #10409. The only later change to the file, #15101, renamed fast to speed.

Fix direction. Dedupe within each conversation in readDatabase, before the id merge:

  1. Drop a retry entry whose counters match its entry's main usage, unless it carries a different id. The existing retry test, with different counters and its own id, should still pass.
  2. Pair id-less step and generation usages that have identical counters, nth with nth, so that two real identical calls are both kept.
  3. Keep the response-id merge across files as it is.

Add a fixture with id-less step, generation and retry copies.

Caveats:

  • Matching on counters is a heuristic. Two genuinely separate calls in one conversation with exactly the same counters (fields 2/3/4/5/9/10) would be collapsed if their copies don't line up nth with nth. That is probably rare, but it can happen.
  • It's not clear from the code whether steps.idx and gen_metadata.idx line up one-to-one. The model fallback appears to assume they do (L238), but pairing by idx isn't verified, so pairing by counters is the safer choice.
  • Reading only generations would undercount, because step-only stores and step usages with no generation are handled on purpose (L741-L758).

Related.

  • Open PRs #13902 (skip unchanged Antigravity DBs on repeat scans) and #13809 (Gemini Flash pricing) also edit antigravityUsageReader.ts, so a fix here will likely conflict with them.
  • #16104, #15198 and #15299 are about limits, not token counts. #7858 is double counting from a directory shared across Windows and WSL. They're related but separate.

Unconfirmed:

  • Whether real Antigravity DBs store the same usage in all three places, and the reporter's numbers (34 files, 10,592 records vs 3,598 calls, 2.94×; 165 of 10,702 usages with ids). I can't check either without the databases. The code path above counts such copies separately if they're there.
Synced from GitHub

On the unconfirmed point: I ran the reader on my real Antigravity databases, and the triple storage shows up there too. #23622 brings the count down to one record per call.

Data: 143 conversation databases (9 from the Antigravity app, 134 from T3 Code's Antigravity profile), copied first so nothing was writing to them. 0 read errors. Only aggregate counts are shown below.

Trees: main at f40c7c6, and the same tree with antigravityUsageReader.ts from #23622's head 752c416 swapped in.

TreeRecordsTokensDistinct callsDistinct tokens
main26,1192,874,645,4018,788966,519,786
#236228,788966,519,7868,788966,519,786

"Distinct" uses the issue's measure: identical counters within one conversation count as one call.

On main, 8,598 calls come back 3 times, 135 twice and 55 once (2.97× overall). With #23622, every call comes back exactly once, and the token total matches the distinct total.

One limit: this measure would also merge two genuinely separate calls in the same conversation that happen to have identical counters. So it confirms that #23622 removes the duplicate copies in this data, but it can't rule out rare coincidental matches.

Closing: current main already covers this as of 9a0766b023. If you still see it on a build that includes that commit, reply and we'll reopen.

Synced from GitHub

Could this be reopened? I can still reproduce the id-less triple counting on current main at 85bd3f2a2a0ad4a8ddf2950e01db80765e9a1bf4 (2026-10-31), as well as the installed 0.1.3 runtime.

The commit cited in the closure, 9a0766b0238f2c044a965dcba32c6cb7ea0b63cd, moved antigravityUsageReader.ts from usage/ to provider/Drivers/ without changing its contents (GitHub reports a rename with no patch). The deduplication fix in #23622 is still open/unmerged.

Current-main reproduction

From the repository root, run the following with Node 24+ (uses Node's built-in TypeScript type stripping; no credentials or conversation data required). It executes the current reader's actual decoder and candidate constructor on a synthetic generation/step pair:

js
1// repro-antigravity.cjs
2const fs = require('node:fs');
3const assert = require('node:assert/strict');
4const { stripTypeScriptTypes } = require('node:module');
5const source = fs.readFileSync(
6 'apps/server/src/provider/Drivers/antigravityUsageReader.ts', 'utf8');
7const pure = source.slice(source.indexOf('type FieldValue'),
8 source.indexOf('class AntigravityDatabaseDecodeError'));
9const candidates = source.slice(source.indexOf('function usageCandidates('),
10 source.indexOf('interface CachedDatabase'));
11const { metadata, usageCandidates } = new Function(
12 stripTypeScriptTypes(pure + '\n' + candidates) +
13 '\nreturn { metadata, usageCandidates };')();
14const varint = n => {
15 const b = [];
16 do {
17 const x = n % 128;
18 n = Math.floor(n / 128);
19 b.push(x + (n ? 128 : 0));
20 } while (n);
21 return b;
22};
23const num = (f, n) => [...varint(f * 8), ...varint(n)];
24const bytes = (f, b) => [...varint(f * 8 + 2), ...varint(b.length), ...b];
25const usage = [...num(2, 15822), ...num(3, 365),
26 ...num(9, 257), ...num(10, 108)];
27const generation = new Uint8Array(bytes(1, [
28 ...bytes(4, usage), ...bytes(17, bytes(2, usage))]));
29const step = new Uint8Array(bytes(9, usage));
30const records = usageCandidates('synthetic-session',
31 [{ idx: 0, entry: metadata(generation, false) }],
32 [{ idx: 1, entry: metadata(step, true) }], null, 0);
33const counted = records.reduce((n, { record: { totals: t } }) => n +
34 t.uncachedInputTokens + t.cachedInputTokens +
35 t.cacheCreationTokens + t.outputTokens, 0);
36assert.equal(records.length, 3);
37assert.ok(records.every(r => r.keys.length === 0));
38assert.equal(new Set(records.map(r => r.record.dedupeKey)).size, 3);
39assert.equal(counted, 48561);
40console.log({ actualTokens: 16187, countedTokens: counted,
41 fallbackKeys: records.map(r => r.record.dedupeKey) });

Result: 16,187 tokens become 48,561, with three empty identity-key lists and three distinct fallback keys (step:0:0, generation:0:0, generation:0:1). The subsequent merge only joins shared identity keys, so it cannot merge these candidates.

Additional real-history confirmation

On Linux x86_64, I also inspected real Gemini conversation databases read-only and replayed the installed 0.1.3 reader's decoder, candidate constructor, and identity merge. The same usage blob is byte-for-byte identical in generation usage, its final attempt, and the successful planner step; the recent records omit fields 11/12/7.

For the same local history and fixed interval (2026-10-30 17:00:00 through 2026-10-31 16:59:59, Asia/Taipei), compared with independently parsed generation usage plus failed-call steps:

CallsDistinct tokensCopies emitted by T3T3 tokens
Successful32,432,252397,296,756
Failed448,4372896,874
Total32,880,68998,193,630

The extra 65,312,941 tokens reconcile exactly to duplicate copies. This comparison does not globally deduplicate by token counts, which could collapse distinct calls with coincidentally identical usage. Separate failed calls remain counted.

Only aggregate token counts and a synthetic fixture are included here; no prompts, credentials, conversation titles, or private database files are attached.