Skip to content

Commit 851a2df

Browse files
authored
Add cost, token, and latency metrics across all five adapters (#123)
Adds per-call cost, token, and latency capture across all five framework adapters, feeding the run-detail metric cards that previously had no producer. All additive and non-breaking. Cost is composed at capture time through a pluggable `PriceTable` seam in `dokimos-core` (`Double costUsd(model, tokensIn, tokensOut)`, null on an unknown model or missing counts, never throws). Dokimos ships no price data; the caller supplies the map. - **LangChain4j** / **Spring AI**: `measuredTask` / `measuredAsyncTask` read token usage off the framework response (`TokenUsage` / `Usage`); an all-zero usage sentinel is treated as not measured. - **Koog** / **Spring AI Alibaba**: the agent/graph path exposes no typed usage, so `measuredTextTask` / `measuredAsyncTask` take caller-supplied counts via a small carrier, while latency and cost are still captured. - **Embabel**: `EmbabelTraceCollector.callMetrics(model, priceTable)` reads usage, cost, and running time off the completed `AgentProcess`. Embabel's own cost wins and the `PriceTable` is a fallback; the accessors are called reflectively so the adapter stays source-compatible back to `embabel-agent-api:0.3.5`. The run detail shows an "N/M items priced" subtitle when a run is only partly priced (`SUM(costUsd)` skips unpriced items). It is computed at read time on `RunDetails` from two `COUNT` queries — no new column, no migration, and nothing on the run-list path. Ships a `PriceTable` Key Interface entry, a Cost and Pricing docs page, cost sections in the Embabel and Spring AI Alibaba guides, a runnable `CostMetricsExample`, and the 0.21.0 changelog entry.
1 parent 333d461 commit 851a2df

31 files changed

Lines changed: 1788 additions & 11 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,9 @@ jobs:
156156
- module: dokimos-embabel
157157
property: embabel.version
158158
version: '0.3.5'
159+
# 0.3.5 predates AgentProcess.totalUsage()/totalCost(); the adapter main calls them
160+
# reflectively, but the metrics test stubs them, so exclude that src/it test here.
161+
profiles: '-P!embabel-metrics-it'
159162
- module: dokimos-embabel
160163
property: embabel.version
161164
version: '0.4.0'

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ When working on the core framework, understand these central types in `dokimos-c
107107
- **`Task`** — Functional interface for executing a single example (the LLM call under test).
108108
- **`Reporter`** — Reports experiment results (local logging or server-based).
109109
- **`JudgeLM`** — Functional interface for an LLM used as a judge in evaluations.
110+
- **`PriceTable`** — Functional interface turning a model id and token counts into a USD cost, returning null (never throwing) for an unknown model or a missing token count. A pluggable, point-in-time pricing seam: Dokimos ships no price data; the caller supplies the map. Cost is computed at capture time, where the model id is in scope, and frozen into `CallMetrics.costUsd()` (not recomputed downstream). In `dev.dokimos.core`.
110111

111112
- **`ToolCall`** — Record representing a single tool invocation (name, arguments, result, metadata). In `dev.dokimos.core.agents`.
112113
- **`ToolDefinition`** — Record describing a tool's contract (name, description, JSON schema). In `dev.dokimos.core.agents`.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,10 @@ It integrates with **JUnit**, **LangChain4j**, **Spring AI**, **Spring AI Alibab
3131
## Why Dokimos?
3232

3333
- **JUnit integration**: Run evaluations as parameterized tests in your existing test suite.
34-
- **Framework agnostic**: Works with LangChain4j, Spring AI, Koog or any LLM client. Powered by any LLM.
34+
- **Framework agnostic**: Works with LangChain4j, Spring AI, Spring AI Alibaba, Koog, and Embabel, or any LLM client. Powered by any LLM.
3535
- **Built in evaluators**: Hallucination detection, faithfulness, contextual relevance, LLM as a judge, and more.
3636
- **Agent evaluation**: Evaluate AI agents with tool call validation, task completion, argument hallucination detection, and tool reliability checks.
37+
- **Cost & latency tracking**: Capture per-call tokens, cost, and latency across all five adapters, with a pluggable `PriceTable` seam (you supply the prices) and per-run roll-ups.
3738
- **Custom evaluators**: Build your own metrics by extending `BaseEvaluator` or using `LLMJudgeEvaluator`.
3839
- **Dataset support**: Load test cases from JSON, CSV, or define them programmatically.
3940
- **CI/CD ready**: Runs locally or in any CI/CD environment. Fail builds when quality drops.
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
---
2+
sidebar_position: 8
3+
---
4+
5+
# Cost and Pricing
6+
7+
Dokimos can record what each LLM call cost, roll it up per run, and flag when a cost total only covers part of a run. This page explains how cost capture works, the pluggable pricing seam, and the partial-coverage signal you will see in the UI.
8+
9+
## Capturing cost
10+
11+
Cost, tokens, and latency are captured by switching a plain task to a measured one. A plain `Task` returns only outputs, so each `ItemResult` carries `null` metrics. A `MeasuredTask` returns a `TaskResult` that holds the outputs plus a `CallMetrics` record (`tokensIn`, `tokensOut`, `costUsd`, `latencyMs` — all nullable), and those metrics flow through to every `ItemResult.metrics()`, then to the server, and finally to the run-detail metric cards.
12+
13+
In a builder, the switch is one method:
14+
15+
```java
16+
// before: no metrics
17+
.task(myTask)
18+
19+
// after: tokens, cost, and latency captured
20+
.measuredTask(measuredTask)
21+
```
22+
23+
The full `MeasuredTask` / `CallMetrics` API is documented under [Recording tokens, cost, and latency](./experiments.md#recording-tokens-cost-and-latency). All five framework adapters wire this up:
24+
25+
- **LangChain4j**`LangChain4jSupport.measuredTask(model, modelId, priceTable)` (and `measuredRagTask(...)`). Reads `TokenUsage` from the response.
26+
- **Spring AI**`SpringAiSupport.measuredAsyncTask(client, modelId, priceTable)`. Reads `Usage` from the `ChatResponse`.
27+
- **Spring AI Alibaba**`SpringAiAlibabaSupport.measuredAsyncTask(...)`. The `ReactAgent` graph path returns no typed usage, so you supply token counts via an `AlibabaAgentResponse` carrier; latency and cost are still captured.
28+
- **Koog**`measuredTextTask(...)`. You supply token counts via a `KoogResponse` carrier; latency and cost are captured automatically.
29+
- **Embabel**`EmbabelTraceCollector.callMetrics(model, priceTable)`. Reads token usage, cost, and running time off the completed agent process (see the precedence note below).
30+
31+
Where the framework exposes token usage on the response (LangChain4j, Spring AI), the adapter extracts it for you; where it does not surface usage on the call path (Spring AI Alibaba, Koog), you pass the counts you have. In every case latency is timed automatically and cost is composed from a supplied `PriceTable` (null when none is given).
32+
33+
### Embabel: framework cost takes precedence
34+
35+
Embabel reports its own cost on the completed agent process, so it is the one adapter where the `PriceTable` is a fallback rather than the sole cost source. `EmbabelTraceCollector.callMetrics(model, priceTable)` uses Embabel's own non-zero `totalCost()` when present, and consults the `PriceTable` only when Embabel reported `$0` and a model id is supplied.
36+
37+
## The PriceTable seam
38+
39+
No LLM framework or provider returns a dollar cost — they return token counts. So cost must be computed at capture time, where the model id is in scope. That is the job of `PriceTable`, a functional interface in `dev.dokimos.core`:
40+
41+
```java
42+
@FunctionalInterface
43+
public interface PriceTable {
44+
Double costUsd(String model, Integer tokensIn, Integer tokensOut);
45+
}
46+
```
47+
48+
`PriceTable` is side-effect free and returns `null` (it never throws) for an unknown model or a null token count. A null cost degrades gracefully: the Total Cost card simply stays dark for that item, rather than failing the run. The cost it returns is frozen into `CallMetrics.costUsd()` at capture time and is never recomputed downstream — the server stores and aggregates the number it was given.
49+
50+
## You supply the prices
51+
52+
**Dokimos ships no price data.** Prices change, vary by provider and region, and go stale; baking a price list into the framework would be wrong the day after it shipped. Instead, you supply a `PriceTable` — a lambda over your own price map, an internal pricing service, or the copyable reference map from `dokimos-examples`.
53+
54+
The reference map in `CostMetricsExample` is **illustrative** — a point-in-time snapshot you copy and pin to the current published rates for your model and provider:
55+
56+
```java
57+
// ILLUSTRATIVE per-million-token rates — pin your own current figures.
58+
private static final Map<String, double[]> REFERENCE_PRICES =
59+
Map.of("gpt-5-nano", new double[] {0.05, 0.40}); // { inputPerMillion, outputPerMillion }
60+
61+
private static final PriceTable PRICES = (model, tokensIn, tokensOut) -> {
62+
double[] rate = model == null ? null : REFERENCE_PRICES.get(model);
63+
if (rate == null || tokensIn == null || tokensOut == null) {
64+
return null; // unknown model or unmeasured call -> no cost
65+
}
66+
double usd = ((long) tokensIn * rate[0] + (long) tokensOut * rate[1]) / 1_000_000d;
67+
return Math.round(usd * 1_000_000d) / 1_000_000d; // round to 6 decimal places
68+
};
69+
```
70+
71+
## Precision: compute at 6dp, display at 4dp
72+
73+
Per-call costs are often a fraction of a cent. The reference `PriceTable` rounds each item's cost to **6 decimal places** (`Math.round(usd * 1_000_000d) / 1_000_000d`) so a sub-cent per-call cost survives instead of rounding to zero before it is summed. Rounding is the `PriceTable`'s choice, not a framework guarantee — Dokimos stores whatever `Double` your `PriceTable` returns, unmodified. The run-detail UI then displays the rolled-up **total** to **4 decimal places** (`$x.xxxx`). Compute precise per-item; display the rounded total.
74+
75+
## Partial-coverage signal: "N/M items priced"
76+
77+
The run cost total is `SUM(costUsd)` over the run's items, and SUM skips null-cost rows. So a run that mixes priced and unpriced items would otherwise show a complete-looking total that silently omits the unpriced ones — for example, when your `PriceTable` returned `null` for a model it did not recognize.
78+
79+
To make that visible, the run-detail **Total Cost** card shows a muted subtitle when a run is only partially priced:
80+
81+
> 2/5 items priced
82+
83+
It renders **only** when fewer items are priced than tokenized (`pricedItemCount < tokenizedItemCount`). A fully priced run shows the cost alone, unchanged. A run with no measured items at all shows no Total Cost card.
84+
85+
The denominator is **tokenized** items, not all items:
86+
87+
- An item with **tokens but no cost** is one your `PriceTable` could not price (unknown model). It counts against coverage — this is exactly the gap the signal reports.
88+
- An item with **no tokens at all** was never measured (a plain `.task`). It counts toward neither number — you cannot price what was never measured.
89+
90+
(One edge: Embabel reports its own cost independently of token usage, so an Embabel item can carry a cost without token counts. The displayed total stays correct; only the "priced ≤ tokenized" relationship the signal assumes may not strictly hold for such items.)
91+
92+
This is surfaced as two nullable computed fields, `pricedItemCount` and `tokenizedItemCount`, on `RunDetails` (the run-detail view) only. The run list (`RunSummary`) deliberately carries no coverage signal, so listing runs adds no per-run queries. There is **no new database column and no migration** — the counts are computed at read time from two indexed `COUNT` queries. For an in-progress run they accrue live alongside the totals; for a completed run the totals come from the run's materialized columns while the coverage counts are still computed live from the run's (now immutable) item rows.
93+
94+
:::note
95+
The two TypeScript fields (`pricedItemCount`, `tokenizedItemCount`) in the frontend's generated API types are produced by orval from the server's OpenAPI spec, on the `RunDetails` type only. Regenerating with orval is the canonical path.
96+
:::
97+
98+
## Not yet covered
99+
100+
A few things are intentionally out of scope for now, mostly because no adapter framework surfaces them uniformly:
101+
102+
- **Cached / prompt-cached input tokens.** `CallMetrics` and `PriceTable` model only `tokensIn`/`tokensOut`; cached-token discounts are not represented.
103+
- **Reasoning tokens.** Reasoning/thinking tokens are not split out from the output count.
104+
- **Non-USD currency.** `PriceTable` returns a single USD `Double`; there is no currency conversion (the stored column is `cost_usd`).
105+
- **The zero-priced run in the UI.** When *every* tokenized item is unpriced, the run has no cost total, so the Total Cost card — and with it the "N/M items priced" subtitle — does not render. The Total Tokens card still shows the run was measured; the partial-coverage signal is for runs that are *partly* priced.

docs/docs/integrations/embabel.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,16 @@ Beyond `trace()`, the collector exposes the raw observations. Use these to debug
268268
- `collector.observedToolNames()` returns the distinct tool names seen, in order.
269269
- `collector.trace()` assembles the full `AgentTrace`.
270270

271+
## Cost, tokens, and latency
272+
273+
The same collector captures metrics. After the run, call `collector.callMetrics(model, priceTable)` to get a `CallMetrics` (`tokensIn`, `tokensOut`, `costUsd`, `latencyMs` — any may be null), or `collector.callMetrics()` for tokens and latency only. Feed it into a `MeasuredTask`'s `TaskResult` so the run detail shows Total Tokens, Total Cost, and Avg Latency.
274+
275+
```java
276+
CallMetrics metrics = collector.callMetrics("your-model", priceTable);
277+
```
278+
279+
Embabel reports its own cost on the completed agent process, so cost precedence here differs from the other adapters: Embabel's own non-zero `totalCost()` wins, and the `PriceTable` is consulted only when Embabel reported `$0` and a model id is supplied. All-zero token usage is treated as "not measured" (null), and `callMetrics()` returns `null` when nothing was captured. See [Cost and Pricing](../evaluation/cost-and-pricing) for the pricing seam.
280+
271281
## Limitations
272282

273283
Two limitations follow from how Embabel reports events. Keep them in mind when you pick evaluators.

docs/docs/integrations/spring-ai-alibaba.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,26 @@ See [Agent Evaluation](../evaluation/agent-evaluation) for the full set of agent
268268

269269
## Judges and async tasks
270270

271-
This module deliberately does not add `asJudge` or `asyncTask`. Spring AI Alibaba agents run on a standard Spring AI `ChatModel` or `ChatClient`, so use `SpringAiSupport.asJudge(...)` and `SpringAiSupport.asyncTask(...)` from the [Spring AI integration](./spring-ai) directly.
271+
For judging and plain async execution, this module does not add its own `asJudge` or `asyncTask`. Spring AI Alibaba agents run on a standard Spring AI `ChatModel` or `ChatClient`, so use `SpringAiSupport.asJudge(...)` and `SpringAiSupport.asyncTask(...)` from the [Spring AI integration](./spring-ai) directly.
272+
273+
## Cost, tokens, and latency
274+
275+
For metrics capture, the module **does** add `SpringAiAlibabaSupport.measuredAsyncTask(...)`. The `ReactAgent` graph path returns a bare `AssistantMessage` with no typed `Usage`, so you supply the token counts via an `AlibabaAgentResponse` carrier (`AlibabaAgentResponse.of(text)` for text-only, or with `tokensIn`/`tokensOut` when you have them). Latency is timed automatically and cost is composed from an optional `PriceTable`:
276+
277+
```java
278+
PriceTable prices = (model, in, out) -> /* your price map */ null;
279+
280+
AsyncTask task = SpringAiAlibabaSupport.measuredAsyncTask(
281+
example -> {
282+
String answer = runYourAgent(example.input()); // your ReactAgent call -> text
283+
// supply token counts from your usage source, or AlibabaAgentResponse.of(answer) for latency-only
284+
return new AlibabaAgentResponse(answer, promptTokens, completionTokens);
285+
},
286+
"your-model",
287+
prices);
288+
```
289+
290+
See [Cost and Pricing](../evaluation/cost-and-pricing) for the `PriceTable` seam and the run-detail metric cards.
272291

273292
## Coopetition note
274293

docs/docs/overview.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@ It is an open-source evaluation framework. It works with Spring AI, Spring AI Al
1515
1. Build and manage datasets in code, from files, or with custom sources
1616
2. Run experiments with built-in evaluators, or your own custom evaluators
1717
3. Evaluate AI agents, including their tool calls and execution traces
18-
4. Work with typed, structured data end to end, from task output to evaluator
19-
5. Run evals in a test-driven way with JUnit parameterized tests
20-
6. Track experiment results over time with an optional server and web UI
18+
4. Capture per-call cost, tokens, and latency and roll them up per run
19+
5. Work with typed, structured data end to end, from task output to evaluator
20+
6. Run evals in a test-driven way with JUnit parameterized tests
21+
7. Track experiment results over time with an optional server and web UI
2122

2223
Dokimos is framework agnostic. The core depends on no AI framework, so it works with any LLM client, or none at all. The Spring AI, Spring AI Alibaba, LangChain4j, Koog, and Embabel modules are thin, optional bridges that capture a run in one line. You never need them to use Dokimos.
2324

docs/docusaurus.config.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,8 @@ const config: Config = {
9797
// at build time from the newest GitHub release (see createConfig below); these
9898
// are the offline fallback.
9999
customFields: {
100-
dokimosVersion: "0.20.0",
101-
latestVersion: "0.20.0",
100+
dokimosVersion: "0.21.0",
101+
latestVersion: "0.21.0",
102102
},
103103

104104
// Future flags, see https://docusaurus.io/docs/api/docusaurus-config#future
@@ -233,7 +233,7 @@ const config: Config = {
233233
};
234234

235235
export default async function createConfig(): Promise<Config> {
236-
const latestVersion = await latestReleaseVersion("0.20.0");
236+
const latestVersion = await latestReleaseVersion("0.21.0");
237237
return {
238238
...config,
239239
customFields: { ...config.customFields, dokimosVersion: latestVersion, latestVersion },

docs/src/components/ReleaseTimeline/index.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,36 @@ const RELEASES: Release[] = [
99
{
1010
version: "0.21.0",
1111
date: "June 2026",
12-
lead: "Two new agent integrations: capture an Embabel or Spring AI Alibaba agent run as an AgentTrace and score it with the agent evaluators, using the same trace model as the existing framework adapters.",
12+
lead: "Cost, token, and latency metrics across all five framework adapters with a pluggable pricing seam, plus two new agent integrations (Embabel and Spring AI Alibaba) that capture an agent run as an AgentTrace for the agent evaluators.",
1313
groups: [
1414
{
1515
label: "Added",
1616
items: [
17+
{
18+
title: "Cost, token & latency metrics",
19+
body: (
20+
<>
21+
Capture per-call <code>tokensIn</code>/<code>tokensOut</code>, <code>costUsd</code>,
22+
and <code>latencyMs</code> across all five adapters via measured tasks (
23+
<code>measuredTask</code>/<code>measuredAsyncTask</code>/<code>measuredTextTask</code>,
24+
and <code>EmbabelTraceCollector.callMetrics</code>). Cost is composed at capture time
25+
through a pluggable <code>PriceTable</code> seam in <code>dokimos-core</code>
26+
Dokimos ships no price data, you supply the map. The run detail rolls up Total
27+
Tokens, Total Cost, and Avg Latency.
28+
</>
29+
),
30+
},
31+
{
32+
title: "Partial cost-coverage signal",
33+
body: (
34+
<>
35+
When a run mixes priced and unpriced items, the run-detail Total Cost card shows an{" "}
36+
<code>N/M items priced</code> subtitle so a partial total is never mistaken for a
37+
complete one. Computed at read time on <code>RunDetails</code> — no new column, no
38+
migration.
39+
</>
40+
),
41+
},
1742
{
1843
title: "Embabel integration",
1944
body: (
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package dev.dokimos.core;
2+
3+
/**
4+
* Turns a model id and token counts into a USD cost, returning null when the model is unknown or a
5+
* count is missing (never throws, never fabricates a number).
6+
*
7+
* <p>This is a pluggable, point-in-time pricing seam. Dokimos ships no price data: the caller
8+
* supplies an implementation (a lambda over an in-house price map, a copyable reference map from
9+
* {@code dokimos-examples}, or a live/internal backend). Because no LLM framework or provider
10+
* returns a dollar cost (only token counts), cost must be computed here, at capture time, where the
11+
* model id is in scope. The result is frozen into {@link CallMetrics#costUsd()} and is not
12+
* recomputed downstream.
13+
*
14+
* <p>Implementations must be side-effect free and must return null (rather than throw) for an
15+
* unknown model or a null token count, mirroring the all-nullable {@link CallMetrics} contract so a
16+
* missing price degrades to a dark cost card rather than a failed run.
17+
*/
18+
@FunctionalInterface
19+
public interface PriceTable {
20+
21+
/**
22+
* Computes the USD cost of a single call.
23+
*
24+
* @param model the model id the call used (the lookup key), or null
25+
* @param tokensIn prompt tokens consumed, or null if not measured
26+
* @param tokensOut completion tokens produced, or null if not measured
27+
* @return the cost in US dollars, or null if the model is unknown or either token count is null
28+
*/
29+
Double costUsd(String model, Integer tokensIn, Integer tokensOut);
30+
}

0 commit comments

Comments
 (0)