Account for token usage from incomplete responses (#53670)

## Why

Terminal `response.incomplete` errors discard reported token usage. Counting that usage as a new context baseline can also double-count input and trigger unnecessary compaction on the next turn.

## What changed

- Carry the response ID and valid reported usage with incomplete-response errors.
- Record usage for sampling and foreground compaction in cumulative totals, rollout records, budgets, and token-usage contributors without replacing the context baseline. Preserve the original terminal error, including when accounting exhausts the budget or cancellation races the error.
- Charge incomplete background compaction requests to the rollout budget without publishing foreground token usage.
- Ignore missing or malformed usage rather than estimating it.

## Testing

Extend coverage for SSE and WebSocket incomplete responses, rollout usage records, compaction accounting, deferred token-count events, and token-usage contributors. Add regressions for unnecessary compaction on the next turn and background compaction publishing foreground usage.

GitOrigin-RevId: bf1ee6df3912052ab7c19e1c6af8ad3dc4b07675
This commit is contained in:
jif
2026-10-13 13:36:24 +00:00
committed by copyberry
parent a68a7a6ab0
commit 816fa702ec
12 changed files with 383 additions and 29 deletions
+17 -1
View File
@@ -11,6 +11,7 @@ use crate::telemetry::SseTelemetry;
use codex_client::ByteStream;
use codex_client::StreamResponse;
use codex_protocol::ResponseUsageMetadata;
use codex_protocol::error::ResponseIncompleteError;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::ModelVerification;
use codex_protocol::protocol::TokenUsage;
@@ -429,8 +430,23 @@ pub fn process_responses_event(
return Err(ResponsesEventError::Api(ApiError::ContentFilter));
}
if reason != "interrupted" {
let response = event.response.as_ref();
let response_id = response
.and_then(|response| response.get("id"))
.and_then(Value::as_str)
.map(str::to_owned);
let token_usage = response
.and_then(|response| response.get("usage"))
.and_then(|usage| {
serde_json::from_value::<ResponseCompletedUsage>(usage.clone()).ok()
})
.map(Into::into);
return Err(ResponsesEventError::Api(ApiError::ResponseIncomplete(
reason.to_string(),
Box::new(ResponseIncompleteError {
reason: reason.to_string(),
response_id,
token_usage,
}),
)));
}
}