feat(hive-claude): parse turn telemetry in the lib, return it from run

This commit is contained in:
müde 2026-07-05 20:06:30 +02:00
commit 487e62a9ca
7 changed files with 260 additions and 227 deletions

View file

@ -453,89 +453,6 @@ impl TokenUsage {
pub fn context_tokens(&self) -> u64 {
self.input_tokens + self.cache_read_input_tokens + self.cache_creation_input_tokens
}
/// Parse usage from the terminal `result` stream-json event. This is the
/// **cumulative** sum across every inference in the turn — useful as a
/// cost signal, but NOT the current context size (a tool-heavy turn
/// sums per-call cached prompts and easily exceeds the model window).
#[must_use]
pub fn from_stream_event(v: &serde_json::Value) -> Option<Self> {
if v.get("type").and_then(|t| t.as_str()) != Some("result") {
return None;
}
Some(Self::from_usage_obj(v.get("usage")?))
}
/// Parse usage from a per-inference `assistant` event's
/// `.message.usage` block. Each turn fires one of these for every
/// model call; tracking the LAST one over the turn gives the actual
/// conversation context size — the number to watch for compaction.
#[must_use]
pub fn from_assistant_event(v: &serde_json::Value) -> Option<Self> {
if v.get("type").and_then(|t| t.as_str()) != Some("assistant") {
return None;
}
Some(Self::from_usage_obj(v.get("message")?.get("usage")?))
}
fn from_usage_obj(u: &serde_json::Value) -> Self {
let field = |k: &str| u.get(k).and_then(serde_json::Value::as_u64).unwrap_or(0);
Self {
input_tokens: field("input_tokens"),
output_tokens: field("output_tokens"),
cache_read_input_tokens: field("cache_read_input_tokens"),
cache_creation_input_tokens: field("cache_creation_input_tokens"),
}
}
/// Extract the per-inference context-window limit from a `result`
/// stream-json event's `modelUsage` map. The API reports this as
/// `modelUsage.<model-name>.contextWindow`; we take the first non-zero
/// value across all model keys.
///
/// Returns `None` if the event is not a `result` type or has no
/// `contextWindow` field. The returned value is the authoritative
/// per-inference active window (e.g. 200 000 for `claude-sonnet-4-6`).
/// It may be smaller than the full prompt-cache capacity (which can
/// be several million tokens via cache reads).
#[must_use]
pub fn context_window_from_result_event(v: &serde_json::Value) -> Option<u64> {
if v.get("type").and_then(|t| t.as_str()) != Some("result") {
return None;
}
let model_usage = v.get("modelUsage")?;
let map = model_usage.as_object()?;
for (_model, stats) in map {
if let Some(w) = stats
.get("contextWindow")
.and_then(serde_json::Value::as_u64)
&& w > 0
{
return Some(w);
}
}
None
}
/// Extract the *resolved* model id from an `assistant` stream-json
/// event (`message.model`). Unlike the requested `--model` name
/// (which may be a short alias like `opus` or a default), the API
/// echoes the concrete version it actually ran on (e.g.
/// `claude-opus-4-8`). Recording the resolved id (not the requested
/// name) is what lets the ST4TS model-mix + cost rollup label the
/// exact version that ran. Returns `None` for non-assistant events
/// or ones missing `message.model`.
#[must_use]
pub fn model_from_assistant_event(v: &serde_json::Value) -> Option<String> {
if v.get("type").and_then(|t| t.as_str()) != Some("assistant") {
return None;
}
v.get("message")
.and_then(|m| m.get("model"))
.and_then(serde_json::Value::as_str)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
}
}
/// Authoritative turn-loop state. The harness owns it; the web UI
@ -1225,8 +1142,8 @@ impl Default for Bus {
#[cfg(test)]
mod tests {
use super::{
BusEvent, DEFAULT_EFFORT, EFFORT_LEVELS, LiveEvent, StoredEvent, TokenUsage,
forge_cursor_from_json, is_valid_effort,
BusEvent, DEFAULT_EFFORT, EFFORT_LEVELS, LiveEvent, StoredEvent, forge_cursor_from_json,
is_valid_effort,
};
use serde_json::json;
@ -1305,29 +1222,4 @@ mod tests {
assert!(EFFORT_LEVELS.contains(&DEFAULT_EFFORT));
assert_eq!(DEFAULT_EFFORT, "medium");
}
#[test]
fn resolved_model_from_assistant_event() {
let v = json!({
"type": "assistant",
"message": { "model": "claude-opus-4-8", "role": "assistant" }
});
assert_eq!(
TokenUsage::model_from_assistant_event(&v),
Some("claude-opus-4-8".to_owned())
);
}
#[test]
fn resolved_model_ignores_non_assistant_and_missing() {
// Wrong event type.
let result = json!({ "type": "result", "message": { "model": "claude-opus-4-8" } });
assert_eq!(TokenUsage::model_from_assistant_event(&result), None);
// Assistant event missing message.model.
let no_model = json!({ "type": "assistant", "message": { "role": "assistant" } });
assert_eq!(TokenUsage::model_from_assistant_event(&no_model), None);
// Empty model string is treated as absent.
let empty = json!({ "type": "assistant", "message": { "model": "" } });
assert_eq!(TokenUsage::model_from_assistant_event(&empty), None);
}
}