Class 10: Cost Accounting per Agent Run
Duration: ~20 minutes | Difficulty: Advanced | Prerequisites: Class 9 completed; Ollama with a tool-calling model.
This lesson carries on from Class 9. If you followed along, keep working on the project you already have. If you skipped it, clone the agents_class_9 branch to start from the same place:
git clone --branch agents_class_9 https://github.com/graphqlguy/graphQLBeyondMCP.git
Code blocks are titled with the file they belong to, and the note in brackets says what to do with it. (new file) is a file to create. (edited, whole file) replaces a file you already have. Anything else names the one part to add or replace, as in (edited, the call method). Where a block shows part of a new file, the complete file follows in a collapsed block underneath.
Every agent run costs money, because the provider bills for tokens. By default, that bill arrives weeks later as one number for the whole month. It does not say which run, which customer or which feature spent the money.
That becomes a problem when something goes wrong, for example when an agent gets stuck on a request it cannot answer and retries. Each retry resends the whole conversation so far. The cost rises with every attempt, and nobody sees it until the invoice arrives.
Classes 4 and 6 built the ceilings that stop a loop like that. This lesson builds the measurement that shows the loop while it happens. We will do three things:
- Count the tokens each run spends, using the numbers the provider reports.
- Turn those counts into money, and print a receipt at the end of every run.
- Publish the same numbers as Micrometer metrics, tagged per customer, so they reach the dashboards your team already watches.
The Two-Sided Bill
An agent run costs money in two places, and it helps to price them separately:
| Meter | Priced in | What drives it |
|---|---|---|
| Model side | tokens | every call bills the whole conversation as input, resent each turn, plus its output |
| Graph side | resolver cost | database time, downstream calls, the complexity units Class 4's caps use |
A full accounting converts both sides into one currency per run. This lesson builds the model side, because that side changes from run to run. The graph side stays as Class 4 left it, in complexity units per operation. A production deployment turns those units into fractions of a cent by measuring its own resolvers.
The allow-list discussion made one point about this mapping that is worth repeating. The persisted-query pattern saves mostly on the token side, because the model sends an operation name in place of a document on every turn. It also saves some server CPU, and that saving is small. Treat the pattern as a security measure that also makes prompts smaller.
Where the Numbers Come From
Every model call returns a ChatResponse, and its metadata holds the token counts. These are the provider's own numbers, the same ones your invoice is calculated from, so every figure in this lesson comes from the provider. The loop from Class 4 reads them after each call:
Usage usage = response.getMetadata().getUsage();
costMeter.record(usage.getPromptTokens(), usage.getCompletionTokens());
Spring AI 2.0's Usage reports more than input and output tokens. On providers that support it, cached tokens are counted separately, which matters later in this lesson.
Before we build the counters, we look at how the bill adds up, because an agent conversation is charged differently from an ordinary API call. Each turn sends the whole conversation again. So an early tool result is charged when it arrives, and again on every turn after it:
Take a tool result of 4,000 tokens in a run with four turns left. It costs 20,000 tokens on the invoice, and the system prompt and tool definitions are charged on every row. That is why Class 2 priced the catalog and Class 3 measured how many tokens each approach sends. It is also why prompt caching matters.
Providers with prompt caching charge much less for an input token they have seen before. Agent conversations benefit a lot, because every turn resends the same opening: the system prompt, the tool definitions and the early history. Class 3 showed that this resending is why size matters so much, and caching discounts exactly that part. The cache-read counters in Usage show whether the discount is applied. On a local model, the meter reads zero dollars either way. Self-hosting moves the cost from the provider's bill to your own hardware and electricity, where you still pay it.
Money as a Metric
Our CostMeter does two jobs:
- It prices each call against configured rates, in dollars per million input and output tokens. The rates are configuration on purpose. A local model costs zero at the provider, a hosted model publishes list prices, and the code is the same for both.
- It feeds three Micrometer counters, tagged by tenant and model. They live in the same
MeterRegistrythat the rest of a Spring service uses for its operational metrics.
Micrometer is one dependency, the same one a Spring service already uses for its operational metrics:
<!-- Meters for the cost accounting: the same registry your service already
ships operational metrics through -->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-core</artifactId>
</dependency>
package com.graphqlguy.moviedb.agents.safety;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
/**
* Converts token counts into money and records the money as a metric. Every model
* call reports its provider-side usage; this meter prices it against the
* configured per-million-token rates and feeds the same Micrometer registry the
* rest of a Spring service ships operational metrics through, tagged by tenant
* and model. A cost that shows up only on the monthly invoice arrives too late
* to stop the runs that caused it.
*
* The prices are configuration, deliberately: local models cost zero at the
* provider and something at the electricity meter, hosted models publish list
* prices, and the pattern is identical either way. The demo runs price a local
* model AS IF at hosted list rates, labeled as such, so the arithmetic is
* visible without an API key.
*/
public class CostMeter {
private final Counter promptTokens;
private final Counter completionTokens;
private final Counter cost;
private final double inputPerMillion;
private final double outputPerMillion;
private long promptTotal;
private long completionTotal;
public CostMeter(MeterRegistry registry, String tenant, String model,
double inputPerMillion, double outputPerMillion) {
this.inputPerMillion = inputPerMillion;
this.outputPerMillion = outputPerMillion;
this.promptTokens = Counter.builder("agent.tokens")
.tag("kind", "prompt").tag("tenant", tenant).tag("model", model)
.register(registry);
this.completionTokens = Counter.builder("agent.tokens")
.tag("kind", "completion").tag("tenant", tenant).tag("model", model)
.register(registry);
this.cost = Counter.builder("agent.cost.dollars")
.tag("tenant", tenant).tag("model", model)
.register(registry);
}
public void record(long prompt, long completion) {
promptTotal += prompt;
completionTotal += completion;
promptTokens.increment(prompt);
completionTokens.increment(completion);
cost.increment(dollars(prompt, completion));
}
private double dollars(long prompt, long completion) {
return prompt / 1_000_000.0 * inputPerMillion
+ completion / 1_000_000.0 * outputPerMillion;
}
public String receipt() {
double total = dollars(promptTotal, completionTotal);
return String.format(
"%,d prompt + %,d completion tokens = $%.6f at $%.2f/$%.2f per million (illustrative list rates)",
promptTotal, completionTotal, total, inputPerMillion, outputPerMillion);
}
}
The meter has two counters for tokens and one for dollars. All three carry the same tags, so later we can ask what one tenant spent. receipt() prints the console line at the end of a run, and the counters serve everything after that.
We chose Micrometer because the costs then reach the tools your operations team already runs. Agent costs could go into their own table or an analytics pipeline, but then someone would have to build the queries, charts and alerts for them. As Micrometer meters:
- Prometheus scrapes them.
- Grafana charts them.
- Alerts can fire on a rate, which matters most here.
A runaway agent barely changes the monthly total until the damage is large, and its rate changes in the first minute. Say agent.cost.dollars for one tenant rises at ten times its usual rate. Your existing Alertmanager can fire an alert long before the loop uses up its budget. The tenant tag turns "costs are up" into "this customer's automation is stuck", and that tells the person who receives the alert where to act.
The Receipt
Check It Works
mvn clean spring-boot:run
Choose agent from the menu, and paste this task at the prompt:
add Alien to my To watch this weekend watchlist
The agent reads the catalog, finds the film and the list, and stops at the approval gate. Answer y, then press Enter at the reply prompt to return to the menu. The run looks like this:
task : add Alien to my To watch this weekend watchlist
model asks for: myWatchLists
result: {"data":{"myWatchLists":[{"id":"1","name":"To watch this weekend", ... "itemCount":2}, ...
model asks for: search
result: {"data":{"search":[{"__typename":"Movie","id":"56","title":"Alien","releaseYear":1979,"rating":8.5}]}}
model asks for: addWatchListItem
APPROVAL REQUIRED: the agent wants to run a write.
tool : addWatchListItem
operation : mutation Tool_addWatchListItem($input: AddWatchListItemInput!) { addWatchListItem(input: $input) { success item { id titleType watched userNotes } error { code message } } }
arguments : {"input":{"titleId":"56","titleType":"MOVIE","watchListId":"1"}}
Execute this mutation? [y/N] approved by the human.
result: {"data":{"addWatchListItem":{"success":true,"item":{"id":"5","titleType":"MOVIE","watched":false,"userNotes":null},"error":null}}}
answer: "Alien" (1979) has been successfully added to your "To watch this weekend" watchlist!
budget: 4 model calls (ceiling 8), 3 tool calls (ceiling 6)
cost : 4,773 prompt + 2,338 completion tokens = $0.016463 at $1.00/$5.00 per million (illustrative list rates)
reply, or blank to return to the menu:
meters: [agent.cost.dollars[tag(model=qwen3:8b), tag(tenant=user)]=0.016463, agent.tokens[tag(kind=completion), tag(model=qwen3:8b), tag(tenant=user)]=2338.0, agent.tokens[tag(kind=prompt), tag(model=qwen3:8b), tag(tenant=user)]=4773.0]
Your token counts will differ, because the model writes a different answer each time, and the lines keep the same form.
Two details in that transcript help before you run it:
- The order of the first two tool calls varies, because finding the film and finding the list are independent.
- At the approval prompt, any answer other than
ydeclines the write. If you seeno answer given, so the write was declined, an empty line reached the gate. Run the command again to answer it.
The run behaves as it did in Class 4, with two new lines after the budget: the receipt, and the meters that the registry holds. Both come from the numbers the provider reported, so a run with more tool calls shows higher numbers in both.
AgentRunner takes the meter as a fourth parameter, records the usage the provider reports on every model call, and prints the receipt at the end. The change touches four places, and each one is small.
A second run method takes a CostMeter alongside the task, the tools and the budget. The existing three-argument version keeps its signature and calls the new one with null, so the code from Classes 4 to 7 keeps working unchanged:
public void run(String task, List<ToolCallback> tools, RunBudget budget) {
run(task, tools, budget, null);
}
public void run(String task, List<ToolCallback> tools, RunBudget budget, CostMeter costMeter) {
The recording goes inside the loop, on the line after chatModel.call(prompt) returns. The provider's numbers exist only at that moment, so this is the one place where the recording can go:
if (costMeter != null) {
Usage usage = response.getMetadata().getUsage();
costMeter.record(usage.getPromptTokens(), usage.getCompletionTokens());
}
The receipt prints at the end of the run, in the branch that already prints the answer and the budget summary. A person reading a finished run wants both facts in one place: what it was allowed to spend, and what it spent:
if (costMeter != null) {
System.out.println("cost : " + costMeter.receipt());
}
Two imports come with those, com.graphqlguy.moviedb.agents.safety.CostMeter and org.springframework.ai.chat.metadata.Usage.
The complete AgentRunner.java, with the additions above in place
package com.graphqlguy.moviedb.agents.agent;
import com.graphqlguy.moviedb.agents.safety.CostMeter;
import com.graphqlguy.moviedb.agents.safety.RunBudget;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.model.tool.ToolCallingChatOptions;
import org.springframework.ai.model.tool.ToolCallingManager;
import org.springframework.ai.model.tool.ToolExecutionResult;
import org.springframework.ai.ollama.api.OllamaChatOptions;
import org.springframework.ai.tool.ToolCallback;
import java.util.ArrayList;
import java.util.List;
/**
* The agent loop, driven by hand as in the schema-navigation lesson, with one
* addition: the run budget decides when the loop ends. The model proposes tool
* calls, the tools run them, and the budget stops the run at its ceiling. When the
* ceiling is reached mid-task, the loop ends with a plain statement. A stuck agent
* tends to repeat the same attempt, and every extra model call costs tokens.
*/
public class AgentRunner {
private static final String SYSTEM = """
You are a concierge for the Movie Database. Use the tools to answer and to act;
never invent ids, and never claim an action succeeded without a tool result
proving it. Writes may require the human's approval; if a write is denied,
accept the decision, explain it, and ask what they would like instead.
When you need an id (a watchlist, a movie), find it with a read tool first.""";
private final ChatModel chatModel;
private final String modelName;
private final ToolCallingManager toolCallingManager = ToolCallingManager.builder().build();
/**
* The conversation is kept between calls to {@link #run}, so a reply continues
* where the last answer stopped. Small models often answer a task with a
* question, and the kept conversation lets the user answer it.
*/
private final List<Message> conversation = new ArrayList<>();
public AgentRunner(ChatModel chatModel, String modelName) {
this.chatModel = chatModel;
this.modelName = modelName;
}
public void run(String task, List<ToolCallback> tools, RunBudget budget) {
run(task, tools, budget, null);
}
public void run(String task, List<ToolCallback> tools, RunBudget budget, CostMeter costMeter) {
ToolCallingChatOptions options = OllamaChatOptions.builder()
.model(modelName)
.toolCallbacks(tools)
.build();
if (conversation.isEmpty()) {
conversation.add(new SystemMessage(SYSTEM));
}
conversation.add(new UserMessage(task));
String finalAnswer = null;
while (budget.allowModelCall()) {
Prompt prompt = new Prompt(conversation, options);
ChatResponse response = chatModel.call(prompt);
if (costMeter != null) {
Usage usage = response.getMetadata().getUsage();
costMeter.record(usage.getPromptTokens(), usage.getCompletionTokens());
}
Generation generation = response.getResult();
if (generation == null) {
break; // the provider returned an empty response; there is nothing to act on
}
if (response.hasToolCalls()) {
generation.getOutput().getToolCalls().forEach(call ->
System.out.println("model asks for: " + call.name()));
ToolExecutionResult executed =
toolCallingManager.executeToolCalls(prompt, response);
conversation.clear();
conversation.addAll(executed.conversationHistory());
if (!conversation.isEmpty()
&& conversation.get(conversation.size() - 1)
instanceof ToolResponseMessage toolResponses) {
toolResponses.getResponses().forEach(toolResponse -> {
String data = toolResponse.responseData();
System.out.println(" result: " + (data.length() > 220
? data.substring(0, 220) + "..." : data));
});
}
} else {
finalAnswer = generation.getOutput().getText();
conversation.add(generation.getOutput()); // keep the answer for the next turn
break;
}
}
System.out.println();
if (finalAnswer == null) {
System.out.println("The run budget stopped the loop before a final answer: "
+ budget.summary());
} else {
System.out.println("answer: " + finalAnswer);
System.out.println("budget: " + budget.summary());
if (costMeter != null) {
System.out.println("cost : " + costMeter.receipt());
}
}
}
}
The agent command builds the meter and the registry, then prints what the registry holds:
// Class 10: the same run, priced. A SimpleMeterRegistry keeps the counters
// for the length of this command; in a service it is the one Boot created.
var registry = new SimpleMeterRegistry();
CostMeter costMeter = new CostMeter(registry,
context.username().isBlank() ? "anonymous" : context.username(),
context.modelName(),
context.env().getProperty("agents.pricing.input-per-mtok", Double.class, 1.00),
context.env().getProperty("agents.pricing.output-per-mtok", Double.class, 5.00));
// One runner for the whole exchange: a model that answers with a question
// gets an answer back, and both the budget and the meter keep counting
// across the turns, because a conversation's cost includes every turn.
AgentRunner runner = new AgentRunner(chatModel, context.modelName());
String turn = task;
while (!turn.isBlank()) {
runner.run(turn, tools, budget, costMeter);
System.out.println();
System.out.print("reply, or blank to return to the menu: ");
turn = context.console().hasNextLine() ? context.console().nextLine().strip() : "";
System.out.println();
}
System.out.println("meters: " + registry.getMeters().stream()
.map(meter -> meter.getId().getName() + meter.getId().getTags() + "="
+ ((Counter) meter).count())
.sorted().toList());
The complete RunAgentCommand.java, with the additions above in place
package com.graphqlguy.moviedb.agents.command;
import com.graphqlguy.moviedb.agents.agent.AgentRunner;
import com.graphqlguy.moviedb.agents.auth.AuthSession;
import com.graphqlguy.moviedb.agents.safety.ApprovalGate;
import com.graphqlguy.moviedb.agents.safety.CostMeter;
import com.graphqlguy.moviedb.agents.safety.RunBudget;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
import java.util.List;
/**
* Class 4: the whole loop with every control attached. The login happens once,
* before any tool exists; the gate and the budget are handed to every callback,
* so all three apply to the catalog by construction.
*/
@Component
public class RunAgentCommand implements AgentCommand {
private final ObjectProvider<ChatModel> chatModels;
public RunAgentCommand(ObjectProvider<ChatModel> chatModels) {
this.chatModels = chatModels;
}
@Override
public String name() {
return "agent";
}
@Override
public int order() {
return 40;
}
@Override
public String summary() {
return "run the agent loop: login, approval gate, and run budget";
}
@Override
public String prompt() {
return "task, e.g. add The Matrix to my To watch this weekend watchlist";
}
@Override
public void run(List<String> args, CommandContext context) {
String task = String.join(" ", args);
ChatModel chatModel = chatModels.getIfAvailable();
if (task.isBlank() || chatModel == null) {
System.out.println(task.isBlank()
? "agent needs a task, e.g.: agent add movie 1 to my watchlist"
: "No chat model configured.");
return;
}
String role = context.role();
AuthSession auth = context.login();
RunBudget budget = context.budget();
ApprovalGate gate = new ApprovalGate(context.console());
List<ToolCallback> tools = context.callbacks(context.tools(role), auth, gate, budget);
System.out.println("role : " + role + " (" + tools.size() + " tools)");
System.out.println("user : " + (context.username().isBlank()
? "(anonymous)" : context.username()));
System.out.println("task : " + task);
System.out.println();
// Class 10: the same run, priced. A SimpleMeterRegistry keeps the counters
// for the length of this command; in a service it is the one Boot created.
var registry = new SimpleMeterRegistry();
CostMeter costMeter = new CostMeter(registry,
context.username().isBlank() ? "anonymous" : context.username(),
context.modelName(),
context.env().getProperty("agents.pricing.input-per-mtok", Double.class, 1.00),
context.env().getProperty("agents.pricing.output-per-mtok", Double.class, 5.00));
// One runner for the whole exchange: a model that answers with a question
// gets an answer back, and both the budget and the meter keep counting
// across the turns, because a conversation's cost includes every turn.
AgentRunner runner = new AgentRunner(chatModel, context.modelName());
String turn = task;
while (!turn.isBlank()) {
runner.run(turn, tools, budget, costMeter);
System.out.println();
System.out.print("reply, or blank to return to the menu: ");
turn = context.console().hasNextLine() ? context.console().nextLine().strip() : "";
System.out.println();
}
System.out.println("meters: " + registry.getMeters().stream()
.map(meter -> meter.getId().getName() + meter.getId().getTags() + "="
+ ((Counter) meter).count())
.sorted().toList());
}
}
SimpleMeterRegistry keeps the counters in memory for the length of one command, which is all a demonstration needs. In a service, the registry is the one Spring Boot already created, and the counters go wherever your metrics already go.
Every run of the command now ends like this. The example is from a real run, with the meters line shortened to fit the page:
answer: The movie with ID 5 has been successfully added to your "To watch this
weekend" watchlist!
budget: 3 model calls (ceiling 8), 2 tool calls (ceiling 6)
cost : 2,642 prompt + 789 completion tokens = $0.006587 at $1.00/$5.00 per
million (illustrative list rates)
meters: [agent.cost.dollars[tag(model=qwen3:8b), tag(tenant=user)]=0.006587,
agent.tokens[tag(kind=completion), ...]=789.0, agent.tokens[tag(kind=prompt), ...]=2642.0]
Each of those four lines comes from a different part of the course:
- The
budgetline is Class 4's ceiling. It reports what the run was allowed and what it used. - The token counts on the
costline are the provider's own, recorded one call at a time. - The dollar figure applies the rates you configured. It says "illustrative" because this run used a local model, which is free at the provider.
- The
metersline shows the same numbers as tagged counters, ready for Prometheus to scrape.
A complete authenticated write, approved by a human, cost under a cent. A run over a curated catalog, with ceilings in place, is cheap. The measurement tells you when a run stops being cheap.
From Counters to Ceilings
Class 4's budgets counted calls, because calls were what the loop could see. Now that each run has a price, we can also set the budget in money. A ceiling per run reads "no single run spends more than $0.50". A cap per customer over a time window reads "this tenant's agents stop at $50 a day". Both belong where the call-count budget already is, and both report through the same meters. The code is a small extension of RunBudget that reads the running total from CostMeter.
The important part is what happens when a ceiling is reached, and Class 4 already decided that. The run stops with a plain message, states the reason, and keeps the partial work available for inspection. A ceiling that only writes a log line gets crossed on every run that matters, because the loop continues whatever the log says.
Summary
Measuring cost shows an expensive agent run before the invoice arrives, and the JVM stack builds that measurement from parts it already has. The provider reports usage for each call in the ChatResponse metadata. Providers with prompt caching also report cache reads, and agent loops benefit a lot from caching, because every turn resends the same opening. A fifty-line meter prices the usage against configured rates. The result goes into Micrometer counters, tagged by tenant and model, in the registry your service already has.
An alert on the rate of a tagged cost counter catches a runaway loop in its first minute. The customer tag turns "costs are up" into "this customer's agents are looping". A ceiling in money extends Class 4's budgets from counting calls to counting money. Our receipt priced a complete gated write at under a cent on the illustrative rates. The more useful result is that the number exists per run and per customer, before any invoice arrives.
Further Reading
- Spring AI: Chat Model responses and usage: where the per-call usage metadata lives and what each provider populates.
- Micrometer documentation: counters, tags, and registries; the concepts this lesson's meter is built from.
- Anthropic: prompt caching: the pricing mechanics that discount an agent conversation's resent prefix, and what the cache-read counters in
Usagecorrespond to. - Prometheus: alerting on rates: how to alert on rates, the operational side of detecting a runaway loop.
What's Next
The agent now has ceilings, an approval gate, typed answers and a price per run. The next step is to prove that it keeps them. Class 11 turns to testing:
- It pins the generated catalog in CI, so schema drift fails a build before it reaches an agent.
- It unit-tests the translation layers.
- It replays recorded conversations against a scripted model, so a test can make assertions about the loop's behaviour.