Emit turn cost telemetry for ChatGPT sessions (#41944)

## What changed

- Query workspace-visible turn estimates for ChatGPT-authenticated sessions through the appropriate Codex or ChatGPT endpoint.
- Track completed response IDs and emit `codex.turn.cost_microusd` only when the estimate is nonnegative, visible, and covers every observed response.
- Keep error response bodies out of turn-cost worker logs.

## Testing

- Cover both endpoint styles, nullable estimates, settlement validation, exact micro-USD conversion, and response-ID tracking.
- Add an app-server integration test that verifies a settled ChatGPT turn reaches the OTLP metrics exporter with its turn, conversation, and auth-mode attributes.

GitOrigin-RevId: 6ae3f8df729fad52984a0c6efafd8c96c7214e55
This commit is contained in:
xli-oai authored and copyberry committed 2026-09-01 00:30:55 +00:00
1 parent 9c7edd4bc3
commit e39ab0c185
10 files changed
+776 -32

No files matched your search

@@ -0,0 +1,81 @@
//! Queries SiWC per-turn estimates using the client's Codex or WHAM route and auth.
//! Always request settled response IDs and retain thread/turn association and missing amounts.
use super::Client;
use super::PathStyle;
use super::RequestError;
use http::Method;
use serde::Deserialize;
use serde::Serialize;
use std::collections::BTreeMap;
/// Workspace-visible estimated cost and settled responses for a ChatGPT turn.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct ChatgptTurnCost {
pub turn_id: String,
pub model: Option<String>,
pub estimated_usage_usd_micros: Option<i64>,
pub settled_response_ids: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct ChatgptThreadTurnCosts {
pub thread_id: String,
pub turns: Vec<ChatgptTurnCost>,
}
#[derive(Serialize)]
struct ThreadTurnIds<'a> {
thread_id: &'a str,
turn_ids: &'a [String],
}
#[derive(Serialize)]
struct TurnCostsRequest<'a> {
threads: Vec<ThreadTurnIds<'a>>,
include_settled_response_ids: bool,
}
#[derive(Deserialize)]
struct TurnCostsResponse {
threads: Vec<ChatgptThreadTurnCosts>,
}
impl Client {
/// Queries per-turn estimates using the signed-in user's workspace permissions.
pub async fn query_chatgpt_turn_costs(
&self,
threads: &BTreeMap<String, Vec<String>>,
) -> Result<Vec<ChatgptThreadTurnCosts>, RequestError> {
let url = match self.path_style {
PathStyle::CodexApi => {
format!("{}/api/codex/usage/thread-estimates/query", self.base_url)
}
PathStyle::ChatGptApi => {
format!("{}/wham/usage/thread-estimates/query", self.base_url)
}
};
let request = self
.request(Method::POST, &url)
.headers(self.headers())
.json(&TurnCostsRequest {
threads: threads
.iter()
.map(|(thread_id, turn_ids)| ThreadTurnIds {
thread_id,
turn_ids,
})
.collect(),
include_settled_response_ids: true,
});
let (body, content_type) = self.exec_request_detailed(request, "POST", &url).await?;
let response: TurnCostsResponse = self
.decode_json(&url, &content_type, &body)
.map_err(RequestError::Other)?;
Ok(response.threads)
}
}
#[cfg(test)]
#[path = "chatgpt_turn_cost_tests.rs"]
mod tests;
@@ -0,0 +1,108 @@
//! Exercises both authenticated SiWC routes and preserves nullable/omitted estimate fields.
use super::*;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_login::CodexAuth;
use pretty_assertions::assert_eq;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::body_json;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[tokio::test]
async fn chatgpt_turn_costs_use_workspace_auth_and_chatgpt_path() {
check_query(
"/backend-api",
"/backend-api/wham/usage/thread-estimates/query",
)
.await;
}
#[tokio::test]
async fn chatgpt_turn_costs_use_workspace_auth_and_codex_path() {
check_query("", "/api/codex/usage/thread-estimates/query").await;
}
async fn check_query(base_path: &str, endpoint: &str) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(endpoint))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.and(header("content-type", "application/json"))
.and(body_json(serde_json::json!({
"threads": [
{"thread_id": "thread-a", "turn_ids": ["turn-a"]},
{"thread_id": "thread-b", "turn_ids": ["turn-b", "turn-c"]}
],
"include_settled_response_ids": true
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"threads": [
{"thread_id": "thread-a", "turns": [{
"turn_id": "turn-a", "model": "gpt-5.6",
"estimated_usage_usd_micros": 1250001,
"settled_response_ids": ["resp-a", "resp-b"]
}]},
{"thread_id": "thread-b", "turns": [
{"turn_id": "turn-b", "estimated_usage_usd_micros": null,
"settled_response_ids": null},
{"turn_id": "turn-c", "estimated_usage_usd_micros": 0}
]}
]
})))
.expect(1)
.mount(&server)
.await;
let client = Client::from_auth(
format!("{}{base_path}", server.uri()),
&CodexAuth::create_dummy_chatgpt_auth_for_testing(),
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
);
let result = client
.query_chatgpt_turn_costs(&BTreeMap::from([
("thread-a".to_string(), vec!["turn-a".to_string()]),
(
"thread-b".to_string(),
vec!["turn-b".to_string(), "turn-c".to_string()],
),
]))
.await
.expect("ChatGPT turn costs");
assert_eq!(
result,
vec![
ChatgptThreadTurnCosts {
thread_id: "thread-a".to_string(),
turns: vec![ChatgptTurnCost {
turn_id: "turn-a".to_string(),
model: Some("gpt-5.6".to_string()),
estimated_usage_usd_micros: Some(1250001),
settled_response_ids: Some(vec!["resp-a".to_string(), "resp-b".to_string()]),
}],
},
ChatgptThreadTurnCosts {
thread_id: "thread-b".to_string(),
turns: vec![
ChatgptTurnCost {
turn_id: "turn-b".to_string(),
model: None,
estimated_usage_usd_micros: None,
settled_response_ids: None,
},
ChatgptTurnCost {
turn_id: "turn-c".to_string(),
model: None,
estimated_usage_usd_micros: Some(0),
settled_response_ids: None,
},
],
},
]
);
server.verify().await;
}