refactor(billing): unify the LLM billing domain

Replace the overlapping usage and cost model with canonical billing
primitives centered on ModelRef, ModelHandle, TokenCounts, and
BilledModelUsage. This also renames the public API and web surface from
usage to billing, removes compatibility aliases, and normalizes provider
usage adapters onto the shared billing vocabulary.
This commit is contained in:
Bryan Helmkamp 2026-04-07 14:33:35 -04:00
parent 0a2fd4b0dc
commit 6ca2833e77
109 changed files with 2403 additions and 1389 deletions

1
Cargo.lock generated
View file

@ -2020,6 +2020,7 @@ dependencies = [
"clap",
"dirs",
"fabro-macros",
"fabro-model",
"fabro-util",
"hex",
"serde",

View file

@ -24,7 +24,7 @@ import * as RunStages from "./routes/run-stages";
import * as RunSettings from "./routes/run-settings";
import * as RunGraph from "./routes/run-graph";
import * as RunFiles from "./routes/run-files";
import * as RunUsage from "./routes/run-usage";
import * as RunBilling from "./routes/run-billing";
import * as Insights from "./routes/insights";
import * as InsightsEditor from "./routes/insights-editor";
import * as InsightsNew from "./routes/insights-new";
@ -106,7 +106,7 @@ export const routes: RouteObject[] = [
route("settings", RunSettings),
route("graph", RunGraph),
route("files", RunFiles),
route("usage", RunUsage),
route("billing", RunBilling),
],
}),
route("insights", Insights, {

View file

@ -1,42 +1,47 @@
import { apiJson } from "../api";
import { formatDurationSecs } from "../lib/format";
import type { RunUsage } from "@qltysh/fabro-api-client";
export async function loader({ request, params }: any) {
const usage = await apiJson<RunUsage>(`/runs/${params.id}/usage`, { request });
const stages = usage.stages.map((s) => ({
stage: s.stage.name,
model: s.model.id,
inputTokens: s.usage.input_tokens,
outputTokens: s.usage.output_tokens,
runtime: formatDurationSecs(s.runtime_secs),
cost: s.usage.cost,
}));
const totalRuntime = formatDurationSecs(usage.totals.runtime_secs);
const totalCost = usage.totals.cost;
const totalInput = usage.totals.input_tokens;
const totalOutput = usage.totals.output_tokens;
const modelBreakdown = usage.by_model
.map((m) => ({
model: m.model.id,
stages: m.stages,
inputTokens: m.usage.input_tokens,
outputTokens: m.usage.output_tokens,
cost: m.usage.cost,
}))
.sort((a, b) => b.cost - a.cost);
return { stages, totalRuntime, totalCost, totalInput, totalOutput, modelBreakdown };
}
import type { RunBilling } from "@qltysh/fabro-api-client";
function formatTokens(n: number) {
return `${(n / 1000).toFixed(1)}k`;
}
export default function RunUsage({ loaderData }: any) {
const { stages, totalRuntime, totalCost, totalInput, totalOutput, modelBreakdown } = loaderData;
function formatUsdMicros(usdMicros?: number) {
return usdMicros == null ? "-" : `$${(usdMicros / 1_000_000).toFixed(2)}`;
}
export async function loader({ request, params }: any) {
const billing = await apiJson<RunBilling>(`/runs/${params.id}/billing`, { request });
const stages = billing.stages.map((stage) => ({
stage: stage.stage.name,
model: stage.model.id,
inputTokens: stage.billing.input_tokens,
outputTokens: stage.billing.output_tokens + (stage.billing.reasoning_tokens ?? 0),
runtime: formatDurationSecs(stage.runtime_secs),
totalUsdMicros: stage.billing.total_usd_micros,
}));
const totalRuntime = formatDurationSecs(billing.totals.runtime_secs);
const totalInput = billing.totals.input_tokens;
const totalOutput = billing.totals.output_tokens + (billing.totals.reasoning_tokens ?? 0);
const totalUsdMicros = billing.totals.total_usd_micros;
const modelBreakdown = billing.by_model
.map((entry) => ({
model: entry.model.id,
stages: entry.stages,
inputTokens: entry.billing.input_tokens,
outputTokens: entry.billing.output_tokens + (entry.billing.reasoning_tokens ?? 0),
totalUsdMicros: entry.billing.total_usd_micros,
}))
.sort((a, b) => (b.totalUsdMicros ?? -1) - (a.totalUsdMicros ?? -1));
return { stages, totalRuntime, totalUsdMicros, totalInput, totalOutput, modelBreakdown };
}
export default function RunBilling({ loaderData }: any) {
const { stages, totalRuntime, totalUsdMicros, totalInput, totalOutput, modelBreakdown } =
loaderData;
return (
<div className="space-y-6">
<div className="rounded-md border border-line overflow-hidden">
<div className="overflow-hidden rounded-md border border-line">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line bg-panel/60 text-left text-xs text-fg-muted">
@ -44,7 +49,7 @@ export default function RunUsage({ loaderData }: any) {
<th className="px-4 py-2.5 font-medium">Model</th>
<th className="px-4 py-2.5 font-medium text-right">Tokens</th>
<th className="px-4 py-2.5 font-medium text-right">Run time</th>
<th className="px-4 py-2.5 font-medium text-right">Cost</th>
<th className="px-4 py-2.5 font-medium text-right">Billing</th>
</tr>
</thead>
<tbody>
@ -53,10 +58,13 @@ export default function RunUsage({ loaderData }: any) {
<td className="px-4 py-3 text-fg-2">{row.stage}</td>
<td className="px-4 py-3 font-mono text-xs text-fg-3">{row.model}</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
{formatTokens(row.inputTokens)} <span className="text-fg-muted">/</span> {formatTokens(row.outputTokens)}
{formatTokens(row.inputTokens)} <span className="text-fg-muted">/</span>{" "}
{formatTokens(row.outputTokens)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">{row.runtime}</td>
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">${row.cost.toFixed(2)}</td>
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
{formatUsdMicros(row.totalUsdMicros)}
</td>
</tr>
))}
</tbody>
@ -65,47 +73,64 @@ export default function RunUsage({ loaderData }: any) {
<td className="px-4 py-3 font-medium text-fg">Total</td>
<td />
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
{formatTokens(totalInput)} <span className="text-fg-muted">/</span> {formatTokens(totalOutput)}
{formatTokens(totalInput)} <span className="text-fg-muted">/</span>{" "}
{formatTokens(totalOutput)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
{totalRuntime}
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
{formatUsdMicros(totalUsdMicros)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">{totalRuntime}</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">${totalCost.toFixed(2)}</td>
</tr>
</tfoot>
</table>
</div>
<div>
<h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-fg-muted">By Model</h3>
<div className="rounded-md border border-line overflow-hidden">
<h3 className="mb-3 text-xs font-medium uppercase tracking-wider text-fg-muted">
By Model
</h3>
<div className="overflow-hidden rounded-md border border-line">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-line bg-panel/60 text-left text-xs text-fg-muted">
<th className="px-4 py-2.5 font-medium">Model</th>
<th className="px-4 py-2.5 font-medium text-right">Stages</th>
<th className="px-4 py-2.5 font-medium text-right">Tokens</th>
<th className="px-4 py-2.5 font-medium text-right">Cost</th>
<th className="px-4 py-2.5 font-medium text-right">Billing</th>
</tr>
</thead>
<tbody>
{modelBreakdown.map((row) => (
<tr key={row.model} className="border-b border-line last:border-b-0">
<td className="px-4 py-3 font-mono text-xs text-fg-2">{row.model}</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">{row.stages}</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
{formatTokens(row.inputTokens)} <span className="text-fg-muted">/</span> {formatTokens(row.outputTokens)}
{row.stages}
</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums text-fg-3">
{formatTokens(row.inputTokens)} <span className="text-fg-muted">/</span>{" "}
{formatTokens(row.outputTokens)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
{formatUsdMicros(row.totalUsdMicros)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">${row.cost.toFixed(2)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-line-strong bg-panel/40">
<td className="px-4 py-3 font-medium text-fg">Total</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">{stages.length}</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
{formatTokens(totalInput)} <span className="text-fg-muted">/</span> {formatTokens(totalOutput)}
{stages.length}
</td>
<td className="px-4 py-3 text-right font-mono text-xs tabular-nums font-medium text-fg">
{formatTokens(totalInput)} <span className="text-fg-muted">/</span>{" "}
{formatTokens(totalOutput)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
{formatUsdMicros(totalUsdMicros)}
</td>
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">${totalCost.toFixed(2)}</td>
</tr>
</tfoot>
</table>

View file

@ -11,7 +11,7 @@ const tabs = [
{ name: "Overview", path: "", count: null },
{ name: "Stages", path: "/stages/detect-drift", count: null },
{ name: "Files Changed", path: "/files", count: null },
{ name: "Usage", path: "/usage", count: null },
{ name: "Billing", path: "/billing", count: null },
];
export const handle = { hideHeader: true };

View file

@ -7,7 +7,11 @@ import { useTheme } from "../lib/theme";
import { getGraphTheme } from "../lib/graph-theme";
import { apiJson } from "../api";
import { formatDurationSecs } from "../lib/format";
import type { PaginatedRunStageList, PaginatedRunList, WorkflowDetail } from "@qltysh/fabro-api-client";
import type { PaginatedRunStageList, PaginatedRunList } from "@qltysh/fabro-api-client";
interface WorkflowGraphResponse {
graph: string;
}
export const handle = { wide: true };
@ -35,7 +39,7 @@ export async function loader({ request, params }: any) {
let graphDot: string | null = null;
if (run) {
try {
const workflow = await apiJson<WorkflowDetail>(`/workflows/${run.workflow}`, { request });
const workflow = await apiJson<WorkflowGraphResponse>(`/workflows/${run.workflow}`, { request });
graphDot = workflow.graph;
} catch {
// workflow not found — leave graphDot null

View file

@ -1,7 +1,16 @@
import { ChevronRightIcon } from "@heroicons/react/20/solid";
import { Link, Outlet, useLocation, useParams } from "react-router";
import { apiJson } from "../api";
import type { WorkflowDetail as ApiWorkflowDetail, RunSettings } from "@qltysh/fabro-api-client";
import type { RunSettings } from "@qltysh/fabro-api-client";
interface ApiWorkflowDetail {
name: string;
slug: string;
description: string;
filename: string;
settings: RunSettings;
graph: string;
}
export interface WorkflowEntry {
name: string;

View file

@ -15,7 +15,27 @@ import {
import { Link } from "react-router";
import { apiJson } from "../api";
import { timeAgo, timeUntil } from "../lib/time";
import type { PaginatedWorkflowList } from "@qltysh/fabro-api-client";
interface WorkflowRunSummary {
ran_at?: string | null;
}
interface WorkflowScheduleSummary {
expression: string;
next_run?: string | null;
}
interface WorkflowListItem {
name: string;
slug: string;
filename: string;
last_run?: WorkflowRunSummary | null;
schedule?: WorkflowScheduleSummary | null;
}
interface PaginatedWorkflowList {
data: WorkflowListItem[];
}
export function meta({}: any) {
return [{ title: "Workflows — Fabro" }];

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -61,7 +61,7 @@
<script type="module" src="/assets/chunk-sadshphz.js"></script>
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
<script type="module" src="/assets/entry-xtvaf517.js"></script>
<script type="module" src="/assets/entry-r31bcs2m.js"></script>
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
<script type="module" src="/assets/chunk-9t57pdty.js"></script>

View file

@ -17,8 +17,8 @@ tags:
description: Internal run details (stages, turns, context, configuration)
- name: Workflows
description: Workflow definitions and execution
- name: Usage
description: Token and cost usage
- name: Billing
description: Token counts and billed totals
- name: Insights
description: SQL query editor and history
- name: Models
@ -823,21 +823,21 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/runs/{id}/usage:
/api/v1/runs/{id}/billing:
get:
operationId: retrieveRunUsage
operationId: retrieveRunBilling
tags: [Run Outputs]
summary: Retrieve Run Usage
description: Returns token and cost usage broken down by stage and model for a specific run.
summary: Retrieve Run Billing
description: Returns token counts and billed totals broken down by stage and model for a specific run.
parameters:
- $ref: "#/components/parameters/RunId"
responses:
"200":
description: Usage data
description: Billing data
content:
application/json:
schema:
$ref: "#/components/schemas/RunUsage"
$ref: "#/components/schemas/RunBilling"
"404":
description: Run not found
content:
@ -1186,21 +1186,21 @@ paths:
schema:
$ref: "#/components/schemas/PaginatedHistoryEntryList"
# ── Usage ────────────────────────────────────────────────────────────
# ── Billing ──────────────────────────────────────────────────────────
/api/v1/usage:
/api/v1/billing:
get:
operationId: getAggregateUsage
tags: [Usage]
summary: Aggregate Usage
description: Returns aggregate token/cost usage across all completed runs since server start.
operationId: getAggregateBilling
tags: [Billing]
summary: Aggregate Billing
description: Returns aggregate token counts and billed totals across all completed runs since server start.
responses:
"200":
description: Aggregate usage data
description: Aggregate billing data
content:
application/json:
schema:
$ref: "#/components/schemas/AggregateUsage"
$ref: "#/components/schemas/AggregateBilling"
# ── System ───────────────────────────────────────────────────────────
@ -2955,9 +2955,9 @@ components:
format: int64
minimum: 0
nullable: true
total_cost:
type: number
format: double
total_usd_micros:
type: integer
format: int64
nullable: true
# ── Run Board Schemas ────────────────────────────────────────────────
@ -3050,13 +3050,13 @@ components:
description: Repository name.
example: api-server
TokenUsage:
description: Token and cost usage totals.
BilledTokenCounts:
description: Token counts with optional billed USD micros totals.
type: object
required:
- input_tokens
- output_tokens
- cost
- total_tokens
properties:
input_tokens:
type: integer
@ -3066,10 +3066,28 @@ components:
type: integer
description: Number of output tokens generated.
example: 8750
cost:
type: number
description: Cost in USD.
example: 0.72
total_tokens:
type: integer
description: Total billable tokens aggregated across categories.
example: 37390
reasoning_tokens:
type: integer
description: Number of reasoning tokens.
example: 1200
cache_read_tokens:
type: integer
description: Number of cache read tokens.
example: 4800
cache_write_tokens:
type: integer
description: Number of cache write tokens.
example: 1500
total_usd_micros:
type: integer
format: int64
nullable: true
description: Billed USD amount in micros.
example: 720000
CodeLocation:
description: A file and line location in the codebase.
@ -3180,14 +3198,14 @@ components:
description: Question text.
example: Accept or push for another round?
AggregateUsageTotals:
description: Aggregate usage totals across all runs.
AggregateBillingTotals:
description: Aggregate billing totals across all runs.
type: object
required:
- runs
- input_tokens
- output_tokens
- cost
- total_tokens
- runtime_secs
properties:
runs:
@ -3202,17 +3220,35 @@ components:
type: integer
description: Total output tokens.
example: 189720
cost:
type: number
description: Total cost in USD.
example: 20.34
total_tokens:
type: integer
description: Total tokens aggregated across all billing categories.
example: 833580
reasoning_tokens:
type: integer
description: Total reasoning tokens.
example: 12040
cache_read_tokens:
type: integer
description: Total cache read tokens.
example: 85400
cache_write_tokens:
type: integer
description: Total cache write tokens.
example: 9200
total_usd_micros:
type: integer
format: int64
nullable: true
description: Total billed USD amount in micros.
example: 20340000
runtime_secs:
type: number
description: Total runtime in seconds.
example: 3501.0
UsageStageRef:
description: Reference to a usage stage.
BillingStageRef:
description: Reference to a billing stage.
type: object
required:
- id
@ -3530,36 +3566,36 @@ components:
meta:
$ref: "#/components/schemas/PaginationMeta"
# ── Usage Schemas ────────────────────────────────────────────────────
# ── Billing Schemas ──────────────────────────────────────────────────
UsageStage:
description: Token and cost usage for a single stage within a run.
RunBillingStage:
description: Token counts and billed totals for a single stage within a run.
type: object
required:
- stage
- model
- usage
- billing
- runtime_secs
properties:
stage:
$ref: "#/components/schemas/UsageStageRef"
$ref: "#/components/schemas/BillingStageRef"
model:
$ref: "#/components/schemas/ModelReference"
usage:
$ref: "#/components/schemas/TokenUsage"
billing:
$ref: "#/components/schemas/BilledTokenCounts"
runtime_secs:
type: number
description: Wall-clock runtime in seconds.
example: 154.0
UsageTotals:
description: Aggregate usage totals across all stages of a run.
RunBillingTotals:
description: Aggregate billing totals across all stages of a run.
type: object
required:
- runtime_secs
- input_tokens
- output_tokens
- cost
- total_tokens
properties:
runtime_secs:
type: number
@ -3573,18 +3609,36 @@ components:
type: integer
description: Total output tokens generated.
example: 21080
cost:
type: number
description: Total cost in USD.
example: 2.26
total_tokens:
type: integer
description: Total tokens aggregated across all billing categories.
example: 92620
reasoning_tokens:
type: integer
description: Total reasoning tokens.
example: 3400
cache_read_tokens:
type: integer
description: Total cache read tokens.
example: 22000
cache_write_tokens:
type: integer
description: Total cache write tokens.
example: 4500
total_usd_micros:
type: integer
format: int64
nullable: true
description: Total billed USD amount in micros.
example: 2260000
UsageByModel:
description: Usage statistics grouped by model.
BillingByModel:
description: Billing statistics grouped by model.
type: object
required:
- model
- stages
- usage
- billing
properties:
model:
$ref: "#/components/schemas/ModelReference"
@ -3592,11 +3646,11 @@ components:
type: integer
description: Number of stages that used this model.
example: 2
usage:
$ref: "#/components/schemas/TokenUsage"
billing:
$ref: "#/components/schemas/BilledTokenCounts"
RunUsage:
description: Complete usage breakdown for a single run.
RunBilling:
description: Complete billing breakdown for a single run.
type: object
required:
- stages
@ -3605,31 +3659,31 @@ components:
properties:
stages:
type: array
description: Per-stage usage breakdown.
description: Per-stage billing breakdown.
items:
$ref: "#/components/schemas/UsageStage"
$ref: "#/components/schemas/RunBillingStage"
totals:
$ref: "#/components/schemas/UsageTotals"
$ref: "#/components/schemas/RunBillingTotals"
by_model:
type: array
description: Usage grouped by model.
description: Billing grouped by model.
items:
$ref: "#/components/schemas/UsageByModel"
$ref: "#/components/schemas/BillingByModel"
AggregateUsage:
description: Aggregate token and cost usage across all runs since server start.
AggregateBilling:
description: Aggregate token counts and billed totals across all runs since server start.
type: object
required:
- totals
- by_model
properties:
totals:
$ref: "#/components/schemas/AggregateUsageTotals"
$ref: "#/components/schemas/AggregateBillingTotals"
by_model:
type: array
description: Usage grouped by model.
description: Billing grouped by model.
items:
$ref: "#/components/schemas/UsageByModel"
$ref: "#/components/schemas/BillingByModel"
PreviewUrlRequest:
description: Request body for generating a preview URL from a sandbox port.

View file

@ -14,7 +14,7 @@ use fabro_llm::middleware::{Middleware, NextFn, NextStreamFn};
use fabro_llm::provider::StreamEventStream;
use fabro_llm::types::{Request, Response};
use fabro_mcp::config::McpServerSettings;
use fabro_model::{Catalog, ModelRef, Provider};
use fabro_model::{Catalog, ModelHandle, Provider};
use fabro_util::terminal::Styles;
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
@ -166,8 +166,8 @@ fn build_tool_approval(
})
}
fn summarizer_model_id(provider: Provider) -> ModelRef {
ModelRef::ByName {
fn summarizer_model_id(provider: Provider) -> ModelHandle {
ModelHandle::ByName {
provider,
model: match provider {
Provider::OpenAi | Provider::OpenAiCompatible => "gpt-4o-mini",
@ -257,7 +257,7 @@ fn print_summary(session: &Session, styles: &Styles) {
{
turn_count += 1;
tool_call_count += tool_calls.len();
total_tokens += usage.total_tokens;
total_tokens += usage.total_tokens();
}
}
let token_str = if total_tokens >= 1_000_000 {
@ -303,7 +303,7 @@ impl Middleware for DebugMiddleware {
response.finish_reason,
response.usage.input_tokens,
response.usage.output_tokens,
response.usage.total_tokens,
response.usage.total_tokens(),
)),
);
Ok(response)

View file

@ -256,7 +256,7 @@ mod tests {
use crate::test_support::TestProfile;
use crate::tool_registry::ToolRegistry;
use crate::types::Turn;
use fabro_llm::types::{ToolCall, ToolResult, Usage};
use fabro_llm::types::{TokenCounts, ToolCall, ToolResult};
use std::time::SystemTime;
#[test]
@ -274,7 +274,7 @@ mod tests {
serde_json::json!({"path": "foo.rs"}),
)],
provider_parts: vec![],
usage: Box::new(Usage::default()),
usage: Box::new(TokenCounts::default()),
response_id: "resp_1".into(),
timestamp: SystemTime::now(),
},

View file

@ -136,7 +136,7 @@ fn extract_recent_user_messages(discarded: Vec<Turn>, token_budget: usize) -> Ve
#[cfg(test)]
mod tests {
use super::*;
use fabro_llm::types::{ThinkingData, ToolCall, ToolResult, Usage};
use fabro_llm::types::{ThinkingData, TokenCounts, ToolCall, ToolResult};
use std::time::SystemTime;
#[test]
@ -231,7 +231,7 @@ mod tests {
content: "Hi there".into(),
tool_calls: vec![],
provider_parts: vec![],
usage: Box::new(Usage::default()),
usage: Box::new(TokenCounts::default()),
response_id: "resp_1".into(),
timestamp: SystemTime::now(),
});
@ -249,7 +249,7 @@ mod tests {
content: "Let me read that".into(),
tool_calls: vec![tc],
provider_parts: vec![],
usage: Box::new(Usage::default()),
usage: Box::new(TokenCounts::default()),
response_id: "resp_2".into(),
timestamp: SystemTime::now(),
});
@ -275,7 +275,7 @@ mod tests {
content: "The answer is 42".into(),
tool_calls: vec![],
provider_parts: vec![thinking],
usage: Box::new(Usage::default()),
usage: Box::new(TokenCounts::default()),
response_id: "resp_3".into(),
timestamp: SystemTime::now(),
});
@ -300,7 +300,7 @@ mod tests {
content: "The answer".into(),
tool_calls: vec![],
provider_parts: vec![thinking],
usage: Box::new(Usage::default()),
usage: Box::new(TokenCounts::default()),
response_id: "resp_4".into(),
timestamp: SystemTime::now(),
});
@ -331,7 +331,7 @@ mod tests {
content: String::new(),
tool_calls: vec![tc],
provider_parts: vec![reasoning_item],
usage: Box::new(Usage::default()),
usage: Box::new(TokenCounts::default()),
response_id: "resp_1".into(),
timestamp: SystemTime::now(),
});
@ -397,7 +397,7 @@ mod tests {
content: "Second".into(),
tool_calls: vec![],
provider_parts: vec![],
usage: Box::new(Usage::default()),
usage: Box::new(TokenCounts::default()),
response_id: "resp_1".into(),
timestamp: SystemTime::now(),
});
@ -423,10 +423,9 @@ mod tests {
signature: None,
redacted: false,
})],
usage: Box::new(Usage {
usage: Box::new(TokenCounts {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
..Default::default()
}),
response_id: "resp_1".into(),
@ -467,7 +466,7 @@ mod tests {
content: "response".into(),
tool_calls: vec![tc],
provider_parts: vec![reasoning],
usage: Box::new(Usage::default()),
usage: Box::new(TokenCounts::default()),
response_id: "resp_1".into(),
timestamp: SystemTime::now(),
});
@ -514,7 +513,7 @@ mod tests {
content: "answer".into(),
tool_calls: vec![],
provider_parts: vec![thinking],
usage: Box::new(Usage::default()),
usage: Box::new(TokenCounts::default()),
response_id: "resp_1".into(),
timestamp: SystemTime::now(),
});
@ -551,7 +550,7 @@ mod tests {
kind: ContentPart::OPENAI_REASONING.into(),
data: serde_json::json!({"type": "reasoning", "id": format!("rs_{i}")}),
}],
usage: Box::new(Usage::default()),
usage: Box::new(TokenCounts::default()),
response_id: format!("resp_{i}"),
timestamp: SystemTime::now(),
});
@ -580,7 +579,7 @@ mod tests {
content: "reply".into(),
tool_calls: vec![],
provider_parts: vec![],
usage: Box::new(Usage::default()),
usage: Box::new(TokenCounts::default()),
response_id: "r1".into(),
timestamp: SystemTime::now(),
},
@ -624,7 +623,7 @@ mod tests {
content: "assistant msg".into(),
tool_calls: vec![],
provider_parts: vec![],
usage: Box::new(Usage::default()),
usage: Box::new(TokenCounts::default()),
response_id: "r1".into(),
timestamp: SystemTime::now(),
});

View file

@ -94,7 +94,7 @@ fn is_repeating_pattern(signatures: &[u64], pattern_len: usize) -> bool {
#[cfg(test)]
mod tests {
use super::*;
use fabro_llm::types::{ToolCall, Usage};
use fabro_llm::types::{TokenCounts, ToolCall};
use std::time::SystemTime;
fn assistant_with_tool(name: &str, args: serde_json::Value) -> Turn {
@ -102,7 +102,7 @@ mod tests {
content: String::new(),
tool_calls: vec![ToolCall::new("call_1", name, args)],
provider_parts: vec![],
usage: Box::new(Usage::default()),
usage: Box::new(TokenCounts::default()),
response_id: "resp".into(),
timestamp: SystemTime::now(),
}

View file

@ -11,7 +11,9 @@ use async_trait::async_trait;
use fabro_llm::client::Client;
use fabro_llm::error::SdkError;
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
use fabro_llm::types::{ContentPart, FinishReason, Message, Request, Response, StreamEvent, Usage};
use fabro_llm::types::{
ContentPart, FinishReason, Message, Request, Response, StreamEvent, TokenCounts,
};
use fabro_model::Provider;
use futures::stream;
use std::collections::HashMap;
@ -172,10 +174,9 @@ pub fn text_response(text: &str) -> Response {
provider: "mock".into(),
message: Message::assistant(text),
finish_reason: FinishReason::Stop,
usage: Usage {
usage: TokenCounts {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
..Default::default()
},
raw: None,
@ -248,10 +249,9 @@ pub fn tool_call_response(
tool_call_id: None,
},
finish_reason: FinishReason::ToolCalls,
usage: Usage {
usage: TokenCounts {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
..Default::default()
},
raw: None,
@ -401,10 +401,9 @@ pub fn multi_tool_call_response(calls: Vec<(&str, &str, serde_json::Value)>) ->
tool_call_id: None,
},
finish_reason: FinishReason::ToolCalls,
usage: Usage {
usage: TokenCounts {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
..Default::default()
},
raw: None,

View file

@ -3,7 +3,7 @@ use crate::sandbox::GrepOptions;
use crate::tool_registry::{RegisteredTool, ToolRegistry};
use fabro_llm::client::Client;
use fabro_llm::types::{Message, Request, ToolDefinition};
use fabro_model::ModelRef;
use fabro_model::ModelHandle;
use std::borrow::Cow;
use std::fmt::Write;
use std::sync::Arc;
@ -14,7 +14,7 @@ const MAX_WEB_FETCH_BYTES: usize = 100 * 1024;
#[derive(Clone)]
pub struct WebFetchSummarizer {
pub client: Client,
pub model_id: ModelRef,
pub model_id: ModelHandle,
}
/// Returns true if the input looks like it contains HTML markup.
@ -1257,7 +1257,7 @@ mod tests {
let client = make_client(provider).await;
let summarizer = WebFetchSummarizer {
client,
model_id: ModelRef::ByName {
model_id: ModelHandle::ByName {
provider: fabro_model::Provider::Anthropic,
model: "mock-model".to_string(),
},
@ -1353,7 +1353,7 @@ mod tests {
let summarizer = WebFetchSummarizer {
client,
model_id: ModelRef::ByName {
model_id: ModelHandle::ByName {
provider: fabro_model::Provider::Anthropic,
model: "target-model".to_string(),
},

View file

@ -1,6 +1,6 @@
use crate::error::AgentError;
use fabro_llm::error::SdkError;
use fabro_llm::types::{ContentPart, ThinkingData, ToolCall, ToolResult, Usage};
use fabro_llm::types::{ContentPart, ThinkingData, TokenCounts, ToolCall, ToolResult};
use serde::{Deserialize, Serialize};
use std::time::SystemTime;
@ -41,7 +41,7 @@ pub enum Turn {
/// `Anthropic` thinking blocks with signatures) preserved for round-tripping.
/// Reasoning/thinking text is stored here as `ContentPart::Thinking`.
provider_parts: Vec<ContentPart>,
usage: Box<Usage>,
usage: Box<TokenCounts>,
response_id: String,
timestamp: SystemTime,
},
@ -112,7 +112,7 @@ pub enum AgentEvent {
AssistantMessage {
text: String,
model: String,
usage: Usage,
usage: TokenCounts,
tool_call_count: usize,
},
TextDelta {
@ -661,15 +661,12 @@ mod tests {
#[test]
fn agent_event_assistant_message() {
let usage = Usage {
let usage = TokenCounts {
input_tokens: 100,
output_tokens: 50,
total_tokens: 150,
cache_read_tokens: Some(80),
cache_write_tokens: Some(10),
reasoning_tokens: Some(20),
speed: None,
raw: None,
cache_read_tokens: 80,
cache_write_tokens: 10,
reasoning_tokens: 20,
};
let event = AgentEvent::AssistantMessage {
text: "Hello".into(),
@ -685,8 +682,8 @@ mod tests {
} => {
assert_eq!(*tool_call_count, 2);
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.cache_read_tokens, Some(80));
assert_eq!(usage.reasoning_tokens, Some(20));
assert_eq!(usage.cache_read_tokens, 80);
assert_eq!(usage.reasoning_tokens, 20);
}
_ => panic!("expected AssistantMessage"),
}

View file

@ -11,7 +11,7 @@ use fabro_agent::{
use fabro_llm::client::Client;
use fabro_llm::provider::{Provider, ProviderAdapter};
use fabro_llm::providers::OpenAiAdapter;
use fabro_model::ModelRef;
use fabro_model::ModelHandle;
use fabro_test::{TwinScenario, TwinScenarios, TwinToolCall, twin_openai};
use tokio::sync::Mutex as AsyncMutex;
@ -21,22 +21,22 @@ struct OpenAiTwinOptions {
api_key: String,
}
fn summarizer_model_id(provider: Provider) -> ModelRef {
fn summarizer_model_id(provider: Provider) -> ModelHandle {
match provider {
Provider::OpenAi
| Provider::Kimi
| Provider::Zai
| Provider::Minimax
| Provider::Inception
| Provider::OpenAiCompatible => ModelRef::ByName {
| Provider::OpenAiCompatible => ModelHandle::ByName {
provider: Provider::OpenAi,
model: "gpt-5.4-mini".to_string(),
},
Provider::Gemini => ModelRef::ByName {
Provider::Gemini => ModelHandle::ByName {
provider: Provider::Gemini,
model: "gemini-3-flash-preview".to_string(),
},
Provider::Anthropic => ModelRef::ByName {
Provider::Anthropic => ModelHandle::ByName {
provider: Provider::Anthropic,
model: "claude-haiku-4-5".to_string(),
},

View file

@ -13,6 +13,7 @@ use tracing::{debug, info};
use crate::args::{GlobalArgs, LogsArgs};
use crate::server_client;
use crate::server_runs::ServerSummaryLookup;
use crate::shared::format_usd_micros;
const FOLLOW_TERMINAL_GRACE: Duration = Duration::from_millis(500);
@ -323,7 +324,10 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option<String>
"success" | "partial_success" => &styles.bold_green,
_ => &styles.bold_red,
};
let cost = format_cost(prop_field(&envelope, "total_cost"));
let cost = format_cost(
prop_field(&envelope, "total_usd_micros")
.or_else(|| prop_field(&envelope, "total_cost")),
);
let mut lines = vec![format!(
"{} {} {} {}",
@ -333,8 +337,10 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option<String>
styles.dim.apply_to(&cost),
)];
if let Some(usage) = prop_field(&envelope, "usage") {
let total = usage
if let Some(billing) =
prop_field(&envelope, "billing").or_else(|| prop_field(&envelope, "usage"))
{
let total = billing
.get("total_tokens")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
@ -349,11 +355,11 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option<String>
))
));
}
if let Some(cache_read) = usage
if let Some(cache_read) = billing
.get("cache_read_tokens")
.and_then(serde_json::Value::as_i64)
{
let cache_write = usage
let cache_write = billing
.get("cache_write_tokens")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
@ -367,7 +373,7 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option<String>
))
));
}
if let Some(reasoning) = usage
if let Some(reasoning) = billing
.get("reasoning_tokens")
.and_then(serde_json::Value::as_i64)
{
@ -429,13 +435,18 @@ pub(crate) fn format_event_pretty(line: &str, styles: &Styles) -> Option<String>
"stage.completed" => {
let label = str_field(&envelope, "node_label").unwrap_or("?");
let duration = format_duration_ms(prop_field(&envelope, "duration_ms"));
let usage = prop_field(&envelope, "usage");
let cost = format_cost(usage.and_then(|value| value.get("cost")));
let input_tokens = usage
let billing =
prop_field(&envelope, "billing").or_else(|| prop_field(&envelope, "usage"));
let cost = format_cost(
billing
.and_then(|value| value.get("total_usd_micros"))
.or_else(|| billing.and_then(|value| value.get("cost"))),
);
let input_tokens = billing
.and_then(|value| value.get("input_tokens"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let output_tokens = usage
let output_tokens = billing
.and_then(|value| value.get("output_tokens"))
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
@ -698,11 +709,21 @@ fn format_duration_ms(value: Option<&serde_json::Value>) -> String {
}
fn format_cost(value: Option<&serde_json::Value>) -> String {
let cost = value.and_then(serde_json::Value::as_f64).unwrap_or(0.0);
if cost > 0.0 {
format!("${cost:.2}")
} else {
String::new()
match value {
Some(value) => {
if let Some(usd_micros) = value.as_i64() {
if usd_micros > 0 {
return format_usd_micros(usd_micros);
}
}
let cost = value.as_f64().unwrap_or(0.0);
if cost > 0.0 {
format!("${cost:.2}")
} else {
String::new()
}
}
None => String::new(),
}
}
@ -934,7 +955,7 @@ mod tests {
#[test]
fn pretty_workflow_run_completed() {
let styles = no_color_styles();
let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"run.completed","properties":{"duration_ms":25000,"status":"success","total_cost":0.57,"usage":{"input_tokens":5000,"output_tokens":2000,"total_tokens":7000,"cache_read_tokens":3000,"cache_write_tokens":500,"reasoning_tokens":800}}}"#;
let line = r#"{"ts":"2026-01-01T14:23:32Z","run_id":"abc123","event":"run.completed","properties":{"duration_ms":25000,"status":"success","total_usd_micros":570000,"billing":{"input_tokens":5000,"output_tokens":2000,"total_tokens":7000,"cache_read_tokens":3000,"cache_write_tokens":500,"reasoning_tokens":800}}}"#;
let result = format_event_pretty(line, &styles).unwrap();
assert!(result.contains("SUCCESS"), "got: {result}");
assert!(result.contains("25s"), "got: {result}");

View file

@ -9,12 +9,14 @@ use fabro_util::check_report::{CheckDetail, CheckReport, CheckResult, CheckSecti
use fabro_util::terminal::Styles;
use fabro_util::text::strip_goal_decoration;
use fabro_workflow::artifact_snapshot::collect_artifact_paths;
use fabro_workflow::outcome::{StageStatus, format_cost};
use fabro_workflow::outcome::StageStatus;
use fabro_workflow::records::Conclusion;
use indicatif::HumanDuration;
use crate::server_client;
use crate::shared::{format_tokens_human, print_diagnostics, relative_path, tilde_path};
use crate::shared::{
format_tokens_human, format_usd_micros, print_diagnostics, relative_path, tilde_path,
};
pub(crate) fn print_preflight_workflow_summary(
workflow: &types::PreflightWorkflowSummary,
@ -177,22 +179,54 @@ pub(crate) fn print_run_conclusion(
HumanDuration(Duration::from_millis(conclusion.duration_ms))
);
let total_tokens = conclusion.total_input_tokens + conclusion.total_output_tokens;
if total_tokens > 0 {
if conclusion.has_pricing {
if let Some(cost) = conclusion.total_cost {
if cost > 0.0 {
if let Some(billing) = conclusion.billing.as_ref() {
let total_tokens = i64::try_from(billing.total_tokens).unwrap_or(i64::MAX);
if total_tokens > 0 {
if let Some(total_usd_micros) = billing.total_usd_micros {
if total_usd_micros > 0 {
eprintln!(
"{}",
styles.dim.apply_to(format!(
"Cost: {} ({} toks)",
format_cost(cost),
format_usd_micros(total_usd_micros),
format_tokens_human(total_tokens)
))
);
}
} else {
eprintln!(
"{}",
styles
.dim
.apply_to(format!("Toks: {}", format_tokens_human(total_tokens)))
);
}
} else {
if billing.cache_read_tokens > 0 || billing.cache_write_tokens > 0 {
eprintln!(
"{}",
styles.dim.apply_to(format!(
"Cache: {} read, {} write",
format_tokens_human(
i64::try_from(billing.cache_read_tokens).unwrap_or(i64::MAX)
),
format_tokens_human(
i64::try_from(billing.cache_write_tokens).unwrap_or(i64::MAX)
),
)),
);
}
if billing.reasoning_tokens > 0 {
eprintln!(
"{}",
styles.dim.apply_to(format!(
"Reasoning: {} tokens",
format_tokens_human(
i64::try_from(billing.reasoning_tokens).unwrap_or(i64::MAX)
),
)),
);
}
} else if billing.total_usd_micros.is_none() {
eprintln!(
"{}",
styles
@ -200,25 +234,6 @@ pub(crate) fn print_run_conclusion(
.apply_to(format!("Toks: {}", format_tokens_human(total_tokens)))
);
}
if conclusion.total_cache_read_tokens > 0 {
eprintln!(
"{}",
styles.dim.apply_to(format!(
"Cache: {} read, {} write",
format_tokens_human(conclusion.total_cache_read_tokens),
format_tokens_human(conclusion.total_cache_write_tokens),
)),
);
}
if conclusion.total_reasoning_tokens > 0 {
eprintln!(
"{}",
styles.dim.apply_to(format!(
"Reasoning: {} tokens",
format_tokens_human(conclusion.total_reasoning_tokens),
)),
);
}
}
if let Some(run_dir) = run_dir {

View file

@ -1,28 +1,24 @@
use std::convert::TryFrom;
use chrono::{DateTime, Utc};
use fabro_types::{EventBody, RunEvent, StageUsage};
use fabro_types::{BilledModelUsage, EventBody, RunEvent};
use fabro_workflow::event::RunNoticeLevel;
use fabro_workflow::outcome::compute_stage_cost;
use serde_json::Value;
#[derive(Debug, Clone)]
pub(super) struct ProgressUsage {
pub(super) model: Option<String>,
pub(super) input_tokens: u64,
pub(super) output_tokens: u64,
pub(super) speed: Option<String>,
pub(super) cost: Option<f64>,
}
impl ProgressUsage {
pub(super) fn from_stage_usage(usage: &StageUsage) -> Option<Self> {
pub(super) fn from_stage_usage(usage: &BilledModelUsage) -> Option<Self> {
let tokens = usage.tokens();
Some(Self {
model: Some(usage.model.clone()),
input_tokens: u64::try_from(usage.input_tokens).ok()?,
output_tokens: u64::try_from(usage.output_tokens).ok()?,
speed: usage.speed.clone(),
cost: usage.cost,
input_tokens: u64::try_from(tokens.input_tokens).ok()?,
output_tokens: u64::try_from(tokens.billable_output_tokens()).ok()?,
cost: usage.total_usd_micros.map(|cost| cost as f64 / 1_000_000.0),
})
}
@ -31,22 +27,7 @@ impl ProgressUsage {
}
pub(super) fn display_cost(&self) -> Option<f64> {
self.cost.or_else(|| {
let model = self.model.clone()?;
let input_tokens = i64::try_from(self.input_tokens).ok()?;
let output_tokens = i64::try_from(self.output_tokens).ok()?;
let usage = StageUsage {
model,
input_tokens,
output_tokens,
cache_read_tokens: None,
cache_write_tokens: None,
reasoning_tokens: None,
speed: self.speed.clone(),
cost: None,
};
compute_stage_cost(&usage)
})
self.cost
}
}
@ -331,7 +312,7 @@ pub(super) fn from_run_event(stored: &RunEvent) -> Option<ProgressEvent> {
duration_ms: props.duration_ms,
status: props.status.to_string(),
usage: props
.usage
.billing
.as_ref()
.and_then(ProgressUsage::from_stage_usage),
}),
@ -533,7 +514,7 @@ mod tests {
status: "success".into(),
preferred_label: None,
suggested_next_ids: Vec::new(),
usage: None,
billing: None,
failure: None,
notes: None,
files_touched: Vec::new(),

View file

@ -418,10 +418,11 @@ mod tests {
use chrono::{DateTime, Utc};
use fabro_agent::{AgentEvent, SandboxEvent};
use fabro_llm::types::Usage;
use fabro_llm::types::TokenCounts;
use fabro_model::Provider;
use fabro_types::fixtures;
use fabro_workflow::event::{Event, RunNoticeLevel, to_run_event, to_run_event_at};
use fabro_workflow::outcome::StageUsage;
use fabro_workflow::outcome::billed_model_usage_from_llm;
use super::*;
use crate::commands::run::run_progress::stage_display::ToolCallStatus;
@ -498,7 +499,7 @@ mod tests {
AgentEvent::AssistantMessage {
text: "done".into(),
model: model.into(),
usage: Usage::default(),
usage: TokenCounts::default(),
tool_call_count: 0,
},
)
@ -513,16 +514,16 @@ mod tests {
status: "success".into(),
preferred_label: None,
suggested_next_ids: Vec::new(),
usage: Some(StageUsage {
model: "gpt-5-mini".into(),
input_tokens: 1200,
output_tokens: 300,
cache_read_tokens: None,
cache_write_tokens: None,
reasoning_tokens: None,
speed: None,
cost: Some(0.12),
}),
billing: Some(billed_model_usage_from_llm(
"gpt-5-mini",
Provider::OpenAi,
None,
&TokenCounts {
input_tokens: 1200,
output_tokens: 300,
..TokenCounts::default()
},
)),
failure: None,
notes: None,
files_touched: Vec::new(),
@ -811,9 +812,7 @@ mod tests {
);
emit(&mut ui, stage_completed("plan", "Plan"));
insta::assert_snapshot!(rendered(&buffer), @r"
Plan $0.12 5s
");
insta::assert_snapshot!(rendered(&buffer), @" ✓ Plan $0.00 5s");
}
#[test]
@ -1049,7 +1048,7 @@ mod tests {
Running devcontainer postCreate (1 commands)...
[1/1] npm run setup 1s
Devcontainer: postCreate (1s)
Code $0.12 5s (1 turns, 0 tools, 1.5k toks)
Code $0.00 5s (1 turns, 0 tools, 1.5k toks)
"#);
}

View file

@ -316,10 +316,10 @@ mod tests {
artifact_count: 0,
status: "success".to_string(),
reason: None,
total_cost: None,
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: None,
usage: None,
billing: None,
})),
Some(WorkerTitlePhase::Succeeded)
);

View file

@ -9,7 +9,7 @@ use tracing::info;
use crate::args::{GlobalArgs, WaitArgs};
use crate::server_runs::ServerSummaryLookup;
use crate::shared::format_duration_ms;
use crate::shared::{format_duration_ms, format_usd_micros};
#[cfg(test)]
const WAIT_STARTUP_GRACE: std::time::Duration = std::time::Duration::from_millis(500);
@ -92,8 +92,12 @@ fn build_json_output(
});
if let Some(c) = conclusion {
value["duration_ms"] = c.duration_ms.into();
if let Some(cost) = c.total_cost {
value["total_cost"] = cost.into();
if let Some(total_usd_micros) = c
.billing
.as_ref()
.and_then(|billing| billing.total_usd_micros)
{
value["total_usd_micros"] = total_usd_micros.into();
}
}
value
@ -118,8 +122,10 @@ fn print_human_output(
Some(c) => {
let duration = format_duration_ms(c.duration_ms);
let cost = c
.total_cost
.map(|v| format!(" ${v:.2}"))
.billing
.as_ref()
.and_then(|billing| billing.total_usd_micros)
.map(|value| format!(" {}", format_usd_micros(value)))
.unwrap_or_default();
format!(" {duration}{cost}")
}
@ -136,6 +142,7 @@ fn print_human_output(
#[cfg(test)]
mod tests {
use super::*;
use fabro_types::BilledTokenCounts;
use fabro_types::fixtures;
use fabro_workflow::outcome::StageStatus;
use fabro_workflow::records::Conclusion;
@ -155,20 +162,22 @@ mod tests {
failure_reason: None,
final_git_commit_sha: None,
stages: vec![],
total_cost: Some(0.42),
billing: Some(BilledTokenCounts {
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
reasoning_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
total_usd_micros: Some(420_000),
}),
total_retries: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_read_tokens: 0,
total_cache_write_tokens: 0,
total_reasoning_tokens: 0,
has_pricing: false,
};
let json = build_json_output(RunStatus::Succeeded, &run_id, Some(&conclusion));
assert_eq!(json["run_id"], run_id.to_string());
assert_eq!(json["status"], "succeeded");
assert_eq!(json["duration_ms"], 12345);
assert!((json["total_cost"].as_f64().unwrap() - 0.42).abs() < f64::EPSILON);
assert_eq!(json["total_usd_micros"], 420_000);
}
#[test]
@ -178,7 +187,7 @@ mod tests {
assert_eq!(json["run_id"], run_id.to_string());
assert_eq!(json["status"], "failed");
assert!(json.get("duration_ms").is_none());
assert!(json.get("total_cost").is_none());
assert!(json.get("total_usd_micros").is_none());
}
#[test]
@ -197,17 +206,11 @@ mod tests {
failure_reason: Some("error".into()),
final_git_commit_sha: None,
stages: vec![],
total_cost: None,
billing: None,
total_retries: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_read_tokens: 0,
total_cache_write_tokens: 0,
total_reasoning_tokens: 0,
has_pricing: false,
};
let json = build_json_output(RunStatus::Failed, &run_id, Some(&conclusion));
assert!(json.get("total_cost").is_none());
assert!(json.get("total_usd_micros").is_none());
assert_eq!(json["duration_ms"], 500);
}
@ -222,14 +225,16 @@ mod tests {
failure_reason: None,
final_git_commit_sha: None,
stages: vec![],
total_cost: Some(0.15),
billing: Some(BilledTokenCounts {
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
reasoning_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
total_usd_micros: Some(150_000),
}),
total_retries: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_read_tokens: 0,
total_cache_write_tokens: 0,
total_reasoning_tokens: 0,
has_pricing: false,
};
// Just verify no panic; actual stderr output is hard to capture
print_human_output(RunStatus::Succeeded, &run_id, Some(&conclusion), &styles);

View file

@ -44,7 +44,7 @@ pub(crate) async fn list_command(
"start_time": run.start_time(),
"labels": run.labels(),
"duration_ms": run.duration_ms(),
"total_cost": run.total_cost(),
"total_usd_micros": run.total_usd_micros(),
"host_repo_path": run.host_repo_path(),
"goal": run.goal(),
})

View file

@ -149,9 +149,9 @@ mod tests {
use chrono::{DateTime, Utc};
use fabro_store::{Database, EventEnvelope, EventPayload};
use fabro_types::{
AggregateStats, AttrValue, Checkpoint, Conclusion, Graph, NodeStatusRecord, Retro, RunId,
RunRecord, RunStatus, RunStatusRecord, SandboxRecord, Settings, StageStatus, StartRecord,
StatusReason, fixtures,
AggregateStats, AttrValue, BilledTokenCounts, Checkpoint, Conclusion, Graph,
NodeStatusRecord, Retro, RunId, RunRecord, RunStatus, RunStatusRecord, SandboxRecord,
Settings, StageStatus, StartRecord, StatusReason, fixtures,
};
use fabro_workflow::event::{Event, append_event};
use object_store::memory::InMemory;
@ -241,14 +241,16 @@ mod tests {
failure_reason: None,
final_git_commit_sha: Some("feedbeef".to_string()),
stages: Vec::new(),
total_cost: Some(1.25),
billing: Some(BilledTokenCounts {
input_tokens: 10,
output_tokens: 20,
total_tokens: 150,
reasoning_tokens: 50,
cache_read_tokens: 30,
cache_write_tokens: 40,
total_usd_micros: Some(1_250_000),
}),
total_retries: 2,
total_input_tokens: 10,
total_output_tokens: 20,
total_cache_read_tokens: 30,
total_cache_write_tokens: 40,
total_reasoning_tokens: 50,
has_pricing: true,
}
}
@ -262,7 +264,7 @@ mod tests {
stages: Vec::new(),
stats: AggregateStats {
total_duration_ms: 3210,
total_cost: Some(1.25),
total_billing_usd_micros: Some(1_250_000),
total_retries: 2,
files_touched: vec!["src/lib.rs".to_string()],
stages_completed: 3,
@ -423,7 +425,7 @@ mod tests {
response: "Implemented".to_string(),
model: "gpt-5".to_string(),
provider: "openai".to_string(),
usage: None,
billing: None,
},
)
.await
@ -439,7 +441,7 @@ mod tests {
status: "partial_success".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
usage: None,
billing: None,
failure: None,
notes: Some("captured output".to_string()),
files_touched: Vec::new(),
@ -516,10 +518,13 @@ mod tests {
artifact_count: 0,
status: "success".to_string(),
reason: None,
total_cost: conclusion.total_cost,
total_usd_micros: conclusion
.billing
.as_ref()
.and_then(|billing| billing.total_usd_micros),
final_git_commit_sha: conclusion.final_git_commit_sha.clone(),
final_patch: None,
usage: None,
billing: conclusion.billing.clone(),
},
)
.await

View file

@ -91,8 +91,8 @@ impl ServerRunSummaryInfo {
self.summary.duration_ms
}
pub(crate) fn total_cost(&self) -> Option<f64> {
self.summary.total_cost
pub(crate) fn total_usd_micros(&self) -> Option<i64> {
self.summary.total_usd_micros
}
pub(crate) fn host_repo_path(&self) -> Option<&str> {

View file

@ -72,6 +72,10 @@ pub(crate) fn format_tokens_human(tokens: i64) -> String {
}
}
pub(crate) fn format_usd_micros(usd_micros: i64) -> String {
format!("${:.2}", usd_micros as f64 / 1_000_000.0)
}
pub(crate) fn tilde_path(path: &Path) -> String {
if let Some(home) = dirs::home_dir() {
if let Ok(suffix) = path.strip_prefix(&home) {
@ -132,7 +136,7 @@ pub(crate) fn format_size(bytes: u64) -> String {
#[cfg(test)]
mod tests {
use super::format_tokens_human;
use super::{format_tokens_human, format_usd_micros};
#[test]
fn format_tokens_human_zero() {
@ -163,4 +167,9 @@ mod tests {
fn format_tokens_human_mid_millions() {
assert_eq!(format_tokens_human(3_456_789), "3.5m");
}
#[test]
fn format_usd_micros_two_decimals() {
assert_eq!(format_usd_micros(570_000), "$0.57");
}
}

View file

@ -121,7 +121,7 @@ fn attach_uses_configured_server_target_without_server_flag() {
"status": "running",
"status_reason": null,
"duration_ms": 12,
"total_cost": null
"total_usd_micros": null
}
])
.to_string(),
@ -575,6 +575,19 @@ fn attach_json_errors_without_prompting_for_human_input() {
}
},
"host_repo_path": "[TEMP_DIR]",
"provenance": {
"client": {
"name": "fabro-cli",
"user_agent": "fabro-cli/0.176.2",
"version": "0.176.2"
},
"server": {
"version": "0.176.2"
},
"subject": {
"auth_method": "disabled"
}
},
"run_dir": "[RUN_DIR]",
"settings": {
"goal": "Wait for approval",

View file

@ -38,7 +38,7 @@ fn bare() {
exit_code: 0
----- stdout -----
MODEL PROVIDER ALIASES CONTEXT COST SPEED
claude-opus-4-6 anthropic opus, claude-opus 1m $15.0 / $75.0 25 tok/s
claude-opus-4-6 anthropic opus, claude-opus 1m $5.0 / $25.0 25 tok/s
claude-sonnet-4-5 anthropic 200k $3.0 / $15.0 50 tok/s
claude-sonnet-4-6 anthropic sonnet, claude-sonnet 200k $3.0 / $15.0 50 tok/s
claude-haiku-4-5 anthropic haiku, claude-haiku 200k $0.8 / $4.0 100 tok/s
@ -72,7 +72,7 @@ fn list() {
exit_code: 0
----- stdout -----
MODEL PROVIDER ALIASES CONTEXT COST SPEED
claude-opus-4-6 anthropic opus, claude-opus 1m $15.0 / $75.0 25 tok/s
claude-opus-4-6 anthropic opus, claude-opus 1m $5.0 / $25.0 25 tok/s
claude-sonnet-4-5 anthropic 200k $3.0 / $15.0 50 tok/s
claude-sonnet-4-6 anthropic sonnet, claude-sonnet 200k $3.0 / $15.0 50 tok/s
claude-haiku-4-5 anthropic haiku, claude-haiku 200k $0.8 / $4.0 100 tok/s
@ -105,11 +105,11 @@ fn list_provider() {
success: true
exit_code: 0
----- stdout -----
MODEL PROVIDER ALIASES CONTEXT COST SPEED
claude-opus-4-6 anthropic opus, claude-opus 1m $15.0 / $75.0 25 tok/s
claude-sonnet-4-5 anthropic 200k $3.0 / $15.0 50 tok/s
claude-sonnet-4-6 anthropic sonnet, claude-sonnet 200k $3.0 / $15.0 50 tok/s
claude-haiku-4-5 anthropic haiku, claude-haiku 200k $0.8 / $4.0 100 tok/s
MODEL PROVIDER ALIASES CONTEXT COST SPEED
claude-opus-4-6 anthropic opus, claude-opus 1m $5.0 / $25.0 25 tok/s
claude-sonnet-4-5 anthropic 200k $3.0 / $15.0 50 tok/s
claude-sonnet-4-6 anthropic sonnet, claude-sonnet 200k $3.0 / $15.0 50 tok/s
claude-haiku-4-5 anthropic haiku, claude-haiku 200k $0.8 / $4.0 100 tok/s
----- stderr -----
");
}
@ -123,8 +123,8 @@ fn list_query() {
success: true
exit_code: 0
----- stdout -----
MODEL PROVIDER ALIASES CONTEXT COST SPEED
claude-opus-4-6 anthropic opus, claude-opus 1m $15.0 / $75.0 25 tok/s
MODEL PROVIDER ALIASES CONTEXT COST SPEED
claude-opus-4-6 anthropic opus, claude-opus 1m $5.0 / $25.0 25 tok/s
----- stderr -----
");
}
@ -155,8 +155,8 @@ fn list_query_case_insensitive() {
success: true
exit_code: 0
----- stdout -----
MODEL PROVIDER ALIASES CONTEXT COST SPEED
claude-opus-4-6 anthropic opus, claude-opus 1m $15.0 / $75.0 25 tok/s
MODEL PROVIDER ALIASES CONTEXT COST SPEED
claude-opus-4-6 anthropic opus, claude-opus 1m $5.0 / $25.0 25 tok/s
----- stderr -----
");
}

View file

@ -226,7 +226,7 @@ fn ps_uses_configured_server_target_without_server_flag() {
"status": "succeeded",
"status_reason": null,
"duration_ms": 123,
"total_cost": null
"total_usd_micros": null
}
])
.to_string(),

View file

@ -226,7 +226,7 @@ fn rm_uses_configured_server_target_without_local_run_dir() {
"status": "succeeded",
"status_reason": null,
"duration_ms": 123,
"total_cost": null
"total_usd_micros": null
}
])
.to_string(),

View file

@ -62,14 +62,8 @@ fn remote_run_state_response() -> serde_json::Value {
"status": "success",
"duration_ms": 12,
"stages": [],
"total_cost": null,
"billing": null,
"total_retries": 0,
"total_input_tokens": 0,
"total_output_tokens": 0,
"total_cache_read_tokens": 0,
"total_cache_write_tokens": 0,
"total_reasoning_tokens": 0,
"has_pricing": false
},
"retro": null,
"retro_prompt": null,
@ -746,6 +740,19 @@ fn json_run_implies_auto_approve_for_human_gates() {
}
},
"host_repo_path": "[TEMP_DIR]",
"provenance": {
"client": {
"name": "fabro-cli",
"user_agent": "fabro-cli/0.176.2",
"version": "0.176.2"
},
"server": {
"version": "0.176.2"
},
"subject": {
"auth_method": "disabled"
}
},
"run_dir": "[RUN_DIR]",
"settings": {
"auto_approve": true,

View file

@ -45,10 +45,11 @@ async fn run_real_cli_test(provider: Provider, model: &str) {
"{provider}/{model}: expected response to contain '4', got: {text}"
);
let usage = usage.unwrap_or_else(|| panic!("{provider}/{model}: should have usage"));
let tokens = usage.tokens();
assert!(
usage.input_tokens > 0,
tokens.input_tokens > 0,
"{provider}/{model}: input_tokens should be > 0, got {}",
usage.input_tokens
tokens.input_tokens
);
}
CodergenResult::Full(_) => panic!("expected Text result from {provider}/{model}"),

View file

@ -302,10 +302,9 @@ mod tests {
provider: self.provider_name.clone(),
message: Message::assistant(&self.response_text),
finish_reason: FinishReason::Stop,
usage: Usage {
usage: TokenCounts {
input_tokens: 10,
output_tokens: 20,
total_tokens: 30,
..Default::default()
},
raw: None,
@ -321,14 +320,14 @@ mod tests {
Ok(StreamEvent::text_delta(&text, Some("t1".into()))),
Ok(StreamEvent::finish(
FinishReason::Stop,
Usage::default(),
TokenCounts::default(),
Response {
id: "resp_mock".into(),
model: "mock-model".into(),
provider,
message: Message::assistant(&text),
finish_reason: FinishReason::Stop,
usage: Usage::default(),
usage: TokenCounts::default(),
raw: None,
warnings: vec![],
rate_limit: None,

View file

@ -6,7 +6,7 @@ use crate::tools::{RepairToolCallFn, Tool, execute_all_tools_with_repair};
use crate::types::{
FinishReason, GenerateResult, Message, ObjectStreamEvent, ReasoningEffort, Request, Response,
ResponseFormat, ResponseFormatType, RetryPolicy, StepResult, StreamEvent, TimeoutOptions,
ToolCall, ToolChoice, ToolDefinition, Usage,
TokenCounts, ToolCall, ToolChoice, ToolDefinition,
};
use fabro_util::backoff::BackoffPolicy;
use futures::{Stream, StreamExt, future, stream};
@ -79,7 +79,7 @@ fn build_request(
}
}
fn build_generate_result(steps: Vec<StepResult>, total_usage: Usage) -> GenerateResult {
fn build_generate_result(steps: Vec<StepResult>, total_usage: TokenCounts) -> GenerateResult {
let last = steps.last().expect("steps should not be empty");
let response = last.response.clone();
let tool_results = last.tool_results.clone();
@ -132,7 +132,7 @@ pub async fn generate(params: GenerateParams) -> Result<GenerateResult, SdkError
let generate_future = async {
let mut steps: Vec<StepResult> = Vec::new();
let mut total_usage = Usage::default();
let mut total_usage = TokenCounts::default();
let mut round = 0u32;
loop {
@ -479,7 +479,7 @@ pub struct StreamAccumulator {
reasoning_parts: Vec<String>,
tool_calls: Vec<ToolCall>,
finish_reason: Option<FinishReason>,
usage: Option<Usage>,
usage: Option<TokenCounts>,
response: Option<Response>,
}
@ -1144,10 +1144,9 @@ mod tests {
provider: "mock".into(),
message: Message::assistant(&self.response_text),
finish_reason: FinishReason::Stop,
usage: Usage {
usage: TokenCounts {
input_tokens: 10,
output_tokens: 20,
total_tokens: 30,
..Default::default()
},
raw: None,
@ -1162,10 +1161,9 @@ mod tests {
Ok(StreamEvent::text_delta(&text, Some("t1".into()))),
Ok(StreamEvent::finish(
FinishReason::Stop,
Usage {
TokenCounts {
input_tokens: 10,
output_tokens: 20,
total_tokens: 30,
..Default::default()
},
Response {
@ -1174,10 +1172,9 @@ mod tests {
provider: "mock".into(),
message: Message::assistant(&text),
finish_reason: FinishReason::Stop,
usage: Usage {
usage: TokenCounts {
input_tokens: 10,
output_tokens: 20,
total_tokens: 30,
..Default::default()
},
raw: None,
@ -1292,10 +1289,9 @@ mod tests {
tool_call_id: None,
},
finish_reason: FinishReason::ToolCalls,
usage: Usage {
usage: TokenCounts {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
..Default::default()
},
raw: None,
@ -1310,10 +1306,9 @@ mod tests {
provider: "mock".into(),
message: Message::assistant("The weather in SF is 72F"),
finish_reason: FinishReason::Stop,
usage: Usage {
usage: TokenCounts {
input_tokens: 20,
output_tokens: 10,
total_tokens: 30,
..Default::default()
},
raw: None,
@ -1381,10 +1376,9 @@ mod tests {
provider: "p".into(),
message: Message::assistant("Hello world"),
finish_reason: FinishReason::Stop,
usage: Usage {
usage: TokenCounts {
input_tokens: 5,
output_tokens: 2,
total_tokens: 7,
..Default::default()
},
raw: None,
@ -1597,7 +1591,7 @@ mod tests {
provider: "mock".into(),
message: Message::assistant(&self.full_text),
finish_reason: FinishReason::Stop,
usage: Usage::default(),
usage: TokenCounts::default(),
raw: None,
warnings: vec![],
rate_limit: None,
@ -1613,10 +1607,9 @@ mod tests {
events.push(Ok(StreamEvent::finish(
FinishReason::Stop,
Usage {
TokenCounts {
input_tokens: 10,
output_tokens: 20,
total_tokens: 30,
..Default::default()
},
Response {
@ -1625,10 +1618,9 @@ mod tests {
provider: "mock".into(),
message: Message::assistant(&self.full_text),
finish_reason: FinishReason::Stop,
usage: Usage {
usage: TokenCounts {
input_tokens: 10,
output_tokens: 20,
total_tokens: 30,
..Default::default()
},
raw: None,
@ -1816,7 +1808,7 @@ mod tests {
tool_call_id: None,
},
finish_reason: FinishReason::ToolCalls,
usage: Usage::default(),
usage: TokenCounts::default(),
raw: None,
warnings: vec![],
rate_limit: None,
@ -1993,7 +1985,7 @@ mod tests {
provider: "mock".into(),
message: Message::assistant("fallback"),
finish_reason: FinishReason::Stop,
usage: Usage::default(),
usage: TokenCounts::default(),
raw: None,
warnings: vec![],
rate_limit: None,
@ -2018,10 +2010,9 @@ mod tests {
tool_call_id: None,
},
finish_reason: FinishReason::ToolCalls,
usage: Usage {
usage: TokenCounts {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
..Default::default()
},
raw: None,
@ -2046,10 +2037,9 @@ mod tests {
provider: "mock".into(),
message: Message::assistant(text),
finish_reason: FinishReason::Stop,
usage: Usage {
usage: TokenCounts {
input_tokens: 20,
output_tokens: 10,
total_tokens: 30,
..Default::default()
},
raw: None,
@ -2165,10 +2155,9 @@ mod tests {
provider: "mock".into(),
message: Message::assistant("tool step"),
finish_reason: FinishReason::ToolCalls,
usage: Usage {
usage: TokenCounts {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
..Default::default()
},
raw: None,
@ -2335,7 +2324,7 @@ mod tests {
provider: "mock".into(),
message: Message::assistant("fallback"),
finish_reason: FinishReason::Stop,
usage: Usage::default(),
usage: TokenCounts::default(),
raw: None,
warnings: vec![],
rate_limit: None,
@ -2362,10 +2351,9 @@ mod tests {
provider: "mock".into(),
message: Message::assistant(text),
finish_reason: FinishReason::Stop,
usage: Usage {
usage: TokenCounts {
input_tokens: 10,
output_tokens: 20,
total_tokens: 30,
..Default::default()
},
raw: None,
@ -2450,7 +2438,7 @@ mod tests {
provider: "mock".into(),
message: Message::assistant("fallback"),
finish_reason: FinishReason::Stop,
usage: Usage::default(),
usage: TokenCounts::default(),
raw: None,
warnings: vec![],
rate_limit: None,
@ -2466,7 +2454,7 @@ mod tests {
provider: "mock".into(),
message: Message::assistant(text),
finish_reason: FinishReason::Stop,
usage: Usage::default(),
usage: TokenCounts::default(),
raw: None,
warnings: vec![],
rate_limit: None,
@ -2475,7 +2463,7 @@ mod tests {
Ok(StreamEvent::text_delta(text, Some("t1".into()))),
Ok(StreamEvent::finish(
FinishReason::Stop,
Usage::default(),
TokenCounts::default(),
response,
)),
];
@ -2548,7 +2536,7 @@ mod tests {
provider: "mock".into(),
message: Message::assistant("fallback"),
finish_reason: FinishReason::Stop,
usage: Usage::default(),
usage: TokenCounts::default(),
raw: None,
warnings: vec![],
rate_limit: None,
@ -2573,7 +2561,7 @@ mod tests {
tool_call_id: None,
},
finish_reason: FinishReason::ToolCalls,
usage: Usage::default(),
usage: TokenCounts::default(),
raw: None,
warnings: vec![],
rate_limit: None,
@ -2582,7 +2570,7 @@ mod tests {
Ok(StreamEvent::ToolCallEnd { tool_call }),
Ok(StreamEvent::finish(
FinishReason::ToolCalls,
Usage::default(),
TokenCounts::default(),
response,
)),
];
@ -2597,7 +2585,7 @@ mod tests {
provider: "mock".into(),
message: Message::assistant(text),
finish_reason: FinishReason::Stop,
usage: Usage::default(),
usage: TokenCounts::default(),
raw: None,
warnings: vec![],
rate_limit: None,
@ -2606,7 +2594,7 @@ mod tests {
Ok(StreamEvent::text_delta(text, Some("t1".into()))),
Ok(StreamEvent::finish(
FinishReason::Stop,
Usage::default(),
TokenCounts::default(),
response,
)),
];

View file

@ -10,5 +10,5 @@ pub mod tools;
pub mod types;
// Re-export module-level default client helpers (Section 2.5).
pub use fabro_model::{ModelRef, Provider};
pub use fabro_model::{ModelHandle, Provider};
pub use generate::set_default_client;

View file

@ -223,7 +223,7 @@ fn validate_deep_result(result: &GenerateResult) -> Result<(), String> {
mod tests {
use super::*;
use crate::types::ToolResult;
use crate::types::{FinishReason, Message, Response, StepResult, Usage};
use crate::types::{FinishReason, Message, Response, StepResult, TokenCounts};
use fabro_model::{ModelCosts, ModelFeatures, ModelLimits, Provider};
fn test_model_with(features: ModelFeatures) -> Model {
@ -257,7 +257,7 @@ mod tests {
provider: "anthropic".to_string(),
message: Message::assistant(text),
finish_reason: FinishReason::Stop,
usage: Usage::default(),
usage: TokenCounts::default(),
raw: None,
warnings: vec![],
rate_limit: None,
@ -296,7 +296,7 @@ mod tests {
let result = GenerateResult {
response: response_with_text("84 is even"),
tool_results,
total_usage: Usage::default(),
total_usage: TokenCounts::default(),
steps: vec![first_step, second_step],
output: None,
};

View file

@ -3,7 +3,7 @@ use crate::types::{Request, Response, StreamEvent, ToolChoice};
use futures::Stream;
use std::pin::Pin;
pub use fabro_model::{ModelRef, Provider};
pub use fabro_model::{ModelHandle, Provider};
// ---------------------------------------------------------------------------
// ProviderAdapter trait

View file

@ -9,8 +9,8 @@ use crate::providers::common::{
};
use crate::types::{
AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, Request, Response,
ResponseFormatType, Role, StreamEvent, ThinkingData, ToolCall, ToolChoice, ToolDefinition,
Usage,
ResponseFormatType, Role, StreamEvent, ThinkingData, TokenCounts, ToolCall, ToolChoice,
ToolDefinition,
};
/// Provider adapter for the Anthropic Messages API.
@ -170,8 +170,6 @@ struct ApiUsage {
cache_read_input_tokens: Option<i64>,
#[serde(default)]
cache_creation_input_tokens: Option<i64>,
#[serde(default)]
speed: Option<String>,
}
/// Estimate reasoning tokens from thinking content blocks.
@ -660,7 +658,7 @@ struct StreamAccumulator {
id: String,
model: String,
content_parts: Vec<ContentPart>,
usage: Usage,
usage: TokenCounts,
finish_reason: FinishReason,
/// The kind of the current content block, set by `content_block_start`.
current_block: Option<ContentBlockKind>,
@ -680,7 +678,7 @@ impl StreamAccumulator {
id: String::new(),
model: String::new(),
content_parts: Vec::new(),
usage: Usage::default(),
usage: TokenCounts::default(),
finish_reason: FinishReason::Stop,
current_block: None,
current_text: String::new(),
@ -728,14 +726,12 @@ impl StreamAccumulator {
.unwrap_or(0);
self.usage.cache_read_tokens = usage
.get("cache_read_input_tokens")
.and_then(serde_json::Value::as_i64);
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
self.usage.cache_write_tokens = usage
.get("cache_creation_input_tokens")
.and_then(serde_json::Value::as_i64);
self.usage.speed = usage
.get("speed")
.and_then(serde_json::Value::as_str)
.map(String::from);
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
}
}
vec![StreamEvent::StreamStart]
@ -922,12 +918,13 @@ impl StreamAccumulator {
.get("output_tokens")
.and_then(serde_json::Value::as_i64)
.unwrap_or(0);
self.usage.total_tokens = self.usage.input_tokens + self.usage.output_tokens;
}
}
fn handle_message_stop(&mut self) -> Vec<StreamEvent> {
self.usage.reasoning_tokens = estimate_reasoning_tokens(&self.content_parts);
let reasoning_tokens = estimate_reasoning_tokens(&self.content_parts).unwrap_or(0);
self.usage.reasoning_tokens = reasoning_tokens;
self.usage.output_tokens = self.usage.output_tokens.saturating_sub(reasoning_tokens);
let response = self.take_response();
vec![StreamEvent::Finish {
finish_reason: response.finish_reason.clone(),
@ -1260,8 +1257,7 @@ impl ProviderAdapter for Adapter {
} else {
map_finish_reason(api_resp.stop_reason.as_deref())
};
let total = api_resp.usage.input_tokens + api_resp.usage.output_tokens;
let reasoning_tokens = estimate_reasoning_tokens(&content_parts);
let reasoning_tokens = estimate_reasoning_tokens(&content_parts).unwrap_or(0);
Ok(Response {
id: api_resp.id,
@ -1274,15 +1270,16 @@ impl ProviderAdapter for Adapter {
tool_call_id: None,
},
finish_reason,
usage: Usage {
usage: TokenCounts {
input_tokens: api_resp.usage.input_tokens,
output_tokens: api_resp.usage.output_tokens,
total_tokens: total,
output_tokens: api_resp
.usage
.output_tokens
.saturating_sub(reasoning_tokens),
reasoning_tokens,
cache_read_tokens: api_resp.usage.cache_read_input_tokens,
cache_write_tokens: api_resp.usage.cache_creation_input_tokens,
speed: api_resp.usage.speed,
..Usage::default()
cache_read_tokens: api_resp.usage.cache_read_input_tokens.unwrap_or(0),
cache_write_tokens: api_resp.usage.cache_creation_input_tokens.unwrap_or(0),
..TokenCounts::default()
},
raw: serde_json::from_str(&body).ok(),
warnings: vec![],
@ -1956,14 +1953,14 @@ mod tests {
tool_call_id: None,
},
finish_reason: FinishReason::ToolCalls,
usage: Usage::default(),
usage: TokenCounts::default(),
raw: None,
warnings: vec![],
rate_limit: None,
});
let event = StreamEvent::Finish {
finish_reason: FinishReason::ToolCalls,
usage: Usage::default(),
usage: TokenCounts::default(),
response,
};
let result = convert_stream_event_for_json_schema(event);

View file

@ -1,7 +1,7 @@
use crate::error::{SdkError, error_from_status_code};
use crate::provider::{ProviderAdapter, StreamEventStream};
use crate::providers::common::LineReader;
use crate::types::{FinishReason, Message, Request, Response, StreamEvent, Usage};
use crate::types::{FinishReason, Message, Request, Response, StreamEvent, TokenCounts};
use futures::stream;
use tracing::{debug, error};
@ -135,18 +135,15 @@ impl ProviderAdapter for Adapter {
})?;
let finish_reason = map_stop_reason(&server_resp.stop_reason);
let total = server_resp.usage.input_tokens + server_resp.usage.output_tokens;
Ok(Response {
id: server_resp.id,
model: server_resp.model,
provider: self.provider_name.clone(),
message: server_resp.message,
finish_reason,
usage: Usage {
usage: TokenCounts {
input_tokens: server_resp.usage.input_tokens,
output_tokens: server_resp.usage.output_tokens,
total_tokens: total,
..Default::default()
},
raw: None,
@ -338,7 +335,7 @@ data: {\"type\":\"text_delta\",\"delta\":\" world\",\"text_id\":null}\n\
assert_eq!(response.finish_reason, FinishReason::Stop);
assert_eq!(response.usage.input_tokens, 10);
assert_eq!(response.usage.output_tokens, 5);
assert_eq!(response.usage.total_tokens, 15);
assert_eq!(response.usage.total_tokens(), 15);
}
#[tokio::test]

View file

@ -12,8 +12,8 @@ use crate::providers::common::{
};
use crate::types::{
AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, Request, Response,
ResponseFormat, ResponseFormatType, Role, StreamEvent, ThinkingData, ToolCall, ToolChoice,
ToolDefinition, Usage,
ResponseFormat, ResponseFormatType, Role, StreamEvent, ThinkingData, TokenCounts, ToolCall,
ToolChoice, ToolDefinition,
};
use reqwest::header::HeaderMap;
@ -138,7 +138,6 @@ struct CandidateContent {
struct UsageMetadata {
prompt_token_count: Option<i64>,
candidates_token_count: Option<i64>,
total_token_count: Option<i64>,
thoughts_token_count: Option<i64>,
cached_content_token_count: Option<i64>,
}
@ -487,19 +486,21 @@ fn apply_default_safety_settings(body: &mut serde_json::Value) {
}
}
/// Convert `UsageMetadata` from the Gemini API into a unified `Usage`.
fn parse_usage(metadata: Option<&UsageMetadata>) -> Usage {
metadata.map_or_else(Usage::default, |u| {
/// Convert `UsageMetadata` from the Gemini API into a unified `TokenCounts`.
fn parse_usage(metadata: Option<&UsageMetadata>) -> TokenCounts {
metadata.map_or_else(TokenCounts::default, |u| {
let input = u.prompt_token_count.unwrap_or(0);
let output = u.candidates_token_count.unwrap_or(0);
let total = u.total_token_count.unwrap_or(input + output);
Usage {
let reasoning_tokens = u.thoughts_token_count.unwrap_or(0);
let output = u
.candidates_token_count
.unwrap_or(0)
.saturating_sub(reasoning_tokens);
TokenCounts {
input_tokens: input,
output_tokens: output,
total_tokens: total,
reasoning_tokens: u.thoughts_token_count,
cache_read_tokens: u.cached_content_token_count,
..Usage::default()
reasoning_tokens,
cache_read_tokens: u.cached_content_token_count.unwrap_or(0),
..TokenCounts::default()
}
})
}
@ -696,7 +697,7 @@ struct SseStreamState {
/// The `text_id` used for `TextStart`/`TextDelta`/`TextEnd`.
text_id: String,
/// Latest usage metadata (updated per chunk; final chunk has totals).
usage: Usage,
usage: TokenCounts,
/// The finish reason string from the candidate, if received.
finish_reason_str: Option<String>,
/// Whether we have emitted the `Finish` event.
@ -723,7 +724,7 @@ impl SseStreamState {
accumulated_text: String::new(),
accumulated_tool_calls: Vec::new(),
text_id: uuid::Uuid::new_v4().to_string(),
usage: Usage::default(),
usage: TokenCounts::default(),
finish_reason_str: None,
finished: false,
rate_limit,

View file

@ -9,8 +9,8 @@ use crate::providers::common::{
};
use crate::types::{
AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, Request, Response,
ResponseFormat, ResponseFormatType, Role, StreamEvent, ToolCall, ToolChoice, ToolDefinition,
Usage,
ResponseFormat, ResponseFormatType, Role, StreamEvent, TokenCounts, ToolCall, ToolChoice,
ToolDefinition,
};
const DEFAULT_BASE_URL: &str = "https://api.openai.com/v1";
@ -162,7 +162,6 @@ struct ApiResponse {
struct ApiUsage {
input_tokens: i64,
output_tokens: i64,
total_tokens: Option<i64>,
output_tokens_details: Option<OutputTokenDetails>,
input_tokens_details: Option<InputTokenDetails>,
}
@ -543,7 +542,7 @@ struct SseStreamState {
reasoning_items: Vec<serde_json::Value>,
/// Raw message output items to preserve for round-tripping.
message_items: Vec<serde_json::Value>,
usage: Usage,
usage: TokenCounts,
finish_reason: FinishReason,
emitted_start: bool,
emitted_text_start: bool,
@ -837,19 +836,21 @@ fn handle_response_completed(
if let Some(usage_data) = response_data.get("usage") {
if let Ok(u) = serde_json::from_value::<ApiUsage>(usage_data.clone()) {
state.usage = Usage {
let reasoning_tokens = u
.output_tokens_details
.as_ref()
.and_then(|d| d.reasoning_tokens)
.unwrap_or(0);
state.usage = TokenCounts {
input_tokens: u.input_tokens,
output_tokens: u.output_tokens,
total_tokens: u.total_tokens.unwrap_or(u.input_tokens + u.output_tokens),
reasoning_tokens: u
.output_tokens_details
.as_ref()
.and_then(|d| d.reasoning_tokens),
output_tokens: u.output_tokens.saturating_sub(reasoning_tokens),
reasoning_tokens,
cache_read_tokens: u
.input_tokens_details
.as_ref()
.and_then(|d| d.cached_tokens),
..Usage::default()
.and_then(|d| d.cached_tokens)
.unwrap_or(0),
..TokenCounts::default()
};
}
}
@ -961,19 +962,23 @@ impl ProviderAdapter for Adapter {
let usage = api_resp
.usage
.as_ref()
.map_or_else(Usage::default, |u| Usage {
input_tokens: u.input_tokens,
output_tokens: u.output_tokens,
total_tokens: u.total_tokens.unwrap_or(u.input_tokens + u.output_tokens),
reasoning_tokens: u
.map_or_else(TokenCounts::default, |u| {
let reasoning_tokens = u
.output_tokens_details
.as_ref()
.and_then(|d| d.reasoning_tokens),
cache_read_tokens: u
.input_tokens_details
.as_ref()
.and_then(|d| d.cached_tokens),
..Usage::default()
.and_then(|d| d.reasoning_tokens)
.unwrap_or(0);
TokenCounts {
input_tokens: u.input_tokens,
output_tokens: u.output_tokens.saturating_sub(reasoning_tokens),
reasoning_tokens,
cache_read_tokens: u
.input_tokens_details
.as_ref()
.and_then(|d| d.cached_tokens)
.unwrap_or(0),
..TokenCounts::default()
}
});
Ok(Response {
@ -1039,7 +1044,7 @@ impl ProviderAdapter for Adapter {
tool_calls: Vec::new(),
reasoning_items: Vec::new(),
message_items: Vec::new(),
usage: Usage::default(),
usage: TokenCounts::default(),
finish_reason: FinishReason::Stop,
emitted_start: false,
emitted_text_start: false,
@ -1593,7 +1598,7 @@ mod tests {
tool_calls: Vec::new(),
reasoning_items: Vec::new(),
message_items: Vec::new(),
usage: Usage::default(),
usage: TokenCounts::default(),
finish_reason: FinishReason::Stop,
emitted_start: true,
emitted_text_start: false,

View file

@ -7,8 +7,8 @@ use crate::providers::common::{
};
use crate::types::{
AdapterTimeout, ContentPart, FinishReason, Message, RateLimitInfo, Request, Response,
ResponseFormat, ResponseFormatType, Role, StreamEvent, ThinkingData, ToolCall, ToolChoice,
ToolDefinition, Usage,
ResponseFormat, ResponseFormatType, Role, StreamEvent, ThinkingData, TokenCounts, ToolCall,
ToolChoice, ToolDefinition,
};
/// `OpenAI`-compatible Chat Completions adapter (Section 7.10).
@ -157,7 +157,6 @@ struct ApiFunction {
struct ApiUsage {
prompt_tokens: i64,
completion_tokens: i64,
total_tokens: i64,
}
// --- Streaming response types ---
@ -505,11 +504,10 @@ impl ProviderAdapter for Adapter {
let usage = api_resp
.usage
.as_ref()
.map_or_else(Usage::default, |u| Usage {
.map_or_else(TokenCounts::default, |u| TokenCounts {
input_tokens: u.prompt_tokens,
output_tokens: u.completion_tokens,
total_tokens: u.total_tokens,
..Usage::default()
..TokenCounts::default()
});
Ok(Response {
@ -676,7 +674,7 @@ struct StreamState {
accumulated_text: String,
accumulated_reasoning: String,
tool_calls: Vec<AccumulatedToolCall>,
usage: Usage,
usage: TokenCounts,
finish_reason: FinishReason,
text_started: bool,
done: bool,
@ -702,7 +700,7 @@ impl StreamState {
accumulated_text: String::new(),
accumulated_reasoning: String::new(),
tool_calls: Vec::new(),
usage: Usage::default(),
usage: TokenCounts::default(),
finish_reason: FinishReason::Stop,
text_started: false,
done: false,
@ -740,11 +738,10 @@ impl StreamState {
// Capture usage if present (often in a dedicated chunk).
if let Some(usage) = &chunk.usage {
self.usage = Usage {
self.usage = TokenCounts {
input_tokens: usage.prompt_tokens,
output_tokens: usage.completion_tokens,
total_tokens: usage.total_tokens,
..Usage::default()
..TokenCounts::default()
};
}
@ -956,7 +953,6 @@ mod tests {
let usage = chunk.usage.unwrap();
assert_eq!(usage.prompt_tokens, 10);
assert_eq!(usage.completion_tokens, 20);
assert_eq!(usage.total_tokens, 30);
}
#[test]
@ -1045,11 +1041,10 @@ mod tests {
state.response_model = "gpt-4".into();
state.accumulated_text = "Hello world".into();
state.text_started = true;
state.usage = Usage {
state.usage = TokenCounts {
input_tokens: 5,
output_tokens: 10,
total_tokens: 15,
..Usage::default()
..TokenCounts::default()
};
let events = state.finish_events();

View file

@ -365,50 +365,9 @@ impl<'de> Deserialize<'de> for FinishReason {
}
}
// --- 3.9 Usage ---
// --- 3.9 TokenCounts ---
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Usage {
pub input_tokens: i64,
pub output_tokens: i64,
pub total_tokens: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_tokens: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_tokens: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write_tokens: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speed: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw: Option<serde_json::Value>,
}
impl std::ops::Add for Usage {
type Output = Self;
fn add(self, rhs: Self) -> Self {
const fn add_optional(a: Option<i64>, b: Option<i64>) -> Option<i64> {
match (a, b) {
(None, None) => None,
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(Some(a), Some(b)) => Some(a + b),
}
}
Self {
input_tokens: self.input_tokens + rhs.input_tokens,
output_tokens: self.output_tokens + rhs.output_tokens,
total_tokens: self.total_tokens + rhs.total_tokens,
reasoning_tokens: add_optional(self.reasoning_tokens, rhs.reasoning_tokens),
cache_read_tokens: add_optional(self.cache_read_tokens, rhs.cache_read_tokens),
cache_write_tokens: add_optional(self.cache_write_tokens, rhs.cache_write_tokens),
speed: self.speed,
raw: None,
}
}
}
pub use fabro_model::TokenCounts;
// --- 3.10 ResponseFormat ---
@ -557,7 +516,7 @@ pub struct Response {
pub provider: String,
pub message: Message,
pub finish_reason: FinishReason,
pub usage: Usage,
pub usage: TokenCounts,
pub raw: Option<serde_json::Value>,
pub warnings: Vec<Warning>,
pub rate_limit: Option<RateLimitInfo>,
@ -633,14 +592,14 @@ pub enum StreamEvent {
},
StepFinish {
finish_reason: FinishReason,
usage: Usage,
usage: TokenCounts,
response: Box<Response>,
tool_calls: Vec<ToolCall>,
tool_results: Vec<ToolResult>,
},
Finish {
finish_reason: FinishReason,
usage: Usage,
usage: TokenCounts,
response: Box<Response>,
},
Error {
@ -660,7 +619,7 @@ impl StreamEvent {
#[must_use]
pub fn step_finish(
reason: FinishReason,
usage: Usage,
usage: TokenCounts,
response: Response,
tool_calls: Vec<ToolCall>,
tool_results: Vec<ToolResult>,
@ -675,7 +634,7 @@ impl StreamEvent {
}
#[must_use]
pub fn finish(reason: FinishReason, usage: Usage, response: Response) -> Self {
pub fn finish(reason: FinishReason, usage: TokenCounts, response: Response) -> Self {
Self::Finish {
finish_reason: reason,
usage,
@ -787,7 +746,7 @@ pub enum ObjectStreamEvent {
pub struct GenerateResult {
pub response: Response,
pub tool_results: Vec<ToolResult>,
pub total_usage: Usage,
pub total_usage: TokenCounts,
pub steps: Vec<StepResult>,
pub output: Option<serde_json::Value>,
}
@ -916,42 +875,35 @@ mod tests {
#[test]
fn usage_serialization_skips_none_optional_fields() {
let usage = Usage {
let usage = TokenCounts {
input_tokens: 100,
output_tokens: 50,
total_tokens: 150,
reasoning_tokens: None,
cache_read_tokens: None,
cache_write_tokens: None,
speed: None,
raw: None,
..TokenCounts::default()
};
insta::assert_snapshot!(serde_json::to_string_pretty(&usage).unwrap(), @r#"
{
"input_tokens": 100,
"output_tokens": 50,
"total_tokens": 150
"reasoning_tokens": 0,
"cache_read_tokens": 0,
"cache_write_tokens": 0
}
"#);
}
#[test]
fn usage_serialization_includes_present_optional_fields() {
let usage = Usage {
let usage = TokenCounts {
input_tokens: 100,
output_tokens: 50,
total_tokens: 150,
reasoning_tokens: Some(20),
cache_read_tokens: Some(80),
cache_write_tokens: Some(10),
speed: None,
raw: None,
output_tokens: 30,
reasoning_tokens: 20,
cache_read_tokens: 80,
cache_write_tokens: 10,
};
insta::assert_snapshot!(serde_json::to_string_pretty(&usage).unwrap(), @r#"
{
"input_tokens": 100,
"output_tokens": 50,
"total_tokens": 150,
"output_tokens": 30,
"reasoning_tokens": 20,
"cache_read_tokens": 80,
"cache_write_tokens": 10
@ -961,70 +913,57 @@ mod tests {
#[test]
fn usage_deserialization_without_optional_fields() {
let json = r#"{"input_tokens":100,"output_tokens":50,"total_tokens":150}"#;
let usage: Usage = serde_json::from_str(json).unwrap();
let json = r#"{"input_tokens":100,"output_tokens":50}"#;
let usage: TokenCounts = serde_json::from_str(json).unwrap();
assert_eq!(usage.input_tokens, 100);
assert_eq!(usage.reasoning_tokens, None);
assert_eq!(usage.cache_read_tokens, None);
assert_eq!(usage.reasoning_tokens, 0);
assert_eq!(usage.cache_read_tokens, 0);
assert_eq!(usage.total_tokens(), 150);
}
#[test]
fn usage_addition_both_filled() {
let a = Usage {
let a = TokenCounts {
input_tokens: 10,
output_tokens: 20,
total_tokens: 30,
reasoning_tokens: Some(5),
cache_read_tokens: Some(3),
cache_write_tokens: Some(1),
speed: None,
raw: None,
output_tokens: 15,
reasoning_tokens: 5,
cache_read_tokens: 3,
cache_write_tokens: 1,
};
let b = Usage {
let b = TokenCounts {
input_tokens: 15,
output_tokens: 25,
total_tokens: 40,
reasoning_tokens: Some(10),
cache_read_tokens: Some(7),
cache_write_tokens: Some(2),
speed: None,
raw: None,
output_tokens: 15,
reasoning_tokens: 10,
cache_read_tokens: 7,
cache_write_tokens: 2,
};
let sum = a + b;
assert_eq!(sum.input_tokens, 25);
assert_eq!(sum.output_tokens, 45);
assert_eq!(sum.total_tokens, 70);
assert_eq!(sum.reasoning_tokens, Some(15));
assert_eq!(sum.cache_read_tokens, Some(10));
assert_eq!(sum.cache_write_tokens, Some(3));
assert_eq!(sum.output_tokens, 30);
assert_eq!(sum.total_tokens(), 83);
assert_eq!(sum.reasoning_tokens, 15);
assert_eq!(sum.cache_read_tokens, 10);
assert_eq!(sum.cache_write_tokens, 3);
}
#[test]
fn usage_addition_one_none() {
let a = Usage {
let a = TokenCounts {
input_tokens: 10,
output_tokens: 20,
total_tokens: 30,
reasoning_tokens: Some(5),
cache_read_tokens: None,
cache_write_tokens: None,
speed: None,
raw: None,
output_tokens: 15,
reasoning_tokens: 5,
..TokenCounts::default()
};
let b = Usage {
let b = TokenCounts {
input_tokens: 15,
output_tokens: 25,
total_tokens: 40,
reasoning_tokens: None,
cache_read_tokens: Some(7),
cache_write_tokens: None,
speed: None,
raw: None,
cache_read_tokens: 7,
..TokenCounts::default()
};
let sum = a + b;
assert_eq!(sum.reasoning_tokens, Some(5));
assert_eq!(sum.cache_read_tokens, Some(7));
assert_eq!(sum.cache_write_tokens, None);
assert_eq!(sum.reasoning_tokens, 5);
assert_eq!(sum.cache_read_tokens, 7);
assert_eq!(sum.cache_write_tokens, 0);
}
#[test]
@ -1049,7 +988,7 @@ mod tests {
provider: "test".into(),
message: Message::assistant("Hello world"),
finish_reason: FinishReason::Stop,
usage: Usage::default(),
usage: TokenCounts::default(),
raw: None,
warnings: vec![],
rate_limit: None,
@ -1077,7 +1016,7 @@ mod tests {
tool_call_id: None,
},
finish_reason: FinishReason::ToolCalls,
usage: Usage::default(),
usage: TokenCounts::default(),
raw: None,
warnings: vec![],
rate_limit: None,
@ -1108,7 +1047,7 @@ mod tests {
tool_call_id: None,
},
finish_reason: FinishReason::Stop,
usage: Usage::default(),
usage: TokenCounts::default(),
raw: None,
warnings: vec![],
rate_limit: None,
@ -1125,7 +1064,7 @@ mod tests {
provider: "test".into(),
message: Message::assistant("Hello"),
finish_reason: FinishReason::Stop,
usage: Usage::default(),
usage: TokenCounts::default(),
raw: None,
warnings: vec![],
rate_limit: None,
@ -1291,10 +1230,9 @@ mod tests {
provider: "test".into(),
message: Message::assistant("tool response"),
finish_reason: FinishReason::ToolCalls,
usage: Usage {
usage: TokenCounts {
input_tokens: 10,
output_tokens: 5,
total_tokens: 15,
..Default::default()
},
raw: None,

View file

@ -179,7 +179,7 @@ async fn run_multi_turn_cache_test(
"response text should not be empty on turn {turn}"
);
let cache_read = response.usage.cache_read_tokens.unwrap_or(0) as f64;
let cache_read = response.usage.cache_read_tokens as f64;
let input = response.usage.input_tokens as f64;
let ratio = cache_read / input;
best_cache_ratio = best_cache_ratio.max(ratio);

View file

@ -0,0 +1,692 @@
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use crate::{Model, Provider};
const USD_MICROS_PER_USD: i128 = 1_000_000;
const TOKENS_PER_MTOK: i128 = 1_000_000;
const ANTHROPIC_FAST_MODE_MULTIPLIER_NUMERATOR: i64 = 6;
const ANTHROPIC_FAST_MODE_MULTIPLIER_DENOMINATOR: i64 = 1;
const ANTHROPIC_CACHE_WRITE_5M_NUMERATOR: i64 = 5;
const ANTHROPIC_CACHE_WRITE_5M_DENOMINATOR: i64 = 4;
const ANTHROPIC_CACHE_WRITE_1H_NUMERATOR: i64 = 2;
const ANTHROPIC_CACHE_WRITE_1H_DENOMINATOR: i64 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
pub struct UsdMicros(pub i64);
impl std::ops::Add for UsdMicros {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Self(self.0 + rhs.0)
}
}
impl std::ops::AddAssign for UsdMicros {
fn add_assign(&mut self, rhs: Self) {
self.0 += rhs.0;
}
}
impl std::iter::Sum for UsdMicros {
fn sum<I: Iterator<Item = Self>>(iter: I) -> Self {
iter.fold(Self::default(), |acc, value| acc + value)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PricePerMTok {
pub usd_micros: i64,
}
impl PricePerMTok {
#[must_use]
pub fn from_usd(usd: f64) -> Self {
Self {
usd_micros: (usd * USD_MICROS_PER_USD as f64).round() as i64,
}
}
#[must_use]
pub fn multiply_ratio(self, numerator: i64, denominator: i64) -> Self {
Self {
usd_micros: self.usd_micros.saturating_mul(numerator) / denominator,
}
}
#[must_use]
pub fn bill(self, tokens: i64) -> UsdMicros {
let total = i128::from(tokens) * i128::from(self.usd_micros);
UsdMicros((total / TOKENS_PER_MTOK) as i64)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Speed {
Standard,
Fast,
}
impl Speed {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Standard => "standard",
Self::Fast => "fast",
}
}
}
impl FromStr for Speed {
type Err = String;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"standard" => Ok(Self::Standard),
"fast" => Ok(Self::Fast),
other => Err(format!("unknown speed: {other}")),
}
}
}
impl std::fmt::Display for Speed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct ModelRef {
pub provider: Provider,
pub model_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speed: Option<Speed>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct TokenCounts {
pub input_tokens: i64,
pub output_tokens: i64,
#[serde(default)]
pub reasoning_tokens: i64,
#[serde(default)]
pub cache_read_tokens: i64,
#[serde(default)]
pub cache_write_tokens: i64,
}
impl TokenCounts {
#[must_use]
pub fn billable_output_tokens(&self) -> i64 {
self.output_tokens + self.reasoning_tokens
}
#[must_use]
pub fn total_tokens(&self) -> i64 {
self.input_tokens
+ self.billable_output_tokens()
+ self.cache_read_tokens
+ self.cache_write_tokens
}
}
impl std::ops::Add for TokenCounts {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Self {
input_tokens: self.input_tokens + rhs.input_tokens,
output_tokens: self.output_tokens + rhs.output_tokens,
reasoning_tokens: self.reasoning_tokens + rhs.reasoning_tokens,
cache_read_tokens: self.cache_read_tokens + rhs.cache_read_tokens,
cache_write_tokens: self.cache_write_tokens + rhs.cache_write_tokens,
}
}
}
impl std::ops::AddAssign for TokenCounts {
fn add_assign(&mut self, rhs: Self) {
self.input_tokens += rhs.input_tokens;
self.output_tokens += rhs.output_tokens;
self.reasoning_tokens += rhs.reasoning_tokens;
self.cache_read_tokens += rhs.cache_read_tokens;
self.cache_write_tokens += rhs.cache_write_tokens;
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelUsage {
pub model: ModelRef,
pub tokens: TokenCounts,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OpenAiModelPricing {
pub input: PricePerMTok,
pub cached_input: Option<PricePerMTok>,
pub output: PricePerMTok,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AnthropicModelPricing {
pub input: PricePerMTok,
pub cache_read: Option<PricePerMTok>,
pub cache_write_5m: Option<PricePerMTok>,
pub cache_write_1h: Option<PricePerMTok>,
pub output: PricePerMTok,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GeminiStorageSegment {
pub cached_tokens: i64,
pub ttl_seconds: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GeminiStoragePricing {
pub usd_micros_per_mtok_second: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GeminiModelPricing {
pub input: PricePerMTok,
pub output: PricePerMTok,
pub cached_input: Option<PricePerMTok>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage: Option<GeminiStoragePricing>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "provider", rename_all = "snake_case")]
pub enum ModelPricingPolicy {
OpenAi(OpenAiModelPricing),
OpenAiCompatible(OpenAiModelPricing),
Anthropic(AnthropicModelPricing),
Gemini(GeminiModelPricing),
Kimi(OpenAiModelPricing),
Zai(OpenAiModelPricing),
Minimax(OpenAiModelPricing),
Inception(OpenAiModelPricing),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelPricing {
pub model: ModelRef,
pub policy: ModelPricingPolicy,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct OpenAiBillingFacts {}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct AnthropicBillingFacts {
#[serde(default)]
pub cache_write_5m_tokens: i64,
#[serde(default)]
pub cache_write_1h_tokens: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct GeminiBillingFacts {
#[serde(default)]
pub storage_segments: Vec<GeminiStorageSegment>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "provider", rename_all = "snake_case")]
pub enum ModelBillingFacts {
OpenAi(OpenAiBillingFacts),
OpenAiCompatible(OpenAiBillingFacts),
Anthropic(AnthropicBillingFacts),
Gemini(GeminiBillingFacts),
Kimi(OpenAiBillingFacts),
Zai(OpenAiBillingFacts),
Minimax(OpenAiBillingFacts),
Inception(OpenAiBillingFacts),
}
impl ModelBillingFacts {
#[must_use]
pub fn for_provider(provider: Provider) -> Self {
match provider {
Provider::OpenAi => Self::OpenAi(OpenAiBillingFacts::default()),
Provider::OpenAiCompatible => Self::OpenAiCompatible(OpenAiBillingFacts::default()),
Provider::Anthropic => Self::Anthropic(AnthropicBillingFacts::default()),
Provider::Gemini => Self::Gemini(GeminiBillingFacts::default()),
Provider::Kimi => Self::Kimi(OpenAiBillingFacts::default()),
Provider::Zai => Self::Zai(OpenAiBillingFacts::default()),
Provider::Minimax => Self::Minimax(OpenAiBillingFacts::default()),
Provider::Inception => Self::Inception(OpenAiBillingFacts::default()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelBillingInput {
pub usage: ModelUsage,
pub facts: ModelBillingFacts,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BilledModelUsage {
pub input: ModelBillingInput,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_usd_micros: Option<i64>,
}
impl BilledModelUsage {
#[must_use]
pub fn model(&self) -> &ModelRef {
&self.input.usage.model
}
#[must_use]
pub fn model_id(&self) -> &str {
&self.input.usage.model.model_id
}
#[must_use]
pub fn tokens(&self) -> &TokenCounts {
&self.input.usage.tokens
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct BilledTokenCounts {
pub input_tokens: i64,
pub output_tokens: i64,
pub total_tokens: i64,
#[serde(default)]
pub reasoning_tokens: i64,
#[serde(default)]
pub cache_read_tokens: i64,
#[serde(default)]
pub cache_write_tokens: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_usd_micros: Option<i64>,
}
impl BilledTokenCounts {
#[must_use]
pub fn from_billed_usage(billed: &[BilledModelUsage]) -> Self {
let mut tokens = TokenCounts::default();
let mut total_usd_micros = 0_i64;
let mut has_total = false;
for entry in billed {
tokens += entry.input.usage.tokens.clone();
if let Some(value) = entry.total_usd_micros {
total_usd_micros += value;
has_total = true;
}
}
Self {
input_tokens: tokens.input_tokens,
output_tokens: tokens.output_tokens,
total_tokens: tokens.total_tokens(),
reasoning_tokens: tokens.reasoning_tokens,
cache_read_tokens: tokens.cache_read_tokens,
cache_write_tokens: tokens.cache_write_tokens,
total_usd_micros: has_total.then_some(total_usd_micros),
}
}
}
impl Model {
#[must_use]
pub fn billing_model_ref(&self, speed: Option<Speed>) -> ModelRef {
ModelRef {
provider: self.provider,
model_id: self.id.clone(),
speed,
}
}
#[must_use]
pub fn pricing_for(&self, speed: Option<Speed>) -> Option<ModelPricing> {
let input = self.costs.input_cost_per_mtok.map(PricePerMTok::from_usd)?;
let output = self
.costs
.output_cost_per_mtok
.map(PricePerMTok::from_usd)?;
let cached_input = self
.costs
.cache_input_cost_per_mtok
.map(PricePerMTok::from_usd);
let (input, output, cached_input) = match (self.provider, speed) {
(Provider::Anthropic, Some(Speed::Fast)) if self.id == "claude-opus-4-6" => (
input.multiply_ratio(
ANTHROPIC_FAST_MODE_MULTIPLIER_NUMERATOR,
ANTHROPIC_FAST_MODE_MULTIPLIER_DENOMINATOR,
),
output.multiply_ratio(
ANTHROPIC_FAST_MODE_MULTIPLIER_NUMERATOR,
ANTHROPIC_FAST_MODE_MULTIPLIER_DENOMINATOR,
),
cached_input.map(|rate| {
rate.multiply_ratio(
ANTHROPIC_FAST_MODE_MULTIPLIER_NUMERATOR,
ANTHROPIC_FAST_MODE_MULTIPLIER_DENOMINATOR,
)
}),
),
(_, None | Some(Speed::Standard)) => (input, output, cached_input),
_ => return None,
};
let policy = match self.provider {
Provider::OpenAi => ModelPricingPolicy::OpenAi(OpenAiModelPricing {
input,
cached_input,
output,
}),
Provider::OpenAiCompatible => {
ModelPricingPolicy::OpenAiCompatible(OpenAiModelPricing {
input,
cached_input,
output,
})
}
Provider::Anthropic => ModelPricingPolicy::Anthropic(AnthropicModelPricing {
input,
cache_read: cached_input,
cache_write_5m: Some(input.multiply_ratio(
ANTHROPIC_CACHE_WRITE_5M_NUMERATOR,
ANTHROPIC_CACHE_WRITE_5M_DENOMINATOR,
)),
cache_write_1h: Some(input.multiply_ratio(
ANTHROPIC_CACHE_WRITE_1H_NUMERATOR,
ANTHROPIC_CACHE_WRITE_1H_DENOMINATOR,
)),
output,
}),
Provider::Gemini => ModelPricingPolicy::Gemini(GeminiModelPricing {
input,
output,
cached_input,
storage: None,
}),
Provider::Kimi => ModelPricingPolicy::Kimi(OpenAiModelPricing {
input,
cached_input,
output,
}),
Provider::Zai => ModelPricingPolicy::Zai(OpenAiModelPricing {
input,
cached_input,
output,
}),
Provider::Minimax => ModelPricingPolicy::Minimax(OpenAiModelPricing {
input,
cached_input,
output,
}),
Provider::Inception => ModelPricingPolicy::Inception(OpenAiModelPricing {
input,
cached_input,
output,
}),
};
Some(ModelPricing {
model: self.billing_model_ref(speed),
policy,
})
}
}
impl ModelPricing {
#[must_use]
pub fn bill(&self, input: &ModelBillingInput) -> Option<UsdMicros> {
if input.usage.model != self.model {
return None;
}
let bill = match (&self.policy, &input.facts) {
(ModelPricingPolicy::OpenAi(pricing), ModelBillingFacts::OpenAi(_))
| (
ModelPricingPolicy::OpenAiCompatible(pricing),
ModelBillingFacts::OpenAiCompatible(_),
)
| (ModelPricingPolicy::Kimi(pricing), ModelBillingFacts::Kimi(_))
| (ModelPricingPolicy::Zai(pricing), ModelBillingFacts::Zai(_))
| (ModelPricingPolicy::Minimax(pricing), ModelBillingFacts::Minimax(_))
| (ModelPricingPolicy::Inception(pricing), ModelBillingFacts::Inception(_)) => {
Some(bill_openai_like(pricing, &input.usage.tokens))
}
(ModelPricingPolicy::Anthropic(pricing), ModelBillingFacts::Anthropic(facts)) => {
Some(bill_anthropic(pricing, &input.usage.tokens, facts))
}
(ModelPricingPolicy::Gemini(pricing), ModelBillingFacts::Gemini(facts)) => {
bill_gemini(pricing, &input.usage.tokens, facts)
}
_ => None,
}?;
Some(bill)
}
#[must_use]
pub fn bill_usage(&self, input: ModelBillingInput) -> BilledModelUsage {
let total_usd_micros = self.bill(&input).map(|amount| amount.0);
BilledModelUsage {
input,
total_usd_micros,
}
}
}
fn bill_openai_like(pricing: &OpenAiModelPricing, tokens: &TokenCounts) -> UsdMicros {
let mut total = pricing.input.bill(tokens.input_tokens);
total += pricing.output.bill(tokens.billable_output_tokens());
if let Some(cached_input) = pricing.cached_input {
total += cached_input.bill(tokens.cache_read_tokens);
}
total
}
fn bill_anthropic(
pricing: &AnthropicModelPricing,
tokens: &TokenCounts,
facts: &AnthropicBillingFacts,
) -> UsdMicros {
let mut total = pricing.input.bill(tokens.input_tokens);
total += pricing.output.bill(tokens.billable_output_tokens());
if let Some(cache_read) = pricing.cache_read {
total += cache_read.bill(tokens.cache_read_tokens);
}
if let Some(cache_write_5m) = pricing.cache_write_5m {
total += cache_write_5m.bill(facts.cache_write_5m_tokens);
}
if let Some(cache_write_1h) = pricing.cache_write_1h {
total += cache_write_1h.bill(facts.cache_write_1h_tokens);
}
total
}
fn bill_gemini(
pricing: &GeminiModelPricing,
tokens: &TokenCounts,
facts: &GeminiBillingFacts,
) -> Option<UsdMicros> {
if tokens.cache_read_tokens > 0 && pricing.cached_input.is_none() {
return None;
}
if !facts.storage_segments.is_empty() && pricing.storage.is_none() {
return None;
}
let mut total = pricing.input.bill(tokens.input_tokens);
total += pricing.output.bill(tokens.billable_output_tokens());
if let Some(cached_input) = pricing.cached_input {
total += cached_input.bill(tokens.cache_read_tokens);
}
if let Some(storage) = pricing.storage.as_ref() {
let storage_cost = facts
.storage_segments
.iter()
.map(|segment| {
let token_seconds =
i128::from(segment.cached_tokens) * i128::from(segment.ttl_seconds);
UsdMicros(
(token_seconds * i128::from(storage.usd_micros_per_mtok_second)
/ TOKENS_PER_MTOK) as i64,
)
})
.sum::<UsdMicros>();
total += storage_cost;
}
Some(total)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Catalog;
#[test]
fn openai_pricing_bills_cached_input_and_reasoning_output() {
let pricing = ModelPricing {
model: ModelRef {
provider: Provider::OpenAi,
model_id: "gpt-5.4".to_string(),
speed: None,
},
policy: ModelPricingPolicy::OpenAi(OpenAiModelPricing {
input: PricePerMTok {
usd_micros: 1_250_000,
},
cached_input: Some(PricePerMTok {
usd_micros: 125_000,
}),
output: PricePerMTok {
usd_micros: 10_000_000,
},
}),
};
let input = ModelBillingInput {
usage: ModelUsage {
model: pricing.model.clone(),
tokens: TokenCounts {
input_tokens: 500_000,
output_tokens: 125_000,
reasoning_tokens: 25_000,
cache_read_tokens: 250_000,
cache_write_tokens: 0,
},
},
facts: ModelBillingFacts::OpenAi(OpenAiBillingFacts::default()),
};
assert_eq!(pricing.bill(&input), Some(UsdMicros(2_156_250)));
}
#[test]
fn anthropic_fast_mode_derives_cache_write_rates_from_base_input() {
let model = Catalog::builtin().get("claude-opus-4-6").unwrap();
let pricing = model.pricing_for(Some(Speed::Fast)).unwrap();
let ModelPricingPolicy::Anthropic(anthropic) = pricing.policy else {
panic!("expected anthropic pricing");
};
assert_eq!(anthropic.input.usd_micros, 30_000_000);
assert_eq!(anthropic.output.usd_micros, 150_000_000);
assert_eq!(anthropic.cache_read.unwrap().usd_micros, 3_000_000);
assert_eq!(anthropic.cache_write_5m.unwrap().usd_micros, 37_500_000);
assert_eq!(anthropic.cache_write_1h.unwrap().usd_micros, 60_000_000);
}
#[test]
fn anthropic_billing_supports_distinct_cache_write_buckets() {
let pricing = ModelPricing {
model: ModelRef {
provider: Provider::Anthropic,
model_id: "claude-opus-4-6".to_string(),
speed: Some(Speed::Fast),
},
policy: ModelPricingPolicy::Anthropic(AnthropicModelPricing {
input: PricePerMTok {
usd_micros: 30_000_000,
},
cache_read: Some(PricePerMTok {
usd_micros: 3_000_000,
}),
cache_write_5m: Some(PricePerMTok {
usd_micros: 37_500_000,
}),
cache_write_1h: Some(PricePerMTok {
usd_micros: 60_000_000,
}),
output: PricePerMTok {
usd_micros: 150_000_000,
},
}),
};
let input = ModelBillingInput {
usage: ModelUsage {
model: pricing.model.clone(),
tokens: TokenCounts {
input_tokens: 100_000,
output_tokens: 10_000,
reasoning_tokens: 5_000,
cache_read_tokens: 20_000,
cache_write_tokens: 0,
},
},
facts: ModelBillingFacts::Anthropic(AnthropicBillingFacts {
cache_write_5m_tokens: 30_000,
cache_write_1h_tokens: 40_000,
}),
};
assert_eq!(pricing.bill(&input), Some(UsdMicros(8_835_000)));
}
#[test]
fn gemini_billing_requires_storage_pricing_when_storage_facts_exist() {
let pricing = ModelPricing {
model: ModelRef {
provider: Provider::Gemini,
model_id: "gemini-3.1-pro-preview".to_string(),
speed: None,
},
policy: ModelPricingPolicy::Gemini(GeminiModelPricing {
input: PricePerMTok {
usd_micros: 1_250_000,
},
output: PricePerMTok {
usd_micros: 10_000_000,
},
cached_input: None,
storage: None,
}),
};
let input = ModelBillingInput {
usage: ModelUsage {
model: pricing.model.clone(),
tokens: TokenCounts {
input_tokens: 100_000,
output_tokens: 10_000,
reasoning_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
},
},
facts: ModelBillingFacts::Gemini(GeminiBillingFacts {
storage_segments: vec![GeminiStorageSegment {
cached_tokens: 100_000,
ttl_seconds: 60,
}],
}),
};
assert_eq!(pricing.bill(&input), None);
}
}

View file

@ -9,9 +9,9 @@
"knowledge_cutoff": "May 2025",
"features": { "tools": true, "vision": true, "reasoning": true, "effort": true },
"costs": {
"input_cost_per_mtok": 15.0,
"output_cost_per_mtok": 75.0,
"cache_input_cost_per_mtok": 1.50
"input_cost_per_mtok": 5.0,
"output_cost_per_mtok": 25.0,
"cache_input_cost_per_mtok": 0.50
},
"estimated_output_tps": 25,
"aliases": ["opus", "claude-opus"]

View file

@ -458,13 +458,13 @@ mod tests {
},
costs: ModelCosts {
input_cost_per_mtok: Some(
15.0,
5.0,
),
output_cost_per_mtok: Some(
75.0,
25.0,
),
cache_input_cost_per_mtok: Some(
1.5,
0.5,
),
},
estimated_output_tps: Some(
@ -874,8 +874,8 @@ mod tests {
#[test]
fn model_info_costs() {
let claude = Catalog::builtin().get("claude-opus-4-6").unwrap();
assert_eq!(claude.costs.input_cost_per_mtok, Some(15.0));
assert_eq!(claude.costs.output_cost_per_mtok, Some(75.0));
assert_eq!(claude.costs.input_cost_per_mtok, Some(5.0));
assert_eq!(claude.costs.output_cost_per_mtok, Some(25.0));
let sonnet = Catalog::builtin().get("claude-sonnet-4-5").unwrap();
assert_eq!(sonnet.costs.input_cost_per_mtok, Some(3.0));

View file

@ -1,9 +1,16 @@
pub mod billing;
pub mod catalog;
pub mod model_ref;
pub mod provider;
pub mod types;
pub use billing::{
AnthropicBillingFacts, AnthropicModelPricing, BilledModelUsage, BilledTokenCounts,
GeminiBillingFacts, GeminiModelPricing, GeminiStoragePricing, GeminiStorageSegment,
ModelBillingFacts, ModelBillingInput, ModelPricing, ModelPricingPolicy, ModelRef, ModelUsage,
OpenAiBillingFacts, OpenAiModelPricing, PricePerMTok, Speed, TokenCounts, UsdMicros,
};
pub use catalog::{Catalog, FallbackTarget};
pub use model_ref::ModelRef;
pub use model_ref::ModelHandle;
pub use provider::Provider;
pub use types::{Model, ModelCosts, ModelFeatures, ModelLimits};

View file

@ -7,14 +7,14 @@ use crate::types::Model;
/// A reference to a model — either a fully resolved `Model` or a
/// provider + model-name pair that hasn't been looked up yet.
#[derive(Clone)]
pub enum ModelRef {
pub enum ModelHandle {
/// A model whose metadata has been resolved from the catalog.
Resolved(Arc<Model>),
/// An unresolved provider:model pair (e.g. from CLI input or config).
ByName { provider: Provider, model: String },
}
impl ModelRef {
impl ModelHandle {
/// The model identifier string (e.g. `"claude-opus-4-6"`).
#[must_use]
pub fn model_id(&self) -> &str {
@ -34,13 +34,13 @@ impl ModelRef {
}
}
impl fmt::Display for ModelRef {
impl fmt::Display for ModelHandle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}", self.provider(), self.model_id())
}
}
impl fmt::Debug for ModelRef {
impl fmt::Debug for ModelHandle {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Resolved(m) => write!(f, "ModelRef::Resolved({:?})", m.id),
@ -60,7 +60,7 @@ mod tests {
#[test]
fn by_name_display() {
let r = ModelRef::ByName {
let r = ModelHandle::ByName {
provider: Provider::Anthropic,
model: "claude-opus-4-6".to_string(),
};
@ -69,7 +69,7 @@ mod tests {
#[test]
fn by_name_accessors() {
let r = ModelRef::ByName {
let r = ModelHandle::ByName {
provider: Provider::OpenAi,
model: "gpt-5.4".to_string(),
};
@ -80,21 +80,21 @@ mod tests {
#[test]
fn resolved_display() {
let info = Catalog::builtin().get("claude-opus-4-6").unwrap().clone();
let r = ModelRef::Resolved(Arc::new(info));
let r = ModelHandle::Resolved(Arc::new(info));
assert_eq!(r.to_string(), "anthropic:claude-opus-4-6");
}
#[test]
fn resolved_accessors() {
let info = Catalog::builtin().get("gpt-5.4").unwrap().clone();
let r = ModelRef::Resolved(Arc::new(info));
let r = ModelHandle::Resolved(Arc::new(info));
assert_eq!(r.model_id(), "gpt-5.4");
assert_eq!(r.provider(), Provider::OpenAi);
}
#[test]
fn debug_format() {
let r = ModelRef::ByName {
let r = ModelHandle::ByName {
provider: Provider::Gemini,
model: "gemini-3.1-pro-preview".to_string(),
};

View file

@ -141,9 +141,9 @@ mod tests {
assert!(info.supports_effort());
assert_eq!(info.training(), Some("2025-08-01"));
assert_eq!(info.knowledge_cutoff(), Some("May 2025"));
assert_eq!(info.input_cost_per_mtok(), Some(15.0));
assert_eq!(info.output_cost_per_mtok(), Some(75.0));
assert_eq!(info.cache_input_cost_per_mtok(), Some(1.5));
assert_eq!(info.input_cost_per_mtok(), Some(5.0));
assert_eq!(info.output_cost_per_mtok(), Some(25.0));
assert_eq!(info.cache_input_cost_per_mtok(), Some(0.5));
assert_eq!(info.estimated_output_tps(), Some(25.0));
assert!(!info.aliases().is_empty());
assert!(!info.is_default());

View file

@ -12,7 +12,7 @@ pub struct CompletedStage {
pub succeeded: bool,
pub failed: bool,
pub retries: u32,
pub cost: Option<f64>,
pub billing_usd_micros: Option<i64>,
pub notes: Option<String>,
pub failure_reason: Option<String>,
pub files_touched: Vec<String>,
@ -28,7 +28,7 @@ pub fn derive_retro(
) -> Retro {
let mut stages = Vec::new();
let mut all_files: Vec<String> = Vec::new();
let mut total_cost: Option<f64> = None;
let mut total_billing_usd_micros: Option<i64> = None;
let mut total_retries: u32 = 0;
let mut stages_completed: usize = 0;
let mut stages_failed: usize = 0;
@ -43,8 +43,8 @@ pub fn derive_retro(
stages_failed += 1;
}
if let Some(c) = cs.cost {
*total_cost.get_or_insert(0.0) += c;
if let Some(cost) = cs.billing_usd_micros {
*total_billing_usd_micros.get_or_insert(0) += cost;
}
let dur = stage_durations.get(&cs.node_id).copied().unwrap_or(0);
@ -53,7 +53,7 @@ pub fn derive_retro(
stage_label: cs.node_id.clone(),
duration_ms: dur,
retries: cs.retries,
cost: cs.cost,
billing_usd_micros: cs.billing_usd_micros,
stage_id: cs.node_id,
status: cs.status,
notes: cs.notes,
@ -76,7 +76,7 @@ pub fn derive_retro(
let stats = AggregateStats {
total_duration_ms: duration_ms,
total_cost,
total_billing_usd_micros,
total_retries,
files_touched: all_files,
stages_completed,

View file

@ -97,12 +97,12 @@ pub(crate) async fn list_run_artifacts_stub(
.into_response()
}
pub(crate) async fn get_run_usage(
pub(crate) async fn get_run_billing(
_auth: AuthenticatedService,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
(StatusCode::OK, Json(runs::usage())).into_response()
(StatusCode::OK, Json(runs::billing())).into_response()
}
pub(crate) async fn get_run_settings(
@ -588,11 +588,11 @@ pub(crate) async fn prune_runs(
// ── Usage ──────────────────────────────────────────────────────────────
pub(crate) async fn get_aggregate_usage(
pub(crate) async fn get_aggregate_billing(
_auth: AuthenticatedService,
State(_state): State<Arc<AppState>>,
) -> Response {
(StatusCode::OK, Json(usage::aggregate())).into_response()
(StatusCode::OK, Json(billing::aggregate())).into_response()
}
// ── Data modules ───────────────────────────────────────────────────────
@ -1101,109 +1101,141 @@ mod runs {
]
}
pub(super) fn usage() -> RunUsage {
RunUsage {
pub(super) fn billing() -> RunBilling {
RunBilling {
stages: vec![
UsageStage {
stage: UsageStageRef {
RunBillingStage {
stage: BillingStageRef {
id: "detect-drift".into(),
name: "Detect Drift".into(),
},
model: ModelReference {
id: "Opus 4.6".into(),
},
usage: TokenUsage {
billing: BilledTokenCounts {
cache_read_tokens: None,
cache_write_tokens: None,
input_tokens: 12480,
output_tokens: 3210,
cost: 0.48,
reasoning_tokens: None,
total_tokens: 15690,
total_usd_micros: Some(480_000),
},
runtime_secs: 72.0,
},
UsageStage {
stage: UsageStageRef {
RunBillingStage {
stage: BillingStageRef {
id: "propose-changes".into(),
name: "Propose Changes".into(),
},
model: ModelReference {
id: "Gemini 3.1".into(),
},
usage: TokenUsage {
billing: BilledTokenCounts {
cache_read_tokens: None,
cache_write_tokens: None,
input_tokens: 28640,
output_tokens: 8750,
cost: 0.72,
reasoning_tokens: None,
total_tokens: 37390,
total_usd_micros: Some(720_000),
},
runtime_secs: 154.0,
},
UsageStage {
stage: UsageStageRef {
RunBillingStage {
stage: BillingStageRef {
id: "review-changes".into(),
name: "Review Changes".into(),
},
model: ModelReference {
id: "Codex 5.3".into(),
},
usage: TokenUsage {
billing: BilledTokenCounts {
cache_read_tokens: None,
cache_write_tokens: None,
input_tokens: 9120,
output_tokens: 2640,
cost: 0.19,
reasoning_tokens: None,
total_tokens: 11760,
total_usd_micros: Some(190_000),
},
runtime_secs: 45.0,
},
UsageStage {
stage: UsageStageRef {
RunBillingStage {
stage: BillingStageRef {
id: "apply-changes".into(),
name: "Apply Changes".into(),
},
model: ModelReference {
id: "Opus 4.6".into(),
},
usage: TokenUsage {
billing: BilledTokenCounts {
cache_read_tokens: None,
cache_write_tokens: None,
input_tokens: 21300,
output_tokens: 6480,
cost: 0.87,
reasoning_tokens: None,
total_tokens: 27780,
total_usd_micros: Some(870_000),
},
runtime_secs: 118.0,
},
],
totals: UsageTotals {
totals: RunBillingTotals {
cache_read_tokens: None,
cache_write_tokens: None,
runtime_secs: 389.0,
input_tokens: 71540,
output_tokens: 21080,
cost: 2.26,
reasoning_tokens: None,
total_tokens: 92620,
total_usd_micros: Some(2_260_000),
},
by_model: vec![
UsageByModel {
BillingByModel {
billing: BilledTokenCounts {
cache_read_tokens: None,
cache_write_tokens: None,
input_tokens: 33780,
output_tokens: 9690,
reasoning_tokens: None,
total_tokens: 43470,
total_usd_micros: Some(1_350_000),
},
model: ModelReference {
id: "Opus 4.6".into(),
},
stages: 2,
usage: TokenUsage {
input_tokens: 33780,
output_tokens: 9690,
cost: 1.35,
},
},
UsageByModel {
BillingByModel {
billing: BilledTokenCounts {
cache_read_tokens: None,
cache_write_tokens: None,
input_tokens: 28640,
output_tokens: 8750,
reasoning_tokens: None,
total_tokens: 37390,
total_usd_micros: Some(720_000),
},
model: ModelReference {
id: "Gemini 3.1".into(),
},
stages: 1,
usage: TokenUsage {
input_tokens: 28640,
output_tokens: 8750,
cost: 0.72,
},
},
UsageByModel {
BillingByModel {
billing: BilledTokenCounts {
cache_read_tokens: None,
cache_write_tokens: None,
input_tokens: 9120,
output_tokens: 2640,
reasoning_tokens: None,
total_tokens: 11760,
total_usd_micros: Some(190_000),
},
model: ModelReference {
id: "Codex 5.3".into(),
},
stages: 1,
usage: TokenUsage {
input_tokens: 9120,
output_tokens: 2640,
cost: 0.19,
},
},
],
}
@ -1303,51 +1335,67 @@ mod runs {
}
}
mod usage {
mod billing {
use fabro_api::types::*;
pub(super) fn aggregate() -> AggregateUsage {
AggregateUsage {
totals: AggregateUsageTotals {
pub(super) fn aggregate() -> AggregateBilling {
AggregateBilling {
totals: AggregateBillingTotals {
cache_read_tokens: None,
cache_write_tokens: None,
runs: 9,
input_tokens: 643_860,
output_tokens: 189_720,
cost: 20.34,
reasoning_tokens: None,
runtime_secs: 3_501.0,
total_tokens: 833_580,
total_usd_micros: Some(20_340_000),
},
by_model: vec![
UsageByModel {
BillingByModel {
billing: BilledTokenCounts {
cache_read_tokens: None,
cache_write_tokens: None,
input_tokens: 304_020,
output_tokens: 87_210,
reasoning_tokens: None,
total_tokens: 391_230,
total_usd_micros: Some(12_150_000),
},
model: ModelReference {
id: "Opus 4.6".into(),
},
stages: 18,
usage: TokenUsage {
input_tokens: 304_020,
output_tokens: 87_210,
cost: 12.15,
},
},
UsageByModel {
BillingByModel {
billing: BilledTokenCounts {
cache_read_tokens: None,
cache_write_tokens: None,
input_tokens: 257_760,
output_tokens: 78_750,
reasoning_tokens: None,
total_tokens: 336_510,
total_usd_micros: Some(6_480_000),
},
model: ModelReference {
id: "Gemini 3.1".into(),
},
stages: 9,
usage: TokenUsage {
input_tokens: 257_760,
output_tokens: 78_750,
cost: 6.48,
},
},
UsageByModel {
BillingByModel {
billing: BilledTokenCounts {
cache_read_tokens: None,
cache_write_tokens: None,
input_tokens: 82_080,
output_tokens: 23_760,
reasoning_tokens: None,
total_tokens: 105_840,
total_usd_micros: Some(1_710_000),
},
model: ModelReference {
id: "Codex 5.3".into(),
},
stages: 9,
usage: TokenUsage {
input_tokens: 82_080,
output_tokens: 23_760,
cost: 1.71,
},
},
],
}

View file

@ -26,8 +26,9 @@ use fabro_llm::generate::{GenerateParams, generate_object};
use fabro_llm::model_test::{ModelTestMode, run_model_test_with_client};
use fabro_llm::types::{
ContentPart, FinishReason, Message as LlmMessage, Request as LlmRequest,
Response as LlmResponse, Role, StreamEvent, ToolChoice, ToolDefinition, Usage,
Response as LlmResponse, Role, StreamEvent, TokenCounts, ToolChoice, ToolDefinition,
};
use fabro_model::{BilledModelUsage, BilledTokenCounts};
use fabro_store::{ArtifactStore, Database, EventEnvelope, EventPayload, StageId};
use fabro_types::{
RunBlobId, RunClientProvenance, RunControlAction, RunEvent, RunId, RunProvenance,
@ -80,21 +81,21 @@ use fabro_workflow::run_lookup::{
use fabro_workflow::run_status::RunStatus as WorkflowRunStatus;
use fabro_workflow::run_status::StatusReason as WorkflowStatusReason;
use fabro_api::types::AggregateUsageTotals;
pub use fabro_api::types::{
AggregateUsage, ApiQuestion, ApiQuestionOption, AppendEventResponse, ArtifactEntry,
ArtifactListResponse, CompletionContentPart, CompletionMessage, CompletionMessageRole,
AggregateBilling, AggregateBillingTotals, ApiQuestion, ApiQuestionOption, AppendEventResponse,
ArtifactEntry, ArtifactListResponse, BilledTokenCounts as ApiBilledTokenCounts, BillingByModel,
BillingStageRef, CompletionContentPart, CompletionMessage, CompletionMessageRole,
CompletionResponse, CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest,
DiskUsageResponse, DiskUsageRunRow, DiskUsageSummaryRow, EventEnvelope as ApiEventEnvelope,
ModelReference, PaginatedEventList, PaginatedRunList, PaginationMeta, PreflightResponse,
PreviewUrlRequest, PreviewUrlResponse, PruneRunEntry, PruneRunsRequest, PruneRunsResponse,
QuestionType as ApiQuestionType, RenderWorkflowGraphDirection, RenderWorkflowGraphFormat,
RenderWorkflowGraphRequest, RunArtifactEntry, RunArtifactListResponse,
RunControlAction as ApiRunControlAction, RunError, RunEvent as ApiRunEvent, RunManifest,
RunStatus, RunStatusResponse, SandboxFileEntry, SandboxFileListResponse, ServerSettings,
SetSecretRequest, SshAccessRequest, SshAccessResponse, StartRunRequest,
StatusReason as ApiStatusReason, SubmitAnswerRequest, SystemInfoResponse, SystemRunCounts,
TokenUsage, UsageByModel, WriteBlobResponse,
RenderWorkflowGraphRequest, RunArtifactEntry, RunArtifactListResponse, RunBilling,
RunBillingStage, RunBillingTotals, RunControlAction as ApiRunControlAction, RunError,
RunEvent as ApiRunEvent, RunManifest, RunStatus, RunStatusResponse, SandboxFileEntry,
SandboxFileListResponse, ServerSettings, SetSecretRequest, SshAccessRequest, SshAccessResponse,
StartRunRequest, StatusReason as ApiStatusReason, SubmitAnswerRequest, SystemInfoResponse,
SystemRunCounts, WriteBlobResponse,
};
use fabro_graphviz::render::GraphFormat;
@ -232,21 +233,19 @@ const FILE_INTERVIEW_QUESTION_ID: &str = "q-file";
const WORKER_STDERR_LOG: &str = "worker.stderr.log";
const WORKER_CANCEL_GRACE: Duration = Duration::from_secs(5);
/// Per-model usage totals.
/// Per-model billing totals.
#[derive(Default)]
struct ModelUsageTotals {
struct ModelBillingTotals {
stages: i64,
input_tokens: i64,
output_tokens: i64,
cost: f64,
billing: BilledTokenCounts,
}
/// In-memory aggregate usage counters, reset on server restart.
/// In-memory aggregate billing counters, reset on server restart.
#[derive(Default)]
struct UsageAccumulator {
struct BillingAccumulator {
total_runs: i64,
total_runtime_secs: f64,
by_model: HashMap<String, ModelUsageTotals>,
by_model: HashMap<String, ModelBillingTotals>,
}
type RegistryFactoryOverride = dyn Fn(Arc<dyn Interviewer>) -> HandlerRegistry + Send + Sync;
@ -254,7 +253,7 @@ type RegistryFactoryOverride = dyn Fn(Arc<dyn Interviewer>) -> HandlerRegistry +
/// Shared application state for the server.
pub struct AppState {
runs: Mutex<HashMap<RunId, ManagedRun>>,
aggregate_usage: Mutex<UsageAccumulator>,
aggregate_billing: Mutex<BillingAccumulator>,
store: Arc<Database>,
artifact_store: ArtifactStore,
started_at: Instant,
@ -270,6 +269,49 @@ pub struct AppState {
registry_factory_override: Option<Box<RegistryFactoryOverride>>,
}
fn nonzero_i64(value: i64) -> Option<i64> {
(value != 0).then_some(value)
}
fn api_billed_token_counts_from_domain(billing: &BilledTokenCounts) -> ApiBilledTokenCounts {
ApiBilledTokenCounts {
cache_read_tokens: nonzero_i64(billing.cache_read_tokens),
cache_write_tokens: nonzero_i64(billing.cache_write_tokens),
input_tokens: billing.input_tokens,
output_tokens: billing.output_tokens,
reasoning_tokens: nonzero_i64(billing.reasoning_tokens),
total_tokens: billing.total_tokens,
total_usd_micros: billing.total_usd_micros,
}
}
fn api_billed_token_counts_from_usage(usage: &BilledModelUsage) -> ApiBilledTokenCounts {
let tokens = usage.tokens();
ApiBilledTokenCounts {
cache_read_tokens: nonzero_i64(tokens.cache_read_tokens),
cache_write_tokens: nonzero_i64(tokens.cache_write_tokens),
input_tokens: tokens.input_tokens,
output_tokens: tokens.output_tokens,
reasoning_tokens: nonzero_i64(tokens.reasoning_tokens),
total_tokens: tokens.total_tokens(),
total_usd_micros: usage.total_usd_micros,
}
}
fn accumulate_model_billing(entry: &mut ModelBillingTotals, usage: &BilledModelUsage) {
let tokens = usage.tokens();
entry.stages += 1;
entry.billing.input_tokens += tokens.input_tokens;
entry.billing.output_tokens += tokens.output_tokens;
entry.billing.reasoning_tokens += tokens.reasoning_tokens;
entry.billing.cache_read_tokens += tokens.cache_read_tokens;
entry.billing.cache_write_tokens += tokens.cache_write_tokens;
entry.billing.total_tokens += tokens.total_tokens();
if let Some(value) = usage.total_usd_micros {
*entry.billing.total_usd_micros.get_or_insert(0) += value;
}
}
impl AppState {
pub(crate) fn dry_run(&self) -> bool {
self.settings.read().unwrap().dry_run_enabled()
@ -440,7 +482,7 @@ fn demo_routes() -> Router<Arc<AppState>> {
"/runs/{id}/stages/{stageId}/artifacts/download",
get(not_implemented),
)
.route("/runs/{id}/usage", get(demo::get_run_usage))
.route("/runs/{id}/billing", get(demo::get_run_billing))
.route("/runs/{id}/settings", get(demo::get_run_settings))
.route("/runs/{id}/preview", post(demo::generate_preview_url_stub))
.route("/runs/{id}/ssh", post(demo::create_ssh_access_stub))
@ -478,7 +520,7 @@ fn demo_routes() -> Router<Arc<AppState>> {
.route("/system/info", get(demo::get_system_info))
.route("/system/df", get(demo::get_system_disk_usage))
.route("/system/prune/runs", post(demo::prune_runs))
.route("/usage", get(demo::get_aggregate_usage))
.route("/billing", get(demo::get_aggregate_billing))
}
fn real_routes() -> Router<Arc<AppState>> {
@ -516,7 +558,7 @@ fn real_routes() -> Router<Arc<AppState>> {
"/runs/{id}/stages/{stageId}/artifacts/download",
get(get_stage_artifact),
)
.route("/runs/{id}/usage", get(not_implemented))
.route("/runs/{id}/billing", get(get_run_billing))
.route("/runs/{id}/settings", get(not_implemented))
.route("/runs/{id}/steer", post(not_implemented))
.route("/runs/{id}/preview", post(generate_preview_url))
@ -552,7 +594,7 @@ fn real_routes() -> Router<Arc<AppState>> {
.route("/system/info", get(get_system_info))
.route("/system/df", get(get_system_df))
.route("/system/prune/runs", post(prune_runs))
.route("/usage", get(get_aggregate_usage))
.route("/billing", get(get_aggregate_billing))
}
async fn not_implemented() -> Response {
@ -1266,40 +1308,161 @@ async fn cookie_and_demo_middleware(
next.run(req).await
}
async fn get_aggregate_usage(
async fn get_aggregate_billing(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
) -> Response {
let agg = state
.aggregate_usage
.aggregate_billing
.lock()
.expect("aggregate_usage lock poisoned");
let by_model: Vec<UsageByModel> = agg
.expect("aggregate_billing lock poisoned");
let by_model: Vec<BillingByModel> = agg
.by_model
.iter()
.map(|(model, totals)| UsageByModel {
.map(|(model, totals)| BillingByModel {
billing: api_billed_token_counts_from_domain(&totals.billing),
model: ModelReference { id: model.clone() },
stages: totals.stages,
usage: TokenUsage {
input_tokens: totals.input_tokens,
output_tokens: totals.output_tokens,
cost: totals.cost,
},
})
.collect();
let response = AggregateUsage {
totals: AggregateUsageTotals {
let total_billing =
by_model
.iter()
.fold(BilledTokenCounts::default(), |mut acc, model| {
acc.input_tokens += model.billing.input_tokens;
acc.output_tokens += model.billing.output_tokens;
acc.reasoning_tokens += model.billing.reasoning_tokens.unwrap_or(0);
acc.cache_read_tokens += model.billing.cache_read_tokens.unwrap_or(0);
acc.cache_write_tokens += model.billing.cache_write_tokens.unwrap_or(0);
acc.total_tokens += model.billing.total_tokens;
if let Some(value) = model.billing.total_usd_micros {
*acc.total_usd_micros.get_or_insert(0) += value;
}
acc
});
let response = AggregateBilling {
totals: AggregateBillingTotals {
cache_read_tokens: nonzero_i64(total_billing.cache_read_tokens),
cache_write_tokens: nonzero_i64(total_billing.cache_write_tokens),
input_tokens: total_billing.input_tokens,
output_tokens: total_billing.output_tokens,
reasoning_tokens: nonzero_i64(total_billing.reasoning_tokens),
runs: agg.total_runs,
input_tokens: by_model.iter().map(|m| m.usage.input_tokens).sum(),
output_tokens: by_model.iter().map(|m| m.usage.output_tokens).sum(),
cost: by_model.iter().map(|m| m.usage.cost).sum(),
runtime_secs: agg.total_runtime_secs,
total_tokens: total_billing.total_tokens,
total_usd_micros: total_billing.total_usd_micros,
},
by_model,
};
(StatusCode::OK, Json(response)).into_response()
}
async fn get_run_billing(
_auth: AuthenticatedService,
State(state): State<Arc<AppState>>,
Path(id): Path<RunId>,
) -> Response {
let run_store = match state.store.open_run_reader(&id).await {
Ok(run_store) => run_store,
Err(err) => {
return ApiError::new(StatusCode::NOT_FOUND, err.to_string()).into_response();
}
};
let checkpoint = match run_store.state().await {
Ok(state) => state.checkpoint,
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
let Some(checkpoint) = checkpoint else {
let empty = RunBilling {
by_model: Vec::new(),
stages: Vec::new(),
totals: RunBillingTotals {
cache_read_tokens: None,
cache_write_tokens: None,
input_tokens: 0,
output_tokens: 0,
reasoning_tokens: None,
runtime_secs: 0.0,
total_tokens: 0,
total_usd_micros: None,
},
};
return (StatusCode::OK, Json(empty)).into_response();
};
let stage_durations = match run_store.list_events().await {
Ok(events) => fabro_workflow::extract_stage_durations_from_events(&events),
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
let mut by_model_totals = HashMap::<String, ModelBillingTotals>::new();
let mut billed_usages = Vec::new();
let mut runtime_secs = 0.0_f64;
let mut stages = Vec::new();
for node_id in &checkpoint.completed_nodes {
let duration_ms = stage_durations.get(node_id).copied().unwrap_or(0);
runtime_secs += duration_ms as f64 / 1000.0;
let Some(usage) = checkpoint
.node_outcomes
.get(node_id)
.and_then(|outcome| outcome.usage.as_ref())
else {
continue;
};
billed_usages.push(usage.clone());
let billing = api_billed_token_counts_from_usage(usage);
let model_id = usage.model_id().to_string();
accumulate_model_billing(by_model_totals.entry(model_id.clone()).or_default(), usage);
stages.push(RunBillingStage {
billing,
model: ModelReference { id: model_id },
runtime_secs: duration_ms as f64 / 1000.0,
stage: BillingStageRef {
id: node_id.clone(),
name: node_id.clone(),
},
});
}
let totals = BilledTokenCounts::from_billed_usage(&billed_usages);
let by_model = by_model_totals
.into_iter()
.map(|(model, totals)| BillingByModel {
billing: api_billed_token_counts_from_domain(&totals.billing),
model: ModelReference { id: model },
stages: totals.stages,
})
.collect::<Vec<_>>();
let response = RunBilling {
by_model,
stages,
totals: RunBillingTotals {
cache_read_tokens: nonzero_i64(totals.cache_read_tokens),
cache_write_tokens: nonzero_i64(totals.cache_write_tokens),
input_tokens: totals.input_tokens,
output_tokens: totals.output_tokens,
reasoning_tokens: nonzero_i64(totals.reasoning_tokens),
runtime_secs,
total_tokens: totals.total_tokens,
total_usd_micros: totals.total_usd_micros,
},
};
(StatusCode::OK, Json(response)).into_response()
}
/// Create an `AppState` with default settings.
pub fn create_app_state() -> Arc<AppState> {
create_app_state_with_options(Settings::default(), 5)
@ -1392,7 +1555,7 @@ pub(crate) fn build_app_state_with_path(
let (global_event_tx, _) = broadcast::channel(4096);
Ok(Arc::new(AppState {
runs: Mutex::new(HashMap::new()),
aggregate_usage: Mutex::new(UsageAccumulator::default()),
aggregate_billing: Mutex::new(BillingAccumulator::default()),
store,
artifact_store,
started_at: Instant::now(),
@ -2695,18 +2858,18 @@ async fn execute_run_in_process(state: Arc<AppState>, run_id: RunId) {
}
};
let mut agg = state
.aggregate_usage
.aggregate_billing
.lock()
.expect("aggregate_usage lock poisoned");
.expect("aggregate_billing lock poisoned");
agg.total_runs += 1;
let mut run_runtime: f64 = 0.0;
for (node_id, outcome) in &cp.node_outcomes {
if let Some(usage) = &outcome.usage {
let entry = agg.by_model.entry(usage.model.clone()).or_default();
entry.stages += 1;
entry.input_tokens += usage.input_tokens;
entry.output_tokens += usage.output_tokens;
entry.cost += usage.cost.unwrap_or(0.0);
let entry = agg
.by_model
.entry(usage.model_id().to_string())
.or_default();
accumulate_model_billing(entry, usage);
}
let duration_ms = stage_durations.get(node_id).copied().unwrap_or(0);
run_runtime += duration_ms as f64 / 1000.0;
@ -2918,18 +3081,18 @@ async fn execute_run_subprocess(state: Arc<AppState>, run_id: RunId) {
}
};
let mut agg = state
.aggregate_usage
.aggregate_billing
.lock()
.expect("aggregate_usage lock poisoned");
.expect("aggregate_billing lock poisoned");
agg.total_runs += 1;
let mut run_runtime: f64 = 0.0;
for (node_id, outcome) in &checkpoint.node_outcomes {
if let Some(usage) = &outcome.usage {
let entry = agg.by_model.entry(usage.model.clone()).or_default();
entry.stages += 1;
entry.input_tokens += usage.input_tokens;
entry.output_tokens += usage.output_tokens;
entry.cost += usage.cost.unwrap_or(0.0);
let entry = agg
.by_model
.entry(usage.model_id().to_string())
.or_default();
accumulate_model_billing(entry, usage);
}
let duration_ms = stage_durations.get(node_id).copied().unwrap_or(0);
run_runtime += duration_ms as f64 / 1000.0;
@ -4309,14 +4472,14 @@ async fn create_completion(
if use_stream {
let finish_event = StreamEvent::finish(
FinishReason::Stop,
Usage::default(),
TokenCounts::default(),
LlmResponse {
id: msg_id.clone(),
model: model_id.clone(),
provider: String::new(),
message: LlmMessage::assistant(""),
finish_reason: FinishReason::Stop,
usage: Usage::default(),
usage: TokenCounts::default(),
raw: None,
warnings: vec![],
rate_limit: None,

View file

@ -9,7 +9,7 @@ use crate::helpers::{
};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn aggregate_usage_increments_after_run_completes() {
async fn aggregate_billing_increments_after_run_completes() {
let state = test_app_state_with_options(dry_run_settings(), 5);
let app = test_app_with_scheduler(state);
@ -23,7 +23,7 @@ async fn aggregate_usage_increments_after_run_completes() {
for _ in 0..POLL_ATTEMPTS {
let req = Request::builder()
.method("GET")
.uri(api("/usage"))
.uri(api("/billing"))
.body(Body::empty())
.unwrap();

View file

@ -11,9 +11,9 @@ use fabro_types::run_event::{
RunFailedProps, StageCompletedProps, StagePromptProps,
};
use fabro_types::{
Checkpoint, Conclusion, EventBody, FailureSignature, NodeStatusRecord, Outcome,
PullRequestRecord, Retro, RunControlAction, RunEvent, RunId, RunRecord, RunStatus,
RunStatusRecord, SandboxRecord, StageStatus, StageUsage, StartRecord, StatusReason, TokenUsage,
BilledModelUsage, Checkpoint, Conclusion, EventBody, FailureSignature, NodeStatusRecord,
Outcome, PullRequestRecord, Retro, RunControlAction, RunEvent, RunId, RunRecord, RunStatus,
RunStatusRecord, SandboxRecord, StageStatus, StartRecord, StatusReason,
};
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
@ -50,20 +50,6 @@ pub struct NodeState {
pub stderr: Option<String>,
}
#[derive(Debug, Clone, serde::Deserialize)]
struct RunUsage {
input_tokens: i64,
output_tokens: i64,
#[serde(default)]
reasoning_tokens: Option<i64>,
#[serde(default)]
cache_read_tokens: Option<i64>,
#[serde(default)]
cache_write_tokens: Option<i64>,
#[serde(default)]
cost: Option<f64>,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct EventProjectionCache {
pub last_seq: u32,
@ -357,10 +343,11 @@ impl RunProjection {
.conclusion
.as_ref()
.map(|conclusion| conclusion.duration_ms),
total_cost: self
total_usd_micros: self
.conclusion
.as_ref()
.and_then(|conclusion| conclusion.total_cost),
.and_then(|conclusion| conclusion.billing.as_ref())
.and_then(|billing| billing.total_usd_micros),
}
}
@ -437,7 +424,6 @@ fn conclusion_from_completed(
props: &RunCompletedProps,
timestamp: DateTime<Utc>,
) -> Result<Conclusion> {
let usage = props.usage.as_ref().map(run_usage_from_token_usage);
Ok(Conclusion {
timestamp,
status: StageStatus::from_str(&props.status).map_err(|err| {
@ -447,23 +433,8 @@ fn conclusion_from_completed(
failure_reason: None,
final_git_commit_sha: props.final_git_commit_sha.clone(),
stages: Vec::new(),
total_cost: props.total_cost,
billing: props.billing.clone(),
total_retries: 0,
total_input_tokens: usage.as_ref().map_or(0, |usage| usage.input_tokens),
total_output_tokens: usage.as_ref().map_or(0, |usage| usage.output_tokens),
total_cache_read_tokens: usage
.as_ref()
.and_then(|usage| usage.cache_read_tokens)
.unwrap_or(0),
total_cache_write_tokens: usage
.as_ref()
.and_then(|usage| usage.cache_write_tokens)
.unwrap_or(0),
total_reasoning_tokens: usage
.as_ref()
.and_then(|usage| usage.reasoning_tokens)
.unwrap_or(0),
has_pricing: usage.as_ref().is_some_and(|usage| usage.cost.is_some()),
})
}
@ -475,14 +446,8 @@ fn conclusion_from_failed(props: &RunFailedProps, timestamp: DateTime<Utc>) -> C
failure_reason: Some(props.error.clone()),
final_git_commit_sha: props.git_commit_sha.clone(),
stages: Vec::new(),
total_cost: None,
billing: None,
total_retries: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_read_tokens: 0,
total_cache_write_tokens: 0,
total_reasoning_tokens: 0,
has_pricing: false,
}
}
@ -497,7 +462,7 @@ fn stage_visit(
.or_else(|| state.current_visit_for(node_id))
}
fn stage_outcome_from_props(props: &StageCompletedProps) -> Outcome<Option<StageUsage>> {
fn stage_outcome_from_props(props: &StageCompletedProps) -> Outcome<Option<BilledModelUsage>> {
Outcome {
status: props.status.clone(),
preferred_label: props.preferred_label.clone(),
@ -511,14 +476,14 @@ fn stage_outcome_from_props(props: &StageCompletedProps) -> Outcome<Option<Stage
jump_to_node: props.jump_to_node.clone(),
notes: props.notes.clone(),
failure: props.failure.clone(),
usage: props.usage.clone(),
usage: props.billing.clone(),
files_touched: props.files_touched.clone(),
duration_ms: Some(props.duration_ms),
}
}
fn node_status_from_outcome(
outcome: &Outcome<Option<StageUsage>>,
outcome: &Outcome<Option<BilledModelUsage>>,
timestamp: DateTime<Utc>,
) -> NodeStatusRecord {
NodeStatusRecord {
@ -570,17 +535,6 @@ fn provider_used_from_agent_cli_started(props: &AgentCliStartedProps) -> Value {
Value::Object(provider_used)
}
fn run_usage_from_token_usage(usage: &TokenUsage) -> RunUsage {
RunUsage {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
reasoning_tokens: usage.reasoning_tokens,
cache_read_tokens: usage.cache_read_tokens,
cache_write_tokens: usage.cache_write_tokens,
cost: None,
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;

View file

@ -19,7 +19,7 @@ pub struct RunSummary {
pub status_reason: Option<StatusReason>,
pub pending_control: Option<RunControlAction>,
pub duration_ms: Option<u64>,
pub total_cost: Option<f64>,
pub total_usd_micros: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View file

@ -21,6 +21,7 @@ chrono = { workspace = true, features = ["serde"] }
clap = { workspace = true, optional = true }
dirs.workspace = true
fabro-macros = { path = "../fabro-macros" }
fabro-model = { path = "../fabro-model" }
fabro-util = { path = "../fabro-util" }
hex.workspace = true
serde.workspace = true

View file

@ -0,0 +1,6 @@
pub use fabro_model::{
AnthropicBillingFacts, AnthropicModelPricing, BilledModelUsage, BilledTokenCounts,
GeminiBillingFacts, GeminiModelPricing, GeminiStoragePricing, GeminiStorageSegment,
ModelBillingFacts, ModelBillingInput, ModelPricing, ModelPricingPolicy, ModelRef, ModelUsage,
OpenAiBillingFacts, OpenAiModelPricing, PricePerMTok, Speed, TokenCounts, UsdMicros,
};

View file

@ -4,9 +4,9 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::billing::BilledModelUsage;
use crate::failure_signature::FailureSignature;
use crate::outcome::Outcome;
use crate::usage::StageUsage;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Checkpoint {
@ -16,7 +16,7 @@ pub struct Checkpoint {
pub node_retries: HashMap<String, u32>,
pub context_values: HashMap<String, Value>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub node_outcomes: HashMap<String, Outcome<Option<StageUsage>>>,
pub node_outcomes: HashMap<String, Outcome<Option<BilledModelUsage>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_node_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]

View file

@ -1,6 +1,7 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::BilledTokenCounts;
use crate::outcome::StageStatus;
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -9,7 +10,7 @@ pub struct StageSummary {
pub stage_label: String,
pub duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<f64>,
pub billing_usd_micros: Option<i64>,
pub retries: u32,
}
@ -25,19 +26,7 @@ pub struct Conclusion {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stages: Vec<StageSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_cost: Option<f64>,
pub billing: Option<BilledTokenCounts>,
#[serde(default)]
pub total_retries: u32,
#[serde(default)]
pub total_input_tokens: i64,
#[serde(default)]
pub total_output_tokens: i64,
#[serde(default)]
pub total_cache_read_tokens: i64,
#[serde(default)]
pub total_cache_write_tokens: i64,
#[serde(default)]
pub total_reasoning_tokens: i64,
#[serde(default)]
pub has_pricing: bool,
}

View file

@ -1,5 +1,6 @@
extern crate self as fabro_types;
pub mod billing;
pub mod checkpoint;
pub mod combine;
pub mod conclusion;
@ -18,10 +19,16 @@ pub mod settings;
pub mod stage_id;
pub mod start;
pub mod status;
pub mod usage;
pub use billing::{
AnthropicBillingFacts, AnthropicModelPricing, BilledModelUsage, BilledTokenCounts,
GeminiBillingFacts, GeminiModelPricing, GeminiStoragePricing, GeminiStorageSegment,
ModelBillingFacts, ModelBillingInput, ModelPricing, ModelPricingPolicy, ModelRef, ModelUsage,
OpenAiBillingFacts, OpenAiModelPricing, PricePerMTok, Speed, TokenCounts, UsdMicros,
};
pub use checkpoint::Checkpoint;
pub use conclusion::{Conclusion, StageSummary};
pub use fabro_macros::Combine;
pub use failure_signature::FailureSignature;
pub use graph::{AttrValue, Edge, Graph, Node, is_llm_handler_type, shape_to_handler_type};
pub use node_status::NodeStatusRecord;
@ -36,7 +43,7 @@ pub use run::{
RunSubjectProvenance,
};
pub use run_blob_id::RunBlobId;
pub use run_event::{EventBody, RunEvent, RunNoticeLevel, TokenUsage};
pub use run_event::{EventBody, RunEvent, RunNoticeLevel};
pub use run_id::RunId;
pub use run_id::fixtures;
pub use sandbox_record::SandboxRecord;
@ -47,6 +54,3 @@ pub use status::{
InvalidTransition, ParseRunStatusError, RunControlAction, RunStatus, RunStatusRecord,
StatusReason,
};
pub use usage::StageUsage;
pub use fabro_macros::Combine;

View file

@ -84,7 +84,7 @@ pub struct StageRetro {
pub duration_ms: u64,
pub retries: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<f64>,
pub billing_usd_micros: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -97,7 +97,7 @@ pub struct StageRetro {
pub struct AggregateStats {
pub total_duration_ms: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_cost: Option<f64>,
pub total_billing_usd_micros: Option<i64>,
pub total_retries: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub files_touched: Vec<String>,

View file

@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::TokenUsage;
use super::BilledTokenCounts;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentSessionStartedProps {
@ -32,7 +32,7 @@ pub struct AgentInputProps {
pub struct AgentMessageProps {
pub text: String,
pub model: String,
pub usage: TokenUsage,
pub billing: BilledTokenCounts,
pub tool_call_count: usize,
pub visit: u32,
}

View file

@ -5,6 +5,7 @@ pub mod run;
pub mod stage;
use chrono::{DateTime, Utc};
pub use fabro_model::BilledTokenCounts;
use serde::de::Error as DeError;
use serde::ser::Error as SerError;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
@ -26,23 +27,6 @@ pub enum RunNoticeLevel {
Error,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TokenUsage {
pub input_tokens: i64,
pub output_tokens: i64,
pub total_tokens: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_tokens: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_tokens: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write_tokens: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speed: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub raw: Option<Value>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RunEvent {
pub id: String,

View file

@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use crate::{Graph, RunControlAction, RunProvenance, Settings, StatusReason};
use super::{RunNoticeLevel, TokenUsage};
use super::{BilledTokenCounts, RunNoticeLevel};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct RunCreatedProps {
@ -81,13 +81,13 @@ pub struct RunCompletedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<StatusReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_cost: Option<f64>,
pub total_usd_micros: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_git_commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_patch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<TokenUsage>,
pub billing: Option<BilledTokenCounts>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]

View file

@ -3,7 +3,7 @@ use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{FailureDetail, Outcome, StageStatus, StageUsage};
use crate::{BilledModelUsage, FailureDetail, Outcome, StageStatus};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StageStartedProps {
@ -23,7 +23,7 @@ pub struct StageCompletedProps {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub suggested_next_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<StageUsage>,
pub billing: Option<BilledModelUsage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure: Option<FailureDetail>,
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -82,7 +82,7 @@ pub struct PromptCompletedProps {
pub model: String,
pub provider: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<StageUsage>,
pub billing: Option<BilledModelUsage>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -96,7 +96,7 @@ pub struct CheckpointCompletedProps {
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub context_values: BTreeMap<String, Value>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub node_outcomes: BTreeMap<String, Outcome<Option<StageUsage>>>,
pub node_outcomes: BTreeMap<String, Outcome<Option<BilledModelUsage>>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_node_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]

View file

@ -1,18 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct StageUsage {
pub model: String,
pub input_tokens: i64,
pub output_tokens: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_read_tokens: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache_write_tokens: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_tokens: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speed: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost: Option<f64>,
}

View file

@ -4,7 +4,9 @@ use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use ::fabro_types::run_event as fabro_types;
use ::fabro_types::{RunControlAction, RunEvent, RunId, StageStatus, StatusReason};
use ::fabro_types::{
BilledTokenCounts, RunControlAction, RunEvent, RunId, StageStatus, StatusReason,
};
use anyhow::{Context, Result};
use chrono::Utc;
use fabro_store::{EventPayload, RunDatabase};
@ -17,9 +19,9 @@ use tokio::sync::{Mutex as AsyncMutex, mpsc, oneshot};
use uuid::Uuid;
use crate::error::FabroError;
use crate::outcome::{FailureDetail, Outcome, StageUsage};
use crate::outcome::{BilledModelUsage, FailureDetail, Outcome};
use fabro_agent::{AgentEvent, SandboxEvent, WorktreeEvent, WorktreeEventCallback};
use fabro_llm::types::Usage as LlmUsage;
use fabro_llm::types::TokenCounts as LlmTokenCounts;
use fabro_util::redact::redact_json_value;
pub use fabro_types::{EventBody, RunNoticeLevel};
@ -104,13 +106,13 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Option::is_none")]
reason: Option<StatusReason>,
#[serde(default, skip_serializing_if = "Option::is_none")]
total_cost: Option<f64>,
total_usd_micros: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
final_git_commit_sha: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
final_patch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
usage: Option<LlmUsage>,
billing: Option<BilledTokenCounts>,
},
WorkflowRunFailed {
error: FabroError,
@ -141,7 +143,7 @@ pub enum Event {
status: String,
preferred_label: Option<String>,
suggested_next_ids: Vec<String>,
usage: Option<StageUsage>,
billing: Option<BilledModelUsage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
failure: Option<FailureDetail>,
notes: Option<String>,
@ -315,7 +317,7 @@ pub enum Event {
model: String,
provider: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
usage: Option<StageUsage>,
billing: Option<BilledModelUsage>,
},
/// Forwarded from an agent session, tagged with the workflow stage.
Agent {
@ -1222,16 +1224,15 @@ fn default_node_label(node_id: Option<&String>, node_label: Option<String>) -> O
node_label.or_else(|| node_id.cloned())
}
fn token_usage_from_llm(usage: &LlmUsage) -> fabro_types::TokenUsage {
fabro_types::TokenUsage {
fn billed_token_counts_from_llm(usage: &LlmTokenCounts) -> BilledTokenCounts {
BilledTokenCounts {
input_tokens: usage.input_tokens,
output_tokens: usage.output_tokens,
total_tokens: usage.total_tokens,
total_tokens: usage.total_tokens(),
reasoning_tokens: usage.reasoning_tokens,
cache_read_tokens: usage.cache_read_tokens,
cache_write_tokens: usage.cache_write_tokens,
speed: usage.speed.clone(),
raw: usage.raw.clone(),
total_usd_micros: None,
}
}
@ -1437,19 +1438,19 @@ fn event_body_from_event(event: &Event) -> EventBody {
artifact_count,
status,
reason,
total_cost,
total_usd_micros,
final_git_commit_sha,
final_patch,
usage,
billing,
} => EventBody::RunCompleted(fabro_types::RunCompletedProps {
duration_ms: *duration_ms,
artifact_count: *artifact_count,
status: status.clone(),
reason: *reason,
total_cost: *total_cost,
total_usd_micros: *total_usd_micros,
final_git_commit_sha: final_git_commit_sha.clone(),
final_patch: final_patch.clone(),
usage: usage.as_ref().map(token_usage_from_llm),
billing: billing.clone(),
}),
Event::WorkflowRunFailed {
error,
@ -1489,7 +1490,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
status,
preferred_label,
suggested_next_ids,
usage,
billing,
failure,
notes,
files_touched,
@ -1509,7 +1510,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
status: stage_status_from_string(status),
preferred_label: preferred_label.clone(),
suggested_next_ids: suggested_next_ids.clone(),
usage: usage.clone(),
billing: billing.clone(),
failure: failure.clone(),
notes: notes.clone(),
files_touched: files_touched.clone(),
@ -1716,13 +1717,13 @@ fn event_body_from_event(event: &Event) -> EventBody {
response,
model,
provider,
usage,
billing,
..
} => EventBody::PromptCompleted(fabro_types::PromptCompletedProps {
response: response.clone(),
model: model.clone(),
provider: provider.clone(),
usage: usage.clone(),
billing: billing.clone(),
}),
Event::Agent { visit, event, .. } => match event {
AgentEvent::SessionStarted { provider, model } => {
@ -1752,7 +1753,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
} => EventBody::AgentMessage(fabro_types::AgentMessageProps {
text: text.clone(),
model: model.clone(),
usage: token_usage_from_llm(usage),
billing: billed_token_counts_from_llm(usage),
tool_call_count: *tool_call_count,
visit: *visit,
}),
@ -2690,7 +2691,7 @@ mod tests {
status: "success".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
usage: None,
billing: None,
failure: None,
notes: None,
files_touched: Vec::new(),
@ -2728,7 +2729,7 @@ mod tests {
status: "success".to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
usage: None,
billing: None,
failure: None,
notes: None,
files_touched: Vec::new(),

View file

@ -437,7 +437,7 @@ mod tests {
response: "world".into(),
model: "gpt-5.4".into(),
provider: "openai".into(),
usage: None,
billing: None,
},
)
.await
@ -453,7 +453,7 @@ mod tests {
status: "success".into(),
preferred_label: None,
suggested_next_ids: Vec::new(),
usage: None,
billing: None,
failure: None,
notes: None,
files_touched: Vec::new(),

View file

@ -8,7 +8,7 @@ use fabro_core::graph::{EdgeSelection as CoreEdgeSelection, EdgeSpec, Graph, Nod
use fabro_graphviz::graph::types::{Edge as GvEdge, Graph as GvGraph, Node as GvNode};
use crate::context::Context;
use crate::outcome::{Outcome, StageUsage};
use crate::outcome::{BilledModelUsage, Outcome};
// ---- WorkflowNode ----
@ -76,7 +76,7 @@ impl WorkflowGraph {
impl Graph for WorkflowGraph {
type Node = WorkflowNode;
type Edge = WorkflowEdge;
type Meta = Option<StageUsage>;
type Meta = Option<BilledModelUsage>;
fn get_node(&self, id: &str) -> Option<Self::Node> {
self.0

View file

@ -12,7 +12,7 @@ use crate::context::{Context, WorkflowContext};
use crate::error::FabroError;
use crate::event::{Emitter, Event};
use crate::outcome::{
FailureCategory, FailureDetail, Outcome, OutcomeExt, StageStatus, StageUsage,
BilledModelUsage, FailureCategory, FailureDetail, Outcome, OutcomeExt, StageStatus,
};
use crate::run_dir::visit_from_context;
use crate::transforms::variable_expansion::expand_vars;
@ -24,7 +24,7 @@ use super::{EngineServices, Handler};
pub enum CodergenResult {
Text {
text: String,
usage: Option<StageUsage>,
usage: Option<BilledModelUsage>,
files_touched: Vec<String>,
last_file_touched: Option<String>,
},
@ -321,7 +321,7 @@ impl Handler for AgentHandler {
let response_model = stage_usage
.as_ref()
.map(|usage| usage.model.clone())
.map(|usage| usage.model_id().to_string())
.or_else(|| node.model().map(String::from))
.unwrap_or_default();
let response_provider = node
@ -334,7 +334,7 @@ impl Handler for AgentHandler {
response: response_text.clone(),
model: response_model,
provider: response_provider,
usage: stage_usage.clone(),
billing: stage_usage.clone(),
});
// Build and write status

View file

@ -274,7 +274,7 @@ async fn llm_evaluate(
response: response_text.clone(),
model: String::new(),
provider: String::new(),
usage: None,
billing: None,
});
Ok(Candidate {
id: best_id,
@ -288,7 +288,7 @@ async fn llm_evaluate(
response: text.clone(),
model: String::new(),
provider: String::new(),
usage: None,
billing: None,
});
// The LLM responded with text; try to find a matching candidate ID

View file

@ -9,7 +9,7 @@ use fabro_agent::{
subagent::{SessionFactory, SubAgentManager},
};
use fabro_llm::client::Client;
use fabro_llm::types::{Message, Request, Usage};
use fabro_llm::types::{Message, Request, TokenCounts};
use fabro_mcp::config::McpServerSettings;
use fabro_model::FallbackTarget;
use fabro_model::Provider;
@ -20,8 +20,7 @@ use crate::context::keys::Fidelity;
use crate::context::{Context, WorkflowContext};
use crate::error::FabroError;
use crate::event::{Emitter, Event};
use crate::outcome::StageUsage;
use crate::outcome::compute_stage_cost;
use crate::outcome::billed_model_usage_from_llm;
use crate::run_dir::visit_from_context;
use fabro_graphviz::graph::Node;
@ -320,7 +319,7 @@ impl CodergenBackend for AgentApiBackend {
let default_provider = self.provider.as_str().to_string();
let (response, actual_model, _actual_provider) = match result {
let (response, actual_model, actual_provider) = match result {
Ok(resp) => (
resp,
request.model.clone(),
@ -384,17 +383,13 @@ impl CodergenBackend for AgentApiBackend {
Err(sdk_err) => return Err(FabroError::Llm(sdk_err)),
};
let mut stage_usage = StageUsage {
model: actual_model,
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
cache_read_tokens: response.usage.cache_read_tokens,
cache_write_tokens: response.usage.cache_write_tokens,
reasoning_tokens: response.usage.reasoning_tokens,
speed: response.usage.speed.clone(),
cost: None,
};
stage_usage.cost = compute_stage_cost(&stage_usage);
let actual_provider = actual_provider.parse::<Provider>().unwrap_or(self.provider);
let stage_usage = billed_model_usage_from_llm(
&actual_model,
actual_provider,
node.speed(),
&response.usage,
);
Ok(CodergenResult::Text {
text: response.text(),
@ -569,24 +564,19 @@ impl CodergenBackend for AgentApiBackend {
result?;
// Aggregate token usage only from new turns (prevents double-counting on reuse).
let mut total_usage = Usage::default();
let mut total_usage = TokenCounts::default();
for turn in &session.history().turns()[turns_before..] {
if let Turn::Assistant { usage, .. } = turn {
total_usage = total_usage + *usage.clone();
}
}
let mut stage_usage = StageUsage {
model: actual_model.clone(),
input_tokens: total_usage.input_tokens,
output_tokens: total_usage.output_tokens,
cache_read_tokens: total_usage.cache_read_tokens,
cache_write_tokens: total_usage.cache_write_tokens,
reasoning_tokens: total_usage.reasoning_tokens,
speed: total_usage.speed.clone(),
cost: None,
};
stage_usage.cost = compute_stage_cost(&stage_usage);
let stage_usage = billed_model_usage_from_llm(
&actual_model,
_actual_provider,
node.speed(),
&total_usage,
);
// Extract last assistant response from the session history.
let response = session

View file

@ -11,10 +11,10 @@ use super::super::agent::{CodergenBackend, CodergenResult};
use crate::context::Context;
use crate::error::FabroError;
use crate::event::{Emitter, Event};
use crate::outcome::StageUsage;
use crate::outcome::compute_stage_cost;
use crate::outcome::billed_model_usage_from_llm;
use crate::run_dir::visit_from_context;
use fabro_graphviz::graph::Node;
use fabro_llm::types::TokenCounts;
/// Maps a provider to its corresponding CLI tool metadata.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -261,7 +261,7 @@ fn parse_claude_ndjson(output: &str) -> Option<CliResponse> {
/// Parse NDJSON output from Codex CLI (`codex exec --json`).
///
/// Codex emits NDJSON lines. Text comes from `item.completed` events where
/// `item.type == "agent_message"`. Usage comes from the `turn.completed` event.
/// `item.type == "agent_message"`. TokenCounts comes from the `turn.completed` event.
fn parse_codex_ndjson(output: &str) -> Option<CliResponse> {
let mut last_message_text = String::new();
let mut input_tokens: i64 = 0;
@ -689,17 +689,16 @@ impl CodergenBackend for AgentCliBackend {
}
};
let mut stage_usage = StageUsage {
model: model.to_string(),
input_tokens: parsed.input_tokens,
output_tokens: parsed.output_tokens,
cache_read_tokens: None,
cache_write_tokens: None,
reasoning_tokens: None,
speed: None,
cost: None,
};
stage_usage.cost = compute_stage_cost(&stage_usage);
let stage_usage = billed_model_usage_from_llm(
model,
provider,
node.speed(),
&TokenCounts {
input_tokens: parsed.input_tokens,
output_tokens: parsed.output_tokens,
..TokenCounts::default()
},
);
Ok(CodergenResult::Text {
text: parsed.text,

View file

@ -4,8 +4,7 @@ use std::fmt::Write;
use crate::artifact::{artifact_path, format_artifact_reference};
use crate::context::keys;
use crate::context::{Context, WorkflowContext};
use crate::outcome::Outcome;
use crate::outcome::OutcomeExt;
use crate::outcome::{Outcome, OutcomeExt};
use fabro_graphviz::graph::{Graph, Node, is_llm_handler_type};
const COMPACT_OUTPUT_MAX_LINES: usize = 25;
@ -209,11 +208,13 @@ fn render_compact_stage_details(
h if is_llm_handler_type(h) => {
let mut lines = Vec::new();
if let Some(usage) = &outcome.usage {
let input = format_token_count(usage.input_tokens);
let output = format_token_count(usage.output_tokens);
let input = format_token_count(usage.tokens().input_tokens);
let output = format_token_count(usage.tokens().billable_output_tokens());
lines.push(format!(
" - Model: {}, {} tokens in / {} out",
usage.model, input, output
usage.model_id(),
input,
output
));
}
if !outcome.files_touched.is_empty() {
@ -293,11 +294,11 @@ fn render_summary_high_stage_section(
}
h if is_llm_handler_type(h) => {
if let Some(usage) = &outcome.usage {
lines.push(format!("- Model: {}", usage.model));
lines.push(format!("- Model: {}", usage.model_id()));
lines.push(format!(
"- Tokens: {} in / {} out",
format_token_count(usage.input_tokens),
format_token_count(usage.output_tokens)
format_token_count(usage.tokens().input_tokens),
format_token_count(usage.tokens().billable_output_tokens())
));
}
if !outcome.files_touched.is_empty() {
@ -595,7 +596,7 @@ fn build_summary_preamble(
}
h if is_llm_handler_type(h) => {
if let Some(usage) = &outcome.usage {
parts.push(format!(" - Model: {}", usage.model));
parts.push(format!(" - Model: {}", usage.model_id()));
}
}
_ => {}
@ -615,8 +616,23 @@ fn build_summary_preamble(
#[cfg(test)]
mod tests {
use super::*;
use crate::outcome::StageUsage;
use crate::outcome::{BilledModelUsage, billed_model_usage_from_llm};
use fabro_graphviz::graph::AttrValue;
use fabro_llm::types::TokenCounts;
use fabro_model::Provider;
fn stage_usage(model: &str, input_tokens: i64, output_tokens: i64) -> BilledModelUsage {
billed_model_usage_from_llm(
model,
Provider::Anthropic,
None,
&TokenCounts {
input_tokens,
output_tokens,
..TokenCounts::default()
},
)
}
// --- truncate mode ---
@ -920,16 +936,7 @@ mod tests {
let completed_nodes = vec!["report".to_string()];
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
let mut outcome = Outcome::success();
outcome.usage = Some(StageUsage {
model: "claude-sonnet-4-20250514".to_string(),
input_tokens: 1234,
output_tokens: 567,
cache_read_tokens: None,
cache_write_tokens: None,
reasoning_tokens: None,
speed: None,
cost: None,
});
outcome.usage = Some(stage_usage("claude-sonnet-4-20250514", 1234, 567));
outcome.files_touched = vec!["src/lib.rs".to_string(), "src/main.rs".to_string()];
node_outcomes.insert("report".to_string(), outcome);
@ -1187,16 +1194,7 @@ mod tests {
let completed_nodes = vec!["report".to_string()];
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
let mut outcome = Outcome::success();
outcome.usage = Some(StageUsage {
model: "claude-sonnet-4-20250514".to_string(),
input_tokens: 1000,
output_tokens: 200,
cache_read_tokens: None,
cache_write_tokens: None,
reasoning_tokens: None,
speed: None,
cost: None,
});
outcome.usage = Some(stage_usage("claude-sonnet-4-20250514", 1000, 200));
node_outcomes.insert("report".to_string(), outcome);
let preamble = build_preamble(
@ -1367,16 +1365,7 @@ mod tests {
let completed_nodes = vec!["report".to_string()];
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
let mut outcome = Outcome::success();
outcome.usage = Some(StageUsage {
model: "claude-sonnet-4-20250514".to_string(),
input_tokens: 1500,
output_tokens: 300,
cache_read_tokens: None,
cache_write_tokens: None,
reasoning_tokens: None,
speed: None,
cost: None,
});
outcome.usage = Some(stage_usage("claude-sonnet-4-20250514", 1500, 300));
outcome.files_touched = vec!["src/lib.rs".to_string()];
node_outcomes.insert("report".to_string(), outcome);
@ -1587,16 +1576,7 @@ mod tests {
let completed_nodes = vec!["report".to_string()];
let mut node_outcomes: HashMap<String, Outcome> = HashMap::new();
let mut outcome = Outcome::success();
outcome.usage = Some(StageUsage {
model: "claude-sonnet-4-20250514".to_string(),
input_tokens: 1500,
output_tokens: 300,
cache_read_tokens: None,
cache_write_tokens: None,
reasoning_tokens: None,
speed: None,
cost: None,
});
outcome.usage = Some(stage_usage("claude-sonnet-4-20250514", 1500, 300));
outcome.files_touched = vec!["src/lib.rs".to_string()];
outcome.context_updates.insert(
keys::response_key("report"),

View file

@ -131,7 +131,7 @@ impl Handler for PromptHandler {
let response_model = stage_usage
.as_ref()
.map(|usage| usage.model.clone())
.map(|usage| usage.model_id().to_string())
.or_else(|| node.model().map(String::from))
.unwrap_or_default();
let response_provider = node
@ -145,7 +145,7 @@ impl Handler for PromptHandler {
response: response_text.clone(),
model: response_model,
provider: response_provider,
usage: stage_usage.clone(),
billing: stage_usage.clone(),
});
// 4. Build and write status

View file

@ -57,7 +57,9 @@ pub fn build_completed_stages(cp: &records::Checkpoint, run_failed: bool) -> Vec
succeeded,
failed,
retries,
cost: outcome.and_then(|o| o.usage.as_ref()).and_then(|u| u.cost),
billing_usd_micros: outcome
.and_then(|o| o.usage.as_ref())
.and_then(|usage| usage.total_usd_micros),
notes: outcome.and_then(|o| o.notes.clone()),
failure_reason: outcome.and_then(|o| o.failure_reason().map(String::from)),
files_touched: outcome.map(|o| o.files_touched.clone()).unwrap_or_default(),
@ -75,7 +77,7 @@ pub fn build_completed_stages(cp: &records::Checkpoint, run_failed: bool) -> Vec
succeeded: false,
failed: true,
retries: 0,
cost: None,
billing_usd_micros: None,
notes: None,
failure_reason: None,
files_touched: vec![],

View file

@ -15,13 +15,13 @@ use crate::artifact_snapshot::collect_artifacts;
use crate::event::{Emitter, Event, RunNoticeLevel};
use crate::graph::WorkflowGraph;
use crate::graph::WorkflowNode;
use crate::outcome::StageUsage;
use crate::outcome::BilledModelUsage;
use fabro_core::error::Result as CoreResult;
use fabro_core::lifecycle::NodeDecision;
type WfRunState = ExecutionState<Option<StageUsage>>;
type WfNodeResult = NodeResult<Option<StageUsage>>;
type WfNodeDecision = NodeDecision<Option<StageUsage>>;
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
type WfNodeDecision = NodeDecision<Option<BilledModelUsage>>;
/// Sub-lifecycle responsible for artifact collection, offloading, and syncing.
pub(crate) struct ArtifactLifecycle {

View file

@ -7,10 +7,10 @@ use fabro_core::state::ExecutionState;
use crate::graph::WorkflowGraph;
use crate::graph::WorkflowNode;
use crate::outcome::{StageStatus, StageUsage};
use crate::outcome::{BilledModelUsage, StageStatus};
type WfRunState = ExecutionState<Option<StageUsage>>;
type WfNodeResult = NodeResult<Option<StageUsage>>;
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
/// Sub-lifecycle responsible for auto-status override on nodes with `auto_status=true`.
pub(crate) struct AutoStatusLifecycle;

View file

@ -11,10 +11,10 @@ use fabro_core::state::ExecutionState;
use crate::error::{FailureCategory, FailureSignature, FailureSignatureExt};
use crate::graph::WorkflowGraph;
use crate::graph::WorkflowNode;
use crate::outcome::{OutcomeExt, StageStatus, StageUsage};
use crate::outcome::{BilledModelUsage, OutcomeExt, StageStatus};
type WfRunState = ExecutionState<Option<StageUsage>>;
type WfNodeResult = NodeResult<Option<StageUsage>>;
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
/// Sub-lifecycle responsible for tracking failure signatures and tripping the
/// circuit breaker when deterministic failure cycles are detected.

View file

@ -20,13 +20,11 @@ use crate::error::FabroError;
use crate::event::{Emitter, Event};
use crate::graph::WorkflowGraph;
use crate::graph::WorkflowNode;
use crate::outcome::{
FailureCategory, FailureDetail, Outcome, StageStatus, StageUsage, stage_usage_to_llm,
};
use fabro_types::{RunId, StatusReason};
use crate::outcome::{BilledModelUsage, FailureCategory, FailureDetail, Outcome, StageStatus};
use fabro_types::{BilledTokenCounts, RunId, StatusReason};
type WfRunState = ExecutionState<Option<StageUsage>>;
type WfNodeResult = NodeResult<Option<StageUsage>>;
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
type FailureSignatureSnapshot = (
Option<BTreeMap<String, usize>>,
Option<BTreeMap<String, usize>>,
@ -139,7 +137,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
status: StageStatus::Success.to_string(),
preferred_label: None,
suggested_next_ids: Vec::new(),
usage: None,
billing: None,
failure: None,
notes: None,
files_touched: Vec::new(),
@ -162,7 +160,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
&self,
ctx: &AttemptContext<'_, WorkflowGraph>,
state: &WfRunState,
) -> CoreResult<NodeDecision<Option<StageUsage>>> {
) -> CoreResult<NodeDecision<Option<BilledModelUsage>>> {
let gv = ctx.node.inner();
self.emitter.emit(&Event::StageStarted {
node_id: gv.id.clone(),
@ -245,7 +243,7 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
status: outcome.status.to_string(),
preferred_label: outcome.preferred_label.clone(),
suggested_next_ids: outcome.suggested_next_ids.clone(),
usage: outcome.usage.clone(),
billing: outcome.usage.clone(),
failure: outcome.failure.clone(),
notes: outcome.notes.clone(),
files_touched: outcome.files_touched.clone(),
@ -387,19 +385,31 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
let artifact_count = self.captured_artifact_count.load(Ordering::Relaxed);
let last_sha = self.last_git_sha.lock().unwrap().clone();
let final_patch = self.final_patch.lock().unwrap().clone();
let total_cost = {
let sum: f64 = state
.node_outcomes
.values()
.filter_map(|o| o.usage.as_ref()?.cost)
.sum();
if sum > 0.0 { Some(sum) } else { None }
};
let run_usage = state
let run_billing_entries = state
.node_outcomes
.values()
.filter_map(|o| o.usage.as_ref().map(stage_usage_to_llm))
.reduce(|a, b| a + b);
.filter_map(|o| o.usage.clone())
.collect::<Vec<_>>();
let run_billing = (!run_billing_entries.is_empty())
.then(|| BilledTokenCounts::from_billed_usage(&run_billing_entries));
let total_usd_micros = run_billing
.as_ref()
.and_then(|billing| billing.total_usd_micros)
.or_else(|| {
let mut total = 0_i64;
let mut has_total = false;
for usage in state
.node_outcomes
.values()
.filter_map(|o| o.usage.as_ref())
{
if let Some(value) = usage.total_usd_micros {
total += value;
has_total = true;
}
}
has_total.then_some(total)
});
if outcome.status == StageStatus::Success || outcome.status == StageStatus::PartialSuccess {
self.emitter.emit(&Event::WorkflowRunCompleted {
@ -410,10 +420,10 @@ impl RunLifecycle<WorkflowGraph> for EventLifecycle {
StageStatus::PartialSuccess => StatusReason::PartialSuccess,
_ => StatusReason::Completed,
}),
total_cost,
total_usd_micros,
final_git_commit_sha: last_sha,
final_patch,
usage: run_usage,
billing: run_billing,
});
} else {
let error_msg = outcome

View file

@ -12,10 +12,10 @@ use crate::context::keys;
use crate::graph::WorkflowGraph;
use crate::graph::WorkflowNode;
use crate::handler::llm::preamble::build_preamble;
use crate::outcome::StageUsage;
use crate::outcome::BilledModelUsage;
type WfRunState = ExecutionState<Option<StageUsage>>;
type WfNodeDecision = NodeDecision<Option<StageUsage>>;
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
type WfNodeDecision = NodeDecision<Option<BilledModelUsage>>;
/// Graphviz edge captured from edge selection, passed to the next node's before_node
/// for fidelity/thread resolution.

View file

@ -18,13 +18,13 @@ use crate::event::{Emitter, Event, RunNoticeLevel};
use crate::git::MetadataStore;
use crate::graph::WorkflowGraph;
use crate::graph::WorkflowNode;
use crate::outcome::{Outcome, StageStatus, StageUsage};
use crate::outcome::{BilledModelUsage, Outcome, StageStatus};
use crate::run_dump::RunDump;
use crate::run_options::RunOptions;
use crate::sandbox_git::{git_checkpoint, git_diff, git_push_host};
type WfRunState = ExecutionState<Option<StageUsage>>;
type WfNodeResult = NodeResult<Option<StageUsage>>;
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
fn build_checkpoint(
node: &WorkflowNode,

View file

@ -13,14 +13,14 @@ use fabro_core::state::ExecutionState;
use crate::graph::WorkflowGraph;
use crate::graph::WorkflowNode;
use crate::hook_context::set_hook_node;
use crate::outcome::{Outcome, OutcomeExt, StageStatus, StageUsage};
use crate::outcome::{BilledModelUsage, Outcome, OutcomeExt, StageStatus};
use fabro_hooks::{HookContext, HookDecision, HookEvent, HookRunner};
use fabro_sandbox::Sandbox;
use fabro_types::RunId;
type WfRunState = ExecutionState<Option<StageUsage>>;
type WfNodeResult = NodeResult<Option<StageUsage>>;
type WfNodeDecision = NodeDecision<Option<StageUsage>>;
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
type WfNodeDecision = NodeDecision<Option<BilledModelUsage>>;
/// Sub-lifecycle responsible for running workflow hooks.
pub(crate) struct HookLifecycle {

View file

@ -30,7 +30,7 @@ use crate::error::{FailureSignature, FailureSignatureExt};
use crate::event::Emitter;
use crate::graph::WorkflowGraph;
use crate::graph::WorkflowNode;
use crate::outcome::{Outcome, StageUsage};
use crate::outcome::{BilledModelUsage, Outcome};
use crate::run_control::RunControlState;
use crate::run_options::RunOptions;
use fabro_graphviz::graph::types::Graph as GvGraph;
@ -46,9 +46,9 @@ use self::git::{GitCheckpointResult, GitLifecycle};
use self::hook::HookLifecycle;
use crate::outcome::OutcomeExt;
type WfRunState = ExecutionState<Option<StageUsage>>;
type WfNodeResult = NodeResult<Option<StageUsage>>;
type WfNodeDecision = NodeDecision<Option<StageUsage>>;
type WfRunState = ExecutionState<Option<BilledModelUsage>>;
type WfNodeResult = NodeResult<Option<BilledModelUsage>>;
type WfNodeDecision = NodeDecision<Option<BilledModelUsage>>;
/// Orchestrates all sub-lifecycles with explicit per-callback ordering.
/// Implements `RunLifecycle<WorkflowGraph>` by delegating to focused structs.

View file

@ -1105,14 +1105,8 @@ mod tests {
failure_reason: None,
final_git_commit_sha: None,
stages: vec![],
total_cost: None,
billing: None,
total_retries: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_read_tokens: 0,
total_cache_write_tokens: 0,
total_reasoning_tokens: 0,
has_pricing: false,
};
std::fs::write(
run_dir.join("conclusion.json"),

View file

@ -1,24 +1,51 @@
pub use fabro_core::outcome::{FailureCategory, FailureDetail, OutcomeMeta, StageStatus};
pub use fabro_types::usage::StageUsage;
pub use fabro_types::BilledModelUsage;
use crate::error::classify_failure_reason;
use fabro_llm::types::Usage as LlmUsage;
use fabro_llm::types::TokenCounts as LlmTokenCounts;
use fabro_model::{
AnthropicBillingFacts, Catalog, ModelBillingFacts, ModelBillingInput, ModelRef, ModelUsage,
Provider, Speed, TokenCounts,
};
pub fn stage_usage_to_llm(u: &StageUsage) -> LlmUsage {
LlmUsage {
input_tokens: u.input_tokens,
output_tokens: u.output_tokens,
total_tokens: u.input_tokens + u.output_tokens,
cache_read_tokens: u.cache_read_tokens,
cache_write_tokens: u.cache_write_tokens,
reasoning_tokens: u.reasoning_tokens,
speed: u.speed.clone(),
raw: None,
pub type Outcome = fabro_core::Outcome<Option<BilledModelUsage>>;
#[must_use]
pub fn billed_model_usage_from_llm(
model_id: &str,
provider: Provider,
requested_speed: Option<&str>,
usage: &LlmTokenCounts,
) -> BilledModelUsage {
let speed = parse_speed(requested_speed);
let model = ModelRef {
provider,
model_id: model_id.to_string(),
speed,
};
let tokens = token_counts_from_llm_usage(usage);
let facts = billing_facts_for_stage_usage(provider, &tokens);
let input = ModelBillingInput {
usage: ModelUsage {
model: model.clone(),
tokens,
},
facts,
};
let total_usd_micros = Catalog::builtin()
.get(model_id)
.filter(|candidate| candidate.provider == provider)
.and_then(|candidate| candidate.pricing_for(speed))
.and_then(|pricing| pricing.bill(&input))
.map(|amount| amount.0);
BilledModelUsage {
input,
total_usd_micros,
}
}
pub type Outcome = fabro_core::Outcome<Option<StageUsage>>;
pub trait OutcomeExt: Sized {
fn fail_deterministic(reason: impl Into<String>) -> Self;
fn fail_classify(reason: impl Into<String>) -> Self;
@ -68,18 +95,20 @@ impl OutcomeExt for Outcome {
}
fn with_signature(mut self, sig: Option<impl Into<String>>) -> Self {
if let Some(ref mut f) = self.failure {
f.signature = sig.map(Into::into);
if let Some(ref mut failure) = self.failure {
failure.signature = sig.map(Into::into);
}
self
}
fn failure_reason(&self) -> Option<&str> {
self.failure.as_ref().map(|f| f.message.as_str())
self.failure
.as_ref()
.map(|failure| failure.message.as_str())
}
fn failure_category(&self) -> Option<FailureCategory> {
self.failure.as_ref().map(|f| f.category)
self.failure.as_ref().map(|failure| failure.category)
}
fn classified_failure_category(&self) -> Option<FailureCategory> {
@ -92,24 +121,82 @@ impl OutcomeExt for Outcome {
}
}
#[must_use]
pub fn compute_stage_cost(usage: &StageUsage) -> Option<f64> {
let info = fabro_model::Catalog::builtin().get(&usage.model)?;
let input_rate = info.costs.input_cost_per_mtok?;
let output_rate = info.costs.output_cost_per_mtok?;
let multiplier = if usage.speed.as_deref() == Some("fast") {
6.0
} else {
1.0
};
Some(
(usage.input_tokens as f64 * input_rate / 1_000_000.0
+ usage.output_tokens as f64 * output_rate / 1_000_000.0)
* multiplier,
)
}
#[must_use]
pub fn format_cost(cost: f64) -> String {
format!("${cost:.2}")
}
fn parse_speed(speed: Option<&str>) -> Option<Speed> {
speed.and_then(|value| value.parse::<Speed>().ok())
}
fn token_counts_from_llm_usage(usage: &LlmTokenCounts) -> TokenCounts {
usage.clone()
}
fn billing_facts_for_stage_usage(provider: Provider, tokens: &TokenCounts) -> ModelBillingFacts {
match provider {
Provider::Anthropic => ModelBillingFacts::Anthropic(AnthropicBillingFacts {
cache_write_5m_tokens: tokens.cache_write_tokens,
cache_write_1h_tokens: 0,
}),
other => ModelBillingFacts::for_provider(other),
}
}
#[cfg(test)]
mod tests {
use super::billed_model_usage_from_llm;
use fabro_llm::types::TokenCounts;
use fabro_model::Provider;
#[test]
fn billed_model_usage_from_llm_bills_openai_cached_input_and_reasoning_output() {
let usage = TokenCounts {
input_tokens: 500_000,
output_tokens: 125_000,
reasoning_tokens: 25_000,
cache_read_tokens: 250_000,
..TokenCounts::default()
};
let billed = billed_model_usage_from_llm("gpt-5.4", Provider::OpenAi, None, &usage);
assert_eq!(billed.total_usd_micros, Some(3_562_500));
assert_eq!(billed.tokens().output_tokens, 125_000);
assert_eq!(billed.tokens().reasoning_tokens, 25_000);
}
#[test]
fn billed_model_usage_from_llm_bills_anthropic_fast_mode_cache_write_pricing() {
let usage = TokenCounts {
input_tokens: 100_000,
output_tokens: 10_000,
reasoning_tokens: 5_000,
cache_read_tokens: 20_000,
cache_write_tokens: 30_000,
};
let billed = billed_model_usage_from_llm(
"claude-opus-4-6",
Provider::Anthropic,
Some("fast"),
&usage,
);
assert_eq!(billed.total_usd_micros, Some(6_435_000));
}
#[test]
fn billed_model_usage_round_trips_dense_token_counts() {
let usage = TokenCounts {
input_tokens: 100,
output_tokens: 40,
reasoning_tokens: 5,
cache_read_tokens: 20,
cache_write_tokens: 10,
};
let billed =
billed_model_usage_from_llm("claude-opus-4-6", Provider::Anthropic, None, &usage);
assert_eq!(billed.tokens().clone(), usage);
}
}

View file

@ -11,6 +11,7 @@ use crate::run_status::{RunStatus, StatusReason};
use crate::sandbox_git::git_push_host;
use fabro_hooks::{HookContext, HookEvent, HookRunner};
use fabro_store::RunDatabase;
use fabro_types::BilledTokenCounts;
use super::types::{Concluded, FinalizeOptions, Retroed};
@ -98,17 +99,10 @@ fn build_conclusion_from_parts(
run_duration_ms: u64,
final_git_commit_sha: Option<String>,
) -> Conclusion {
let mut total_input_tokens: i64 = 0;
let mut total_output_tokens: i64 = 0;
let mut total_cache_read_tokens: i64 = 0;
let mut total_cache_write_tokens: i64 = 0;
let mut total_reasoning_tokens: i64 = 0;
let mut has_pricing = false;
let (stages, total_cost, total_retries) = if let Some(cp) = checkpoint {
let (stages, billing, total_retries) = if let Some(cp) = checkpoint {
let mut stages = Vec::new();
let mut cost_sum: Option<f64> = None;
let mut retries_sum: u32 = 0;
let mut billed_usage = Vec::new();
for node_id in &cp.completed_nodes {
let outcome = cp.node_outcomes.get(node_id);
@ -120,29 +114,25 @@ fn build_conclusion_from_parts(
.saturating_sub(1);
retries_sum += retries;
let cost = outcome.and_then(|o| o.usage.as_ref()).and_then(|u| u.cost);
if let Some(c) = cost {
*cost_sum.get_or_insert(0.0) += c;
has_pricing = true;
}
if let Some(usage) = outcome.and_then(|o| o.usage.as_ref()) {
total_input_tokens += usage.input_tokens;
total_output_tokens += usage.output_tokens;
total_cache_read_tokens += usage.cache_read_tokens.unwrap_or(0);
total_cache_write_tokens += usage.cache_write_tokens.unwrap_or(0);
total_reasoning_tokens += usage.reasoning_tokens.unwrap_or(0);
billed_usage.push(usage.clone());
}
stages.push(StageSummary {
stage_id: node_id.clone(),
stage_label: node_id.clone(),
duration_ms: stage_durations.get(node_id).copied().unwrap_or(0),
cost,
billing_usd_micros: outcome
.and_then(|o| o.usage.as_ref())
.and_then(|usage| usage.total_usd_micros),
retries,
});
}
(stages, cost_sum, retries_sum)
(
stages,
(!billed_usage.is_empty()).then(|| BilledTokenCounts::from_billed_usage(&billed_usage)),
retries_sum,
)
} else {
(vec![], None, 0)
};
@ -154,14 +144,8 @@ fn build_conclusion_from_parts(
failure_reason,
final_git_commit_sha,
stages,
total_cost,
billing,
total_retries,
total_input_tokens,
total_output_tokens,
total_cache_read_tokens,
total_cache_write_tokens,
total_reasoning_tokens,
has_pricing,
}
}

View file

@ -39,8 +39,10 @@ fn truncate_pr_body(body: &str) -> String {
}
/// Format an optional cost as `$X.XX` or an en-dash when absent.
fn format_cost(cost: Option<f64>) -> String {
cost.map_or_else(|| "\u{2013}".to_string(), outcome_format_cost)
fn format_cost(cost_usd_micros: Option<i64>) -> String {
cost_usd_micros
.map(|value| value as f64 / 1_000_000.0)
.map_or_else(|| "\u{2013}".to_string(), outcome_format_cost)
}
/// Format a duration in milliseconds as a human-readable string.
@ -113,7 +115,7 @@ fn format_arc_details_section(
// Cost table
let total_duration = format_duration_ms(conclusion.duration_ms);
let total_cost_str = format_cost(conclusion.total_cost);
let total_cost_str = format_cost(conclusion.billing.as_ref().and_then(|b| b.total_usd_micros));
let stage_count = conclusion.stages.len();
parts.push(format!(
"<details>\n<summary>Ran {stage_count} {} in {total_duration} for {total_cost_str}</summary>",
@ -125,7 +127,7 @@ fn format_arc_details_section(
parts.push("|---|---|---|---|".to_string());
for stage in &conclusion.stages {
let dur = format_duration_ms(stage.duration_ms);
let cost = format_cost(stage.cost);
let cost = format_cost(stage.billing_usd_micros);
parts.push(format!(
"| {} | {} | {} | {} |",
stage.stage_label, dur, cost, stage.retries
@ -587,12 +589,12 @@ mod tests {
use fabro_llm::error::SdkError;
use fabro_llm::provider::{ProviderAdapter, StreamEventStream};
use fabro_llm::set_default_client;
use fabro_llm::types::{FinishReason, Message, Request, Response, StreamEvent, Usage};
use fabro_llm::types::{FinishReason, Message, Request, Response, StreamEvent, TokenCounts};
use fabro_retro::retro::{
AggregateStats, FrictionKind, FrictionPoint, OpenItem, OpenItemKind, StageRetro,
};
use fabro_store::Database;
use fabro_types::{RunRecord, Settings, fixtures};
use fabro_types::{BilledTokenCounts, RunRecord, Settings, fixtures};
use futures::stream;
use object_store::memory::InMemory;
use std::time::Duration;
@ -622,10 +624,9 @@ mod tests {
provider: "mock".into(),
message: Message::assistant(&self.response_text),
finish_reason: FinishReason::Stop,
usage: Usage {
usage: TokenCounts {
input_tokens: 10,
output_tokens: 20,
total_tokens: 30,
..Default::default()
},
raw: None,
@ -640,10 +641,9 @@ mod tests {
Ok(StreamEvent::text_delta(&text, Some("t1".into()))),
Ok(StreamEvent::finish(
FinishReason::Stop,
Usage {
TokenCounts {
input_tokens: 10,
output_tokens: 20,
total_tokens: 30,
..Default::default()
},
Response {
@ -652,10 +652,9 @@ mod tests {
provider: "mock".into(),
message: Message::assistant(&text),
finish_reason: FinishReason::Stop,
usage: Usage {
usage: TokenCounts {
input_tokens: 10,
output_tokens: 20,
total_tokens: 30,
..Default::default()
},
raw: None,
@ -701,32 +700,29 @@ mod tests {
stage_id: "plan".to_string(),
stage_label: "plan".to_string(),
duration_ms: 45_000,
cost: Some(0.12),
billing_usd_micros: Some(120_000),
retries: 0,
},
StageSummary {
stage_id: "implement".to_string(),
stage_label: "implement".to_string(),
duration_ms: 90_000,
cost: Some(0.25),
billing_usd_micros: Some(250_000),
retries: 0,
},
StageSummary {
stage_id: "simplify".to_string(),
stage_label: "simplify".to_string(),
duration_ms: 15_000,
cost: Some(0.05),
billing_usd_micros: Some(50_000),
retries: 0,
},
],
total_cost: Some(0.42),
billing: Some(BilledTokenCounts {
total_usd_micros: Some(420_000),
..BilledTokenCounts::default()
}),
total_retries: 0,
total_input_tokens: 0,
total_output_tokens: 0,
total_cache_read_tokens: 0,
total_cache_write_tokens: 0,
total_reasoning_tokens: 0,
has_pricing: true,
}
}
@ -744,7 +740,7 @@ mod tests {
status: "success".to_string(),
duration_ms: 45_000,
retries: 0,
cost: Some(0.12),
billing_usd_micros: Some(120_000),
notes: None,
failure_reason: None,
files_touched: vec![],
@ -755,7 +751,7 @@ mod tests {
status: "success".to_string(),
duration_ms: 90_000,
retries: 0,
cost: Some(0.25),
billing_usd_micros: Some(250_000),
notes: None,
failure_reason: None,
files_touched: vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
@ -766,7 +762,7 @@ mod tests {
status: "success".to_string(),
duration_ms: 15_000,
retries: 0,
cost: Some(0.05),
billing_usd_micros: Some(50_000),
notes: None,
failure_reason: None,
files_touched: vec![],
@ -774,7 +770,7 @@ mod tests {
],
stats: AggregateStats {
total_duration_ms: 150_000,
total_cost: Some(0.42),
total_billing_usd_micros: Some(420_000),
total_retries: 0,
files_touched: vec!["src/lib.rs".to_string(), "src/main.rs".to_string()],
stages_completed: 3,
@ -843,7 +839,7 @@ mod tests {
stages: vec![],
stats: AggregateStats {
total_duration_ms: 0,
total_cost: None,
total_billing_usd_micros: None,
total_retries: 0,
files_touched: vec![],
stages_completed: 0,
@ -880,9 +876,9 @@ mod tests {
fn format_arc_details_no_cost() {
let mut conclusion = make_test_conclusion();
for stage in &mut conclusion.stages {
stage.cost = None;
stage.billing_usd_micros = None;
}
conclusion.total_cost = None;
conclusion.billing = None;
let section = format_arc_details_section(&conclusion, None, None);
// En-dash for missing costs
@ -1198,7 +1194,7 @@ mod tests {
status: "success".to_string(),
preferred_label: None,
suggested_next_ids: vec![],
usage: None,
billing: None,
failure: None,
notes: None,
files_touched: vec![],
@ -1418,12 +1414,12 @@ mod tests {
artifact_count: 0,
status: "success".to_string(),
reason: None,
total_cost: None,
total_usd_micros: None,
final_git_commit_sha: None,
final_patch: Some(
"diff --git a/src/lib.rs b/src/lib.rs\n+fn from_store() {}\n".to_string(),
),
usage: None,
billing: None,
},
)
.await

View file

@ -105,7 +105,16 @@ impl RunInfo {
}
pub fn total_cost(&self) -> Option<f64> {
self.summary.as_ref().and_then(|summary| summary.total_cost)
self.summary
.as_ref()
.and_then(|summary| summary.total_usd_micros)
.map(|value| value as f64 / 1_000_000.0)
}
pub fn total_usd_micros(&self) -> Option<i64> {
self.summary
.as_ref()
.and_then(|summary| summary.total_usd_micros)
}
pub fn host_repo_path(&self) -> Option<&str> {

View file

@ -1048,7 +1048,7 @@ async fn run_daytona_cli_test(provider: Provider, model: &str, install_command:
);
if let Some(u) = usage {
assert!(
u.input_tokens > 0,
u.tokens().input_tokens > 0,
"{provider}/{model}: input_tokens should be > 0"
);
}

View file

@ -9237,8 +9237,8 @@ async fn cli_backend_run_writes_prompt_and_calls_exec() {
} => {
assert_eq!(text, "I fixed the bug.");
let usage = usage.expect("should have usage");
assert_eq!(usage.input_tokens, 500);
assert_eq!(usage.output_tokens, 200);
assert_eq!(usage.tokens().input_tokens, 500);
assert_eq!(usage.tokens().output_tokens, 200);
assert!(files_touched.is_empty(), "no files changed before/after");
}
CodergenResult::Full(_) => panic!("expected Text result, got Full"),
@ -9311,8 +9311,8 @@ async fn cli_backend_run_with_codex_provider() {
CodergenResult::Text { text, usage, .. } => {
assert_eq!(text, "Implemented the feature.");
let usage = usage.expect("should have usage");
assert_eq!(usage.input_tokens, 300);
assert_eq!(usage.output_tokens, 150);
assert_eq!(usage.tokens().input_tokens, 300);
assert_eq!(usage.tokens().output_tokens, 150);
}
CodergenResult::Full(_) => panic!("expected Text result"),
}
@ -9593,9 +9593,9 @@ async fn cli_backend_run_returns_text_and_usage() {
CodergenResult::Text { text, usage, .. } => {
assert_eq!(text, "done");
let usage = usage.expect("CLI backend should report usage");
assert_eq!(usage.input_tokens, 10);
assert_eq!(usage.output_tokens, 5);
assert_eq!(usage.model, "claude-opus-4-6");
assert_eq!(usage.tokens().input_tokens, 10);
assert_eq!(usage.tokens().output_tokens, 5);
assert_eq!(usage.model_id(), "claude-opus-4-6");
}
CodergenResult::Full(_) => panic!("expected Text result"),
}

View file

@ -1,4 +1,5 @@
api.ts
api/billing-api.ts
api/completions-api.ts
api/discovery-api.ts
api/human-in-the-loop-api.ts
@ -11,13 +12,12 @@ api/runs-api.ts
api/secrets-api.ts
api/settings-api.ts
api/system-api.ts
api/usage-api.ts
base.ts
common.ts
configuration.ts
index.ts
models/aggregate-usage-totals.ts
models/aggregate-usage.ts
models/aggregate-billing-totals.ts
models/aggregate-billing.ts
models/api-question-option.ts
models/api-question.ts
models/api-settings.ts
@ -27,6 +27,9 @@ models/artifact-list-response.ts
models/artifacts-settings.ts
models/assistant-stage-turn.ts
models/auth-settings.ts
models/billed-token-counts.ts
models/billing-by-model.ts
models/billing-stage-ref.ts
models/board-column.ts
models/check-run-status.ts
models/check-run.ts
@ -125,6 +128,9 @@ models/root-response-urls.ts
models/root-response.ts
models/run-artifact-entry.ts
models/run-artifact-list-response.ts
models/run-billing-stage.ts
models/run-billing-totals.ts
models/run-billing.ts
models/run-checkpoint.ts
models/run-control-action.ts
models/run-error.ts
@ -143,7 +149,6 @@ models/run-status-record.ts
models/run-status-response.ts
models/run-status.ts
models/run-timings.ts
models/run-usage.ts
models/sandbox-file-entry.ts
models/sandbox-file-list-response.ts
models/sandbox-resources.ts
@ -171,13 +176,8 @@ models/system-info-response.ts
models/system-run-counts.ts
models/system-stage-turn.ts
models/tls-settings.ts
models/token-usage.ts
models/tool-stage-turn.ts
models/tool-use.ts
models/usage-by-model.ts
models/usage-stage-ref.ts
models/usage-stage.ts
models/usage-totals.ts
models/user-response.ts
models/web-settings.ts
models/webhook-settings.ts

View file

@ -14,6 +14,7 @@
export * from './api/billing-api';
export * from './api/completions-api';
export * from './api/discovery-api';
export * from './api/human-in-the-loop-api';
@ -26,5 +27,4 @@ export * from './api/runs-api';
export * from './api/secrets-api';
export * from './api/settings-api';
export * from './api/system-api';
export * from './api/usage-api';

View file

@ -22,20 +22,20 @@ import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObj
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base';
// @ts-ignore
import type { AggregateUsage } from '../models';
import type { AggregateBilling } from '../models';
/**
* UsageApi - axios parameter creator
* BillingApi - axios parameter creator
*/
export const UsageApiAxiosParamCreator = function (configuration?: Configuration) {
export const BillingApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
* Returns aggregate token/cost usage across all completed runs since server start.
* @summary Aggregate Usage
* Returns aggregate token counts and billed totals across all completed runs since server start.
* @summary Aggregate Billing
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAggregateUsage: async (options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/api/v1/usage`;
getAggregateBilling: async (options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/api/v1/billing`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
@ -69,56 +69,56 @@ export const UsageApiAxiosParamCreator = function (configuration?: Configuration
};
/**
* UsageApi - functional programming interface
* BillingApi - functional programming interface
*/
export const UsageApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = UsageApiAxiosParamCreator(configuration)
export const BillingApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = BillingApiAxiosParamCreator(configuration)
return {
/**
* Returns aggregate token/cost usage across all completed runs since server start.
* @summary Aggregate Usage
* Returns aggregate token counts and billed totals across all completed runs since server start.
* @summary Aggregate Billing
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAggregateUsage(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AggregateUsage>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAggregateUsage(options);
async getAggregateBilling(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AggregateBilling>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAggregateBilling(options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['UsageApi.getAggregateUsage']?.[localVarOperationServerIndex]?.url;
const localVarOperationServerBasePath = operationServerMap['BillingApi.getAggregateBilling']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
}
};
/**
* UsageApi - factory interface
* BillingApi - factory interface
*/
export const UsageApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = UsageApiFp(configuration)
export const BillingApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = BillingApiFp(configuration)
return {
/**
* Returns aggregate token/cost usage across all completed runs since server start.
* @summary Aggregate Usage
* Returns aggregate token counts and billed totals across all completed runs since server start.
* @summary Aggregate Billing
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAggregateUsage(options?: RawAxiosRequestConfig): AxiosPromise<AggregateUsage> {
return localVarFp.getAggregateUsage(options).then((request) => request(axios, basePath));
getAggregateBilling(options?: RawAxiosRequestConfig): AxiosPromise<AggregateBilling> {
return localVarFp.getAggregateBilling(options).then((request) => request(axios, basePath));
},
};
};
/**
* UsageApi - object-oriented interface
* BillingApi - object-oriented interface
*/
export class UsageApi extends BaseAPI {
export class BillingApi extends BaseAPI {
/**
* Returns aggregate token/cost usage across all completed runs since server start.
* @summary Aggregate Usage
* Returns aggregate token counts and billed totals across all completed runs since server start.
* @summary Aggregate Billing
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public getAggregateUsage(options?: RawAxiosRequestConfig) {
return UsageApiFp(this.configuration).getAggregateUsage(options).then((request) => request(this.axios, this.basePath));
public getAggregateBilling(options?: RawAxiosRequestConfig) {
return BillingApiFp(this.configuration).getAggregateBilling(options).then((request) => request(this.axios, this.basePath));
}
}

View file

@ -24,23 +24,23 @@ import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError
// @ts-ignore
import type { ErrorResponse } from '../models';
// @ts-ignore
import type { RunUsage } from '../models';
import type { RunBilling } from '../models';
/**
* RunOutputsApi - axios parameter creator
*/
export const RunOutputsApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
* Returns token and cost usage broken down by stage and model for a specific run.
* @summary Retrieve Run Usage
* Returns token counts and billed totals broken down by stage and model for a specific run.
* @summary Retrieve Run Billing
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
retrieveRunUsage: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
retrieveRunBilling: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('retrieveRunUsage', 'id', id)
const localVarPath = `/api/v1/runs/{id}/usage`
assertParamExists('retrieveRunBilling', 'id', id)
const localVarPath = `/api/v1/runs/{id}/billing`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -81,16 +81,16 @@ export const RunOutputsApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = RunOutputsApiAxiosParamCreator(configuration)
return {
/**
* Returns token and cost usage broken down by stage and model for a specific run.
* @summary Retrieve Run Usage
* Returns token counts and billed totals broken down by stage and model for a specific run.
* @summary Retrieve Run Billing
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async retrieveRunUsage(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunUsage>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveRunUsage(id, options);
async retrieveRunBilling(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RunBilling>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.retrieveRunBilling(id, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['RunOutputsApi.retrieveRunUsage']?.[localVarOperationServerIndex]?.url;
const localVarOperationServerBasePath = operationServerMap['RunOutputsApi.retrieveRunBilling']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
}
@ -103,14 +103,14 @@ export const RunOutputsApiFactory = function (configuration?: Configuration, bas
const localVarFp = RunOutputsApiFp(configuration)
return {
/**
* Returns token and cost usage broken down by stage and model for a specific run.
* @summary Retrieve Run Usage
* Returns token counts and billed totals broken down by stage and model for a specific run.
* @summary Retrieve Run Billing
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
retrieveRunUsage(id: string, options?: RawAxiosRequestConfig): AxiosPromise<RunUsage> {
return localVarFp.retrieveRunUsage(id, options).then((request) => request(axios, basePath));
retrieveRunBilling(id: string, options?: RawAxiosRequestConfig): AxiosPromise<RunBilling> {
return localVarFp.retrieveRunBilling(id, options).then((request) => request(axios, basePath));
},
};
};
@ -120,14 +120,14 @@ export const RunOutputsApiFactory = function (configuration?: Configuration, bas
*/
export class RunOutputsApi extends BaseAPI {
/**
* Returns token and cost usage broken down by stage and model for a specific run.
* @summary Retrieve Run Usage
* Returns token counts and billed totals broken down by stage and model for a specific run.
* @summary Retrieve Run Billing
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public retrieveRunUsage(id: string, options?: RawAxiosRequestConfig) {
return RunOutputsApiFp(this.configuration).retrieveRunUsage(id, options).then((request) => request(this.axios, this.basePath));
public retrieveRunBilling(id: string, options?: RawAxiosRequestConfig) {
return RunOutputsApiFp(this.configuration).retrieveRunBilling(id, options).then((request) => request(this.axios, this.basePath));
}
}

View file

@ -15,9 +15,9 @@
/**
* Aggregate usage totals across all runs.
* Aggregate billing totals across all runs.
*/
export interface AggregateUsageTotals {
export interface AggregateBillingTotals {
/**
* Total number of completed runs.
*/
@ -31,9 +31,25 @@ export interface AggregateUsageTotals {
*/
'output_tokens': number;
/**
* Total cost in USD.
* Total tokens aggregated across all billing categories.
*/
'cost': number;
'total_tokens': number;
/**
* Total reasoning tokens.
*/
'reasoning_tokens'?: number;
/**
* Total cache read tokens.
*/
'cache_read_tokens'?: number;
/**
* Total cache write tokens.
*/
'cache_write_tokens'?: number;
/**
* Total billed USD amount in micros.
*/
'total_usd_micros'?: number;
/**
* Total runtime in seconds.
*/

Some files were not shown because too many files have changed in this diff Show more