From 43d4c2ee7394e478844c65df1aa57188af45760d Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Fri, 22 May 2026 15:29:55 -0300 Subject: [PATCH] feat(usage): attribute token usage --- codex-rs/core/src/client_common.rs | 4 + codex-rs/core/src/compact_remote.rs | 1 + codex-rs/core/src/compact_remote_v2.rs | 1 + codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/session/mod.rs | 30 ++ codex-rs/core/src/session/turn.rs | 13 + codex-rs/core/src/tools/handlers/mcp.rs | 34 +++ codex-rs/core/src/tools/parallel.rs | 2 + codex-rs/core/src/tools/registry.rs | 5 + codex-rs/core/src/tools/router.rs | 30 +- codex-rs/core/src/tools/spec_plan.rs | 44 ++- codex-rs/core/src/usage.rs | 363 ++++++++++++++++++++++++ 12 files changed, 522 insertions(+), 6 deletions(-) create mode 100644 codex-rs/core/src/usage.rs diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index d9edadbed4..d83d92809f 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -1,3 +1,4 @@ +use crate::usage::UsagePromptAttribution; pub use codex_api::ResponseEvent; use codex_config::types::Personality; use codex_protocol::error::Result; @@ -33,6 +34,8 @@ pub struct Prompt { /// Whether parallel tool calls are permitted for this prompt. pub(crate) parallel_tool_calls: bool, + pub(crate) usage_attribution: UsagePromptAttribution, + pub base_instructions: BaseInstructions, /// Optionally specify the personality of the model. @@ -51,6 +54,7 @@ impl Default for Prompt { input: Vec::new(), tools: Vec::new(), parallel_tool_calls: false, + usage_attribution: UsagePromptAttribution::default(), base_instructions: BaseInstructions::default(), personality: None, output_schema: None, diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index b4456dc717..2433fd455d 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -194,6 +194,7 @@ async fn run_remote_compact_task_inner_impl( input: prompt_input, tools: tool_router.model_visible_specs(), parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, + usage_attribution: Default::default(), base_instructions, personality: turn_context.personality, output_schema: None, diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 0da3017f7d..4909818bf7 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -211,6 +211,7 @@ async fn run_remote_compact_task_inner_impl( input, tools: tool_router.model_visible_specs(), parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, + usage_attribution: Default::default(), base_instructions, personality: turn_context.personality, output_schema: None, diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index aa5784d4fe..b9fbb7358d 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -99,6 +99,7 @@ pub(crate) use skills::skills_load_input_from_config; mod stream_events_utils; pub mod test_support; mod unified_exec; +mod usage; pub mod windows_sandbox; pub use client::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER; pub use codex_protocol::config_types::ModelProviderAuthInfo; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 6331635664..06b67bc776 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2947,6 +2947,36 @@ impl Session { } } + pub(crate) async fn record_usage_attribution( + &self, + turn_context: &TurnContext, + prompt: &crate::client_common::Prompt, + response_id: &str, + token_usage: Option<&TokenUsage>, + ) { + let Some(token_usage) = token_usage else { + return; + }; + let occurred_at = chrono::Utc::now().timestamp(); + let attribution = prompt.usage_attribution.complete( + format!("{}:{response_id}", self.conversation_id), + turn_context.sub_id.clone(), + response_id.to_string(), + occurred_at, + token_usage.clone(), + ); + if let Some(state_db) = self.state_db() + && let Err(err) = state_db + .record_usage_sample(&codex_state::UsageSample { + thread_id: self.conversation_id, + attribution, + }) + .await + { + warn!("failed to persist usage sample: {err}"); + } + } + pub(crate) async fn recompute_token_usage(&self, turn_context: &TurnContext) { let history = self.clone_history().await; let base_instructions = self.get_base_instructions().await; diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 477e38c81b..14b5769dbf 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -900,10 +900,16 @@ pub(crate) fn build_prompt( turn_context: &TurnContext, base_instructions: BaseInstructions, ) -> Prompt { + let usage_attribution = crate::usage::UsagePromptAttribution::from_prompt( + &input, + router, + base_instructions.text.as_str(), + ); Prompt { input, tools: router.model_visible_specs(), parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, + usage_attribution, base_instructions, personality: turn_context.personality, output_schema: turn_context.final_output_json_schema.clone(), @@ -2006,6 +2012,13 @@ async fn try_run_sampling_request( &mut assistant_message_stream_parsers, ) .await; + sess.record_usage_attribution( + &turn_context, + prompt, + response_id.as_str(), + token_usage.as_ref(), + ) + .await; sess.record_token_usage_info(&turn_context, token_usage.as_ref()) .await; should_emit_token_count = true; diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index 8f18d5b55e..a48e1cd417 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -17,6 +17,8 @@ use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolTelemetryTags; use crate::tools::tool_search_entry::ToolSearchInfo; use codex_mcp::ToolInfo; +use codex_protocol::protocol::UsageContributor; +use codex_protocol::protocol::UsageContributorKind; use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolName; @@ -131,6 +133,38 @@ impl ToolExecutor for McpHandler { } impl CoreToolRuntime for McpHandler { + fn usage_contributors(&self) -> Vec { + let mut contributors = Vec::new(); + if let Some(connector_id) = self.tool_info.connector_id.as_ref() { + contributors.push(UsageContributor { + kind: UsageContributorKind::App, + id: connector_id.clone(), + label: self + .tool_info + .connector_name + .clone() + .unwrap_or_else(|| connector_id.clone()), + }); + } else { + contributors.push(UsageContributor { + kind: UsageContributorKind::McpServer, + id: self.tool_info.server_name.clone(), + label: self.tool_info.server_name.clone(), + }); + } + contributors.extend( + self.tool_info + .plugin_display_names + .iter() + .map(|plugin_name| UsageContributor { + kind: UsageContributorKind::Plugin, + id: plugin_name.clone(), + label: plugin_name.clone(), + }), + ); + contributors + } + fn search_info(&self) -> Option { let source_name = self .tool_info diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 7db1ec9643..d071b17ecd 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -423,6 +423,8 @@ mod tests { let router = Arc::new(ToolRouter::from_parts( ToolRegistry::from_tools([handler]), Vec::new(), + Vec::new(), + std::collections::HashMap::new(), )); let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); let runtime = ToolCallRuntime::new(router, session, turn_context, tracker); diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index f0fdf424a9..1a2e1a5552 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -30,6 +30,7 @@ use codex_extension_api::ToolCallOutcome; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::UsageContributor; use codex_tools::ToolName; use codex_tools::ToolSpec; use futures::future::BoxFuture; @@ -104,6 +105,10 @@ pub(crate) trait CoreToolRuntime: ToolExecutor { }) } + fn usage_contributors(&self) -> Vec { + Vec::new() + } + fn pre_tool_use_payload(&self, invocation: &ToolInvocation) -> Option { let ToolPayload::Function { arguments } = &invocation.payload else { return None; diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index a095499ea0..1a93fcaa89 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -17,6 +17,7 @@ use codex_tools::ToolCall as ExtensionToolCall; use codex_tools::ToolExecutor; use codex_tools::ToolName; use codex_tools::ToolSpec; +use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::AtomicBool; use tokio_util::sync::CancellationToken; @@ -34,6 +35,9 @@ pub struct ToolCall { pub struct ToolRouter { registry: ToolRegistry, model_visible_specs: Vec, + usage_contributors: Vec, + usage_contributors_by_tool_name: + HashMap>, } pub(crate) struct ToolRouterParams<'a> { @@ -49,10 +53,20 @@ impl ToolRouter { build_tool_router(turn_context, params) } - pub(crate) fn from_parts(registry: ToolRegistry, model_visible_specs: Vec) -> Self { + pub(crate) fn from_parts( + registry: ToolRegistry, + model_visible_specs: Vec, + usage_contributors: Vec, + usage_contributors_by_tool_name: HashMap< + ToolName, + Vec, + >, + ) -> Self { Self { registry, model_visible_specs, + usage_contributors, + usage_contributors_by_tool_name, } } @@ -60,6 +74,20 @@ impl ToolRouter { self.model_visible_specs.clone() } + pub(crate) fn usage_contributors(&self) -> Vec { + self.usage_contributors.clone() + } + + pub(crate) fn usage_contributors_for_tool_name( + &self, + tool_name: &ToolName, + ) -> Vec { + self.usage_contributors_by_tool_name + .get(tool_name) + .cloned() + .unwrap_or_default() + } + #[cfg(test)] pub(crate) fn registered_tool_names_for_test(&self) -> Vec { self.registry.tool_names_for_test() diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 2610bee95c..8b0bb8fc56 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -80,6 +80,7 @@ use codex_tools::request_user_input_available_modes; use codex_tools::shell_command_backend_for_features; use codex_tools::shell_type_for_model_and_features; use std::collections::BTreeMap; +use std::collections::HashMap; use std::collections::HashSet; use std::sync::Arc; use tracing::warn; @@ -146,14 +147,25 @@ pub(crate) fn build_tool_router( turn_context: &TurnContext, params: ToolRouterParams<'_>, ) -> ToolRouter { - let (model_visible_specs, registry) = build_tool_specs_and_registry(turn_context, params); - ToolRouter::from_parts(registry, model_visible_specs) + let (model_visible_specs, registry, usage_contributors, usage_contributors_by_tool_name) = + build_tool_specs_and_registry(turn_context, params); + ToolRouter::from_parts( + registry, + model_visible_specs, + usage_contributors, + usage_contributors_by_tool_name, + ) } fn build_tool_specs_and_registry( turn_context: &TurnContext, params: ToolRouterParams<'_>, -) -> (Vec, ToolRegistry) { +) -> ( + Vec, + ToolRegistry, + Vec, + HashMap>, +) { let ToolRouterParams { mcp_tools, deferred_mcp_tools, @@ -183,12 +195,19 @@ fn build_tool_specs_and_registry( fn build_model_visible_specs_and_registry( turn_context: &TurnContext, planned_tools: PlannedTools, -) -> (Vec, ToolRegistry) { +) -> ( + Vec, + ToolRegistry, + Vec, + HashMap>, +) { let PlannedTools { runtimes, hosted_specs, } = planned_tools; let mut specs = Vec::new(); + let mut usage_contributors = Vec::new(); + let mut usage_contributors_by_tool_name = HashMap::new(); let mut seen_tool_names = HashSet::new(); for runtime in &runtimes { let tool_name = runtime.tool_name(); @@ -199,6 +218,16 @@ fn build_model_visible_specs_and_registry( if exposure.is_direct() && !is_hidden_by_code_mode_only(turn_context, &tool_name, exposure) { let spec = runtime.spec(); + let estimated_tokens = crate::usage::estimate_serialized_tokens(&spec); + let runtime_usage_contributors = runtime.usage_contributors(); + usage_contributors_by_tool_name + .insert(tool_name.clone(), runtime_usage_contributors.clone()); + usage_contributors.extend(runtime_usage_contributors.into_iter().map(|contributor| { + crate::usage::UsagePromptContributor { + contributor, + source_estimated_tokens: estimated_tokens, + } + })); specs.push(spec_for_model_request(turn_context, exposure, spec)); } } @@ -220,7 +249,12 @@ fn build_model_visible_specs_and_registry( }) .collect(); - (model_visible_specs, registry) + ( + model_visible_specs, + registry, + usage_contributors, + usage_contributors_by_tool_name, + ) } fn spec_for_model_request( diff --git a/codex-rs/core/src/usage.rs b/codex-rs/core/src/usage.rs new file mode 100644 index 0000000000..df6ec1d87a --- /dev/null +++ b/codex-rs/core/src/usage.rs @@ -0,0 +1,363 @@ +use crate::tools::router::ToolRouter; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::UsageAttributionContributor; +use codex_protocol::protocol::UsageAttributionItem; +use codex_protocol::protocol::UsageContributor; +use codex_protocol::protocol::UsageContributorKind; +use codex_tools::ToolName; +use codex_utils_output_truncation::approx_token_count; +use std::collections::BTreeMap; +use std::collections::HashMap; + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct UsagePromptAttribution { + pub(crate) prompt_estimated_tokens: i64, + pub(crate) contributors: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct UsagePromptContributor { + pub(crate) contributor: UsageContributor, + pub(crate) source_estimated_tokens: i64, +} + +impl UsagePromptAttribution { + pub(crate) fn from_prompt( + input: &[ResponseItem], + router: &ToolRouter, + base_instructions: &str, + ) -> Self { + let mut contributors = skill_contributors(input); + contributors.extend(router.usage_contributors()); + contributors.extend(tool_result_contributors(input, router)); + let input_tokens = input + .iter() + .map(estimate_response_item_tokens) + .fold(0i64, i64::saturating_add); + let tool_tokens = router + .model_visible_specs() + .iter() + .map(estimate_serialized_tokens) + .fold(0i64, i64::saturating_add); + let base_tokens = i64::try_from(approx_token_count(base_instructions)).unwrap_or(i64::MAX); + Self { + prompt_estimated_tokens: base_tokens + .saturating_add(input_tokens) + .saturating_add(tool_tokens), + contributors: aggregate_contributors(contributors), + } + } + + pub(crate) fn complete( + &self, + sample_id: String, + turn_id: String, + response_id: String, + occurred_at: i64, + token_usage: TokenUsage, + ) -> UsageAttributionItem { + let non_cached_input = token_usage.non_cached_input(); + let contributors = self + .contributors + .iter() + .map(|contributor| UsageAttributionContributor { + contributor: contributor.contributor.clone(), + source_estimated_tokens: contributor.source_estimated_tokens, + attributed_tokens: attributable_tokens( + non_cached_input, + contributor.source_estimated_tokens, + self.prompt_estimated_tokens, + ), + }) + .filter(|contributor| contributor.attributed_tokens > 0) + .collect(); + UsageAttributionItem { + sample_id, + turn_id, + response_id, + occurred_at, + token_usage, + prompt_estimated_tokens: self.prompt_estimated_tokens, + contributors, + } + } +} + +pub(crate) fn estimate_serialized_tokens(value: &T) -> i64 { + serde_json::to_string(value) + .map(|serialized| i64::try_from(approx_token_count(&serialized)).unwrap_or(i64::MAX)) + .unwrap_or(/*default*/ 0) +} + +fn estimate_response_item_tokens(item: &ResponseItem) -> i64 { + estimate_serialized_tokens(item) +} + +fn skill_contributors(input: &[ResponseItem]) -> Vec { + input.iter().filter_map(skill_contributor).collect() +} + +fn tool_result_contributors( + input: &[ResponseItem], + router: &ToolRouter, +) -> Vec { + let contributors_by_call_id = input + .iter() + .filter_map(|item| tool_call_contributors(item, router)) + .collect::>(); + input + .iter() + .filter_map(|item| tool_result_contributor(item, &contributors_by_call_id)) + .flatten() + .collect() +} + +fn tool_call_contributors( + item: &ResponseItem, + router: &ToolRouter, +) -> Option<(String, Vec)> { + let (call_id, tool_name) = match item { + ResponseItem::FunctionCall { + call_id, + name, + namespace, + .. + } => (call_id, ToolName::new(namespace.clone(), name)), + ResponseItem::CustomToolCall { call_id, name, .. } => (call_id, ToolName::plain(name)), + _ => return None, + }; + let contributors = router.usage_contributors_for_tool_name(&tool_name); + (!contributors.is_empty()).then(|| (call_id.clone(), contributors)) +} + +fn tool_result_contributor( + item: &ResponseItem, + contributors_by_call_id: &HashMap>, +) -> Option> { + let call_id = match item { + ResponseItem::FunctionCallOutput { call_id, .. } + | ResponseItem::CustomToolCallOutput { call_id, .. } => call_id, + _ => return None, + }; + let source_estimated_tokens = estimate_response_item_tokens(item); + Some( + contributors_by_call_id + .get(call_id)? + .iter() + .cloned() + .map(|contributor| UsagePromptContributor { + contributor, + source_estimated_tokens, + }) + .collect(), + ) +} + +fn skill_contributor(item: &ResponseItem) -> Option { + let ResponseItem::Message { content, .. } = item else { + return None; + }; + let text = content.iter().find_map(|content| match content { + ContentItem::InputText { text } if text.contains("") => Some(text.as_str()), + _ => None, + })?; + let name = tag_contents(text, "name")?; + let path = tag_contents(text, "path")?; + Some(UsagePromptContributor { + contributor: UsageContributor { + kind: UsageContributorKind::Skill, + id: path.to_string(), + label: name.to_string(), + }, + source_estimated_tokens: i64::try_from(approx_token_count(text)).unwrap_or(i64::MAX), + }) +} + +fn tag_contents<'a>(text: &'a str, tag: &str) -> Option<&'a str> { + let open = format!("<{tag}>"); + let close = format!(""); + let start = text.find(open.as_str())? + open.len(); + let end = text[start..].find(close.as_str())? + start; + Some(text[start..end].trim()) +} + +fn aggregate_contributors( + contributors: Vec, +) -> Vec { + let mut aggregated = BTreeMap::new(); + for contributor in contributors { + let key = ( + contributor.contributor.kind as u8, + contributor.contributor.id.clone(), + contributor.contributor.label.clone(), + ); + aggregated + .entry(key) + .and_modify(|existing: &mut UsagePromptContributor| { + existing.source_estimated_tokens = existing + .source_estimated_tokens + .saturating_add(contributor.source_estimated_tokens); + }) + .or_insert(contributor); + } + aggregated.into_values().collect() +} + +fn attributable_tokens(non_cached_input: i64, source_tokens: i64, prompt_tokens: i64) -> i64 { + if non_cached_input <= 0 || source_tokens <= 0 || prompt_tokens <= 0 { + return 0; + } + non_cached_input + .saturating_mul(source_tokens) + .saturating_add(prompt_tokens / 2) + / prompt_tokens +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::registry::ToolRegistry; + use codex_protocol::models::FunctionCallOutputPayload; + use pretty_assertions::assert_eq; + + #[test] + fn complete_attributes_only_non_cached_input_tokens() { + let attribution = UsagePromptAttribution { + prompt_estimated_tokens: 100, + contributors: vec![ + usage_prompt_contributor( + UsageContributorKind::Skill, + "/skills/tmux", + "tmux", + /*source_estimated_tokens*/ 25, + ), + usage_prompt_contributor( + UsageContributorKind::App, + "slack", + "Slack", + /*source_estimated_tokens*/ 10, + ), + ], + }; + + let usage = attribution.complete( + "sample".to_string(), + "turn".to_string(), + "response".to_string(), + /*occurred_at*/ 1_700_000_000, + TokenUsage { + input_tokens: 100, + cached_input_tokens: 40, + output_tokens: 20, + reasoning_output_tokens: 0, + total_tokens: 120, + }, + ); + + assert_eq!( + usage.contributors, + vec![ + UsageAttributionContributor { + contributor: usage_contributor( + UsageContributorKind::Skill, + "/skills/tmux", + "tmux", + ), + source_estimated_tokens: 25, + attributed_tokens: 15, + }, + UsageAttributionContributor { + contributor: usage_contributor(UsageContributorKind::App, "slack", "Slack"), + source_estimated_tokens: 10, + attributed_tokens: 6, + }, + ] + ); + } + + #[test] + fn skill_contributors_use_skill_path_as_stable_id() { + let item = ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: "tmux/skills/tmux/SKILL.md" + .to_string(), + }], + phase: None, + }; + + assert_eq!( + skill_contributors(&[item]), + vec![UsagePromptContributor { + contributor: usage_contributor( + UsageContributorKind::Skill, + "/skills/tmux/SKILL.md", + "tmux", + ), + source_estimated_tokens: i64::try_from(approx_token_count( + "tmux/skills/tmux/SKILL.md", + )) + .expect("skill prompt token estimate should fit in i64"), + }] + ); + } + + #[test] + fn tool_results_reuse_tool_usage_provenance() { + let contributor = usage_contributor(UsageContributorKind::App, "slack", "Slack"); + let tool_name = ToolName::plain("mcp__slack__search"); + let router = ToolRouter::from_parts( + ToolRegistry::from_tools(Vec::< + std::sync::Arc, + >::new()), + Vec::new(), + Vec::new(), + HashMap::from([(tool_name.clone(), vec![contributor.clone()])]), + ); + let tool_result = ResponseItem::FunctionCallOutput { + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload::from_text("result".to_string()), + }; + let input = vec![ + ResponseItem::FunctionCall { + id: None, + name: tool_name.name, + namespace: tool_name.namespace, + arguments: "{}".to_string(), + call_id: "call-1".to_string(), + }, + tool_result.clone(), + ]; + + assert_eq!( + tool_result_contributors(&input, &router), + vec![UsagePromptContributor { + contributor, + source_estimated_tokens: estimate_response_item_tokens(&tool_result), + }] + ); + } + + fn usage_prompt_contributor( + kind: UsageContributorKind, + id: &str, + label: &str, + source_estimated_tokens: i64, + ) -> UsagePromptContributor { + UsagePromptContributor { + contributor: usage_contributor(kind, id, label), + source_estimated_tokens, + } + } + + fn usage_contributor(kind: UsageContributorKind, id: &str, label: &str) -> UsageContributor { + UsageContributor { + kind, + id: id.to_string(), + label: label.to_string(), + } + } +}