Emit API-key turn cost telemetry from app-server (#39006)

## What changed

- Track app-server turn lifecycle events for OpenAI API-key sessions when OTLP telemetry export is enabled.
- Query the turn-cost endpoint after completed or aborted turns and emit `codex.turn_cost` with the estimated USD cost, interruption state, model, speed, and reasoning effort.
- Preserve provider organization and project headers, wait for all observed responses to be priced, and retry pending or transiently failed queries with bounded tracking.
- Re-probe availability after authentication changes and shut the worker down with other app-server background tasks.

## Testing

- Cover late API-key login, transient probe failures, telemetry after thread removal, and turns with multiple responses.
- Verify turn-cost requests use API-key authentication and provider scope headers and deserialize priced response data.

GitOrigin-RevId: a303162a54798b554fcb2e9e2b372e1cf9f49f39
This commit is contained in:
xli-oai
2026-08-17 13:20:58 +00:00
committed by copyberry
parent 02e9bfaac7
commit 04caa22c82
11 changed files with 934 additions and 0 deletions
+1
View File
@@ -121,6 +121,7 @@ mod skills_watcher;
mod thread_state;
mod thread_status;
mod transport;
mod turn_cost_worker;
pub use crate::code_mode_host::AppServerCodeModeHostArgs;
pub use crate::code_mode_host::CodeModeHostTransport;
@@ -52,6 +52,7 @@ use crate::thread_state::ConnectionCapabilities;
use crate::thread_state::ThreadStateManager;
use crate::transport::AppServerTransport;
use crate::transport::RemoteControlHandle;
use crate::turn_cost_worker::TurnCostWorker;
use codex_analytics::AnalyticsEventsClient;
use codex_analytics::AppServerRpcTransport;
use codex_app_server_protocol::ClientNotification;
@@ -131,6 +132,7 @@ fn reject_removed_permission_profile(request: &JSONRPCRequest) -> Result<(), JSO
pub(crate) struct MessageProcessor {
outgoing: Arc<OutgoingMessageSender>,
models_refresh_worker: ModelsRefreshWorker,
turn_cost_worker: Option<TurnCostWorker>,
skills_watcher: Arc<SkillsWatcher>,
account_processor: AccountRequestProcessor,
apps_processor: AppsRequestProcessor,
@@ -358,6 +360,8 @@ impl MessageProcessor {
let models_manager = thread_manager.get_models_manager();
let models_refresh_worker =
crate::models_refresh_worker::spawn(&models_manager, config.http_client_factory());
let turn_cost_worker =
TurnCostWorker::spawn(Arc::clone(&config), Arc::clone(&auth_manager));
thread_manager
.plugins_manager()
.set_analytics_events_client(analytics_events_client.clone());
@@ -491,6 +495,7 @@ impl MessageProcessor {
state_db.clone(),
log_db,
Arc::clone(&skills_watcher),
turn_cost_worker.as_ref().map(TurnCostWorker::handle),
config_warnings,
);
let turn_processor = TurnRequestProcessor::new(
@@ -506,6 +511,7 @@ impl MessageProcessor {
thread_watch_manager,
thread_list_state_permit,
Arc::clone(&skills_watcher),
turn_cost_worker.as_ref().map(TurnCostWorker::handle),
);
if matches!(plugin_startup_tasks, crate::PluginStartupTasks::Start) {
// Keep plugin startup warmups aligned at app-server startup.
@@ -546,6 +552,7 @@ impl MessageProcessor {
Self {
outgoing,
models_refresh_worker,
turn_cost_worker,
skills_watcher,
account_processor,
apps_processor,
@@ -755,6 +762,9 @@ impl MessageProcessor {
pub(crate) async fn drain_background_tasks(&self) {
self.models_refresh_worker.shutdown();
if let Some(worker) = &self.turn_cost_worker {
worker.shutdown();
}
self.thread_processor.drain_background_tasks().await;
}
@@ -17,6 +17,7 @@ pub(super) struct ListenerTaskContext {
pub(super) fallback_model_provider: String,
pub(super) codex_home: PathBuf,
pub(super) skills_watcher: Arc<SkillsWatcher>,
pub(super) turn_cost_worker: Option<crate::turn_cost_worker::TurnCostWorkerHandle>,
}
struct UnloadingState {
@@ -274,6 +275,7 @@ pub(super) async fn ensure_listener_task_running(
thread_list_state_permit,
fallback_model_provider,
codex_home,
turn_cost_worker,
..
} = listener_task_context;
let outgoing_for_task = Arc::clone(&outgoing);
@@ -311,6 +313,12 @@ pub(super) async fn ensure_listener_task_running(
}
};
if let Some(worker) = &turn_cost_worker {
worker.observe_event(conversation_id, &event, || {
conversation.session_telemetry()
});
}
// Track the event before emitting any typed translations
// so thread-local state such as raw event opt-in stays
// synchronized with the conversation.
@@ -481,6 +481,7 @@ pub(crate) struct ThreadRequestProcessor {
pub(super) log_db: Option<LogDbLayer>,
pub(super) background_tasks: TaskTracker,
pub(super) skills_watcher: Arc<SkillsWatcher>,
pub(super) turn_cost_worker: Option<crate::turn_cost_worker::TurnCostWorkerHandle>,
pub(super) initial_config_warnings: Arc<Vec<ConfigWarningNotification>>,
}
@@ -513,6 +514,7 @@ impl ThreadRequestProcessor {
state_db: Option<StateDbHandle>,
log_db: Option<LogDbLayer>,
skills_watcher: Arc<SkillsWatcher>,
turn_cost_worker: Option<crate::turn_cost_worker::TurnCostWorkerHandle>,
initial_config_warnings: Vec<ConfigWarningNotification>,
) -> Self {
Self {
@@ -532,6 +534,7 @@ impl ThreadRequestProcessor {
log_db,
background_tasks: TaskTracker::new(),
skills_watcher,
turn_cost_worker,
initial_config_warnings: Arc::new(initial_config_warnings),
}
}
@@ -1058,6 +1061,7 @@ impl ThreadRequestProcessor {
fallback_model_provider: self.config.model_provider_id.clone(),
codex_home: self.config.codex_home.to_path_buf(),
skills_watcher: Arc::clone(&self.skills_watcher),
turn_cost_worker: self.turn_cost_worker.clone(),
}
}
@@ -1188,6 +1192,7 @@ impl ThreadRequestProcessor {
fallback_model_provider: self.config.model_provider_id.clone(),
codex_home: self.config.codex_home.to_path_buf(),
skills_watcher: Arc::clone(&self.skills_watcher),
turn_cost_worker: self.turn_cost_worker.clone(),
};
let request_trace = request_context.request_trace();
let config_manager = self.config_manager.clone();
@@ -100,6 +100,7 @@ pub(crate) struct TurnRequestProcessor {
thread_watch_manager: ThreadWatchManager,
thread_list_state_permit: Arc<Semaphore>,
skills_watcher: Arc<SkillsWatcher>,
turn_cost_worker: Option<crate::turn_cost_worker::TurnCostWorkerHandle>,
}
fn map_additional_context(
@@ -155,6 +156,7 @@ impl TurnRequestProcessor {
thread_watch_manager: ThreadWatchManager,
thread_list_state_permit: Arc<Semaphore>,
skills_watcher: Arc<SkillsWatcher>,
turn_cost_worker: Option<crate::turn_cost_worker::TurnCostWorkerHandle>,
) -> Self {
let agent_runner = AgentRunner::new(Arc::downgrade(&thread_manager));
Self {
@@ -171,6 +173,7 @@ impl TurnRequestProcessor {
thread_watch_manager,
thread_list_state_permit,
skills_watcher,
turn_cost_worker,
}
}
@@ -1516,6 +1519,7 @@ impl TurnRequestProcessor {
fallback_model_provider: self.config.model_provider_id.clone(),
codex_home: self.config.codex_home.to_path_buf(),
skills_watcher: Arc::clone(&self.skills_watcher),
turn_cost_worker: self.turn_cost_worker.clone(),
}
}
+454
View File
@@ -0,0 +1,454 @@
use codex_backend_client::ApiKeyTurnCost;
use codex_backend_client::ApiKeyTurnCostStatus;
use codex_backend_client::Client as BackendClient;
use codex_config::types::OtelExporterKind;
use codex_core::config::Config;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_otel::SessionTelemetry;
use codex_protocol::ThreadId;
use codex_protocol::auth::AuthMode;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tracing::warn;
const POLL_INTERVAL: Duration = Duration::from_secs(150);
const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
const OBSERVATION_CHANNEL_CAPACITY: usize = 16_384;
const MAX_TRACKED_TURNS: usize = 4_096;
const MAX_QUERY_TURNS: usize = 100;
const MAX_STALLED_POLL_ATTEMPTS: u8 = 5;
pub(crate) struct TurnCostWorker {
handle: TurnCostWorkerHandle,
shutdown: CancellationToken,
_task: JoinHandle<()>,
}
#[derive(Clone)]
pub(crate) struct TurnCostWorkerHandle {
sender: mpsc::Sender<TurnCostObservation>,
auth_manager: Arc<AuthManager>,
}
enum TurnCostObservationKind {
Started {
session_telemetry: Box<SessionTelemetry>,
},
ResponseCompleted,
Finished {
interrupted: bool,
},
}
struct TurnCostObservation {
thread_id: ThreadId,
turn_id: String,
kind: TurnCostObservationKind,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum TurnCostStatus {
Running,
Completed,
Interrupted,
}
struct TurnCostEntry {
thread_id: ThreadId,
session_telemetry: SessionTelemetry,
expected_response_count: u64,
status: TurnCostStatus,
next_poll_at: Instant,
attempt_count: u8,
}
struct WorkerRuntime {
config: Arc<Config>,
auth_manager: Arc<AuthManager>,
turns: HashMap<String, TurnCostEntry>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BackendAvailability {
AwaitingAuthChange,
RetryProbe,
Ready,
Disabled,
}
impl TurnCostWorker {
pub(crate) fn spawn(config: Arc<Config>, auth_manager: Arc<AuthManager>) -> Option<Self> {
if !matches!(
config.otel.exporter,
OtelExporterKind::OtlpHttp { .. } | OtelExporterKind::OtlpGrpc { .. }
) || !config.model_provider.is_openai()
{
return None;
}
let (sender, receiver) = mpsc::channel(OBSERVATION_CHANNEL_CAPACITY);
let shutdown = CancellationToken::new();
let runtime = WorkerRuntime {
config: Arc::clone(&config),
auth_manager: Arc::clone(&auth_manager),
turns: HashMap::new(),
};
let worker_shutdown = shutdown.clone();
let task = tokio::spawn(async move {
runtime.run(receiver, worker_shutdown).await;
});
Some(Self {
handle: TurnCostWorkerHandle {
sender,
auth_manager,
},
shutdown,
_task: task,
})
}
pub(crate) fn handle(&self) -> TurnCostWorkerHandle {
self.handle.clone()
}
pub(crate) fn shutdown(&self) {
self.shutdown.cancel();
}
}
impl Drop for TurnCostWorker {
fn drop(&mut self) {
self.shutdown();
}
}
impl TurnCostWorkerHandle {
pub(crate) fn observe_event(
&self,
thread_id: ThreadId,
event: &Event,
session_telemetry: impl FnOnce() -> SessionTelemetry,
) {
let Some(auth) = self.auth_manager.auth_cached() else {
return;
};
if !auth.is_api_key_auth() {
return;
}
let kind = match &event.msg {
EventMsg::TurnStarted(_) => TurnCostObservationKind::Started {
session_telemetry: Box::new(session_telemetry()),
},
EventMsg::RawResponseCompleted(_) => TurnCostObservationKind::ResponseCompleted,
EventMsg::TurnComplete(_) => TurnCostObservationKind::Finished { interrupted: false },
EventMsg::TurnAborted(_) => TurnCostObservationKind::Finished { interrupted: true },
_ => return,
};
let _ = self.sender.try_send(TurnCostObservation {
thread_id,
turn_id: event.id.clone(),
kind,
});
}
}
impl WorkerRuntime {
async fn run(self, receiver: mpsc::Receiver<TurnCostObservation>, shutdown: CancellationToken) {
let auth_changes = self.auth_manager.auth_change_receiver();
let backend_availability = self.probe_backend().await;
self.run_with_backend_availability(receiver, shutdown, auth_changes, backend_availability)
.await;
}
async fn run_with_backend_availability(
mut self,
mut receiver: mpsc::Receiver<TurnCostObservation>,
shutdown: CancellationToken,
mut auth_changes: tokio::sync::watch::Receiver<u64>,
mut backend_availability: BackendAvailability,
) {
let mut ticker = tokio::time::interval(POLL_INTERVAL);
ticker.tick().await;
loop {
tokio::select! {
biased;
_ = shutdown.cancelled() => break,
changed = auth_changes.changed() => {
if changed.is_err() {
break;
}
self.turns.clear();
while receiver.try_recv().is_ok() {}
backend_availability = self.probe_backend().await;
}
observation = receiver.recv() => {
let Some(observation) = observation else {
break;
};
if !matches!(
backend_availability,
BackendAvailability::Ready | BackendAvailability::RetryProbe
) {
continue;
}
self.record_observation(observation);
}
_ = ticker.tick() => {
match backend_availability {
BackendAvailability::Ready => self.poll_due().await,
BackendAvailability::RetryProbe => {
backend_availability = self.probe_backend().await;
}
BackendAvailability::AwaitingAuthChange
| BackendAvailability::Disabled => {}
}
}
}
}
}
async fn probe_backend(&self) -> BackendAvailability {
let Some(auth) = self.auth_manager.auth().await else {
return BackendAvailability::AwaitingAuthChange;
};
if !auth.is_api_key_auth() {
return BackendAvailability::AwaitingAuthChange;
}
let provider = match self
.config
.model_provider
.to_api_provider(Some(AuthMode::ApiKey))
{
Ok(provider) => provider,
Err(error) => {
warn!("failed to resolve OpenAI API-key provider headers: {error}");
return BackendAvailability::RetryProbe;
}
};
let client = BackendClient::from_auth(
self.config.chatgpt_base_url.clone(),
&auth,
self.config.http_client_factory(),
);
let probe_turn_ids = [uuid::Uuid::new_v4().to_string()];
match tokio::time::timeout(
REQUEST_TIMEOUT,
client.query_api_key_turn_costs(&probe_turn_ids, &provider.headers),
)
.await
{
Ok(Ok(_)) => BackendAvailability::Ready,
Ok(Err(error)) => match error.status().map(|status| status.as_u16()) {
Some(401 | 403) => {
tracing::debug!(
"turn cost worker waiting for auth change after backend availability check: {error}"
);
BackendAvailability::AwaitingAuthChange
}
Some(429) => BackendAvailability::RetryProbe,
Some(400..=499) => {
tracing::debug!(
"turn cost worker disabled by backend availability check: {error}"
);
BackendAvailability::Disabled
}
_ => {
tracing::debug!(
"turn cost worker backend availability check failed temporarily: {error}"
);
BackendAvailability::RetryProbe
}
},
Err(_) => {
tracing::debug!(
"turn cost worker backend availability check timed out; will retry"
);
BackendAvailability::RetryProbe
}
}
}
fn record_observation(&mut self, observation: TurnCostObservation) {
match observation.kind {
TurnCostObservationKind::Started { session_telemetry } => {
if self.turns.len() < MAX_TRACKED_TURNS {
self.turns
.entry(observation.turn_id)
.or_insert(TurnCostEntry {
thread_id: observation.thread_id,
session_telemetry: *session_telemetry,
expected_response_count: 0,
status: TurnCostStatus::Running,
next_poll_at: Instant::now(),
attempt_count: 0,
});
}
}
TurnCostObservationKind::ResponseCompleted => {
if let Some(entry) = self.turns.get_mut(&observation.turn_id)
&& entry.status == TurnCostStatus::Running
{
entry.expected_response_count = entry.expected_response_count.saturating_add(1);
}
}
TurnCostObservationKind::Finished { interrupted } => {
let Some(entry) = self.turns.get_mut(&observation.turn_id) else {
return;
};
if entry.status != TurnCostStatus::Running {
return;
}
entry.status = if interrupted {
TurnCostStatus::Interrupted
} else {
TurnCostStatus::Completed
};
entry.next_poll_at = Instant::now();
}
}
}
async fn poll_due(&mut self) {
let Some(auth) = self.auth_manager.auth().await else {
return;
};
if !auth.is_api_key_auth() {
return;
}
let now = Instant::now();
let due_turn_ids: Vec<String> = self
.turns
.iter()
.filter(|(_, entry)| {
entry.status != TurnCostStatus::Running && entry.next_poll_at <= now
})
.take(MAX_QUERY_TURNS)
.map(|(turn_id, _)| turn_id.clone())
.collect();
if !due_turn_ids.is_empty() {
self.poll_api_key_entries(&due_turn_ids, &auth).await;
}
}
async fn poll_api_key_entries(&mut self, turn_ids: &[String], auth: &CodexAuth) {
let provider = match self
.config
.model_provider
.to_api_provider(Some(AuthMode::ApiKey))
{
Ok(provider) => provider,
Err(error) => {
warn!("failed to resolve OpenAI API-key provider headers: {error}");
self.retry_entries(turn_ids);
return;
}
};
let client = BackendClient::from_auth(
self.config.chatgpt_base_url.clone(),
auth,
self.config.http_client_factory(),
);
let costs = match tokio::time::timeout(
REQUEST_TIMEOUT,
client.query_api_key_turn_costs(turn_ids, &provider.headers),
)
.await
{
Ok(Ok(costs)) => costs,
Ok(Err(error)) => {
warn!("failed to query OpenAI API-key turn costs: {error}");
self.retry_entries(turn_ids);
return;
}
Err(_) => {
warn!("timed out querying OpenAI API-key turn costs");
self.retry_entries(turn_ids);
return;
}
};
let costs_by_turn: HashMap<String, ApiKeyTurnCost> = costs
.into_iter()
.map(|cost| (cost.turn_id.clone(), cost))
.collect();
for turn_id in turn_ids {
let Some(cost) = costs_by_turn.get(turn_id) else {
self.retry_entry(turn_id);
continue;
};
self.process_api_key_cost(turn_id, cost);
}
}
fn process_api_key_cost(&mut self, turn_id: &str, cost: &ApiKeyTurnCost) {
if cost.status != ApiKeyTurnCostStatus::Priced {
self.retry_entry(turn_id);
return;
}
let response_count = cost
.responses
.as_ref()
.map(|responses| responses.len() as u64)
.or(cost.event_count);
let (Some(total_usd), Some(response_count)) = (cost.total_usd.as_deref(), response_count)
else {
self.retry_entry(turn_id);
return;
};
let Some(entry) = self.turns.get(turn_id) else {
return;
};
if response_count < entry.expected_response_count {
self.retry_entry(turn_id);
return;
}
let mut session_telemetry = entry.session_telemetry.clone();
if let Some(model) = cost.model.as_deref() {
session_telemetry = session_telemetry.with_model(model, model);
}
let Some(entry) = self.turns.remove(turn_id) else {
return;
};
session_telemetry.record_turn_cost(
turn_id,
total_usd,
entry.status == TurnCostStatus::Interrupted,
cost.speed.as_deref(),
cost.reasoning_effort.as_deref(),
);
}
fn retry_entries(&mut self, turn_ids: &[String]) {
for turn_id in turn_ids {
self.retry_entry(turn_id);
}
}
fn retry_entry(&mut self, turn_id: &str) {
let Some(entry) = self.turns.get_mut(turn_id) else {
return;
};
entry.attempt_count = entry.attempt_count.saturating_add(1);
if entry.attempt_count >= MAX_STALLED_POLL_ATTEMPTS {
warn!(
thread_id = %entry.thread_id,
turn_id,
attempts = MAX_STALLED_POLL_ATTEMPTS,
"dropping turn cost event after repeated unsuccessful polls"
);
self.turns.remove(turn_id);
return;
}
entry.next_poll_at = Instant::now() + POLL_INTERVAL;
}
}
#[cfg(test)]
#[path = "turn_cost_worker_tests.rs"]
mod tests;
@@ -0,0 +1,241 @@
use super::*;
use codex_backend_client::ApiKeyResponseCost;
use codex_core::config::ConfigBuilder;
use codex_login::AuthCredentialsStoreMode;
use codex_login::AuthKeyringBackendKind;
use codex_login::login_with_api_key;
use codex_otel::TelemetryAuthMode;
use codex_protocol::protocol::SessionSource;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
const TURN_COST_PATH: &str = "/v1/analytics/codex/turn-costs";
#[tokio::test]
async fn worker_waits_for_late_api_key_login() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(TURN_COST_PATH))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"turns": []
})))
.expect(1)
.mount(&server)
.await;
let auth_home = TempDir::new().expect("temporary auth home");
let auth_manager = Arc::new(
AuthManager::new(
auth_home.path().to_path_buf(),
/*enable_codex_api_key_env*/ false,
AuthCredentialsStoreMode::File,
/*forced_chatgpt_workspace_id*/ None,
/*chatgpt_base_url*/ None,
AuthKeyringBackendKind::default(),
codex_login::test_support::transport_default_auth_route_config(),
)
.await,
);
let runtime = test_runtime(&server, Arc::clone(&auth_manager)).await;
let (_sender, receiver) = mpsc::channel(OBSERVATION_CHANNEL_CAPACITY);
let shutdown = CancellationToken::new();
let mut task = tokio::spawn(runtime.run(receiver, shutdown.clone()));
assert!(
timeout(Duration::from_millis(/*millis*/ 50), &mut task)
.await
.is_err(),
"worker exited while waiting for login"
);
login_with_api_key(
auth_home.path(),
"sk-test",
AuthCredentialsStoreMode::File,
AuthKeyringBackendKind::default(),
)
.expect("write API-key auth");
assert!(auth_manager.reload().await);
wait_for_request_count(&server, /*expected*/ 1).await;
shutdown.cancel();
task.await.expect("worker task");
server.verify().await;
}
#[tokio::test]
async fn transient_probe_failure_keeps_worker_alive() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path(TURN_COST_PATH))
.respond_with(ResponseTemplate::new(500).set_body_string("temporary failure"))
.expect(1)
.mount(&server)
.await;
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("sk-test"));
let runtime = test_runtime(&server, Arc::clone(&auth_manager)).await;
let backend_availability = runtime.probe_backend().await;
assert_eq!(backend_availability, BackendAvailability::RetryProbe);
let (_sender, receiver) = mpsc::channel(OBSERVATION_CHANNEL_CAPACITY);
let shutdown = CancellationToken::new();
let auth_changes = auth_manager.auth_change_receiver();
let mut task = tokio::spawn(runtime.run_with_backend_availability(
receiver,
shutdown.clone(),
auth_changes,
backend_availability,
));
assert!(
timeout(Duration::from_millis(/*millis*/ 50), &mut task)
.await
.is_err(),
"worker exited after a transient probe failure"
);
shutdown.cancel();
task.await.expect("worker task");
server.verify().await;
}
#[tokio::test]
async fn priced_cost_uses_telemetry_captured_before_thread_removal() {
let server = MockServer::start().await;
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("sk-test"));
let mut runtime = test_runtime(&server, auth_manager).await;
let thread_id = ThreadId::new();
let turn_id = "turn-1";
runtime.record_observation(TurnCostObservation {
thread_id,
turn_id: turn_id.to_string(),
kind: TurnCostObservationKind::Started {
session_telemetry: Box::new(test_session_telemetry(thread_id)),
},
});
runtime.record_observation(TurnCostObservation {
thread_id,
turn_id: turn_id.to_string(),
kind: TurnCostObservationKind::ResponseCompleted,
});
runtime.record_observation(TurnCostObservation {
thread_id,
turn_id: turn_id.to_string(),
kind: TurnCostObservationKind::Finished { interrupted: false },
});
runtime.process_api_key_cost(
turn_id,
&ApiKeyTurnCost {
turn_id: turn_id.to_string(),
status: ApiKeyTurnCostStatus::Priced,
total_usd: Some("1.25".to_string()),
event_count: Some(1),
responses: None,
model: Some("gpt-5.6".to_string()),
speed: Some("fast".to_string()),
reasoning_effort: Some("high".to_string()),
},
);
assert_eq!(runtime.turns.len(), 0);
}
#[tokio::test]
async fn priced_cost_waits_for_every_response_when_response_costs_are_available() {
let server = MockServer::start().await;
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("sk-test"));
let mut runtime = test_runtime(&server, auth_manager).await;
let thread_id = ThreadId::new();
let turn_id = "turn-1";
runtime.turns.insert(
turn_id.to_string(),
TurnCostEntry {
thread_id,
session_telemetry: test_session_telemetry(thread_id),
expected_response_count: 2,
status: TurnCostStatus::Completed,
next_poll_at: Instant::now(),
attempt_count: 0,
},
);
let mut cost = ApiKeyTurnCost {
turn_id: turn_id.to_string(),
status: ApiKeyTurnCostStatus::Priced,
total_usd: Some("1.25".to_string()),
event_count: Some(2),
responses: Some(vec![ApiKeyResponseCost {
response_id: "resp-one".to_string(),
total_usd: "1.25".to_string(),
}]),
model: Some("gpt-5.6".to_string()),
speed: Some("fast".to_string()),
reasoning_effort: Some("high".to_string()),
};
runtime.process_api_key_cost(turn_id, &cost);
let entry = runtime.turns.get(turn_id).expect("turn remains tracked");
assert_eq!(entry.attempt_count, 1);
cost.event_count = None;
cost.responses
.as_mut()
.expect("response costs")
.push(ApiKeyResponseCost {
response_id: "resp-two".to_string(),
total_usd: "0.50".to_string(),
});
runtime.process_api_key_cost(turn_id, &cost);
assert_eq!(runtime.turns.len(), 0);
}
async fn test_runtime(server: &MockServer, auth_manager: Arc<AuthManager>) -> WorkerRuntime {
let codex_home = TempDir::new().expect("temporary Codex home");
let mut config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.build()
.await
.expect("test config");
config.chatgpt_base_url = server.uri();
WorkerRuntime {
config: Arc::new(config),
auth_manager,
turns: HashMap::new(),
}
}
fn test_session_telemetry(thread_id: ThreadId) -> SessionTelemetry {
SessionTelemetry::new(
thread_id,
"gpt-5.6",
"gpt-5.6",
/*account_id*/ None,
/*account_email*/ None,
Some(TelemetryAuthMode::ApiKey),
"test".to_string(),
/*log_user_prompts*/ false,
"test".to_string(),
SessionSource::Cli,
)
}
async fn wait_for_request_count(server: &MockServer, expected: usize) {
timeout(Duration::from_secs(/*secs*/ 15), async {
loop {
let requests = server.received_requests().await.unwrap_or_default();
if requests.len() >= expected {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("timed out waiting for turn-cost request");
}
+1
View File
@@ -36,6 +36,7 @@ use std::fmt;
mod rate_limit_resets;
mod thread_usage;
pub(crate) mod turn_usage;
pub use thread_usage::ThreadUsage;
pub use thread_usage::ThreadUsageBreakdownGroup;
@@ -0,0 +1,188 @@
use super::Client;
use super::RequestError;
use http::HeaderMap;
use http::Method;
use http::header::CONTENT_TYPE;
use http::header::HeaderValue;
use serde::Deserialize;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ApiKeyTurnCostStatus {
Pending,
Priced,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct ApiKeyResponseCost {
pub response_id: String,
pub total_usd: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct ApiKeyTurnCost {
pub turn_id: String,
pub status: ApiKeyTurnCostStatus,
pub total_usd: Option<String>,
pub event_count: Option<u64>,
pub responses: Option<Vec<ApiKeyResponseCost>>,
pub model: Option<String>,
pub speed: Option<String>,
pub reasoning_effort: Option<String>,
}
#[derive(Serialize)]
struct ApiKeyTurnCostsRequest<'a> {
turn_ids: &'a [String],
}
#[derive(Deserialize)]
struct ApiKeyTurnCostsResponse {
turns: Vec<ApiKeyTurnCost>,
}
impl Client {
pub async fn query_api_key_turn_costs(
&self,
turn_ids: &[String],
provider_headers: &HeaderMap,
) -> Result<Vec<ApiKeyTurnCost>, RequestError> {
let mut url =
url::Url::parse(&self.base_url).map_err(|error| RequestError::Other(error.into()))?;
let analytics_host = match url.host_str() {
Some("chatgpt.com" | "chat.openai.com") => Some("api.chatgpt.com"),
Some("chatgpt-staging.com") => Some("api.chatgpt-staging.com"),
_ => None,
};
if let Some(host) = analytics_host {
url.set_host(Some(host))
.map_err(|error| RequestError::Other(error.into()))?;
}
url.set_path("/v1/analytics/codex/turn-costs");
url.set_query(None);
url.set_fragment(None);
let url = url.to_string();
let mut headers = self.headers();
for header_name in ["openai-organization", "openai-project"] {
if let Some(value) = provider_headers.get(header_name) {
headers.insert(header_name, value.clone());
}
}
let request = self
.request(Method::POST, &url)
.headers(headers)
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
.json(&ApiKeyTurnCostsRequest { turn_ids });
let (body, content_type) = self.exec_request_detailed(request, "POST", &url).await?;
let response: ApiKeyTurnCostsResponse = self
.decode_json(&url, &content_type, &body)
.map_err(RequestError::Other)?;
Ok(response.turns)
}
}
#[cfg(test)]
mod tests {
use super::ApiKeyResponseCost;
use super::ApiKeyTurnCost;
use super::ApiKeyTurnCostStatus;
use super::Client;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_login::CodexAuth;
use http::HeaderMap;
use http::HeaderValue;
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 api_key_turn_cost_queries_use_api_key_auth_and_provider_scope() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/v1/analytics/codex/turn-costs"))
.and(header("authorization", "Bearer sk-test"))
.and(header("openai-organization", "org-test"))
.and(header("openai-project", "project-test"))
.and(body_json(serde_json::json!({
"turn_ids": ["turn-priced", "turn-response"]
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"turns": [
{
"turn_id": "turn-priced",
"status": "priced",
"total_usd": "1.2500000001",
"event_count": 2,
"model": "gpt-5.6",
"speed": "fast",
"reasoning_effort": "high"
},
{
"turn_id": "turn-response",
"status": "priced",
"total_usd": "0.5000000000",
"responses": [{
"response_id": "resp-one",
"total_usd": "0.5000000000"
}]
}
]
})))
.expect(1)
.mount(&server)
.await;
let auth = CodexAuth::from_api_key("sk-test");
let client = Client::from_auth(
format!("{}/backend-api", server.uri()),
&auth,
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
);
let mut provider_headers = HeaderMap::new();
provider_headers.insert("openai-organization", HeaderValue::from_static("org-test"));
provider_headers.insert("openai-project", HeaderValue::from_static("project-test"));
let costs = client
.query_api_key_turn_costs(
&["turn-priced".to_string(), "turn-response".to_string()],
&provider_headers,
)
.await
.expect("query API key turn costs");
assert_eq!(
costs,
vec![
ApiKeyTurnCost {
turn_id: "turn-priced".to_string(),
status: ApiKeyTurnCostStatus::Priced,
total_usd: Some("1.2500000001".to_string()),
event_count: Some(2),
responses: None,
model: Some("gpt-5.6".to_string()),
speed: Some("fast".to_string()),
reasoning_effort: Some("high".to_string()),
},
ApiKeyTurnCost {
turn_id: "turn-response".to_string(),
status: ApiKeyTurnCostStatus::Priced,
total_usd: Some("0.5000000000".to_string()),
event_count: None,
responses: Some(vec![ApiKeyResponseCost {
response_id: "resp-one".to_string(),
total_usd: "0.5000000000".to_string(),
}]),
model: None,
speed: None,
reasoning_effort: None,
},
]
);
}
}
+3
View File
@@ -6,6 +6,9 @@ pub use client::Client;
pub use client::RequestError;
pub use client::ThreadUsage;
pub use client::ThreadUsageBreakdownGroup;
pub use client::turn_usage::ApiKeyResponseCost;
pub use client::turn_usage::ApiKeyTurnCost;
pub use client::turn_usage::ApiKeyTurnCostStatus;
pub use types::AccountEntry;
pub use types::AccountsCheckResponse;
pub use types::CodeTaskDetailsResponse;
@@ -260,6 +260,25 @@ impl SessionTelemetry {
);
}
pub fn record_turn_cost(
&self,
turn_id: &str,
estimated_usd: &str,
interrupted: bool,
speed: Option<&str>,
reasoning_effort: Option<&str>,
) {
log_event!(
self,
event.name = "codex.turn_cost",
turn.id = turn_id,
usage.estimated_usd = estimated_usd,
turn.interrupted = interrupted,
speed = speed,
reasoning_effort = reasoning_effort,
);
}
/// Records the moment a plugin or connector install elicitation is dispatched.
pub fn record_plugin_install_elicitation_sent(
&self,