diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 4137226d7b..6cef24bf3c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -18732,7 +18732,8 @@ "string", "null" ] - } + }, + "metadata": true }, "type": "object" }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 6f577129b9..1538744ee9 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -14804,7 +14804,8 @@ "string", "null" ] - } + }, + "metadata": true }, "type": "object" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseCompletedNotification.json index 266caa465d..5b65b9dfc2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseCompletedNotification.json @@ -9,7 +9,8 @@ "string", "null" ] - } + }, + "metadata": true }, "type": "object" }, diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 0955f15e23..2206603c71 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index ec98166740..ffafe4350e 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ResponseUsageMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ResponseUsageMetadata.ts index 1a32767aad..34b492bed2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ResponseUsageMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ResponseUsageMetadata.ts @@ -1,8 +1,9 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "../serde_json/JsonValue"; /** * Usage metadata reported for one upstream response. */ -export type ResponseUsageMetadata = { amount: string | null, }; +export type ResponseUsageMetadata = { amount: string | null, metadata: JsonValue | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 5c1b192124..2a08c9fd1d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -1856,12 +1856,14 @@ pub struct RawResponseCompletedNotification { #[ts(export_to = "v2/")] pub struct ResponseUsageMetadata { pub amount: Option, + pub metadata: Option, } impl From for ResponseUsageMetadata { fn from(value: codex_protocol::ResponseUsageMetadata) -> Self { Self { amount: value.amount, + metadata: value.metadata, } } } diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs index 88272b0749..498c19755b 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -254,6 +254,8 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() - ]); let mut completed = responses::ev_completed_with_tokens("r1", /*total_tokens*/ 200); completed["response"]["usage_metadata"] = serde_json::json!({ "amount": "0.125" }); + completed["response"]["usage"]["extra"] = serde_json::json!({ "label": "example" }); + let expected_metadata = completed["response"]["usage"].clone(); let sse = responses::sse(vec![ responses::ev_assistant_message("m1", "MANUAL_COMPACT_SUMMARY"), completed, @@ -338,6 +340,7 @@ async fn thread_compact_start_triggers_compaction_and_returns_empty_response() - response_id: "r1".to_string(), usage_metadata: Some(ResponseUsageMetadata { amount: Some("0.125".to_string()), + metadata: Some(expected_metadata), }), usage: Some(TokenUsageBreakdown { total_tokens: 200, diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 13aec895fd..8b92460ffa 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -904,19 +904,24 @@ async fn turn_start_sends_service_tier_id_to_model_request() -> Result<()> { Ok(()) } -#[test_case(None, json!(null); "without_usage_metadata")] -#[test_case(Some(json!({})), json!({ "amount": null }); "without_amount")] -#[test_case(Some(json!({ "amount": null })), json!({ "amount": null }); "null_amount")] -#[test_case(Some(json!({ "amount": "0" })), json!({ "amount": "0" }); "zero_amount")] +#[test_case(None, None, None; "without_usage_metadata")] +#[test_case(Some(json!({})), None, None; "without_amount")] +#[test_case(Some(json!({ "amount": null })), None, None; "null_amount")] +#[test_case(Some(json!({ "amount": "0" })), Some("0"), None; "zero_amount")] #[test_case( Some(json!({ "amount": "0.12345678901234567890" })), - json!({ "amount": "0.12345678901234567890" }); + Some("0.12345678901234567890"), + Some(json!({ "label": "example", "items": [0, null, true] })); "exact_amount" )] +#[test_case(None, None, Some(json!(null)); "null_extra")] +#[test_case(None, None, Some(json!(0)); "zero_extra")] +#[test_case(None, None, Some(json!({ "label": "example" })); "nested_extra")] #[tokio::test] async fn turn_start_emits_raw_response_completed_with_upstream_usage( upstream_metadata: Option, - expected_metadata: Value, + expected_amount: Option<&str>, + extra: Option, ) -> Result<()> { let server = responses::start_mock_server().await; let mut completed = json!({ @@ -935,6 +940,13 @@ async fn turn_start_emits_raw_response_completed_with_upstream_usage( if let Some(metadata) = upstream_metadata { completed["response"]["usage_metadata"] = metadata; } + if let Some(extra) = extra { + completed["response"]["usage"]["extra"] = extra; + } + let expected_metadata = json!({ + "amount": expected_amount, + "metadata": completed["response"]["usage"], + }); let body = responses::sse(vec![ responses::ev_response_created("resp-1"), responses::ev_assistant_message("msg-1", "Done"), diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 58b55091bb..abef2468cc 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -478,8 +478,15 @@ pub fn process_responses_event( } "response.completed" => { if let Some(resp_val) = event.response { + let metadata = resp_val + .get("usage") + .filter(|usage| !usage.is_null()) + .cloned(); match serde_json::from_value::(resp_val) { - Ok(resp) => { + Ok(mut resp) => { + if let Some(metadata) = metadata { + resp.usage_metadata.get_or_insert_default().metadata = Some(metadata); + } return Ok(Some(ResponseEvent::Completed { response_id: resp.id, token_usage: resp.usage.map(Into::into), diff --git a/codex-rs/codex-api/tests/sse_end_to_end.rs b/codex-rs/codex-api/tests/sse_end_to_end.rs index 308ae54be3..5c1dcb8ee5 100644 --- a/codex-rs/codex-api/tests/sse_end_to_end.rs +++ b/codex-rs/codex-api/tests/sse_end_to_end.rs @@ -118,11 +118,13 @@ async fn responses_stream_parses_items_and_completed_end_to_end() -> Result<()> "input_tokens": 10, "output_tokens": 5, "total_tokens": 15, + "extra": { "label": "example", "items": [0, null, true] }, "codex_rollout_budget_units": 2.5 } } }); + let expected_metadata = completed["response"]["usage"].clone(); let body = build_responses_body(vec![item1, item2, completed]); let transport = FixtureSseTransport::new(body); let client = ResponsesClient::new(transport, provider("openai"), Arc::new(NoAuth)); @@ -174,6 +176,7 @@ async fn responses_stream_parses_items_and_completed_end_to_end() -> Result<()> usage_metadata, &Some(codex_protocol::ResponseUsageMetadata { amount: Some("0.12345678901234567890".to_string()), + metadata: Some(expected_metadata), }) ); assert_eq!( diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index def91ff2f4..68e2eaeda6 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -1175,18 +1175,32 @@ mod tests { }), usage_metadata: Some(codex_protocol::ResponseUsageMetadata { amount: Some("0.125".to_string()), + metadata: Some(serde_json::json!({ "extra": { "label": "example" } })), }), end_turn: Some(true), }), ]); - let (sess, turn_context) = crate::session::tests::make_session_and_context().await; + let (sess, turn_context, rx) = + crate::session::tests::make_session_and_context_with_rx().await; let output = collect_compaction_output(&sess, &turn_context, stream) .await .expect("compaction should be collected"); assert_eq!(output.compaction_output, compaction); assert_eq!(output.response_id, "resp-compact"); + let event = rx.recv().await.expect("raw response completion"); + let EventMsg::RawResponseCompleted(completed) = event.msg else { + panic!("expected raw response completion, got {:?}", event.msg); + }; + assert_eq!(completed.response_id, "resp-compact"); + assert_eq!( + completed.usage_metadata, + Some(codex_protocol::ResponseUsageMetadata { + amount: Some("0.125".to_string()), + metadata: Some(serde_json::json!({ "extra": { "label": "example" } })), + }) + ); assert_eq!( output.token_usage, Some(TokenUsage { diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 1ab1a640d0..3263b9e4c9 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -158,6 +158,7 @@ async fn responses_websocket_preserves_credit_usage_metadata() { let mut completed = ev_completed("resp-1"); completed["response"]["usage_metadata"] = json!({ "amount": "0.12345678901234567890" }); + let expected_metadata = completed["response"]["usage"].clone(); let server = start_websocket_server(vec![vec![vec![ev_response_created("resp-1"), completed]]]).await; let harness = websocket_harness_for_codex_backend(&server).await; @@ -193,6 +194,7 @@ async fn responses_websocket_preserves_credit_usage_metadata() { usage_metadata, Some(codex_protocol::ResponseUsageMetadata { amount: Some("0.12345678901234567890".to_string()), + metadata: Some(expected_metadata), }), ); assert_eq!(server.single_connection().len(), 1); diff --git a/codex-rs/protocol/src/response_usage.rs b/codex-rs/protocol/src/response_usage.rs index f1a75689fd..83383bf829 100644 --- a/codex-rs/protocol/src/response_usage.rs +++ b/codex-rs/protocol/src/response_usage.rs @@ -3,10 +3,12 @@ use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; +use serde_json::Value; use ts_rs::TS; /// Usage metadata reported for one upstream response. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, TS, JsonSchema)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq, TS, JsonSchema)] pub struct ResponseUsageMetadata { pub amount: Option, + pub metadata: Option, }