Watch
0
0
Fork
You've already forked hyperhive
0

review classifier: commit shas and index lines stay out of the prompt

Every classification's shared context began with the PR's head sha,
headed each commit with its sha, and carried each file's
`index <hash>..<hash>` line in the diff. A push or rebase rewrites all
of those, so the provider's prompt cache missed from the first line of
the user message even when the commit messages and hunks were
unchanged (#5245: "cache hits show as 0.1x").

The header now names only repo#pr, each commit is headed by its
position, and the diff drops `index` lines before lockfiles collapse.
The `diff --git` paths and every hunk stay. The TryOut's head_sha is
untouched: the controller still posts the review against it, and the
journal line still logs it.

On the ACP runtime the cost sink also adds each classification's tokens
to `hyperhive.review_classifier.token.usage`, typed input / output /
cacheRead / cacheCreation like claude's own `claude_code.token.usage`,
so the hit rate is a number in the metrics store on both runtimes.
This commit is contained in:
atlas 2026-10-14 10:43:41 +02:00 • committed by mara
commit 74b63dd031
4 changed files with 190 additions and 35 deletions

View file

@ -1,6 +1,11 @@
//! What a classification on the ACP runtime cost, exported as the counter
//! `hyperhive.review_classifier.cost.usage` with the run's `repo`, `pr`,
//! `model` and `currency`. On the claude runtime `claude` exports its own.
//! `model` and `currency`, and the tokens it used, as the counter
//! `hyperhive.review_classifier.token.usage` with the same labels bar
//! `currency` and a `type` of `input`, `output`, `cacheRead` or
//! `cacheCreation`. Those are `claude_code.token.usage`'s types, which
//! `claude` exports for itself on the claude runtime, so the cache hit rate is
//! `cacheRead` over the three input types on either runtime.
//!
//! The amount is computed here from the prices the run carries and the
//! tokens the agent reports, so it does not depend on how, or whether, the
@ -26,7 +31,7 @@ pub const CURRENCY: &str = "USD";
/// How often the counter is exported.
const INTERVAL: Duration = Duration::from_mins(1);
/// One classification's cost, and what it is labelled with.
/// One classification's cost and tokens, and what they are labelled with.
#[derive(Debug, Clone, PartialEq)]
pub struct Cost {
pub repo: String,
@ -34,6 +39,7 @@ pub struct Cost {
pub model: String,
/// In [`CURRENCY`].
pub amount: f64,
pub usage: TokenUsage,
}
/// Where a classification's cost goes: the metrics store in production, a
@ -57,43 +63,67 @@ pub fn amount(prices: &Prices, usage: &TokenUsage) -> f64 {
* per_token(prices.cache_write.unwrap_or(prices.input))
}
/// The counter in the metrics store.
/// The counters in the metrics store.
pub struct Otel;
impl CostSink for Otel {
fn record(&self, cost: &Cost) {
static COUNTER: OnceLock<Option<(SdkMeterProvider, Counter<f64>)>> = OnceLock::new();
let Some((_, counter)) = COUNTER.get_or_init(build) else {
static COUNTERS: OnceLock<Option<(SdkMeterProvider, Counters)>> = OnceLock::new();
let Some((_, counters)) = COUNTERS.get_or_init(build) else {
return;
};
add(counter, cost);
add(counters, cost);
}
}
/// The counter on `provider`.
fn cost_counter(provider: &SdkMeterProvider) -> Counter<f64> {
provider
.meter("hyperhive.review_classifier")
.f64_counter("hyperhive.review_classifier.cost.usage")
.build()
/// The cost counter, and the token counter beside it.
struct Counters {
cost: Counter<f64>,
tokens: Counter<u64>,
}
/// Adds `cost` to `counter` with the run's labels.
fn add(counter: &Counter<f64>, cost: &Cost) {
counter.add(
cost.amount,
&[
KeyValue::new("repo", cost.repo.clone()),
KeyValue::new("pr", i64::try_from(cost.pr).unwrap_or(i64::MAX)),
KeyValue::new("model", cost.model.clone()),
KeyValue::new("currency", CURRENCY),
],
);
/// The counters on `provider`.
fn counters(provider: &SdkMeterProvider) -> Counters {
let meter = provider.meter("hyperhive.review_classifier");
Counters {
cost: meter
.f64_counter("hyperhive.review_classifier.cost.usage")
.build(),
tokens: meter
.u64_counter("hyperhive.review_classifier.token.usage")
.build(),
}
}
/// The provider, kept alive with the counter since it exports only while it
/// Adds `cost` to `counters` with the run's labels.
fn add(counters: &Counters, cost: &Cost) {
let labels = [
KeyValue::new("repo", cost.repo.clone()),
KeyValue::new("pr", i64::try_from(cost.pr).unwrap_or(i64::MAX)),
KeyValue::new("model", cost.model.clone()),
];
let with = |extra: KeyValue| {
let mut all = labels.to_vec();
all.push(extra);
all
};
counters
.cost
.add(cost.amount, &with(KeyValue::new("currency", CURRENCY)));
let usage = &cost.usage;
for (kind, n) in [
("input", usage.input_tokens),
("output", usage.output_tokens),
("cacheRead", usage.cache_read_input_tokens),
("cacheCreation", usage.cache_creation_input_tokens),
] {
counters.tokens.add(n, &with(KeyValue::new("type", kind)));
}
}
/// The provider, kept alive with the counters since it exports only while it
/// lives, or `None` when no collector is configured or the exporter failed.
fn build() -> Option<(SdkMeterProvider, Counter<f64>)> {
fn build() -> Option<(SdkMeterProvider, Counters)> {
if !std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").is_ok_and(|s| !s.trim().is_empty()) {
return None;
}
@ -114,8 +144,8 @@ fn build() -> Option<(SdkMeterProvider, Counter<f64>)> {
.with_interval(INTERVAL)
.build();
let provider = SdkMeterProvider::builder().with_reader(reader).build();
let counter = cost_counter(&provider);
Some((provider, counter))
let counters = counters(&provider);
Some((provider, counters))
}
#[cfg(test)]
@ -168,7 +198,7 @@ mod tests {
let exporter = InMemoryMetricExporter::default();
let reader = PeriodicReader::builder(exporter.clone()).build();
let provider = SdkMeterProvider::builder().with_reader(reader).build();
let counter = cost_counter(&provider);
let counters = counters(&provider);
let prices = Prices {
input: 3.0,
output: 15.0,
@ -180,8 +210,9 @@ mod tests {
pr: 5214,
model: "some-model".to_owned(),
amount: amount(&prices, &usage(1_000_000, 100_000, 0, 0)),
usage: usage(1_000_000, 100_000, 0, 0),
};
add(&counter, &cost);
add(&counters, &cost);
provider.force_flush().expect("flush");
let metrics = exporter.get_finished_metrics().expect("metrics");
@ -214,4 +245,52 @@ mod tests {
assert_eq!(label("currency"), Some(Value::from("USD")));
assert_eq!(labels.len(), 4);
}
#[test]
fn a_record_adds_each_token_class_under_its_type() {
let exporter = InMemoryMetricExporter::default();
let reader = PeriodicReader::builder(exporter.clone()).build();
let provider = SdkMeterProvider::builder().with_reader(reader).build();
let cost = Cost {
repo: "hyperhive/hyperhive".to_owned(),
pr: 5245,
model: "some-model".to_owned(),
amount: 0.0,
usage: usage(100, 20, 9_000, 400),
};
add(&counters(&provider), &cost);
provider.force_flush().expect("flush");
let metrics = exporter.get_finished_metrics().expect("metrics");
let metric = metrics
.iter()
.flat_map(ResourceMetrics::scope_metrics)
.flat_map(ScopeMetrics::metrics)
.find(|m| m.name() == "hyperhive.review_classifier.token.usage")
.expect("the token counter was exported");
let AggregatedMetrics::U64(MetricData::Sum(sum)) = metric.data() else {
panic!("not a u64 sum: {:?}", metric.data());
};
let mut by_type: Vec<(String, u64, usize)> = sum
.data_points()
.map(|point| {
let kind = point
.attributes()
.find(|kv| kv.key.as_str() == "type")
.map(|kv| kv.value.to_string())
.unwrap_or_default();
(kind, point.value(), point.attributes().count())
})
.collect();
by_type.sort();
assert_eq!(
by_type,
[
("cacheCreation".to_owned(), 400, 4),
("cacheRead".to_owned(), 9_000, 4),
("input".to_owned(), 100, 4),
("output".to_owned(), 20, 4),
]
);
}
}