Manage AI SaaS cost from both actual usage and the monthly cap
You cannot manage the cost of an AI SaaS by calculating the model price per call. Even if you record actual usage on every call, the monthly cost is not determined unless there is a cap on how many times it is used. The other way around, a monthly credit balance does not work as a cap unless it is settled against actual usage.
I made the second mistake. I had set one analysis at a flat 2,000 credits, and under the current calculation a typical run is 94,400 credits. The reserved amount and actual usage differed by 47 times.
I am building Shoryaku AI ( https://shoryaku.net ), a marketing-support SaaS, as an indie developer. Inference is Gemini on Vertex AI, payments are Stripe, and the infrastructure is Firebase. Now, usage per call is derived from the model’s actual price, and the cap on AI cost is 25% of monthly revenue.
The current estimate, first
This is the result of running backend/scripts/plan-economics.ts. The prices were lowered, so the values have changed since publication (the sequence is below).
| Plan | Monthly price | Main analysis model | AI cost in the typical case | Monthly cap on AI cost | Remainder after AI cost and the Stripe fee |
|---|---|---|---|---|---|
| Light | ¥980 | Gemini 2.5 Flash | ¥53 | ¥244 | typical 91% / cap 71% |
| Standard | ¥2,980 | Gemini 2.5 Flash | ¥212 | ¥745 | typical 89% / cap 71% |
| Pro | ¥4,980 | Gemini 2.5 Pro | ¥1,069 | ¥1,244 | typical 75% / cap 71% |
“Typical” here is not an average of real usage. There are still no paying customers, so the token counts are an estimate placed from the implementation.
| Estimate profile | Input | Output (including thinking) | Calls per analysis |
|---|---|---|---|
| Typical | 30,000 | 20,000 | 1.2 |
| Heavy | 60,000 | 50,000 | 2 |
Prices, model assignment, and plan caps are the real values in the code. Only the token volume is an estimate, so read the table above as a cost estimate from the current implementation, not a measured cost.
Firebase usage is measured separately, and the items that could be compared over 26 days were inside the free tier. This article stays with the AI cost that grows with usage.
The cost of one call includes thinking tokens
The current cost calculation splits the usage Vertex AI returns into these four kinds.
| Usage | How it is treated in cost |
|---|---|
| Ordinary input tokens | Input price |
| Cached input | Cache price |
| Input for tool use | Input price |
| Output + thinking tokens | Output price |
The center of the implementation is this line.
const outputTokens = tokenCount(usage.outputTokens) + tokenCount(usage.thoughtTokens);
Gemini’s thinking tokens are consumed separately from the answer text the user sees. If you do not add them to output, a process with a short answer is estimated too cheap.
The global prices of the models actually routed are managed in the code as follows.
| Model | Input / 1,000,000 | Cached input / 1,000,000 | Output / 1,000,000 |
|---|---|---|---|
| Gemini 2.5 Pro | $1.25 | $0.125 | $10.00 |
| Gemini 2.5 Flash | $0.30 | $0.03 | $2.50 |
| Gemini 2.5 Flash-Lite | $0.10 | $0.01 | $0.40 |
The “output ÷ input” ratio differs by model, so a fixed weight such as input 1 and output 5 is not used in the cost calculation.
The old credit system did not bind monthly cost
In the implementation at publication, 2,000 credits were reserved before an analysis started, and they were not settled against actual usage after it finished.
Compared with the current estimate, it looks like this.
| One analysis | Old reserved amount | Current conversion of actual usage | Gap |
|---|---|---|---|
| Typical | 2,000 | 94,400 | 47 times |
| Heavy | 2,000 | 381,334 | 191 times |
The Standard cap at the time was 100,000 credits. Counted as actual usage, one typical analysis uses almost all of that. It could still be run 30 times because the credits were not cheap. Actual usage was not posted to the ledger.
Now, the reservation before a run remains as a minimum, and the difference is posted after completion.
const overage = actual - ANALYSIS_CREDIT_RESERVE;
A reservation is needed so concurrent runs do not go over the cap, but it is not the source of truth for cost. The source of truth for cost is the model, the input and output, and the thinking tokens returned after completion.
One credit was tied to an amount of money, not to a model
The fixed “input 1 / output 5” was dropped. One credit is now defined as 0.75 microsUSD.
export const CREDIT_MICROS_USD = 0.75;
Usage for one call divides the actual model cost by this amount and rounds up.
return Math.ceil(micros / CREDIT_MICROS_USD);
In this form, switching to a more expensive model increases credit usage even for the same token count. The monthly cap is then the cost cap as it stands.
| Plan | Monthly credits | 1 credit | Cap on AI cost (converted at ¥150 per dollar) |
|---|---|---|---|
| Light | 2,170,000 | 0.75 microsUSD | ¥244 |
| Standard | 6,620,000 | 0.75 microsUSD | ¥745 |
| Pro | 11,060,000 | 0.75 microsUSD | ¥1,244 |
Each of these is about 25% of monthly revenue. If heavy analyses continue, credits run out before the displayed number of analyses is used up. The analysis count is shown as a ceiling inside the remaining credits, not as a guarantee.
Three things that broke when the model changed
1. The price ratio differs by model
On Gemini 2.5 Pro, output is 8 times input. On Flash it is about 8.3 times. On Flash-Lite it is 4 times. A fixed weight diverges from actual cost the moment you switch models.
2. A model that is not on the price list looks like ¥0
The model name is a string, so an ID that does not exist, or an ID that is not on the price list, still passes the type check. Earlier, a model ID that was not in the settings got in. It was not used as the production default, but switching to it was a state that would fail.
Now, startup checks that every model in the routing targets is on the price list. A call to an unregistered model is not booked at ¥0. It is booked provisionally at the price of the most expensive model currently in use. That is so it is noticed by booking high, rather than looking cheap and being missed.
3. Today’s prices alone cannot estimate the future
The price list carries a confirmation date. A model with an announced price change also carries the price from the effective date onward, separately. The code now records which date a price belongs to, without assuming that “LLM prices keep falling.”
Separate formulas for the ledger and for billing drift again
After the 47-times gap was fixed, one more path was still there. The cost ledger and credit usage were calculated with separate formulas.
Action generation produced both the cost ledger and the credits from the list of calls returned after completion (the actual model, input, cache, output, and thinking tokens). Chat and article-draft generation, though, went through an old convenience wrapper that calculated credits from the sum of input tokens and output tokens only.
Three things fell off on that path.
| What fell off | What happens |
|---|---|
| Thinking tokens | Vertex AI bills thinking as output. Routes that raise the quality of the answer are set to use thinking for up to 4/10 of the output limit, so the shorter the answer text, the more of the usage never lands on the credits |
| Cache discount | Cached input is counted at the ordinary input price. This error goes in the direction of counting too much |
| Model switching | Even if congestion (429) switches from Pro to Flash, it is counted at the price of the model chosen before the call |
On top of that, the classifier that judges the kind of question left the route name "fast" in the model-name field, not the actual model. That name is not on the price list, so the rule from the previous section — “an unregistered model is booked provisionally at the highest price” — applied, and the cheapest process was billed at the Pro price. A rule that had been tilted so it would not look cheap showed up this time in the direction of charging too much.
The fix was to make the formula one.
export function creditsForModelCalls(calls: ReadonlyArray<AiModelCallUsage>): number {
return calls.reduce((sum, call) => sum + creditsForUsage(call.model, call), 0);
}
Action generation, chat, and draft generation all pass this function the same call list that goes on the ledger. Each call is calculated at the actual model’s price and rounded up, so a turn that mixes Pro and Flash does not fall apart. Draft-generation calls had not been on the ledger before, so they are now recorded as content_generation.
The convenience wrapper that accepts only the sum of input and output is still there, but a comment binds it so it is not used for a call that has thinking tokens. Once the source of truth for cost is “the call list after completion,” billing has to come from there too, and no other path should remain. That was the lesson the 47-times gap was supposed to have taught.
Correcting the conclusion at publication
At publication I wrote that Standard’s AI cost was ¥140 a month, total cost was ¥340, and the gross margin was about 92%. Those are not the same conditions as the current implementation.
The amounts in the table below are against the Standard of that time (¥4,980 a month). For the current amounts, see “The current estimate, first.”
| What was written at publication | Correction (against the ¥4,980 monthly price of that time) |
|---|---|
| AI cost is ¥140 a month | The typical estimate is ¥319 a month, and the cap is ¥1,238 |
| The Stripe fee is higher than the AI cost | In the typical estimate the AI cost is ¥319 and the Stripe fee is about ¥179, so the AI side is higher |
| The gross margin is about 92% | The remainder after AI cost and the Stripe fee is 90% in the typical case and 72% at the cap |
| Credits can be calculated with a fixed price ratio | Convert each model’s actual cost into credits denominated in money |
| One analysis is 2,000 credits | 2,000 is the reserved amount. After completion, the difference from actual usage is settled |
The error was upstream of the model-price calculation. If one path is left that does not settle against actual usage, monthly cost cannot be managed even when the price list is correct.
The price was cut, and the ratio was not moved
Prices were lowered 8 days after publication. Standard went from ¥4,980 to ¥2,980, Pro from ¥9,800 to ¥4,980, and a Light plan at ¥980 was added underneath, making three tiers.
At that point the ratio that places the AI-cost cap at 25% of revenue was not changed. Because it is set as a ratio, lowering the price lowers the cap automatically.
| Old Standard ¥4,980 | New Standard ¥2,980 | |
|---|---|---|
| Monthly credits | 11,000,000 | 6,620,000 |
| Cap on AI cost | ¥1,238 | ¥745 |
| Ratio to revenue | 25% | 25% |
| Remainder when the cap is fully used | 72% | 71% |
Set it as a ratio, instead of deciding the amount first and fitting a cap on afterward, and a price change does not require rebuilding the cost design. That is a point that could be checked only after the price was cut.
The ratio did have a floor. Below 22%, credits run out before the analysis count shown on screen is reached. Credit usage per call in the case where heavy analyses continue, multiplied by the displayed count, exceeds the cap. “You have used this month’s analysis count” can be explained. “There are not enough credits” is harder to explain, so the displayed count was moved toward a number that can actually be reached (Pro went from the original proposal of 30 times to 25 times). This condition is fixed in tests.
The reason for the price cut was not cost. Acquisition moved from direct sales with a conversation to search traffic, so the price had to carry the whole explanation. Cost was not the constraint. How the price itself was judged is written in Is a monthly SaaS price of ¥4,980 too cheap? Checked against 10 competitors and CAC.
A cost cap does not mean the business works
Keeping AI cost within 25% of revenue does not mean customer acquisition cost can be recovered. If price, churn, and the paid conversion rate are unmeasured, the uncertainty outside cost is larger.
In a Google Ads CAC estimate that used our own assumptions, payback was 19 months against the ¥4,980 monthly price of that time. Those 19 months are an assumption, not a result. After the price cut, payback gets longer under the same assumptions. It is an example of the difference between being able to keep the cost cap and being able to recover acquisition cost.
What the cost design can confirm is only that losses do not become unbounded when the product is used. Whether it sells, and whether it continues, have to be measured separately.
Summary
- The cost of one call is calculated at the model’s own price for input, cache, tool use, output, and thinking tokens
- Do not stop at the amount reserved before the run. Settle the difference from actual usage after completion
- Tie one credit to an amount of money, and make the monthly cap the AI-cost cap as it stands
- The AI-cost cap is 25% of revenue on all three tiers. In the current estimate the typical case is ¥53–¥1,069, and the cap is ¥244–¥1,244. The average of real usage is unmeasured
- Set the cap as a ratio rather than as an amount, and a price cut does not require rebuilding the cost design
- The published ¥140 a month and 92% gross margin were corrected because there was a path that did not post actual usage
- The cost ledger and the credits come from the same call list, through one formula. If a separate formula remains, thinking tokens and model switches drop out of billing
- A cost cap is a breakwater against losses. It does not guarantee that acquisition or retention will work