feat(hive-claude): parse turn telemetry in the lib, return it from run
This commit is contained in:
parent
faa7f982af
commit
487e62a9ca
7 changed files with 260 additions and 227 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue