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
|
|
@ -62,7 +62,7 @@ mod policy;
|
|||
mod session;
|
||||
mod sink;
|
||||
mod store;
|
||||
mod usage;
|
||||
mod telemetry;
|
||||
|
||||
pub use config::{Attach, Config};
|
||||
pub use driver::Claude;
|
||||
|
|
@ -71,4 +71,4 @@ pub use policy::{CompactionPolicy, NeverCompact, PercentPolicy};
|
|||
pub use session::{InfiniteSession, Progress};
|
||||
pub use sink::{NoopSink, Sink};
|
||||
pub use store::SessionStore;
|
||||
pub use usage::Usage;
|
||||
pub use telemetry::{Telemetry, TokenUsage, Usage};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use std::sync::Mutex;
|
|||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{Attach, Claude, CompactionPolicy, Config, Error, Result, SessionStore, Sink, Usage, usage};
|
||||
use crate::{Attach, Claude, CompactionPolicy, Config, Error, Result, SessionStore, Sink, Telemetry};
|
||||
|
||||
/// A named claude session that outlives the model's context window by
|
||||
/// compacting itself. Bundles the three things a durable session needs:
|
||||
|
|
@ -32,12 +32,16 @@ pub struct InfiniteSession<P: CompactionPolicy> {
|
|||
}
|
||||
|
||||
/// What [`InfiniteSession::run`] did, beyond streaming the turn to the sink.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Progress {
|
||||
/// The turn resumed no existing session — a fresh one was created.
|
||||
pub created: bool,
|
||||
/// A compaction ran (reactively on overflow, or proactively per policy).
|
||||
pub compacted: bool,
|
||||
/// Everything parsed from the answering turn's stream (usage, cost,
|
||||
/// context window, resolved model). The authoritative copy — finalised at
|
||||
/// the turn's `result` event.
|
||||
pub telemetry: Telemetry,
|
||||
}
|
||||
|
||||
impl<P: CompactionPolicy> InfiniteSession<P> {
|
||||
|
|
@ -60,26 +64,30 @@ impl<P: CompactionPolicy> InfiniteSession<P> {
|
|||
/// [`Error::PromptTooLong`]; rate-limit / auth / hard failures propagate
|
||||
/// unchanged for the caller to handle.
|
||||
pub async fn run(&self, config: &Config, prompt: &str, sink: &impl Sink) -> Result<Progress> {
|
||||
let meter = UsageSink::new(sink);
|
||||
let meter = TelemetrySink::new(sink);
|
||||
let created = match self.attempt(config, prompt, &meter).await {
|
||||
Ok(created) => created,
|
||||
Err(Error::PromptTooLong) => {
|
||||
// The session is already past the window — no turn can run on
|
||||
// it and the detail is gone (no checkpoint possible). Compact,
|
||||
// then retry the same prompt once.
|
||||
// then retry the same prompt once; the retry is the answering
|
||||
// turn, so its telemetry is what we report.
|
||||
self.compact(config, sink).await?;
|
||||
let created = self.attempt(config, prompt, sink).await?;
|
||||
let retry = TelemetrySink::new(sink);
|
||||
let created = self.attempt(config, prompt, &retry).await?;
|
||||
return Ok(Progress {
|
||||
created,
|
||||
compacted: true,
|
||||
telemetry: retry.snapshot(),
|
||||
});
|
||||
}
|
||||
Err(other) => return Err(other),
|
||||
};
|
||||
let telemetry = meter.snapshot();
|
||||
|
||||
// Proactive: the turn completed on a healthy session. If the policy
|
||||
// says it's due, checkpoint (best-effort) then compact.
|
||||
if self.policy.should_compact(meter.snapshot()) {
|
||||
if self.policy.should_compact(telemetry.usage()) {
|
||||
if let Some(checkpoint) = self.policy.checkpoint_prompt() {
|
||||
let _ = self.attempt(config, checkpoint, sink).await;
|
||||
}
|
||||
|
|
@ -87,11 +95,13 @@ impl<P: CompactionPolicy> InfiniteSession<P> {
|
|||
return Ok(Progress {
|
||||
created,
|
||||
compacted: true,
|
||||
telemetry,
|
||||
});
|
||||
}
|
||||
Ok(Progress {
|
||||
created,
|
||||
compacted: false,
|
||||
telemetry,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -133,35 +143,30 @@ impl<P: CompactionPolicy> InfiniteSession<P> {
|
|||
}
|
||||
}
|
||||
|
||||
/// A [`Sink`] that forwards to an inner sink while accumulating the minimal
|
||||
/// [`Usage`] the policy needs from the stream. Cheap; the driver calls it
|
||||
/// synchronously from one reader task, so the `Mutex` only satisfies `&self`.
|
||||
struct UsageSink<'a, S: Sink> {
|
||||
/// A [`Sink`] that forwards to an inner sink while accumulating the turn's
|
||||
/// [`Telemetry`] from the stream. Cheap; the driver calls it synchronously
|
||||
/// from one reader task, so the `Mutex` only satisfies `&self`.
|
||||
struct TelemetrySink<'a, S: Sink> {
|
||||
inner: &'a S,
|
||||
usage: Mutex<Usage>,
|
||||
telemetry: Mutex<Telemetry>,
|
||||
}
|
||||
|
||||
impl<'a, S: Sink> UsageSink<'a, S> {
|
||||
impl<'a, S: Sink> TelemetrySink<'a, S> {
|
||||
fn new(inner: &'a S) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
usage: Mutex::new(Usage::default()),
|
||||
telemetry: Mutex::new(Telemetry::default()),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> Usage {
|
||||
*self.usage.lock().unwrap()
|
||||
fn snapshot(&self) -> Telemetry {
|
||||
self.telemetry.lock().unwrap().clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Sink> Sink for UsageSink<'_, S> {
|
||||
impl<S: Sink> Sink for TelemetrySink<'_, S> {
|
||||
fn on_event(&self, event: &Value) {
|
||||
if let Some(tokens) = usage::context_tokens(event) {
|
||||
self.usage.lock().unwrap().context_tokens = tokens;
|
||||
}
|
||||
if let Some(window) = usage::context_window(event) {
|
||||
self.usage.lock().unwrap().context_window = Some(window);
|
||||
}
|
||||
self.telemetry.lock().unwrap().observe(event);
|
||||
self.inner.on_event(event);
|
||||
}
|
||||
|
||||
|
|
|
|||
181
hive-claude/src/telemetry.rs
Normal file
181
hive-claude/src/telemetry.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
//! What the driver parses out of a turn's stream-json output.
|
||||
//!
|
||||
//! The lib is the single source of truth for reading claude's usage/model
|
||||
//! reporting. A turn's [`Telemetry`] is accumulated by the driver as events
|
||||
//! stream and handed back from [`crate::InfiniteSession::run`]; consumers that
|
||||
//! also want the raw events (for their own SSE / tool-call accounting) still
|
||||
//! get them through their [`crate::Sink`].
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// Token counts from one `usage` block. All in tokens; missing fields read `0`.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct TokenUsage {
|
||||
pub input_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub cache_read_input_tokens: u64,
|
||||
pub cache_creation_input_tokens: u64,
|
||||
}
|
||||
|
||||
impl TokenUsage {
|
||||
/// Context footprint counting against the model window: input + both cache
|
||||
/// classes (not output).
|
||||
#[must_use]
|
||||
pub fn context_tokens(&self) -> u64 {
|
||||
self.input_tokens + self.cache_read_input_tokens + self.cache_creation_input_tokens
|
||||
}
|
||||
|
||||
fn from_obj(u: &Value) -> Self {
|
||||
let field = |k: &str| u.get(k).and_then(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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the driver tracks from one turn's stream-json output.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Telemetry {
|
||||
/// Most recent per-inference usage (from `assistant` events) — the live
|
||||
/// context footprint (the number to watch for compaction).
|
||||
pub context: TokenUsage,
|
||||
/// Cumulative usage across the turn (from the terminal `result` event) —
|
||||
/// the cost signal. Sums per-call prompts and can exceed the window.
|
||||
pub cost: TokenUsage,
|
||||
/// Model-reported active context window (`modelUsage.*.contextWindow` on
|
||||
/// the `result` event), if reported.
|
||||
pub context_window: Option<u64>,
|
||||
/// Resolved model id echoed by the API (`assistant.message.model`, e.g.
|
||||
/// `claude-opus-4-8`) — the concrete version, not the requested alias.
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
impl Telemetry {
|
||||
/// Fold one stream-json event into the running telemetry.
|
||||
pub(crate) fn observe(&mut self, event: &Value) {
|
||||
match event.get("type").and_then(Value::as_str) {
|
||||
Some("assistant") => {
|
||||
let Some(message) = event.get("message") else {
|
||||
return;
|
||||
};
|
||||
if let Some(usage) = message.get("usage") {
|
||||
self.context = TokenUsage::from_obj(usage);
|
||||
}
|
||||
if let Some(model) = message
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
self.model = Some(model.to_string());
|
||||
}
|
||||
}
|
||||
Some("result") => {
|
||||
if let Some(usage) = event.get("usage") {
|
||||
self.cost = TokenUsage::from_obj(usage);
|
||||
}
|
||||
if let Some(window) = context_window_from_result(event) {
|
||||
self.context_window = Some(window);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// The minimal signal a [`crate::CompactionPolicy`] needs.
|
||||
#[must_use]
|
||||
pub fn usage(&self) -> Usage {
|
||||
Usage {
|
||||
context_tokens: self.context.context_tokens(),
|
||||
context_window: self.context_window,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// First non-zero `contextWindow` across the `result` event's `modelUsage` map.
|
||||
fn context_window_from_result(event: &Value) -> Option<u64> {
|
||||
for (_model, stats) in event.get("modelUsage")?.as_object()? {
|
||||
if let Some(w) = stats.get("contextWindow").and_then(Value::as_u64)
|
||||
&& w > 0
|
||||
{
|
||||
return Some(w);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The compaction signal: the live context size and the window it's measured
|
||||
/// against. Derived from [`Telemetry`] via [`Telemetry::usage`].
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct Usage {
|
||||
/// Tokens in the last inference's context. `0` until the first `assistant`
|
||||
/// event.
|
||||
pub context_tokens: u64,
|
||||
/// The model-reported active window, if the turn reported one.
|
||||
pub context_window: Option<u64>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Telemetry;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn context_tokens_sum_excludes_output() {
|
||||
let mut t = Telemetry::default();
|
||||
t.observe(&json!({
|
||||
"type": "assistant",
|
||||
"message": { "model": "claude-opus-4-8", "usage": {
|
||||
"input_tokens": 100, "output_tokens": 999,
|
||||
"cache_read_input_tokens": 20, "cache_creation_input_tokens": 5,
|
||||
}}
|
||||
}));
|
||||
assert_eq!(t.context.context_tokens(), 125);
|
||||
assert_eq!(t.model.as_deref(), Some("claude-opus-4-8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_assistant_wins() {
|
||||
let mut t = Telemetry::default();
|
||||
for n in [10, 20, 30] {
|
||||
t.observe(&json!({
|
||||
"type": "assistant",
|
||||
"message": { "usage": { "input_tokens": n } }
|
||||
}));
|
||||
}
|
||||
assert_eq!(t.context.input_tokens, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn result_event_sets_cost_and_window() {
|
||||
let mut t = Telemetry::default();
|
||||
t.observe(&json!({
|
||||
"type": "result",
|
||||
"usage": { "input_tokens": 5_000, "output_tokens": 1_000 },
|
||||
"modelUsage": { "claude-opus-4-8": { "contextWindow": 200_000 } }
|
||||
}));
|
||||
assert_eq!(t.cost.input_tokens, 5_000);
|
||||
assert_eq!(t.context_window, Some(200_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_model_ignored_and_non_events_no_op() {
|
||||
let mut t = Telemetry::default();
|
||||
t.observe(&json!({ "type": "assistant", "message": { "model": "" } }));
|
||||
assert_eq!(t.model, None);
|
||||
t.observe(&json!({ "type": "system", "subtype": "init" }));
|
||||
assert_eq!(t, Telemetry::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_view_derives_from_context_and_window() {
|
||||
let mut t = Telemetry::default();
|
||||
t.observe(&json!({ "type": "assistant", "message": { "usage": { "input_tokens": 42 } } }));
|
||||
t.observe(&json!({ "type": "result", "modelUsage": { "m": { "contextWindow": 100 } } }));
|
||||
let u = t.usage();
|
||||
assert_eq!(u.context_tokens, 42);
|
||||
assert_eq!(u.context_window, Some(100));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
//! Minimal context-usage signal parsed from the stream, used to drive
|
||||
//! [`crate::CompactionPolicy`]. This is deliberately small — just what a
|
||||
//! compaction decision needs. Consumers that want full per-turn accounting
|
||||
//! parse the raw events in their own [`crate::Sink`].
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
/// The context footprint of the most recent inference in a turn.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct Usage {
|
||||
/// Tokens in the last inference's context (input + cache reads + cache
|
||||
/// writes) — what counts against the model's window. `0` until the first
|
||||
/// `assistant` event is seen.
|
||||
pub context_tokens: u64,
|
||||
/// The model-reported active context window (`modelUsage.*.contextWindow`
|
||||
/// on the terminal `result` event), if the turn reported one.
|
||||
pub context_window: Option<u64>,
|
||||
}
|
||||
|
||||
/// Per-inference context size from an `assistant` event's `message.usage`.
|
||||
/// Tracking the *last* one over a turn gives the live conversation size (the
|
||||
/// cumulative `result` usage double-counts tool-call prompts and overshoots).
|
||||
pub(crate) fn context_tokens(event: &Value) -> Option<u64> {
|
||||
if event.get("type").and_then(Value::as_str) != Some("assistant") {
|
||||
return None;
|
||||
}
|
||||
let usage = event.get("message")?.get("usage")?;
|
||||
let field = |k: &str| usage.get(k).and_then(Value::as_u64).unwrap_or(0);
|
||||
Some(field("input_tokens") + field("cache_read_input_tokens") + field("cache_creation_input_tokens"))
|
||||
}
|
||||
|
||||
/// The per-inference active window from a `result` event's `modelUsage` map
|
||||
/// (first non-zero `contextWindow` across model keys). This is the limit the
|
||||
/// model actually enforces, which can be far below the prompt-cache capacity.
|
||||
pub(crate) fn context_window(event: &Value) -> Option<u64> {
|
||||
if event.get("type").and_then(Value::as_str) != Some("result") {
|
||||
return None;
|
||||
}
|
||||
for (_model, stats) in event.get("modelUsage")?.as_object()? {
|
||||
if let Some(w) = stats.get("contextWindow").and_then(Value::as_u64)
|
||||
&& w > 0
|
||||
{
|
||||
return Some(w);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
Loading…
Reference in a new issue