Watch
0
0
Fork
You've already forked hyperhive
0

hive-agent: export ACP-reported cost and context fill over OTLP

An ACP agent's `usage_update` carries `cost.{amount,currency}`, the
session's running total (opencode sums every assistant message in the
session). hive-runtime now reads it and turns the running total into
what each report added: a new session counts from zero, a session loaded
into a freshly started agent only baselines on its first report, and a
falling total adds nothing. The spend is held on the runtime until
`Runtime::take_reported_cost` drains it; claude's runtime reports none,
since the claude binary already exports `claude_code.cost.usage`.

hive-agent's existing turn-metrics meter records three new instruments:

- `hyperhive.agent.cost.usage` (counter, `model` + `currency`), ACP only;
- `hyperhive.agent.context.used` / `.size` (gauges, no attributes), for
  every backend: the two numbers the web UI's ctx% divides.

The `hyperhive · agents` dashboard gets ACP cost panels on its cost tab
and a context-fill panel on its health tab.

Refs #4845
This commit is contained in:
atlas 2026-09-30 22:23:01 +02:00
commit c2bdf30e05
8 changed files with 605 additions and 26 deletions

View file

@ -7,6 +7,8 @@ use std::collections::HashMap;
use hive_claude::{Telemetry, TokenUsage};
use serde_json::{Map, Value, json};
use super::ReportedCost;
/// Turns one prompt's `session/update` notifications into claude
/// `stream-json` events.
///
@ -258,6 +260,19 @@ pub(super) fn config_options(value: &Value) -> Option<Vec<ConfigOption>> {
)
}
/// The running total cost a `usage_update` reports for its session, or
/// `None` for any other `update` and for one that reports no cost.
pub(super) fn reported_cost(update: &Value) -> Option<ReportedCost> {
if update.get("sessionUpdate").and_then(Value::as_str) != Some("usage_update") {
return None;
}
let cost = update.get("cost")?;
Some(ReportedCost {
amount: cost.get("amount")?.as_f64()?,
currency: cost.get("currency")?.as_str()?.to_owned(),
})
}
/// The options a `config_option_update` sets, or `None` for any other
/// `update`. Each replaces the session's previous options.
pub(super) fn updated_config_options(update: &Value) -> Option<Vec<ConfigOption>> {
@ -402,7 +417,8 @@ fn tool_result(id: &str, output: &str, is_error: bool) -> Value {
#[cfg(test)]
mod tests {
use super::{
ConfigOption, StreamMapper, canonical_tool_name, config_options, mcp_servers, session_model,
ConfigOption, ReportedCost, StreamMapper, canonical_tool_name, config_options, mcp_servers,
reported_cost, session_model,
};
use serde_json::{Value, json};
@ -537,6 +553,33 @@ mod tests {
assert_eq!(t.model.as_deref(), Some("provider/model"));
}
#[test]
fn a_usage_update_reports_its_cost_when_it_carries_one() {
assert_eq!(
reported_cost(&json!({ "sessionUpdate": "usage_update", "used": 5000,
"size": 262_144, "cost": { "amount": 0.045, "currency": "USD" } })),
Some(ReportedCost {
amount: 0.045,
currency: "USD".into()
})
);
assert_eq!(
reported_cost(&json!({ "sessionUpdate": "usage_update", "used": 5000,
"size": 262_144 })),
None
);
assert_eq!(
reported_cost(&json!({ "sessionUpdate": "usage_update",
"cost": { "amount": 0.045 } })),
None
);
assert_eq!(
reported_cost(&json!({ "sessionUpdate": "plan",
"cost": { "amount": 0.045, "currency": "USD" } })),
None
);
}
#[test]
fn updates_with_nothing_to_show_emit_nothing() {
let out = feed(