mirror of
https://github.com/openai/codex.git
synced 2026-11-13 06:45:07 +00:00
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:
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;
|
||||
}
|
||||
Reference in new issue
Block a user