feat: add token usage contributor hook (#22485)

## Why

Extensions need a stable place to observe token accounting after Codex
folds model-provider usage into the session's cached `TokenUsageInfo`.
Without a contributor hook, extension-owned features that need last-turn
or cumulative token usage have to duplicate session plumbing or infer
state from client-facing `TokenCount` notifications.

## What changed

- Added `TokenUsageContributor` to `codex-extension-api`, passing
session/thread `ExtensionData`, `ThreadId`, turn id, and the current
`TokenUsageInfo`.
- Added registry builder/storage support for token-usage contributors.
- Invoked registered contributors from
`Session::record_token_usage_info` after the session token cache is
updated and before the client `TokenCount` notification is emitted.

## Testing

- Added `record_token_usage_info_notifies_extension_contributors`,
covering cumulative token usage updates and access to both extension
stores.
This commit is contained in:
jif-oai
2026-05-13 14:32:23 +02:00
committed by GitHub
Unverified
parent fcc2a92743
commit 083c1962f9
5 changed files with 165 additions and 2 deletions
+17 -2
View File
@@ -2858,8 +2858,23 @@ impl Session {
token_usage: Option<&TokenUsage>,
) {
if let Some(token_usage) = token_usage {
let mut state = self.state.lock().await;
state.update_token_info_from_usage(token_usage, turn_context.model_context_window());
let token_info = {
let mut state = self.state.lock().await;
state
.update_token_info_from_usage(token_usage, turn_context.model_context_window());
state.token_info()
};
if let Some(token_info) = token_info.as_ref() {
for contributor in self.services.extensions.token_usage_contributors() {
contributor.on_token_usage(
&self.services.session_extension_data,
&self.services.thread_extension_data,
self.conversation_id,
&turn_context.sub_id,
token_info,
);
}
}
}
}
+112
View File
@@ -1800,6 +1800,118 @@ async fn recompute_token_usage_updates_model_context_window() {
assert_eq!(actual.model_context_window, Some(128_000));
}
#[tokio::test]
async fn record_token_usage_info_notifies_extension_contributors() {
struct SessionTokenUsageMarker;
struct ThreadTokenUsageMarker;
#[derive(Debug, PartialEq, Eq)]
struct RecordedTokenUsage {
thread_id: ThreadId,
turn_id: String,
token_usage: TokenUsageInfo,
saw_session_store: bool,
saw_thread_store: bool,
}
struct TokenUsageRecorder {
records: Arc<std::sync::Mutex<Vec<RecordedTokenUsage>>>,
}
impl codex_extension_api::TokenUsageContributor for TokenUsageRecorder {
fn on_token_usage(
&self,
session_store: &codex_extension_api::ExtensionData,
thread_store: &codex_extension_api::ExtensionData,
thread_id: ThreadId,
turn_id: &str,
token_usage: &TokenUsageInfo,
) {
self.records
.lock()
.expect("token usage records lock")
.push(RecordedTokenUsage {
thread_id,
turn_id: turn_id.to_string(),
token_usage: token_usage.clone(),
saw_session_store: session_store.get::<SessionTokenUsageMarker>().is_some(),
saw_thread_store: thread_store.get::<ThreadTokenUsageMarker>().is_some(),
});
}
}
let (mut session, turn_context) = make_session_and_context().await;
let records = Arc::new(std::sync::Mutex::new(Vec::new()));
let mut builder = codex_extension_api::ExtensionRegistryBuilder::<crate::config::Config>::new();
builder.token_usage_contributor(Arc::new(TokenUsageRecorder {
records: Arc::clone(&records),
}));
session.services.extensions = Arc::new(builder.build());
session
.services
.session_extension_data
.insert(SessionTokenUsageMarker);
session
.services
.thread_extension_data
.insert(ThreadTokenUsageMarker);
let first_usage = TokenUsage {
input_tokens: 10,
cached_input_tokens: 2,
output_tokens: 20,
reasoning_output_tokens: 3,
total_tokens: 33,
};
let second_usage = TokenUsage {
input_tokens: 7,
cached_input_tokens: 1,
output_tokens: 8,
reasoning_output_tokens: 5,
total_tokens: 20,
};
session
.record_token_usage_info(&turn_context, Some(&first_usage))
.await;
session
.record_token_usage_info(&turn_context, Some(&second_usage))
.await;
let mut expected_total_usage = first_usage.clone();
expected_total_usage.add_assign(&second_usage);
let expected = vec![
RecordedTokenUsage {
thread_id: session.conversation_id,
turn_id: turn_context.sub_id.clone(),
token_usage: TokenUsageInfo {
total_token_usage: first_usage.clone(),
last_token_usage: first_usage,
model_context_window: turn_context.model_context_window(),
},
saw_session_store: true,
saw_thread_store: true,
},
RecordedTokenUsage {
thread_id: session.conversation_id,
turn_id: turn_context.sub_id.clone(),
token_usage: TokenUsageInfo {
total_token_usage: expected_total_usage,
last_token_usage: second_usage,
model_context_window: turn_context.model_context_window(),
},
saw_session_store: true,
saw_thread_store: true,
},
];
let actual = records
.lock()
.expect("token usage records lock")
.drain(..)
.collect::<Vec<_>>();
assert_eq!(expected, actual);
}
#[tokio::test]
async fn record_initial_history_reconstructs_forked_transcript() {
let (session, turn_context) = make_session_and_context().await;