feat(gemini): a second model, on a key Caprock never holds - #8
Merged
Conversation
The second paid feature. A user points Caprock at Google's Gemini with their own AI Studio key; Caprock makes the call and counts what it cost, and Google bills them directly. **The key is never stored.** It is read from GEMINI_API_KEY in the daemon's environment at call time — not written to config.json, not accepted by PUT /v1/settings, not returned by GET /v1/settings, and sent in a header rather than the query string so nothing that logs a URL can capture it. This is the direct answer to the objection in 17-teams.md: "a bug in Caprock shows a wrong number, and with a vault a bug in Caprock leaks credentials." A key held in the environment cannot leak from a database Caprock does not write. The cost is an honest one and the panel says it out loud: you set a variable and restart, which is a worse first run than pasting a key into a field. **The licence is checked on the server**, which the spend cap deliberately does not do. The cap spends nothing, so a React-only paywall costs a free user nothing to walk past; this spends their Gemini quota and opens an outbound connection, so the check runs in the handler before the request leaves. A test asserts an unlicensed ask never reaches the client. ADR-023 records the reasoning and keeps the precedent narrow: server-side gates belong to features that spend money or reach the network, not to features that draw a panel. Answers enter the same event stream as everything else — an ordinary turn.user + turn.assistant pair with source=gemini — so they are priced by the same table, searched by the same index, and filtered by the same agent column. Thinking tokens count as output because that is how Google bills them; leaving them out would under-report exactly the models that reason most. Eight Gemini text models added to pricing.json with their source and the date read. Three limits are stated in the notes rather than hidden: the introductory Flash rates expire 2026-12-31, Gemini has no per-token cache-write price (Google bills storage per hour), and the Pro models are tiered by prompt size so the =<200k rate under-reports a long prompt. Also mocks xterm in Session.test.tsx: mounting the terminal tab made jsdom print six unactionable canvas errors, and an error nobody can act on trains people to ignore the ones they can. Claude-Session: https://claude.ai/code/session_01DR8fggA2LRHcjNWUsqtDcF
Two changes that turn a chat box into the feature people would pay for. **The question carries the user's data.** Asked "what did I spend yesterday" with no figures, a model can only explain what spending is. Every answer now travels with today's and the week's totals, the top models and projects, and what is running right now — all of it already computed for the dashboard, so it costs nothing to hand over. What goes is deliberately narrow: totals, model names, project names, session counts. No prompts, no replies, no tool output, no file paths. The database holds the prose Claude wrote and every command it ran, and none of that belongs in an outbound request the user did not specifically ask for; a test asserts each of those never appears. Someone who wants to ask about one session can paste it, which is a decision rather than a default. The costing basis travels with the figures so the model cannot present a list price as the user's bill. **The model is chosen, and priced before it is spent.** A question costs 0.04 cents on Flash Lite and 1.00 cent on Pro — twenty-five times, on the user's own card — so being locked to whichever default we picked was our choice made with their money. The picker lists every Gemini row in the pricing table, cheapest first, each with what a short question costs at its rates. The list comes from the table rather than a hand-kept array, so one place adds a model and the price shown is the price charged. Sub-cent figures are rendered in cents, not dollars: fmtUSD prints $0.002 as "$0.00", and a reader who believes a question is free will believe it fifty times. Also stops polling /v1/gemini. The key comes from the daemon's environment and cannot change while it runs, so the timer bought nothing — and it outlived the test that mounted it, firing into a torn-down jsdom. Claude-Session: https://claude.ai/code/session_01DR8fggA2LRHcjNWUsqtDcF
17-teams.md said holding keys would trade the product's foundation for a feature. That paragraph is not overturned by the Gemini work — it is the reason the key lives in the environment, and the doc now says so rather than leaving a reader to wonder whether we changed our minds. Claude-Session: https://claude.ai/code/session_01DR8fggA2LRHcjNWUsqtDcF
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The second paid feature. A user points Caprock at Google's Gemini with their own AI Studio key; Caprock makes the call and counts what it cost, and Google bills them directly.
The key is never stored
Read from
GEMINI_API_KEYin the daemon's environment at call time — not written toconfig.json, not accepted byPUT /v1/settings, not returned byGET /v1/settings, and sent in a header rather than a query string so nothing that logs a URL can capture it.This answers an objection that was already written down, in 17-teams.md: "a bug in Caprock shows a wrong number, and with a vault a bug in Caprock leaks credentials." A key held in the environment cannot leak from a database Caprock does not write. The cost is an honest one and the panel says it out loud: you set a variable and restart, which is a worse first run than pasting a key into a field.
The licence is checked on the server
Which the spend cap deliberately does not do. ADR-022 made the key a convenience rather than a lock, and the cap follows it — a free user who sets the threshold by curl gets a working cap, because a cap spends nothing.
This spends the user's Gemini quota and opens an outbound connection, so the check runs in the handler before the request leaves. A test asserts an unlicensed ask never reaches the client. ADR-023 keeps the precedent narrow on purpose: server-side gates belong to features that spend money or reach the network, not to features that draw a panel.
What makes it worth paying for is the context
Asked "what did I spend yesterday" with no figures, a model can only explain what spending is. Every question carries today's and the week's totals, the top models and projects, and what is running — all already computed for the dashboard.
What it does not carry is prompts, replies, tool output and file paths, each pinned by its own test. The database holds the prose Claude wrote and every command it ran, and none of that belongs in a request the user did not specifically ask for.
The model is chosen, and priced before it is spent
A question costs 0.04 cents on Flash Lite and 1.00 cent on Pro — twenty-five times, on the user's own card — so being locked to whichever default we picked was our choice made with their money. The list is built from the pricing table, so one place adds a model and the price shown is the price charged.
Sub-cent figures render in cents:
fmtUSDprints$0.002as$0.00, and a reader who believes a question is free will believe it fifty times.Pricing
Eight Gemini text models added with their source and the date read. Three limits are stated in the notes rather than hidden: the introductory Flash rates expire 2026-12-31, Gemini has no per-token cache-write price (Google bills storage per hour), and the Pro models are tiered by prompt size so the ≤200k rate under-reports a long prompt.
Verification
make checkgreen. 20+ new tests, including that an unlicensed request never reaches Google, that the key appears in no response body, and that prompts and tool output never enter the outbound context. Checked against a live daemon, not only tests: 402 without a licence, 8 priced models, key absent from every endpoint.Also mocks xterm in
Session.test.tsx(mounting the terminal tab printed six unactionable jsdom canvas errors) and stops polling/v1/gemini(the timer outlived its test and fired into a torn-down jsdom).https://claude.ai/code/session_01DR8fggA2LRHcjNWUsqtDcF