fabro/lib/apps/fabro-cli/src/server_runs.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

161 lines
4.1 KiB
Rust

use std::collections::HashMap;
use std::sync::Arc;
use anyhow::Result;
use chrono::{DateTime, Utc};
use fabro_types::{Run, RunId, RunStatus};
use crate::server_client::Client;
#[derive(Debug, Clone)]
pub(crate) struct ServerRunInfo {
run: Run,
}
impl ServerRunInfo {
pub(crate) fn from_run(run: Run) -> Self {
Self { run }
}
pub(crate) fn run_id(&self) -> RunId {
self.run.id
}
pub(crate) fn parent_id(&self) -> Option<RunId> {
self.run.parent_id
}
pub(crate) fn workflow_name(&self) -> Option<&str> {
self.run.workflow.name.as_deref()
}
pub(crate) fn workflow_graph_name(&self) -> Option<&str> {
self.run.workflow.graph_name.as_deref()
}
pub(crate) fn workflow_slug(&self) -> Option<&str> {
self.run.workflow.slug.as_deref()
}
pub(crate) fn workflow_display_name(&self) -> String {
self.run.workflow.display_name().unwrap_or("-").to_string()
}
pub(crate) fn workflow_matches(&self, pattern: &str) -> bool {
[
self.workflow_name(),
self.workflow_graph_name(),
self.workflow_slug(),
]
.into_iter()
.flatten()
.any(|value| value.contains(pattern))
}
pub(crate) fn status(&self) -> RunStatus {
self.run.lifecycle.status
}
pub(crate) fn start_time(&self) -> String {
self.start_time_dt()
.map(|time| time.to_rfc3339())
.unwrap_or_default()
}
pub(crate) fn start_time_dt(&self) -> Option<DateTime<Utc>> {
self.run
.timestamps
.started_at
.or(Some(self.run.id.created_at()))
}
pub(crate) fn labels(&self) -> &HashMap<String, String> {
&self.run.labels
}
pub(crate) fn wall_time_ms(&self) -> Option<u64> {
self.run.timing.as_ref().map(|t| t.wall_time_ms)
}
pub(crate) fn total_usd_micros(&self) -> Option<u64> {
self.run.usage.cost.map(|cost| cost.usd_micros)
}
pub(crate) fn source_directory(&self) -> Option<&str> {
self.run.source_directory.as_deref()
}
pub(crate) fn repo_origin_url(&self) -> Option<&str> {
self.run
.repository
.as_ref()
.and_then(|repository| repository.origin_url.as_deref())
}
pub(crate) fn goal(&self) -> String {
self.run.goal.clone()
}
}
pub(crate) struct ServerRunLookup {
runs: Vec<ServerRunInfo>,
}
impl ServerRunLookup {
pub(crate) async fn from_client(client: Arc<Client>) -> Result<Self> {
let runs = client.list_store_runs().await?;
Ok(Self::from_runs(runs))
}
pub(crate) async fn from_client_by_parent(
client: Arc<Client>,
parent_id: RunId,
) -> Result<Self> {
let runs = client.list_store_runs_by_parent(parent_id).await?;
Ok(Self::from_runs(runs))
}
fn from_runs(runs: Vec<Run>) -> Self {
let mut runs = runs
.into_iter()
.map(ServerRunInfo::from_run)
.collect::<Vec<_>>();
runs.sort_by(|a, b| {
b.start_time_dt()
.cmp(&a.start_time_dt())
.then_with(|| b.run_id().cmp(&a.run_id()))
});
Self { runs }
}
pub(crate) fn runs(&self) -> &[ServerRunInfo] {
&self.runs
}
}
pub(crate) fn filter_server_runs(
runs: &[ServerRunInfo],
before: Option<&str>,
workflow: Option<&str>,
labels: &[(String, String)],
running_only: bool,
) -> Vec<ServerRunInfo> {
runs.iter()
.filter(|run| !running_only || run.status().is_active())
.filter(|run| {
before.is_none_or(|before| {
let start_time = run.start_time();
start_time.is_empty() || start_time.as_str() < before
})
})
.filter(|run| workflow.is_none_or(|pattern| run.workflow_matches(pattern)))
.filter(|run| {
labels.iter().all(|(key, value)| {
run.labels()
.get(key)
.is_some_and(|current| current == value)
})
})
.cloned()
.collect()
}