Repository navigation
feat: add Antigravity CLI as a usage provider - #141
Conversation
Antigravity CLI writes to `~/.gemini/antigravity-cli/`, so it looks like it should already be covered, but it shares nothing with Gemini CLI except that parent directory. Each conversation is its own SQLite database and the per-call token ledger sits in a protobuf blob, so the Gemini file scan — which only matches `.json`/`.jsonl` under `~/.gemini/tmp/` — never sees any of it. Every Antigravity turn has been reading as zero. The field numbers come from the CLI's own binary rather than from the data: it embeds its `FileDescriptorProto` pool, and `ModelUsageStats`, `ChatStartMetadata` and `ChatModelMetadata` were read out of it. That mattered, because two of the fields are shaped so that inferring them from sample values would have been wrong. `input_tokens` excludes cache reads, the opposite of Gemini's `promptTokenCount`, and cache reads are the larger number by an order of magnitude — subtracting them the way the Gemini CLI parser subtracts its own `cached` would have thrown away most of the prompt. And `output_tokens` is already `thinking_output_tokens + response_output_tokens`, an identity that holds on every record, so treating the three as siblings would have doubled the output. There is no total field anywhere in the schema; the total is the sum of the three populated counters, which is the identity `Entry.total` already keeps. Turn time is `chat_start_metadata.created_at` and the identity is `response_id`, which is present and distinct on every recorded call — the turn's own id rather than the file it sits in, so a copied conversation cannot read as fresh spend. `message_id` and `provider_assigned_message_id` are declared but never written, and `cache_write_tokens` is never written either, which is consistent with implicit caching reporting no writes. Opening these databases needs two attempts. They are all in WAL mode, and `mode=ro` cannot create the `-shm` file a WAL database needs, so it fails outright on any conversation that has been checkpointed and has no `-wal` sibling — 28 of the 124 stores here at the time this was written. `immutable=1` reads those, and it is only reached after `mode=ro` fails, which is exactly when there is no uncommitted tail to miss; reaching for it first would silently drop the newest turns of an active conversation. The open also has to be forced to read, because `sqlite3_open_v2` is lazy enough to report success on a database it cannot actually query, and a fallback that never runs looks like an empty conversation rather than a failed open. A scan is only complete when it ends on `SQLITE_DONE` for the same reason the other SQLite-backed readers insist on it: the CLI writes while the app polls, and half a conversation must not be cached as the whole of it. Scanning keys on the newest of the database and its `-wal` and `-shm` siblings. A WAL commit leaves the main file's timestamp alone, so a window keyed on the `.db` would skip precisely the conversations that had just moved. Cost is zero. Antigravity is a subscription, reports no amount, and the models it names include `claude-sonnet-4-6`, which the rate table prices — so the model string carries an `antigravity/` prefix that keeps it out of the exact table, and a short-circuit above the family fallbacks keeps it out of those too. The same shape as Grok, and for the same reason: an untrustworthy bill should read as $0 rather than as an estimate. Two limits worth stating. `steps.metadata` carries the same usage submessage and records 175 calls that `gen_metadata` never flushed, interrupted or errored turns worth 66,820 output tokens; they are left out because `gen_metadata` is the only one of the two that is self-contained, and because `gen_metadata` is the billed figure — on the 3,934 calls both tables record, it is never lower and is higher on 42 of them, where it includes the retried attempts. And token counts are `uint64` on the wire, so anything implausibly large is discarded rather than widened into `Int`, where the next addition would trap the app on every refresh until the file changed. Verified against the live store as well as the fixtures: 3,934 records across 124 conversations, decoded in 0.14s, matching an independent decode of the same databases field for field. 469 tests pass and the logic-core coverage gate reports 87.86%.
fa82d9f to
d10cb4f
Compare
|
Thanks for this — the write-up is unusually thorough, and the two traps you called out ( I verified what I could locally: build and the full suite pass, and the On the provider question you raised: let's split it into its own Four non-blocking notes, three of which are the same shape — a failure that leaves no trace:
None of these block the merge. If you'd rather land the split separately, say so and I'll take the notes as follow-ups. |
Antigravity shares `~/.gemini/` with Gemini CLI and nothing else, so counting it under the Gemini provider meant that someone who runs only Antigravity saw their spend labelled "Gemini" — and knowing which tool the spend went to is the whole point of the per-service tabs. The reader never depended on the choice: `LocalAntigravityProvider` calls the same `LocalAntigravityUsageCache` the Gemini provider used to fan out to, so the parse, the identity and the windows are untouched. A second Gemini-shaped tab costs nothing when it is unused. A provider with no snapshot raises no tab, and the compact menu bar surfaces are already gated on today's tokens, so an installed-but-unused Antigravity stays invisible. `reportsCost` is false. The wire schema has no cost field and `ModelPricing` short-circuits the `antigravity/` prefix, so the cost is zero by construction at both of the two places a cost could come from. Reporting it would print `$0.00` beside the tokens of a subscription that never billed per token — the artifact `reportsCost` was introduced for, on Cursor. No total moves either way; only the `$0.00` goes. Registered directly after Gemini rather than at the end of the array, so the two `~/.gemini/` CLIs stay adjacent in the tab bar. Index 0 is untouched, so the default tab is still Claude Code. `ProviderTabLayoutTests` now asserts its provider list against `registeredProviderIDs`. That list was maintained by hand, so it could have gone on measuring a tab bar that is not the one that ships — which is exactly what it would have done here.
|
Split done — Two decisions in there worth flagging, since neither was forced:
Registered after Gemini, not at the end. Keeps the two One consequence worth naming rather than letting you find it: Antigravity's week/month totals now only reach the aggregates when it has usage today or a live 5h block, because that is the admission rule for a snapshot at all ( While updating READMEs in all three languages: the combined Gemini CLI · Antigravity row is two rows, and "counted under Gemini" / "Gemini 에 합산" / "Gemini に合算" is gone from the data-sources note. 473 tests pass and I'll take the four notes as a follow-up, as you offered — keeps this PR to the one thing you asked for. They're written and green already, so it should be a quick one. Two things that came out of doing them, in case they change how you'd like them landed:
Two things I found next door and left alone, for whenever they're worth their own issue: Last thing: this PR's title is now the opposite of its diff, and it's the squash subject. Something like |
Only `README.ja.md` conflicted: `main` reworded the on-device privacy line (`モデル turn の実行` -> `モデルの推論実行`) on the same line this branch added Antigravity to the tool list. Kept both.
|
@2JIHAN Answers to your questions:
Happy to merge once the title and body are updated. The follow-up PR is welcome whenever it's ready. Thank you for your interest in this project and for a contribution this thorough — it's appreciated. |
|
Title and body updated. Retitled to
I'll file the two next-door findings as separate issues myself once this lands, so no need for you to pick those up. Ready to merge whenever you are — and thank you for the review, it was a genuinely useful one. |
|
Thank you for your contribution! |
Summary
Antigravity CLI writes to
~/.gemini/antigravity-cli/, so it looks like the Gemini provider should already cover it. It shares nothing with Gemini CLI except that parent directory. Each conversation is its own SQLite database and the per-call token ledger sits inside a protobuf blob, while the Gemini scan only matches.json/.jsonlunder~/.gemini/tmp/. Every Antigravity turn currently reads as zero.I took the field numbers from the CLI's own binary rather than from the data. It embeds its
FileDescriptorProtopool, andModelUsageStats,ChatStartMetadataandChatModelMetadatacame out of it directly. That mattered here, because two fields are shaped so that inferring them from sample values would have produced a plausible parser that was wrong.input_tokensexcludes cache reads — the opposite of Gemini'spromptTokenCount— and cache reads are the larger number by an order of magnitude, so subtracting them the way the Gemini CLI parser subtracts its owncachedwould have discarded most of the prompt.output_tokensis alreadythinking_output_tokens + response_output_tokens, an identity that holds on every record, so treating the three as siblings would have doubled the output. The schema has no total field at all; the total is the sum of the three populated counters, which is the identityEntry.totalalready keeps.Turn time is
chat_start_metadata.created_at. Identity isresponse_id, present and distinct on every recorded call — the turn's own id rather than the file it sits in, so a copied conversation cannot read as fresh spend.message_id,provider_assigned_message_idandcache_write_tokensare declared but never written.Opening these databases takes two attempts. They are all in WAL mode, and
mode=rocannot create the-shmfile a WAL database needs, so it fails outright on any conversation that has been checkpointed and has no-walsibling — 28 of the 124 stores on my machine when I measured.immutable=1reads those, and it is only reached aftermode=rofails, which is exactly when there is no uncommitted tail to miss. Reaching for it first would silently drop the newest turns of an active conversation. The open also has to be forced to read:sqlite3_open_v2is lazy enough to report success on a database it cannot query, and a fallback that never runs looks like an empty conversation rather than a failed open. Scanning keys on the newest of the database and its two siblings, because a WAL commit leaves the main file's timestamp alone and a window keyed on the.dbwould skip precisely the conversations that had just moved.Cost is zero. Antigravity is a subscription, reports no amount, and the models it names include
claude-sonnet-4-6, which the rate table prices. The model string therefore carries anantigravity/prefix that keeps it out of the exact table, and a short-circuit above the family fallbacks keeps it out of those. Same shape as Grok, same reason — an untrustworthy bill should read as $0 rather than as an estimate.Two limits worth stating plainly.
steps.metadatacarries the same usage submessage and records 175 calls thatgen_metadatanever flushed, interrupted or errored turns worth 66,820 output tokens — near enough 2.5% of the total whichever of the two figures you divide by. They are left out becausegen_metadatais the only one of the two that is self-contained —steps.metadatahas no model name — and because it is the billed figure: on the 3,934 calls both tables record it is never lower, and it is higher on 42 of them, where it includes the retried attempts. Separately, token counts areuint64on the wire, so an implausibly large value is discarded rather than widened intoInt, where the next addition would trap the app on every refresh until the file changed.Antigravity is its own
UsageProvider, with its own tab.CONTRIBUTING.mdreads a new AI CLI as a new provider, and although it shares~/.geminiand its vendor with Gemini CLI it shares nothing else — folding it in would have labelled an Antigravity-only user's spend "Gemini", and knowing which tool the spend went to is the point of the per-service tabs. An unused Antigravity stays invisible: a provider with no snapshot raises no tab, and the compact surfaces are already gated on today's tokens.reportsCostis false, because the schema has no cost field and the rate lookup is short-circuited by the prefix — the cost is zero by construction, and reporting it would print$0.00beside a subscription that never billed per token. It registers directly after Gemini, so the two~/.gemini/CLIs stay adjacent and index 0 — the default tab — is untouched. One consequence worth naming: Antigravity's week/month totals reach the aggregates only when it has usage today or a live 5h block, which is the admission rule every other provider is already under.Verified against the live store as well as the fixtures: 3,934 records across 124 conversations, decoded in 0.14s, matching an independent decode of the same databases field for field. The fixtures encode the descriptor's field numbers rather than a guess at the shape; no real blob is committed, since
chat_modelcarries the prompt text.Type of change
Checklist
swift buildandswift testpass locallySources/PokeTokenBar/UI/changed, so the UI section is omitted