mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-09 03:20:56 +00:00
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>
85 lines
2.4 KiB
Rust
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")
|
|
);
|
|
}
|