fabro/lib/foundation/fabro-api/tests/token_counts_round_trip.rs
Bryan Helmkamp ef86ad278a
Carry usage as lithos-llm's Usage and rename billing to usage
Re-pin lithos-llm to 55add4596b861a0623d00c3a54aa5c147c8d504b and
pebble to c91810fe51aece80359b9cd8efea971af0c46925, where token usage
and cost travel together as Usage { tokens: TokenCounts, cost:
Option<Cost> }. Fabro now carries that one type everywhere it used to
carry BilledTokenCounts, BilledModelUsage, UsdMicros, or a token count
beside a cost_usd_micros.

fabro-types: billing.rs is usage.rs with ModelRef, ModelUsage { model,
usage }, sum_usage, and usage_is_empty; billing_rollup.rs is
usage_rollup.rs with ProjectionUsageStage, ProjectionUsageByModel,
ProjectionUsageRollup, and usage_rollup_from_projection. Every usage
field is named usage: StageProjection.usage and usage_by_model,
Outcome<Option<ModelUsage>>, stage.completed and stage.failed usage and
usage_by_model, prompt.completed usage, run.completed and run.failed
usage (total_usd_micros is gone), Conclusion.usage, StageSummary.usage,
Run.usage. RunSize buckets by Cost.

fabro-workflow: model_usage_from_llm prices tokens from the catalog with
a Catalog cost source, with_reported_cost keeps a provider cost, and the
pebble handler's stage_usage groups pebble's accounts by model and sums
rows with Usage::saturating_add, so a total has a cost only when every
priced part was priced. The store fold's live usage is the agent's
usage plus its descendants'.

API: the OpenAPI spec deletes BilledTokenCounts, BilledModelUsage,
CompletionUsage, CompletionCost, TokenUsage, and RunBillingSummary,
adds TokenCounts, Cost, Usage, and ModelUsage, and renames every
billing schema, property, tag, path, and operation to usage. fabro-api
reuses lithos-llm's and fabro-types' types through with_replacement,
with a round-trip test per replacement.

Old stored runs get no migration: their pebble events in the old shape
read back with zero usage, and their rebuilt projections lose agent
usage.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 12:31:34 -06:00

85 lines
2.4 KiB
Rust

use std::any::{TypeId, type_name};
use fabro_api::types::TokenCounts as ApiTokenCounts;
use lithos_llm::types::TokenCounts;
use serde_json::{Value, json};
#[test]
fn token_counts_reuses_canonical_type() {
assert_same_type::<ApiTokenCounts, TokenCounts>();
}
#[test]
fn token_counts_json_matches_openapi_shape() {
let tokens = TokenCounts {
input: 10,
output: 20,
reasoning: 3,
cache_read: 4,
cache_write: 5,
};
let json = serde_json::to_value(tokens).unwrap();
assert_eq!(
json,
json!({
"input": 10,
"output": 20,
"reasoning": 3,
"cache_read": 4,
"cache_write": 5
})
);
assert_valid("TokenCounts", &json);
let round_trip: ApiTokenCounts = serde_json::from_value(json).unwrap();
assert_eq!(round_trip, tokens);
}
#[test]
fn token_counts_missing_buckets_default_to_zero() {
let round_trip: ApiTokenCounts = serde_json::from_value(json!({"input": 7})).unwrap();
assert_eq!(round_trip, TokenCounts {
input: 7,
..TokenCounts::default()
});
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}
#[expect(
clippy::disallowed_methods,
reason = "a synchronous test reads the spec from the repository once"
)]
fn spec() -> Value {
let text = std::fs::read_to_string(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../../docs/public/api-reference/fabro-api.yaml"
))
.expect("the OpenAPI spec is in the repository");
let yaml: serde_yaml::Value = serde_yaml::from_str(&text).expect("the spec parses");
serde_json::to_value(yaml).expect("the spec is JSON-compatible")
}
fn assert_valid(schema_name: &str, value: &Value) {
let mut root = spec();
root["$ref"] = json!(format!("#/components/schemas/{schema_name}"));
let validator = jsonschema::validator_for(&root).expect("the spec compiles as a JSON schema");
let errors: Vec<String> = validator
.iter_errors(value)
.map(|error| format!("{error} at {}", error.instance_path()))
.collect();
assert!(
errors.is_empty(),
"{schema_name} rejects {value:#}:\n{}",
errors.join("\n")
);
}