Skip to content

Commit 68ce046

Browse files
committed
fix(desktop): count the paused leg's tokens and web searches
A streaming turn that Anthropic pauses (`stop_reason: pause_turn`) is finished by a non-streaming continuation whose result is spliced into the open SSE body. That splice reported usage as `merge_stream_usage(message_start, continuation)`, which takes output tokens from the continuation alone — so the streamed leg was dropped from both the client's usage chunk and the Firestore `record_llm_usage` row. The dropped leg is not a rounding error: it is the leg that ran the web search. A turn pauses *because* a server tool ran, so its `usage.server_tool_use` carries the `web_search_requests` Anthropic bills per request (see `compute_cost`, `WEB_SEARCH_COST_PER_REQUEST`), plus every output token spent before the pause (thinking and the search query). Every paused turn was therefore billed as if its first half never happened. The non-streaming lane already sums every leg (`accumulate_anthropic_usage` in transport.rs). Extract `paused_turn_total_usage()` so the streaming splice totals the same way: merge `message_start` with the pausing `message_delta` to make the streamed leg whole, then accumulate the continuation on top. A pause that reported no usage falls back to `message_start`, never to less than before. Verified: cargo test 382 passed; both new tests fail on the pre-fix expression (input 130 vs 230, 110 vs 200) and pass on the fix; cargo fmt --check and cargo clippy --all-targets -D warnings clean. The live Anthropic pause_turn round trip is not driveable on demand and the upstream URL is a constant with no test seam, so the fix is proven through the production usage-totalling function rather than against the provider. Failure-Class: none
1 parent 188dd54 commit 68ce046

3 files changed

Lines changed: 87 additions & 3 deletions

File tree

desktop/macos/Backend-Rust/src/routes/chat/streaming.rs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ use super::request_translation::{compute_cost, response_text_content};
1616
use super::response_or_500;
1717
use super::sse::{drain_sse_events, make_chunk, sse_line, stream_termination_chunks};
1818
use super::transport::{
19-
append_pause_turn_continuation, complete_anthropic_server_tool_turn, send_anthropic_with_retry,
19+
accumulate_anthropic_usage, append_pause_turn_continuation,
20+
complete_anthropic_server_tool_turn, send_anthropic_with_retry,
2021
};
2122

2223
/// How long a streaming turn may go without a single byte from Anthropic before we end it.
@@ -167,6 +168,28 @@ pub(super) fn continuation_delta_chunks(
167168
chunks
168169
}
169170

