Add per-thread usage queries to the backend client (#38270)

## What changed

- Add `Client::get_thread_usage` for querying authoritative estimated credit
  and dollar usage for a thread across both supported backend path styles.
- Expose usage totals and optional model, reasoning effort, speed, and token
  breakdowns through `ThreadUsage` and `ThreadUsageBreakdownGroup`.
- Reject responses that do not include the requested thread.

## Testing

- Cover endpoint paths, request serialization, complete and partial usage
  responses, and mismatched thread IDs.

GitOrigin-RevId: 16c83590b2c804e0182b4493cf2aa39206029c77
This commit is contained in:
Felipe Coury
2026-08-12 22:58:46 +00:00
committed by copyberry
parent 5664a5c07c
commit 842fae26c9
4 changed files with 240 additions and 0 deletions
+4
View File
@@ -35,6 +35,10 @@ use serde::de::DeserializeOwned;
use std::fmt;
mod rate_limit_resets;
mod thread_usage;
pub use thread_usage::ThreadUsage;
pub use thread_usage::ThreadUsageBreakdownGroup;
#[derive(Debug)]
pub enum RequestError {
@@ -0,0 +1,88 @@
//! Authoritative estimated credit and dollar usage for an individual Codex thread.
use super::Client;
use super::PathStyle;
use super::RequestError;
use anyhow::anyhow;
use http::Method;
use http::header::CONTENT_TYPE;
use http::header::HeaderValue;
use serde::Deserialize;
use serde::Serialize;
/// Backend usage grouped by model, reasoning effort, and response speed.
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct ThreadUsageBreakdownGroup {
pub model: Option<String>,
pub reasoning_effort: Option<String>,
pub speed: Option<String>,
pub estimated_usage_credits_micros: i64,
pub net_new_input_tokens: Option<i64>,
pub cached_input_tokens: Option<i64>,
pub input_tokens: Option<i64>,
pub output_tokens: Option<i64>,
pub total_tokens: Option<i64>,
}
/// Backend-estimated usage totals expressed in integer millionths.
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
pub struct ThreadUsage {
pub thread_id: String,
pub estimated_usage_credits_micros: i64,
pub estimated_usage_usd_micros: Option<i64>,
#[serde(default)]
pub groups: Vec<ThreadUsageBreakdownGroup>,
}
#[derive(Serialize)]
struct ThreadUsageQueryRequest<'a> {
thread_ids: [&'a str; 1],
}
#[derive(Deserialize)]
struct ThreadUsageQueryResponse {
threads: Vec<ThreadUsage>,
}
impl Client {
/// Reads authoritative estimated totals without maintaining a second usage ledger.
pub async fn get_thread_usage(&self, thread_id: &str) -> Result<ThreadUsage, RequestError> {
let url = self.thread_usage_url();
let request = self
.request(Method::POST, &url)
.headers(self.headers())
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.json(&ThreadUsageQueryRequest {
thread_ids: [thread_id],
});
let (body, content_type) = self.exec_request_detailed(request, "POST", &url).await?;
let response = self
.decode_json::<ThreadUsageQueryResponse>(&url, &content_type, &body)
.map_err(RequestError::from)?;
response
.threads
.into_iter()
.find(|usage| usage.thread_id == thread_id)
.ok_or_else(|| {
RequestError::from(anyhow!(
"thread usage response did not contain requested thread {thread_id}"
))
})
}
fn thread_usage_url(&self) -> String {
match self.path_style {
PathStyle::CodexApi => {
format!("{}/api/codex/usage/thread_usage/query", self.base_url)
}
PathStyle::ChatGptApi => {
format!("{}/wham/usage/thread_usage/query", self.base_url)
}
}
}
}
#[cfg(test)]
#[path = "thread_usage_tests.rs"]
mod tests;
@@ -0,0 +1,146 @@
use super::*;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use pretty_assertions::assert_eq;
use serde_json::json;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_json;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[test]
fn thread_usage_contract_uses_expected_paths_and_payload() {
let factory = HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault);
assert_eq!(
Client::new("https://example.test", factory.clone()).thread_usage_url(),
"https://example.test/api/codex/usage/thread_usage/query"
);
assert_eq!(
Client::new("https://chatgpt.com/backend-api", factory).thread_usage_url(),
"https://chatgpt.com/backend-api/wham/usage/thread_usage/query"
);
assert_eq!(
serde_json::to_value(ThreadUsageQueryRequest {
thread_ids: ["thread-123"],
})
.expect("serialize thread usage request"),
json!({ "thread_ids": ["thread-123"] })
);
}
#[tokio::test]
async fn get_thread_usage_returns_requested_thread_totals() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/codex/usage/thread_usage/query"))
.and(body_json(json!({ "thread_ids": ["thread-123"] })))
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
"threads": [{
"thread_id": "thread-123",
"estimated_usage_credits_micros": 46_000_000,
"estimated_usage_usd_micros": 1_820_000,
"groups": [{
"model": "gpt-5.4",
"reasoning_effort": "high",
"speed": "fast",
"estimated_usage_credits_micros": 46_000_000,
"net_new_input_tokens": 80,
"cached_input_tokens": 20,
"input_tokens": 100,
"output_tokens": 40,
"total_tokens": 140
}]
}]
})))
.expect(/*r*/ 1)
.mount(&server)
.await;
let client = Client::new(
server.uri(),
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
);
assert_eq!(
client
.get_thread_usage("thread-123")
.await
.expect("read thread usage"),
ThreadUsage {
thread_id: "thread-123".to_string(),
estimated_usage_credits_micros: 46_000_000,
estimated_usage_usd_micros: Some(1_820_000),
groups: vec![ThreadUsageBreakdownGroup {
model: Some("gpt-5.4".to_string()),
reasoning_effort: Some("high".to_string()),
speed: Some("fast".to_string()),
estimated_usage_credits_micros: 46_000_000,
net_new_input_tokens: Some(80),
cached_input_tokens: Some(20),
input_tokens: Some(100),
output_tokens: Some(40),
total_tokens: Some(140),
}],
}
);
}
#[tokio::test]
async fn get_thread_usage_accepts_credits_without_usd_estimate() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/codex/usage/thread_usage/query"))
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
"threads": [{
"thread_id": "thread-123",
"estimated_usage_credits_micros": 46_000_000,
"estimated_usage_usd_micros": null
}]
})))
.expect(/*r*/ 1)
.mount(&server)
.await;
let client = Client::new(
server.uri(),
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
);
assert_eq!(
client
.get_thread_usage("thread-123")
.await
.expect("read credits without a dollar estimate"),
ThreadUsage {
thread_id: "thread-123".to_string(),
estimated_usage_credits_micros: 46_000_000,
estimated_usage_usd_micros: None,
groups: Vec::new(),
}
);
}
#[tokio::test]
async fn get_thread_usage_rejects_totals_for_another_thread() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(/*s*/ 200).set_body_json(json!({
"threads": [{
"thread_id": "another-thread",
"estimated_usage_credits_micros": 1,
"estimated_usage_usd_micros": 1
}]
})))
.mount(&server)
.await;
let client = Client::new(
server.uri(),
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
);
let error = client
.get_thread_usage("thread-123")
.await
.expect_err("reject usage for a different thread");
assert!(error.to_string().contains("requested thread thread-123"));
}
+2
View File
@@ -4,6 +4,8 @@ pub(crate) mod types;
pub use client::AddCreditsNudgeCreditType;
pub use client::Client;
pub use client::RequestError;
pub use client::ThreadUsage;
pub use client::ThreadUsageBreakdownGroup;
pub use types::AccountEntry;
pub use types::AccountsCheckResponse;
pub use types::CodeTaskDetailsResponse;