mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
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:
@@ -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"));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user