171+
/// Total the usage of a turn that paused: the streamed leg plus the continuation.
172+
///
173+
/// The paused leg is the leg that ran the server tool — it holds the output tokens
174+
/// generated before the pause *and* the `web_search_requests` Anthropic bills per
175+
/// request. Reporting only the continuation's usage drops both, so a paused turn is
176+
/// billed as if its first half never happened. The non-streaming lane already sums
177+
/// every leg (`accumulate_anthropic_usage`); the streaming splice totals the same way.
178+
pub(super) fn paused_turn_total_usage(
179+
initial: Option<&AnthropicUsage>,
180+
streamed_final: Option<&AnthropicUsage>,
181+
continuation: &AnthropicUsage,
182+
) -> AnthropicUsage {
183+
// `message_start` carries the input side, `message_delta` the output side, so the
184+
// streamed leg is only whole once the two are merged.
185+
let mut total = match streamed_final {
186+
Some(final_usage) => merge_stream_usage(initial, final_usage),
187+
None => initial.cloned().unwrap_or_default(),
188+
};
189+
accumulate_anthropic_usage(&mut total, continuation);
190+
total
191+
}
192+
170193
fn append_string_field(block: &mut Value, key: &str, suffix: &str) {
171194
if let Some(current) = block.get_mut(key).and_then(|value| value.as_str()) {
172195
let mut combined = current.to_string();
@@ -547,7 +570,11 @@ where
547570
);
548571
yield Ok(sse_line(&chunk_val));
549572

550-
let merged = merge_stream_usage(initial_usage.as_ref(), &anthropic_resp.usage);
573+
let merged = paused_turn_total_usage(
574+
initial_usage.as_ref(),
575+
final_usage.as_ref(),
576+
&anthropic_resp.usage,
577+
);
551578
let openai_usage = anthropic_usage_to_openai(&merged);
552579
let usage_chunk = ChatCompletionChunk {
553580
id: stream_id.clone(),

desktop/macos/Backend-Rust/src/routes/chat/tests/all.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1329,6 +1329,63 @@ fn pause_turn_continuation_tool_ordinals_continue_from_the_streamed_prefix() {
13291329
);
13301330
}
13311331

1332+
/// Regression: a paused turn used to report only its continuation's usage, so the
1333+
/// leg that actually ran the web search — its output tokens and its billed
1334+
/// `web_search_requests` — was never recorded. Every paused turn under-billed.
1335+
#[test]
1336+
fn paused_turn_usage_totals_the_streamed_leg_and_the_continuation() {
1337+
let initial: AnthropicUsage = serde_json::from_value(json!({
1338+
"input_tokens": 100,
1339+
"cache_read_input_tokens": 20
1340+
}))
1341+
.expect("message_start usage must decode");
1342+
let streamed_final: AnthropicUsage = serde_json::from_value(json!({
1343+
"output_tokens": 40,
1344+
"server_tool_use": {"web_search_requests": 1}
1345+
}))
1346+
.expect("message_delta usage must decode");
1347+
let continuation: AnthropicUsage = serde_json::from_value(json!({
1348+
"input_tokens": 130,
1349+
"output_tokens": 25,
1350+
"server_tool_use": {"web_search_requests": 1}
1351+
}))
1352+
.expect("continuation usage must decode");
1353+
1354+
let total = paused_turn_total_usage(Some(&initial), Some(&streamed_final), &continuation);
1355+
1356+
assert_eq!(total.input_tokens, 230);
1357+
assert_eq!(total.cache_read_input_tokens, 20);
1358+
assert_eq!(
1359+
total.output_tokens, 65,
1360+
"the paused leg's output is billable"
1361+
);
1362+
assert_eq!(
1363+
total
1364+
.server_tool_use
1365+
.as_ref()
1366+
.map(|tool_use| tool_use.web_search_requests),
1367+
Some(2),
1368+
"both legs' web searches are billed per request"
1369+
);
1370+
}
1371+
1372+
/// A `message_delta` may carry no usage at all. The turn then totals what
1373+
/// `message_start` reported plus the continuation, never less than before.
1374+
#[test]
1375+
fn paused_turn_usage_falls_back_to_message_start_when_the_pause_reported_none() {
1376+
let initial: AnthropicUsage =
1377+
serde_json::from_value(json!({"input_tokens": 90})).expect("usage must decode");
1378+
let continuation: AnthropicUsage =
1379+
serde_json::from_value(json!({"input_tokens": 110, "output_tokens": 7}))
1380+
.expect("usage must decode");
1381+
1382+
let total = paused_turn_total_usage(Some(&initial), None, &continuation);
1383+
1384+
assert_eq!(total.input_tokens, 200);
1385+
assert_eq!(total.output_tokens, 7);
1386+
assert!(total.server_tool_use.is_none());
1387+
}
1388+
13321389
#[test]
13331390
fn test_translate_request_degrades_when_web_search_disabled() {
13341391
let mut req = test_request(vec![user_message("Search the web for HumanPost")]);

desktop/macos/Backend-Rust/src/routes/chat/transport.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ async fn receive_anthropic_response(
153153
})
154154
}
155155

156-
fn accumulate_anthropic_usage(total: &mut AnthropicUsage, usage: &AnthropicUsage) {
156+
pub(super) fn accumulate_anthropic_usage(total: &mut AnthropicUsage, usage: &AnthropicUsage) {
157157
total.input_tokens += usage.input_tokens;
158158
total.output_tokens += usage.output_tokens;
159159
total.cache_creation_input_tokens += usage.cache_creation_input_tokens;

0 commit comments

Comments
 (0)