From d49d44ad5070e53c6c8e11a55bb520c6b67438b4 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 28 Aug 2026 22:14:06 +0200 Subject: [PATCH] feat(acp): publish enriched Lody usage and compaction activity Preserve provider-reported reasoning token counts, including Baseten's nested Chat Completions field, and expose them through normalized Lody usage updates without double-counting visible output. Resolve context windows and opt-in custom-provider pricing from model profiles so ACP clients can display context capacity and calculated USD costs. Publish automatic compaction through standard ACP tool lifecycle updates. Keep rate-limit capability advertising conservative. Surface real 429 metadata and Retry-After values through telemetry and notices without inventing quota windows or reset percentages. --- README.md | 9 +- .../vtcode-acp/src/zed/agent/compaction.rs | 42 +++- .../vtcode-acp/src/zed/agent/handlers.rs | 129 +++++++++-- .../vtcode-acp/src/zed/agent/lody_activity.rs | 215 ++++++++++++++++++ .../vtcode-acp/src/zed/agent/lody_notice.rs | 124 ++++++++++ .../vtcode-acp/src/zed/agent/lody_usage.rs | 125 ++++++++-- .../codegen/vtcode-acp/src/zed/agent/mod.rs | 2 + .../vtcode-config/src/core/custom_provider.rs | 136 ++++++++++- crates/codegen/vtcode-config/src/core/mod.rs | 4 +- crates/codegen/vtcode-config/src/lib.rs | 22 +- .../codegen/vtcode-config/src/loader/tests.rs | 20 +- .../codegen/vtcode-core/src/commands/ask.rs | 1 + .../codegen/vtcode-core/src/core/telemetry.rs | 2 + crates/codegen/vtcode-core/src/llm/factory.rs | 5 + .../codegen/vtcode-core/src/llm/usage_cost.rs | 3 + .../codegen/vtcode-llm/src/model_resolver.rs | 1 + .../vtcode-llm/src/open_responses/bridge.rs | 1 + .../vtcode-llm/src/open_responses/usage.rs | 24 +- .../vtcode-llm/src/provider/response.rs | 1 + .../providers/anthropic/response_parser.rs | 1 + .../src/providers/anthropic/stream_decoder.rs | 1 + .../vtcode-llm/src/providers/common.rs | 55 +++++ .../src/providers/custom_provider.rs | 4 +- .../vtcode-llm/src/providers/evolink.rs | 1 + .../src/providers/gemini/helpers.rs | 1 + .../vtcode-llm/src/providers/huggingface.rs | 2 + .../vtcode-llm/src/providers/ollama.rs | 1 + .../openai/provider/harmony_client.rs | 1 + .../src/providers/openai/response_parser.rs | 31 +++ .../src/providers/openai/responses_api.rs | 23 +- .../src/providers/openai/stream_decoder.rs | 31 ++- .../providers/openrouter/stream_decoder.rs | 1 + .../vtcode-llm/src/providers/shared/mod.rs | 3 + .../src/providers/shared/responses_stream.rs | 2 + crates/codegen/vtcode-llm/src/utils.rs | 1 + crates/common/vtcode-commons/src/llm.rs | 5 + docs/acp/ACP_INTEGRATION.md | 30 ++- docs/acp/ACP_QUICK_REFERENCE.md | 21 +- docs/config/CONFIG_FIELD_REFERENCE.md | 10 + docs/config/config.md | 20 +- .../runloop/unified/context_manager_tests.rs | 4 + .../runloop/unified/turn/turn_loop/tests.rs | 3 + vtcode.toml.example | 6 + 43 files changed, 1045 insertions(+), 79 deletions(-) create mode 100644 crates/codegen/vtcode-acp/src/zed/agent/lody_activity.rs create mode 100644 crates/codegen/vtcode-acp/src/zed/agent/lody_notice.rs diff --git a/README.md b/README.md index 0ba1cfbd2..a3115acd6 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ vtcode update # self-update ## Providers -VT Code supports 24+ LLM providers out of the box, plus any custom API via `[[custom_providers]]`. Custom providers can declare their model capability with `context_window` (tokens), an optional `api_format` hint, and capability defaults such as `supports_tools` or `supports_vision`. `api_format` accepts `auto`, `openai-chat`, `openai-responses`, or `anthropic-messages`; when omitted VT Code preserves legacy autodetection, while an explicit value is honored and will not silently fall back. Per-model sparse profiles under `custom_providers.profiles.""` can override these defaults for an already-allowed model — profiles do not add models to the picker. The configured `context_window` still drives UI context sizing, compaction thresholds, and preflight token checks. +VT Code supports 24+ LLM providers out of the box, plus any custom API via `[[custom_providers]]`. Custom providers can declare their model capability with `context_window` (tokens), opt-in pricing in USD per million tokens, an optional `api_format` hint, and capability defaults such as `supports_tools` or `supports_vision`. `api_format` accepts `auto`, `openai-chat`, `openai-responses`, or `anthropic-messages`; when omitted VT Code preserves legacy autodetection, while an explicit value is honored and will not silently fall back. Per-model sparse profiles under `custom_providers.profiles.""` can override these defaults for an already-allowed model — profiles do not add models to the picker. The configured `context_window` still drives UI context sizing, compaction thresholds, and preflight token checks. In ACP usage updates, `costUSD` is emitted only when both input and output pricing are explicitly configured. | Category | Providers | | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -113,6 +113,13 @@ base_url = "https://llm.corp.example/v1" api_key_env = "MYCORP_API_KEY" model = "gpt-5-mini" context_window = 256000 # optional; defaults to 128000 tokens + +[custom_providers.pricing] +# USD per million tokens; costUSD requires both input and output rates. +input_per_million_usd = 0.15 +output_per_million_usd = 0.50 +# cache_read_per_million_usd = 0.03 +# cache_write_per_million_usd = 0.00 ``` `context_window` is the provider capability in tokens. The separate diff --git a/crates/codegen/vtcode-acp/src/zed/agent/compaction.rs b/crates/codegen/vtcode-acp/src/zed/agent/compaction.rs index cf84189d1..c5db4d174 100644 --- a/crates/codegen/vtcode-acp/src/zed/agent/compaction.rs +++ b/crates/codegen/vtcode-acp/src/zed/agent/compaction.rs @@ -79,18 +79,27 @@ impl ZedAgent { return Ok(false); } - let (thread, session_id, mut history, mut auto_compact_suppressed) = { + let (thread, acp_session_id, mut history, mut auto_compact_suppressed) = { let data = session .data .lock() .map_err(|error| anyhow::anyhow!("ACP session lock poisoned: {error}"))?; - ( - data.thread.clone(), - data.session_id.to_string(), - data.thread.messages(), - data.auto_compact_suppressed, - ) + (data.thread.clone(), data.session_id.clone(), data.thread.messages(), data.auto_compact_suppressed) }; + let activity = super::lody_activity::CompactionActivity::begin(&acp_session_id, prompt_tokens); + match activity.started_update() { + Ok(update) => { + if let Err(error) = self.send_update(&acp_session_id, update).await { + warn!(%error, session_id = %acp_session_id, "Failed to publish ACP compaction start update"); + } + } + Err(error) => { + warn!(%error, session_id = %acp_session_id, "Failed to serialize ACP compaction start update") + } + } + + let compaction_result: Result = async { + let session_id = acp_session_id.to_string(); let original_len = history.len(); let force_compaction = admission_budget.is_some_and(|budget| prompt_tokens >= budget) && configured_threshold.is_none_or(|threshold| prompt_tokens < threshold); @@ -204,6 +213,25 @@ impl ZedAgent { "Applied automatic ACP conversation compaction" ); Ok(true) + } + .await; + + let used_tokens_after = compaction_result + .as_ref() + .ok() + .map(|_| estimated_prompt_tokens(&self.resolved_messages(session), tools)); + let failure_reason = compaction_result.as_ref().err().map(ToString::to_string); + match activity.finished_update(used_tokens_after, failure_reason.as_deref()) { + Ok(update) => { + if let Err(error) = self.send_update(&acp_session_id, update).await { + warn!(%error, session_id = %acp_session_id, "Failed to publish ACP compaction terminal update"); + } + } + Err(error) => { + warn!(%error, session_id = %acp_session_id, "Failed to serialize ACP compaction terminal update"); + } + } + compaction_result } } diff --git a/crates/codegen/vtcode-acp/src/zed/agent/handlers.rs b/crates/codegen/vtcode-acp/src/zed/agent/handlers.rs index 6598cf8f1..eb0438cca 100644 --- a/crates/codegen/vtcode-acp/src/zed/agent/handlers.rs +++ b/crates/codegen/vtcode-acp/src/zed/agent/handlers.rs @@ -298,7 +298,14 @@ struct ProviderErrorTelemetry<'a> { } fn provider_error_telemetry(error: &LLMError) -> ProviderErrorTelemetry<'_> { - let LLMError::Network { metadata: Some(metadata), .. } = error else { + let metadata = match error { + LLMError::Authentication { metadata, .. } + | LLMError::RateLimit { metadata } + | LLMError::InvalidRequest { metadata, .. } + | LLMError::Network { metadata, .. } + | LLMError::Provider { metadata, .. } => metadata.as_deref(), + }; + let Some(metadata) = metadata else { return ProviderErrorTelemetry::default(); }; ProviderErrorTelemetry { @@ -448,6 +455,7 @@ async fn generate_with_retry( request: LLMRequest, runtime: &ProviderRequestRuntime, cancellation: &super::super::types::SessionCancellation, + notice_target: Option<(&ZedAgent, &acp::SessionId)>, ) -> Result { let policy = runtime.retry_policy(); let mut attempt_index = 0; @@ -484,10 +492,18 @@ async fn generate_with_retry( let decision = policy.decision_for_llm_error(&error, attempt_index); telemetry.failed(runtime, attempt_index, retry_disposition(&decision), &error); drop(permit); + let retry_delay = decision + .retryable + .then(|| decision.delay.unwrap_or_else(|| policy.delay_for_attempt(attempt_index))); + if let Some((agent, session_id)) = notice_target { + agent + .publish_rate_limit_notice(session_id, runtime.provider_name(), &error, retry_delay) + .await; + } if !decision.retryable { return Err(ProviderCallError::Failed(error.to_string())); } - let delay = decision.delay.unwrap_or_else(|| policy.delay_for_attempt(attempt_index)); + let delay = retry_delay.unwrap_or_else(|| policy.delay_for_attempt(attempt_index)); info!( provider = runtime.provider_name(), next_attempt = attempt_index + 2, @@ -718,6 +734,7 @@ fn advertised_agent_capabilities(has_subagent_controller: bool, background_enabl .list(acp::SessionListCapabilities::new()) .resume(acp::SessionResumeCapabilities::new()); super::lody_usage::add_lody_usage_capability(&mut capabilities); + super::lody_activity::add_lody_compaction_capability(&mut capabilities); if has_subagent_controller { super::lody::add_lody_subagent_management_capability(&mut capabilities, background_enabled); } @@ -1078,6 +1095,17 @@ async fn run_prompt(agent: Arc, args: PromptRequest) -> Result, args: PromptRequest) -> Result, args: PromptRequest) -> Result, args: PromptRequest) -> Result, args: PromptRequest) -> Result { telemetry.failed(&provider_runtime, attempt_index, "partial_output_visible", &error); + agent + .publish_rate_limit_notice(&args.session_id, provider_runtime.provider_name(), &error, None) + .await; return Ok(finish_failed_provider_turn( &agent, &session, @@ -1306,7 +1348,7 @@ async fn run_prompt(agent: Arc, args: PromptRequest) -> Result, args: PromptRequest) -> Result response, - Err(ProviderCallError::Cancelled) => { - stop_reason = acp::StopReason::Cancelled; - break; - } - Err(ProviderCallError::Failed(error)) => { - return Ok(finish_failed_provider_turn( - &agent, - &session, - &args.session_id, - &assistant_message, - &assistant_reasoning, - &error, - ) - .await); - } - }; + let response = match generate_with_retry( + provider.as_ref(), + request, + &provider_runtime, + &session.cancellation, + Some((&agent, &args.session_id)), + ) + .await + { + Ok(response) => response, + Err(ProviderCallError::Cancelled) => { + stop_reason = acp::StopReason::Cancelled; + break; + } + Err(ProviderCallError::Failed(error)) => { + return Ok(finish_failed_provider_turn( + &agent, + &session, + &args.session_id, + &assistant_message, + &assistant_reasoning, + &error, + ) + .await); + } + }; - agent.publish_lody_usage(&args.session_id, &session_model, &response); + agent.publish_lody_usage(&args.session_id, &session_provider_name, &session_model, &response); if session.cancellation.is_cancelled() { stop_reason = acp::StopReason::Cancelled; break; @@ -1688,6 +1737,8 @@ mod tests { assert!(capabilities.session_capabilities.resume.is_some()); let lody = &capabilities.meta.expect("Lody capability metadata")["lody"]; assert_eq!(lody["usage"]["version"], 1); + assert_eq!(lody["compaction"]["version"], 1); + assert!(lody.get("rateLimits").is_none(), "no trustworthy quota source is configured"); assert!(lody.get("subagents").is_none()); } @@ -1937,6 +1988,30 @@ Run the managed background fixture. ); } + #[test] + fn provider_error_telemetry_exposes_rate_limit_diagnostics() { + let error = LLMError::RateLimit { + metadata: Some(LLMErrorMetadata::new( + "baseten", + Some(429), + Some("rate_limit_error".to_string()), + None, + None, + Some("17".to_string()), + Some("capacity temporarily exhausted".to_string()), + )), + }; + + assert_eq!( + provider_error_telemetry(&error), + ProviderErrorTelemetry { + code: Some("rate_limit_error"), + status: Some(429), + detail: Some("capacity temporarily exhausted"), + } + ); + } + proptest! { #[test] fn streaming_eligibility_depends_only_on_provider_support_and_stop_hooks( @@ -3399,6 +3474,7 @@ Run the managed background fixture. LLMRequest::default(), &timeout_runtime(), &super::super::super::types::SessionCancellation::default(), + None, ) .await; @@ -3441,6 +3517,7 @@ Run the managed background fixture. LLMRequest::default(), &retry_runtime(), &super::super::super::types::SessionCancellation::default(), + None, ) .await .expect("transient request should recover"); @@ -3458,6 +3535,7 @@ Run the managed background fixture. LLMRequest::default(), &retry_runtime(), &super::super::super::types::SessionCancellation::default(), + None, ) .await; @@ -3481,6 +3559,7 @@ Run the managed background fixture. LLMRequest::default(), &retry_runtime(), &super::super::super::types::SessionCancellation::default(), + None, ) .await; diff --git a/crates/codegen/vtcode-acp/src/zed/agent/lody_activity.rs b/crates/codegen/vtcode-acp/src/zed/agent/lody_activity.rs new file mode 100644 index 000000000..44fe050cd --- /dev/null +++ b/crates/codegen/vtcode-acp/src/zed/agent/lody_activity.rs @@ -0,0 +1,215 @@ +use crate::acp; +use serde::Serialize; +use serde_json::{Map, Value}; +use std::time::{Duration, Instant}; + +use super::lody::lody_capabilities_mut; + +const LODY_EXTENSION_VERSION: u8 = 1; +const COMPACTION_TITLE: &str = "Compact conversation context"; + +#[derive(Debug)] +pub(super) struct CompactionActivity { + tool_call_id: String, + used_tokens_before: usize, + started_at: Instant, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct LodyActivityMeta<'a> { + version: u8, + kind: &'static str, + automatic: bool, + used_tokens_before: usize, + #[serde(skip_serializing_if = "Option::is_none")] + used_tokens_after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + duration_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + failure_reason: Option<&'a str>, +} + +impl CompactionActivity { + pub(super) fn begin(session_id: &acp::SessionId, used_tokens_before: usize) -> Self { + Self { + tool_call_id: format!("activity:compaction:{}:{}", session_id.0, uuid::Uuid::new_v4()), + used_tokens_before, + started_at: Instant::now(), + } + } + + pub(super) fn started_update(&self) -> anyhow::Result { + let meta = activity_meta(LodyActivityMeta { + version: LODY_EXTENSION_VERSION, + kind: "context_compaction", + automatic: true, + used_tokens_before: self.used_tokens_before, + used_tokens_after: None, + duration_ms: None, + failure_reason: None, + })?; + Ok(acp::SessionUpdate::ToolCall( + acp::ToolCall::new(self.tool_call_id.clone(), COMPACTION_TITLE) + .kind(acp::ToolKind::Think) + .status(acp::ToolCallStatus::InProgress) + .meta(meta), + )) + } + + pub(super) fn finished_update( + &self, + used_tokens_after: Option, + failure_reason: Option<&str>, + ) -> anyhow::Result { + let status = if failure_reason.is_some() { + acp::ToolCallStatus::Failed + } else { + acp::ToolCallStatus::Completed + }; + let meta = activity_meta(LodyActivityMeta { + version: LODY_EXTENSION_VERSION, + kind: "context_compaction", + automatic: true, + used_tokens_before: self.used_tokens_before, + used_tokens_after, + duration_ms: Some(duration_millis(self.started_at.elapsed())), + failure_reason, + })?; + let fields = acp::ToolCallUpdateFields::new() + .title(COMPACTION_TITLE) + .kind(acp::ToolKind::Think) + .status(status); + Ok(acp::SessionUpdate::ToolCallUpdate( + acp::ToolCallUpdate::new(self.tool_call_id.clone(), fields).meta(meta), + )) + } +} + +pub(super) fn add_lody_compaction_capability(capabilities: &mut acp::AgentCapabilities) { + if let Some(lody) = lody_capabilities_mut(capabilities) { + let _ = lody.insert("compaction".to_string(), serde_json::json!({ "version": LODY_EXTENSION_VERSION })); + } +} + +fn activity_meta(activity: LodyActivityMeta<'_>) -> anyhow::Result> { + let mut lody = Map::new(); + let _ = lody.insert("activity".to_string(), serde_json::to_value(activity)?); + let mut meta = Map::new(); + let _ = meta.insert("lody".to_string(), Value::Object(lody)); + Ok(meta) +} + +fn duration_millis(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::zed::connection::ConnectionHandle; + use agent_client_protocol::{Agent, Builder, Client, ConnectionTo, RunWithConnectionTo, on_receive_notification}; + use std::sync::Arc; + use tokio::sync::{Notify, mpsc}; + + #[test] + fn compaction_updates_use_standard_tool_lifecycle_with_lody_activity_meta() { + let activity = CompactionActivity { + tool_call_id: "activity:compaction:test".to_string(), + used_tokens_before: 120_000, + started_at: Instant::now(), + }; + + let started = serde_json::to_value(activity.started_update().expect("started update")).expect("started JSON"); + assert_eq!(started["sessionUpdate"], "tool_call"); + assert_eq!(started["status"], "in_progress"); + assert_eq!(started["_meta"]["lody"]["activity"]["kind"], "context_compaction"); + assert_eq!(started["_meta"]["lody"]["activity"]["usedTokensBefore"], 120_000); + + let finished = serde_json::to_value(activity.finished_update(Some(42_000), None).expect("finished update")) + .expect("finished JSON"); + assert_eq!(finished["sessionUpdate"], "tool_call_update"); + assert_eq!(finished["status"], "completed"); + assert_eq!(finished["_meta"]["lody"]["activity"]["usedTokensAfter"], 42_000); + } + + #[test] + fn failed_compaction_update_carries_the_failure_reason() { + let activity = CompactionActivity::begin(&acp::SessionId::new(Arc::from("session-a")), 32_000); + let update = serde_json::to_value( + activity + .finished_update(None, Some("provider timed out")) + .expect("failed update"), + ) + .expect("failed JSON"); + + assert_eq!(update["status"], "failed"); + assert_eq!(update["_meta"]["lody"]["activity"]["failureReason"], "provider timed out"); + } + + #[tokio::test] + async fn compaction_lifecycle_round_trips_over_the_official_acp_duplex() { + let (agent_channel, client_channel) = agent_client_protocol::Channel::duplex(); + let (received_tx, mut received_rx) = mpsc::unbounded_channel(); + let session_id = acp::SessionId::new(Arc::from("session-compaction")); + let activity = CompactionActivity { + tool_call_id: "activity:compaction:wire".to_string(), + used_tokens_before: 90_000, + started_at: Instant::now(), + }; + let client_ready = Arc::new(Notify::new()); + let agent_ready = Arc::clone(&client_ready); + let notifications_received = Arc::new(Notify::new()); + let agent_ack = Arc::clone(¬ifications_received); + + let agent_connection = Agent.builder().name("vtcode-compaction-test").connect_with( + agent_channel, + move |cx: ConnectionTo| async move { + agent_ready.notified().await; + let client = ConnectionHandle::new(cx); + for update in [ + activity.started_update().expect("start update"), + activity.finished_update(Some(30_000), None).expect("terminal update"), + ] { + client + .send_session_notification(acp::SessionNotification::new(session_id.clone(), update)) + .expect("send compaction update"); + } + agent_ack.notified().await; + Ok(()) + }, + ); + let agent_task = tokio::spawn(agent_connection); + + let client_connection = Client + .builder() + .on_receive_notification( + async move |notification: acp::AgentNotification, _cx| { + drop(received_tx.send(notification)); + Ok(()) + }, + on_receive_notification!(), + ) + .connect_with(client_channel, async move |_cx: ConnectionTo| { + client_ready.notify_one(); + let first = received_rx.recv().await.expect("start notification"); + let second = received_rx.recv().await.expect("terminal notification"); + let acp::AgentNotification::SessionNotification(first) = first else { + panic!("compaction start must use a standard session notification"); + }; + let acp::AgentNotification::SessionNotification(second) = second else { + panic!("compaction terminal state must use a standard session notification"); + }; + assert!(matches!(first.update, acp::SessionUpdate::ToolCall(_))); + assert!(matches!(second.update, acp::SessionUpdate::ToolCallUpdate(_))); + notifications_received.notify_one(); + Ok(()) + }); + + tokio::time::timeout(Duration::from_secs(3), client_connection) + .await + .expect("client connection deadline") + .expect("client connection"); + agent_task.await.expect("agent task").expect("agent connection"); + } +} diff --git a/crates/codegen/vtcode-acp/src/zed/agent/lody_notice.rs b/crates/codegen/vtcode-acp/src/zed/agent/lody_notice.rs new file mode 100644 index 000000000..e6116fe92 --- /dev/null +++ b/crates/codegen/vtcode-acp/src/zed/agent/lody_notice.rs @@ -0,0 +1,124 @@ +use crate::acp; +use serde_json::{Map, Value}; +use std::time::Duration; +use tracing::warn; +use vtcode_core::llm::provider::{LLMError, LLMErrorMetadata}; + +use super::ZedAgent; + +impl ZedAgent { + pub(super) async fn publish_rate_limit_notice( + &self, + session_id: &acp::SessionId, + provider: &str, + error: &LLMError, + retry_delay: Option, + ) { + let Some(update) = rate_limit_notice_update(provider, error, retry_delay) else { + return; + }; + if let Err(error) = self.send_update(session_id, update).await { + warn!(%error, %session_id, "Failed to publish ACP provider rate-limit notice"); + } + } +} + +fn rate_limit_notice_update( + provider: &str, + error: &LLMError, + retry_delay: Option, +) -> Option { + let metadata = rate_limit_metadata(error)?; + let retry_after = metadata.retry_after.as_deref(); + let mut message = format!("{provider} returned HTTP 429 (rate limited)"); + if let Some(retry_after) = retry_after { + message.push_str(&format!("; provider Retry-After: {retry_after}")); + } + if let Some(delay) = retry_delay { + message.push_str(&format!("; VTCode will retry in {:.1}s", delay.as_secs_f64())); + } else { + message.push_str("; no further automatic retry is scheduled"); + } + + let mut notice = Map::new(); + let _ = notice.insert("level".to_string(), Value::String("warning".to_string())); + let _ = notice.insert("message".to_string(), Value::String(message)); + let _ = notice.insert("source".to_string(), Value::String("provider_rate_limit".to_string())); + let mut lody = Map::new(); + let _ = lody.insert("notice".to_string(), Value::Object(notice)); + let mut meta = Map::new(); + let _ = meta.insert("lody".to_string(), Value::Object(lody)); + Some(acp::SessionUpdate::SessionInfoUpdate(acp::SessionInfoUpdate::new().meta(meta))) +} + +fn rate_limit_metadata(error: &LLMError) -> Option<&LLMErrorMetadata> { + let metadata = match error { + LLMError::Authentication { metadata, .. } + | LLMError::RateLimit { metadata } + | LLMError::InvalidRequest { metadata, .. } + | LLMError::Network { metadata, .. } + | LLMError::Provider { metadata, .. } => metadata.as_deref(), + }?; + matches!(error, LLMError::RateLimit { .. }) + .then_some(metadata) + .or_else(|| (metadata.status == Some(429)).then_some(metadata)) +} + +#[cfg(test)] +mod tests { + use super::*; + use vtcode_core::llm::provider::LLMErrorMetadata; + + #[test] + fn rate_limit_notice_preserves_retry_after_without_fabricating_a_window() { + let error = LLMError::RateLimit { + metadata: Some(LLMErrorMetadata::new( + "baseten", + Some(429), + Some("rate_limit_error".to_string()), + None, + None, + Some("17".to_string()), + None, + )), + }; + let update = + rate_limit_notice_update("baseten", &error, Some(Duration::from_secs(20))).expect("rate-limit notice"); + let value = serde_json::to_value(update).expect("notice JSON"); + + assert_eq!(value["sessionUpdate"], "session_info_update"); + assert_eq!(value["_meta"]["lody"]["notice"]["level"], "warning"); + assert_eq!(value["_meta"]["lody"]["notice"]["source"], "provider_rate_limit"); + let message = value["_meta"]["lody"]["notice"]["message"].as_str().expect("notice message"); + assert!(message.contains("Retry-After: 17")); + assert!(message.contains("retry in 20.0s")); + assert!(value["_meta"]["lody"].get("rateLimits").is_none()); + } + + #[test] + fn non_rate_limit_errors_do_not_emit_rate_limit_notices() { + let error = LLMError::Network { + message: "connection reset".to_string(), + metadata: None, + }; + assert!(rate_limit_notice_update("baseten", &error, None).is_none()); + } + + #[test] + fn metadata_status_429_is_reported_even_when_error_is_not_reclassified() { + let error = LLMError::Provider { + message: "provider capacity exhausted".to_string(), + metadata: Some(LLMErrorMetadata::new( + "baseten", + Some(429), + Some("capacity_exhausted".to_string()), + None, + None, + Some("10".to_string()), + None, + )), + }; + + assert!(rate_limit_notice_update("baseten", &error, None).is_some()); + } +} diff --git a/crates/codegen/vtcode-acp/src/zed/agent/lody_usage.rs b/crates/codegen/vtcode-acp/src/zed/agent/lody_usage.rs index 897ac4897..aa7b67e82 100644 --- a/crates/codegen/vtcode-acp/src/zed/agent/lody_usage.rs +++ b/crates/codegen/vtcode-acp/src/zed/agent/lody_usage.rs @@ -4,14 +4,17 @@ use crate::zed::connection::ConnectionHandle; use serde::Serialize; use std::collections::BTreeMap; use std::sync::Arc; +use vtcode_config::models::ModelPricing; use vtcode_core::llm::Usage; +use vtcode_core::llm::model_resolver::ModelResolver; +use vtcode_core::llm::usage_cost::{estimate_session_costs_with_pricing, normalized_turn_usage}; use super::{ZedAgent, lody::lody_capabilities_mut}; const LODY_EXTENSION_VERSION: u8 = 1; pub(super) const LODY_SESSION_USAGE_UPDATE_METHOD: &str = "_lody/session/usage_update"; -#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct LodyUsage { input_tokens: u32, @@ -19,6 +22,18 @@ struct LodyUsage { cache_read_input_tokens: u32, #[serde(skip_serializing_if = "Option::is_none")] cache_creation_input_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reasoning_output_tokens: Option, + #[serde(rename = "costUSD", skip_serializing_if = "Option::is_none")] + cost_usd: Option, + #[serde(skip_serializing_if = "Option::is_none")] + context_window: Option, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct UsageProfile { + context_window: Option, + pricing: Option, } #[derive(Debug, Serialize)] @@ -36,12 +51,31 @@ pub(super) fn add_lody_usage_capability(capabilities: &mut acp::AgentCapabilitie } } -fn usage_notification(session_id: &acp::SessionId, model: &str, usage: &Usage) -> anyhow::Result { +fn usage_notification( + session_id: &acp::SessionId, + provider: &str, + model: &str, + usage: &Usage, + profile: UsageProfile, +) -> anyhow::Result { + let reasoning_output_tokens = usage + .reasoning_output_tokens + .map(|reasoning| reasoning.min(usage.completion_tokens)); + let output_tokens = usage + .completion_tokens + .saturating_sub(reasoning_output_tokens.unwrap_or_default()); + let cost_usd = profile.pricing.and_then(|pricing| { + let normalized = normalized_turn_usage(provider, usage); + estimate_session_costs_with_pricing(pricing, &normalized).map(|estimate| estimate.effective_usd) + }); let delta = LodyUsage { input_tokens: usage.prompt_tokens, - output_tokens: usage.completion_tokens, + output_tokens, cache_read_input_tokens: usage.cache_read_tokens_or_fallback(), cache_creation_input_tokens: usage.cache_creation_tokens, + reasoning_output_tokens, + cost_usd, + context_window: profile.context_window, }; let payload = LodySessionUsageUpdate { session_id: session_id.0.as_ref(), @@ -54,8 +88,10 @@ fn usage_notification(session_id: &acp::SessionId, model: &str, usage: &Usage) - fn response_usage_notification( session_id: &acp::SessionId, + provider: &str, fallback_model: &str, response: &vtcode_core::llm::provider::LLMResponse, + profile: UsageProfile, ) -> anyhow::Result> { let Some(usage) = response.usage.as_ref() else { return Ok(None); @@ -65,32 +101,58 @@ fn response_usage_notification( } else { response.model.as_str() }; - usage_notification(session_id, model, usage).map(Some) + usage_notification(session_id, provider, model, usage, profile).map(Some) } #[cfg(test)] fn send_usage_update( client: &ConnectionHandle, session_id: &acp::SessionId, + provider: &str, model: &str, usage: &Usage, + profile: UsageProfile, ) -> anyhow::Result<()> { client - .send_ext_notification(usage_notification(session_id, model, usage)?) + .send_ext_notification(usage_notification(session_id, provider, model, usage, profile)?) .map_err(|error| anyhow::anyhow!(error.to_string())) } impl ZedAgent { + fn lody_usage_profile(&self, provider: &str, model: &str) -> UsageProfile { + if let Some(custom_provider) = self + .workspace_runtime_config + .custom_providers + .iter() + .find(|candidate| candidate.name.eq_ignore_ascii_case(provider)) + { + let resolved = custom_provider.resolved_profile(model); + return UsageProfile { + context_window: resolved.context_window.filter(|window| *window > 0), + pricing: resolved.pricing.model_pricing(), + }; + } + + ModelResolver::resolve(Some(provider), model, &[], None).map_or_else(UsageProfile::default, |resolved| { + UsageProfile { + context_window: resolved.context_window(), + pricing: resolved.pricing(), + } + }) + } + pub(super) fn publish_lody_usage( &self, session_id: &acp::SessionId, + provider: &str, fallback_model: &str, response: &vtcode_core::llm::provider::LLMResponse, ) { let Some(client) = self.client() else { return; }; - let notification = match response_usage_notification(session_id, fallback_model, response) { + let profile = self.lody_usage_profile(provider, fallback_model); + let notification = match response_usage_notification(session_id, provider, fallback_model, response, profile) { Ok(Some(notification)) => notification, Ok(None) => return, Err(error) => { @@ -116,20 +178,38 @@ mod tests { let usage = Usage { prompt_tokens: 21, completion_tokens: 8, + reasoning_output_tokens: Some(3), cached_prompt_tokens: Some(5), cache_creation_tokens: Some(3), ..Usage::default() }; - let notification = usage_notification(&acp::SessionId::new(Arc::from("session-1")), "model-a", &usage) - .expect("usage notification"); + let notification = usage_notification( + &acp::SessionId::new(Arc::from("session-1")), + "baseten", + "model-a", + &usage, + UsageProfile { + context_window: Some(1_048_576), + pricing: Some(ModelPricing { + input: Some(0.15 / 1_000_000.0), + output: Some(0.50 / 1_000_000.0), + cache_read: Some(0.03 / 1_000_000.0), + cache_write: None, + }), + }, + ) + .expect("usage notification"); let value: serde_json::Value = serde_json::from_str(notification.params.get()).expect("usage JSON"); assert_eq!(notification.method.as_ref(), LODY_SESSION_USAGE_UPDATE_METHOD); assert_eq!(value["sessionId"], "session-1"); assert_eq!(value["usage"]["inputTokens"], 21); - assert_eq!(value["usage"]["outputTokens"], 8); + assert_eq!(value["usage"]["outputTokens"], 5); + assert_eq!(value["usage"]["reasoningOutputTokens"], 3); assert_eq!(value["usage"]["cacheReadInputTokens"], 5); assert_eq!(value["usage"]["cacheCreationInputTokens"], 3); + assert_eq!(value["usage"]["contextWindow"], 1_048_576); + assert!(value["usage"]["costUSD"].as_f64().is_some_and(|cost| cost > 0.0)); assert_eq!(value["modelUsage"]["model-a"], value["usage"]); } @@ -137,9 +217,14 @@ mod tests { fn response_without_usage_produces_no_notification() { let response = vtcode_core::llm::provider::LLMResponse::new("model-a", "answer"); - let notification = - response_usage_notification(&acp::SessionId::new(Arc::from("session-1")), "fallback-model", &response) - .expect("optional usage notification"); + let notification = response_usage_notification( + &acp::SessionId::new(Arc::from("session-1")), + "baseten", + "fallback-model", + &response, + UsageProfile::default(), + ) + .expect("optional usage notification"); assert!(notification.is_none()); } @@ -151,24 +236,35 @@ mod tests { output in any::(), cache_read in any::(), cache_creation in proptest::option::of(any::()), + raw_reasoning in any::(), model in "[^\\p{C}]{1,48}", ) { let usage = Usage { prompt_tokens: input, completion_tokens: output, + reasoning_output_tokens: Some(raw_reasoning), cache_read_tokens: Some(cache_read), cache_creation_tokens: cache_creation, ..Usage::default() }; let notification = usage_notification( &acp::SessionId::new(Arc::from("property-session")), + "baseten", &model, &usage, + UsageProfile::default(), ).expect("usage notification"); let value: serde_json::Value = serde_json::from_str(notification.params.get()).expect("usage JSON"); prop_assert_eq!(value["usage"]["inputTokens"].as_u64(), Some(u64::from(input))); - prop_assert_eq!(value["usage"]["outputTokens"].as_u64(), Some(u64::from(output))); + prop_assert_eq!( + value["usage"]["outputTokens"].as_u64(), + Some(u64::from(output.saturating_sub(raw_reasoning.min(output)))), + ); + prop_assert_eq!( + value["usage"]["reasoningOutputTokens"].as_u64(), + Some(u64::from(raw_reasoning.min(output))), + ); prop_assert_eq!(value["usage"]["cacheReadInputTokens"].as_u64(), Some(u64::from(cache_read))); match cache_creation { Some(count) => prop_assert_eq!( @@ -198,12 +294,14 @@ mod tests { send_usage_update( &client, &acp::SessionId::new(Arc::from("session-wire")), + "baseten", "wire-model", &Usage { prompt_tokens: 13, completion_tokens: 5, ..Usage::default() }, + UsageProfile { context_window: Some(524_288), pricing: None }, ) .expect("send usage update"); agent_ack.notified().await; @@ -239,6 +337,7 @@ mod tests { assert_eq!(value["sessionId"], "session-wire"); assert_eq!(value["usage"]["inputTokens"], 13); assert_eq!(value["usage"]["outputTokens"], 5); + assert_eq!(value["usage"]["contextWindow"], 524_288); notification_received.notify_one(); Ok(()) }); diff --git a/crates/codegen/vtcode-acp/src/zed/agent/mod.rs b/crates/codegen/vtcode-acp/src/zed/agent/mod.rs index 10b6fb269..f5c11b77d 100644 --- a/crates/codegen/vtcode-acp/src/zed/agent/mod.rs +++ b/crates/codegen/vtcode-acp/src/zed/agent/mod.rs @@ -32,6 +32,8 @@ use super::types::SessionHandle; mod compaction; pub(crate) mod handlers; mod lody; +mod lody_activity; +mod lody_notice; #[cfg(test)] mod lody_tests; mod lody_usage; diff --git a/crates/codegen/vtcode-config/src/core/custom_provider.rs b/crates/codegen/vtcode-config/src/core/custom_provider.rs index b5a65cb5e..5f8b73983 100644 --- a/crates/codegen/vtcode-config/src/core/custom_provider.rs +++ b/crates/codegen/vtcode-config/src/core/custom_provider.rs @@ -3,6 +3,8 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use crate::models::ModelPricing; + fn default_auth_timeout_ms() -> u64 { 5_000 } @@ -30,6 +32,72 @@ pub enum CustomProviderApiFormat { AnthropicMessages, } +/// Optional per-token pricing for a custom provider model. +/// +/// Values are configured in USD per million tokens and converted to the +/// per-token representation used by the runtime cost estimator. +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq)] +pub struct CustomProviderPricingConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input_per_million_usd: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_per_million_usd: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_read_per_million_usd: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cache_write_per_million_usd: Option, +} + +impl CustomProviderPricingConfig { + const TOKENS_PER_MILLION: f64 = 1_000_000.0; + + fn from_layers(defaults: Self, profile: Self) -> Self { + Self { + input_per_million_usd: profile.input_per_million_usd.or(defaults.input_per_million_usd), + output_per_million_usd: profile.output_per_million_usd.or(defaults.output_per_million_usd), + cache_read_per_million_usd: profile.cache_read_per_million_usd.or(defaults.cache_read_per_million_usd), + cache_write_per_million_usd: profile.cache_write_per_million_usd.or(defaults.cache_write_per_million_usd), + } + } + + pub fn model_pricing(self) -> Option { + let input = self.input_per_million_usd? / Self::TOKENS_PER_MILLION; + let output = self.output_per_million_usd? / Self::TOKENS_PER_MILLION; + Some(ModelPricing { + input: Some(input), + output: Some(output), + cache_read: self.cache_read_per_million_usd.map(|rate| rate / Self::TOKENS_PER_MILLION), + cache_write: self.cache_write_per_million_usd.map(|rate| rate / Self::TOKENS_PER_MILLION), + }) + } + + fn validate(self, subject: &str) -> Result<(), String> { + let rates = [ + ("input_per_million_usd", self.input_per_million_usd), + ("output_per_million_usd", self.output_per_million_usd), + ("cache_read_per_million_usd", self.cache_read_per_million_usd), + ("cache_write_per_million_usd", self.cache_write_per_million_usd), + ]; + for (name, rate) in rates { + if rate.is_some_and(|rate| !rate.is_finite() || rate < 0.0) { + return Err(format!("{subject}.pricing: `{name}` must be a finite non-negative number")); + } + } + Ok(()) + } + + fn is_empty(&self) -> bool { + self.input_per_million_usd.is_none() + && self.output_per_million_usd.is_none() + && self.cache_read_per_million_usd.is_none() + && self.cache_write_per_million_usd.is_none() + } +} + impl CustomProviderApiFormat { pub const fn as_str(self) -> &'static str { match self { @@ -54,7 +122,7 @@ impl CustomProviderApiFormat { /// Sparse per-provider or per-model capability/profile settings. #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq)] pub struct CustomProviderProfileConfig { /// Typed API format for this provider/profile. #[serde(default, skip_serializing_if = "skip_serializing_custom_provider_api_format")] @@ -64,6 +132,10 @@ pub struct CustomProviderProfileConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub context_window: Option, + /// Optional pricing override for this exact model profile. + #[serde(default, skip_serializing_if = "CustomProviderPricingConfig::is_empty")] + pub pricing: CustomProviderPricingConfig, + #[serde(default, skip_serializing_if = "Option::is_none")] pub supports_tools: Option, @@ -104,6 +176,9 @@ impl CustomProviderProfileConfig { )); } + self.pricing + .validate(&format!("custom_providers[{provider_name}].profiles[{profile_key}]"))?; + Ok(()) } } @@ -111,10 +186,11 @@ impl CustomProviderProfileConfig { /// Resolved capability/profile settings after applying provider defaults and /// exact model-specific overrides. #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] -#[derive(Debug, Clone, Default, PartialEq, Eq)] +#[derive(Debug, Clone, Default, PartialEq)] pub struct ResolvedCustomProviderProfile { pub api_format: Option, pub context_window: Option, + pub pricing: CustomProviderPricingConfig, pub supports_tools: Option, pub supports_reasoning: Option, pub supports_reasoning_effort: Option, @@ -141,6 +217,7 @@ impl ResolvedCustomProviderProfile { Self { api_format: profile.api_format.resolved().or(defaults.api_format.resolved()), context_window: profile.context_window.or(defaults.context_window), + pricing: CustomProviderPricingConfig::from_layers(defaults.pricing, profile.pricing), supports_tools: profile.supports_tools.or(defaults.supports_tools), supports_reasoning: profile.supports_reasoning.or(defaults.supports_reasoning), supports_reasoning_effort: profile.supports_reasoning_effort.or(defaults.supports_reasoning_effort), @@ -371,6 +448,10 @@ pub struct CustomProviderConfig { #[serde(default, skip_serializing_if = "Option::is_none")] pub context_window: Option, + /// Optional default pricing in USD per million tokens. + #[serde(default, skip_serializing_if = "CustomProviderPricingConfig::is_empty")] + pub pricing: CustomProviderPricingConfig, + /// Optional support for tool calling. #[serde(default, skip_serializing_if = "Option::is_none")] pub supports_tools: Option, @@ -500,6 +581,7 @@ impl CustomProviderConfig { CustomProviderProfileConfig { api_format: self.api_format, context_window: self.context_window, + pricing: self.pricing, supports_tools: self.supports_tools, supports_reasoning: self.supports_reasoning, supports_reasoning_effort: self.supports_reasoning_effort, @@ -539,6 +621,8 @@ impl CustomProviderConfig { return Err(format!("custom_providers[{}]: `context_window` must be greater than 0", self.name)); } + self.pricing.validate(&format!("custom_providers[{}]", self.name))?; + if let Some(auth) = &self.auth { auth.validate(&self.name)?; if !self.api_key_env.trim().is_empty() { @@ -615,11 +699,39 @@ mod tests { use std::path::PathBuf; use super::{ - CustomProviderApiFormat, CustomProviderCommandAuthConfig, CustomProviderConfig, CustomProviderProfileConfig, - CustomProviderRequestPolicyConfig, ResolvedCustomProviderProfile, default_auth_refresh_interval_ms, - default_auth_timeout_ms, + CustomProviderApiFormat, CustomProviderCommandAuthConfig, CustomProviderConfig, CustomProviderPricingConfig, + CustomProviderProfileConfig, CustomProviderRequestPolicyConfig, ResolvedCustomProviderProfile, + default_auth_refresh_interval_ms, default_auth_timeout_ms, }; + #[test] + fn custom_provider_pricing_converts_per_million_rates_to_per_token_rates() { + let pricing = CustomProviderPricingConfig { + input_per_million_usd: Some(0.13), + output_per_million_usd: Some(0.26), + cache_read_per_million_usd: Some(0.028), + cache_write_per_million_usd: None, + } + .model_pricing() + .expect("complete custom pricing"); + + assert_eq!(pricing.input, Some(0.13 / 1_000_000.0)); + assert_eq!(pricing.output, Some(0.26 / 1_000_000.0)); + assert_eq!(pricing.cache_read, Some(0.028 / 1_000_000.0)); + assert_eq!(pricing.cache_write, None); + } + + #[test] + fn custom_provider_pricing_rejects_negative_or_non_finite_rates() { + for invalid_rate in [-0.01, f64::INFINITY, f64::NAN] { + let pricing = CustomProviderPricingConfig { + input_per_million_usd: Some(invalid_rate), + ..Default::default() + }; + assert!(pricing.validate("custom_providers[test]").is_err()); + } + } + #[test] fn validate_accepts_lowercase_provider_name() { let config = CustomProviderConfig { @@ -628,6 +740,7 @@ mod tests { base_url: "https://llm.example/v1".to_string(), api_format: CustomProviderApiFormat::Auto, context_window: None, + pricing: Default::default(), supports_tools: None, supports_reasoning: None, supports_reasoning_effort: None, @@ -658,6 +771,7 @@ mod tests { base_url: "https://llm.example/v1".to_string(), api_format: CustomProviderApiFormat::Auto, context_window: None, + pricing: Default::default(), supports_tools: None, supports_reasoning: None, supports_reasoning_effort: None, @@ -688,6 +802,7 @@ mod tests { base_url: "https://llm.example/v1".to_string(), api_format: CustomProviderApiFormat::Auto, context_window: None, + pricing: Default::default(), supports_tools: None, supports_reasoning: None, supports_reasoning_effort: None, @@ -724,6 +839,7 @@ mod tests { base_url: "https://llm.example/v1".to_string(), api_format: CustomProviderApiFormat::Auto, context_window: None, + pricing: Default::default(), supports_tools: None, supports_reasoning: None, supports_reasoning_effort: None, @@ -760,6 +876,7 @@ mod tests { base_url: "https://llm.example/v1".to_string(), api_format: CustomProviderApiFormat::Auto, context_window: None, + pricing: Default::default(), supports_tools: None, supports_reasoning: None, supports_reasoning_effort: None, @@ -790,6 +907,7 @@ mod tests { base_url: "https://llm.example/v1".to_string(), api_format: CustomProviderApiFormat::Auto, context_window: Some(0), + pricing: Default::default(), supports_tools: None, supports_reasoning: None, supports_reasoning_effort: None, @@ -820,6 +938,7 @@ mod tests { CustomProviderProfileConfig { api_format: CustomProviderApiFormat::Auto, context_window: Some(128_000), + pricing: Default::default(), supports_tools: None, supports_reasoning: None, supports_reasoning_effort: None, @@ -839,6 +958,7 @@ mod tests { base_url: "https://llm.example/v1".to_string(), api_format: CustomProviderApiFormat::Auto, context_window: None, + pricing: Default::default(), supports_tools: None, supports_reasoning: None, supports_reasoning_effort: None, @@ -869,6 +989,7 @@ mod tests { base_url: "https://api.atlascloud.ai/v1".to_string(), api_format: CustomProviderApiFormat::Auto, context_window: None, + pricing: Default::default(), supports_tools: None, supports_reasoning: None, supports_reasoning_effort: None, @@ -929,6 +1050,7 @@ mod tests { CustomProviderProfileConfig { api_format: CustomProviderApiFormat::OpenAIResponses, context_window: Some(128_000), + pricing: Default::default(), supports_tools: Some(true), supports_reasoning: None, supports_reasoning_effort: None, @@ -948,6 +1070,7 @@ mod tests { base_url: "https://llm.example/v1".to_string(), api_format: CustomProviderApiFormat::OpenAIChat, context_window: Some(256_000), + pricing: Default::default(), supports_tools: Some(true), supports_reasoning: Some(true), supports_reasoning_effort: None, @@ -972,6 +1095,7 @@ mod tests { ResolvedCustomProviderProfile { api_format: Some(CustomProviderApiFormat::OpenAIResponses), context_window: Some(128_000), + pricing: Default::default(), supports_tools: Some(true), supports_reasoning: Some(true), supports_reasoning_effort: None, @@ -995,6 +1119,7 @@ mod tests { CustomProviderProfileConfig { api_format: CustomProviderApiFormat::Auto, context_window: None, + pricing: Default::default(), supports_tools: Some(false), supports_reasoning: None, supports_reasoning_effort: Some(true), @@ -1014,6 +1139,7 @@ mod tests { base_url: "https://llm.example/v1".to_string(), api_format: CustomProviderApiFormat::OpenAIChat, context_window: Some(256_000), + pricing: Default::default(), supports_tools: Some(true), supports_reasoning: Some(false), supports_reasoning_effort: None, diff --git a/crates/codegen/vtcode-config/src/core/mod.rs b/crates/codegen/vtcode-config/src/core/mod.rs index 99af66cf8..c36cd4567 100644 --- a/crates/codegen/vtcode-config/src/core/mod.rs +++ b/crates/codegen/vtcode-config/src/core/mod.rs @@ -25,8 +25,8 @@ pub use auth::{AuthConfig, CopilotAuthConfig, OpenAIAuthConfig, OpenAIPreferredM pub use automation::{AutomationConfig, FullAutoConfig, ScheduledTasksConfig}; pub use commands::CommandsConfig; pub use custom_provider::{ - CustomProviderApiFormat, CustomProviderCommandAuthConfig, CustomProviderConfig, CustomProviderProfileConfig, - CustomProviderRequestPolicyConfig, ResolvedCustomProviderProfile, + CustomProviderApiFormat, CustomProviderCommandAuthConfig, CustomProviderConfig, CustomProviderPricingConfig, + CustomProviderProfileConfig, CustomProviderRequestPolicyConfig, ResolvedCustomProviderProfile, }; pub use dotfile_protection::DotfileProtectionConfig; pub use model::ModelConfig; diff --git a/crates/codegen/vtcode-config/src/lib.rs b/crates/codegen/vtcode-config/src/lib.rs index 563bcbefd..c04337e44 100644 --- a/crates/codegen/vtcode-config/src/lib.rs +++ b/crates/codegen/vtcode-config/src/lib.rs @@ -87,17 +87,17 @@ pub use core::{ AgentCodexAppServerConfig, AgentConfig, AgentOnboardingConfig, AgentPromptSuggestionsConfig, AuthConfig, AutoPermissionConfig, AutoPermissionEnvironmentConfig, AutomationConfig, BundledSkillsConfig, CommandsConfig, CopilotAuthConfig, CustomProviderApiFormat, CustomProviderCommandAuthConfig, CustomProviderConfig, - CustomProviderProfileConfig, DockerSandboxConfig, EditorToolConfig, ExternalSandboxConfig, ExternalSandboxType, - FullAutoConfig, GatekeeperConfig, MemoriesConfig, MicroVMSandboxConfig, MicroVmProvider, ModelConfig, - NetworkAllowlistEntryConfig, NetworkConfig, NetworkPolicy, OpenAIAuthConfig, OpenAIConfig, OpenAIHostedShellConfig, - OpenAIHostedShellDomainSecret, OpenAIHostedShellEnvironment, OpenAIHostedShellNetworkPolicy, - OpenAIHostedShellNetworkPolicyType, OpenAIHostedSkill, OpenAIManualCompactionConfig, OpenAIPreferredMethod, - OpenAIServiceTier, OpenResponsesConfig, OpenRouterAuthConfig, PluginRuntimeConfig, PluginTrustLevel, - PromptCacheRetention, PromptCachingConfig, ProviderOverrideConfig, ProviderPromptCachingConfig, - ResolvedCustomProviderProfile, ResourceLimitsConfig, ResourceLimitsPreset, SandboxConfig, SandboxPolicy, - SeccompConfig, SeccompProfilePreset, SecurityConfig, SensitivePathsConfig, SkillsConfig, SkillsRenderMode, - ThinkingDisplayMode, ToolPolicy, ToolProfile, ToolSearchAlgorithm, ToolsConfig, WebFetchConfig, WebFetchMode, - WebSearchConfig, WebSearchProvider, + CustomProviderPricingConfig, CustomProviderProfileConfig, DockerSandboxConfig, EditorToolConfig, + ExternalSandboxConfig, ExternalSandboxType, FullAutoConfig, GatekeeperConfig, MemoriesConfig, MicroVMSandboxConfig, + MicroVmProvider, ModelConfig, NetworkAllowlistEntryConfig, NetworkConfig, NetworkPolicy, OpenAIAuthConfig, + OpenAIConfig, OpenAIHostedShellConfig, OpenAIHostedShellDomainSecret, OpenAIHostedShellEnvironment, + OpenAIHostedShellNetworkPolicy, OpenAIHostedShellNetworkPolicyType, OpenAIHostedSkill, + OpenAIManualCompactionConfig, OpenAIPreferredMethod, OpenAIServiceTier, OpenResponsesConfig, OpenRouterAuthConfig, + PluginRuntimeConfig, PluginTrustLevel, PromptCacheRetention, PromptCachingConfig, ProviderOverrideConfig, + ProviderPromptCachingConfig, ResolvedCustomProviderProfile, ResourceLimitsConfig, ResourceLimitsPreset, + SandboxConfig, SandboxPolicy, SeccompConfig, SeccompProfilePreset, SecurityConfig, SensitivePathsConfig, + SkillsConfig, SkillsRenderMode, ThinkingDisplayMode, ToolPolicy, ToolProfile, ToolSearchAlgorithm, ToolsConfig, + WebFetchConfig, WebFetchMode, WebSearchConfig, WebSearchProvider, }; pub use debug::{DebugConfig, TraceLevel}; pub use defaults::{ diff --git a/crates/codegen/vtcode-config/src/loader/tests.rs b/crates/codegen/vtcode-config/src/loader/tests.rs index 37af7e4f1..5fec42aeb 100644 --- a/crates/codegen/vtcode-config/src/loader/tests.rs +++ b/crates/codegen/vtcode-config/src/loader/tests.rs @@ -2,7 +2,9 @@ use super::*; use crate::constants::tool_limits; use crate::core::prompt_cache::PromptCacheRetention; -use crate::core::{CustomProviderApiFormat, CustomProviderConfig, CustomProviderProfileConfig}; +use crate::core::{ + CustomProviderApiFormat, CustomProviderConfig, CustomProviderPricingConfig, CustomProviderProfileConfig, +}; use crate::defaults::{self, SyntaxHighlightingDefaults, WorkspacePathsDefaults}; use crate::ide_context::{IdeContextProviderConfig, IdeContextProviderMode, IdeContextProvidersConfig}; use crate::loader::layers::ConfigLayerSource; @@ -291,6 +293,12 @@ fn custom_providers_fields_round_trip_through_toml() { base_url: "https://llm.corp.example/v1".to_string(), api_format: CustomProviderApiFormat::OpenAIChat, context_window: Some(256_000), + pricing: CustomProviderPricingConfig { + input_per_million_usd: Some(0.13), + output_per_million_usd: Some(0.26), + cache_read_per_million_usd: Some(0.028), + cache_write_per_million_usd: None, + }, supports_tools: Some(true), supports_reasoning: Some(false), supports_reasoning_effort: Some(true), @@ -310,6 +318,10 @@ fn custom_providers_fields_round_trip_through_toml() { CustomProviderProfileConfig { api_format: CustomProviderApiFormat::OpenAIResponses, context_window: None, + pricing: CustomProviderPricingConfig { + output_per_million_usd: Some(0.50), + ..Default::default() + }, supports_tools: Some(false), supports_reasoning: None, supports_reasoning_effort: None, @@ -337,6 +349,7 @@ fn custom_providers_fields_round_trip_through_toml() { assert_eq!(provider.base_url, "https://llm.corp.example/v1"); assert_eq!(provider.api_format, CustomProviderApiFormat::OpenAIChat); assert_eq!(provider.context_window, Some(256_000)); + assert_eq!(provider.pricing.input_per_million_usd, Some(0.13)); assert_eq!(provider.supports_tools, Some(true)); assert_eq!(provider.supports_reasoning, Some(false)); assert_eq!(provider.supports_reasoning_effort, Some(true)); @@ -355,6 +368,10 @@ fn custom_providers_fields_round_trip_through_toml() { CustomProviderProfileConfig { api_format: CustomProviderApiFormat::OpenAIResponses, context_window: None, + pricing: CustomProviderPricingConfig { + output_per_million_usd: Some(0.50), + ..Default::default() + }, supports_tools: Some(false), supports_reasoning: None, supports_reasoning_effort: None, @@ -399,6 +416,7 @@ supports_stream_usage = false crate::core::ResolvedCustomProviderProfile { api_format: Some(CustomProviderApiFormat::OpenAIChat), context_window: Some(256_000), + pricing: Default::default(), supports_tools: Some(false), supports_reasoning: None, supports_reasoning_effort: None, diff --git a/crates/codegen/vtcode-core/src/commands/ask.rs b/crates/codegen/vtcode-core/src/commands/ask.rs index 976b374cb..b8cc97bbd 100644 --- a/crates/codegen/vtcode-core/src/commands/ask.rs +++ b/crates/codegen/vtcode-core/src/commands/ask.rs @@ -219,6 +219,7 @@ mod tests { usage: Usage { prompt_tokens: 10, completion_tokens: 2, + reasoning_output_tokens: None, total_tokens: 12, cached_prompt_tokens: None, cache_creation_tokens: None, diff --git a/crates/codegen/vtcode-core/src/core/telemetry.rs b/crates/codegen/vtcode-core/src/core/telemetry.rs index 65646aecd..6dc249b77 100644 --- a/crates/codegen/vtcode-core/src/core/telemetry.rs +++ b/crates/codegen/vtcode-core/src/core/telemetry.rs @@ -167,6 +167,7 @@ mod tests { Some(&crate::llm::provider::Usage { prompt_tokens: 100, completion_tokens: 200, + reasoning_output_tokens: None, total_tokens: 300, cached_prompt_tokens: Some(50), cache_creation_tokens: None, @@ -197,6 +198,7 @@ mod tests { Some(&crate::llm::provider::Usage { prompt_tokens: 500, completion_tokens: 100, + reasoning_output_tokens: None, total_tokens: 600, cached_prompt_tokens: Some(320), cache_creation_tokens: Some(80), diff --git a/crates/codegen/vtcode-core/src/llm/factory.rs b/crates/codegen/vtcode-core/src/llm/factory.rs index 220337f28..c1c7b72fc 100644 --- a/crates/codegen/vtcode-core/src/llm/factory.rs +++ b/crates/codegen/vtcode-core/src/llm/factory.rs @@ -502,6 +502,7 @@ mod tests { base_url: "https://llm.corp.example/v1".to_string(), api_format: CustomProviderApiFormat::Auto, context_window: None, + pricing: Default::default(), supports_tools: None, supports_reasoning: None, supports_reasoning_effort: None, @@ -553,6 +554,7 @@ mod tests { base_url: "https://llm.corp.example/v1".to_string(), api_format: CustomProviderApiFormat::Auto, context_window: Some(256_000), + pricing: Default::default(), supports_tools: None, supports_reasoning: None, supports_reasoning_effort: None, @@ -602,6 +604,7 @@ mod tests { CustomProviderProfileConfig { api_format, context_window, + pricing: Default::default(), supports_tools, supports_reasoning: None, supports_reasoning_effort: None, @@ -632,6 +635,7 @@ mod tests { base_url: "https://llm.corp.example/v1".to_string(), api_format: CustomProviderApiFormat::Auto, context_window: None, + pricing: Default::default(), supports_tools: None, supports_reasoning: None, supports_reasoning_effort: None, @@ -757,6 +761,7 @@ mod tests { base_url: "https://api.atlascloud.ai/v1".to_string(), api_format: CustomProviderApiFormat::Auto, context_window: None, + pricing: Default::default(), supports_tools: None, supports_reasoning: None, supports_reasoning_effort: None, diff --git a/crates/codegen/vtcode-core/src/llm/usage_cost.rs b/crates/codegen/vtcode-core/src/llm/usage_cost.rs index c2008b11c..21ba87e43 100644 --- a/crates/codegen/vtcode-core/src/llm/usage_cost.rs +++ b/crates/codegen/vtcode-core/src/llm/usage_cost.rs @@ -174,6 +174,7 @@ mod tests { let usage = ProviderUsage { prompt_tokens: 100, completion_tokens: 20, + reasoning_output_tokens: None, total_tokens: 120, cached_prompt_tokens: None, cache_creation_tokens: Some(50), @@ -193,6 +194,7 @@ mod tests { let usage = ProviderUsage { prompt_tokens: 100, completion_tokens: 20, + reasoning_output_tokens: None, total_tokens: 120, cached_prompt_tokens: None, cache_creation_tokens: Some(50), @@ -212,6 +214,7 @@ mod tests { let usage = ProviderUsage { prompt_tokens: 500, completion_tokens: 30, + reasoning_output_tokens: None, total_tokens: 530, cached_prompt_tokens: Some(400), cache_creation_tokens: None, diff --git a/crates/codegen/vtcode-llm/src/model_resolver.rs b/crates/codegen/vtcode-llm/src/model_resolver.rs index 4302f73a8..c697347cb 100644 --- a/crates/codegen/vtcode-llm/src/model_resolver.rs +++ b/crates/codegen/vtcode-llm/src/model_resolver.rs @@ -603,6 +603,7 @@ mod tests { let usage = Usage { prompt_tokens: 100, completion_tokens: 50, + reasoning_output_tokens: None, total_tokens: 150, cached_prompt_tokens: Some(20), cache_creation_tokens: Some(10), diff --git a/crates/codegen/vtcode-llm/src/open_responses/bridge.rs b/crates/codegen/vtcode-llm/src/open_responses/bridge.rs index 8a986a3b3..91bcffdb6 100644 --- a/crates/codegen/vtcode-llm/src/open_responses/bridge.rs +++ b/crates/codegen/vtcode-llm/src/open_responses/bridge.rs @@ -1821,6 +1821,7 @@ mod tests { usage: crate::provider::Usage { prompt_tokens: 10, completion_tokens: 4, + reasoning_output_tokens: None, total_tokens: 14, cached_prompt_tokens: None, cache_creation_tokens: None, diff --git a/crates/codegen/vtcode-llm/src/open_responses/usage.rs b/crates/codegen/vtcode-llm/src/open_responses/usage.rs index 5109cbf4d..c4d469689 100644 --- a/crates/codegen/vtcode-llm/src/open_responses/usage.rs +++ b/crates/codegen/vtcode-llm/src/open_responses/usage.rs @@ -109,6 +109,11 @@ impl OpenUsage { details.cached_tokens = Some(cached as u64); } + let output_details = OutputTokensDetails { + reasoning_tokens: usage.reasoning_output_tokens.map(u64::from), + ..OutputTokensDetails::default() + }; + Self { input_tokens: usage.prompt_tokens as u64, output_tokens: usage.completion_tokens as u64, @@ -118,7 +123,7 @@ impl OpenUsage { } else { None }, - output_tokens_details: None, + output_tokens_details: output_details.into_boxed_if_non_empty(), } } @@ -196,6 +201,7 @@ mod tests { let usage = OpenUsage::from_llm_usage(&crate::provider::Usage { prompt_tokens: 1000, completion_tokens: 250, + reasoning_output_tokens: None, total_tokens: 1250, cached_prompt_tokens: Some(400), cache_creation_tokens: None, @@ -208,6 +214,22 @@ mod tests { assert_eq!(usage.input_tokens_details.and_then(|details| details.cached_tokens), Some(400)); } + #[test] + fn test_from_llm_usage_preserves_reasoning_output_tokens() { + let usage = OpenUsage::from_llm_usage(&crate::provider::Usage { + prompt_tokens: 100, + completion_tokens: 80, + reasoning_output_tokens: Some(35), + total_tokens: 180, + cached_prompt_tokens: None, + cache_creation_tokens: None, + cache_read_tokens: None, + iterations: None, + }); + + assert_eq!(usage.output_tokens_details.and_then(|details| details.reasoning_tokens), Some(35)); + } + #[test] fn empty_details_deserialize_to_none() { let usage: OpenUsage = serde_json::from_str( diff --git a/crates/codegen/vtcode-llm/src/provider/response.rs b/crates/codegen/vtcode-llm/src/provider/response.rs index 2c9215fcd..bdf447476 100644 --- a/crates/codegen/vtcode-llm/src/provider/response.rs +++ b/crates/codegen/vtcode-llm/src/provider/response.rs @@ -58,6 +58,7 @@ mod tests { usage: Some(Usage { prompt_tokens: 10, completion_tokens: 5, + reasoning_output_tokens: None, total_tokens: 15, cached_prompt_tokens: None, cache_creation_tokens: None, diff --git a/crates/codegen/vtcode-llm/src/providers/anthropic/response_parser.rs b/crates/codegen/vtcode-llm/src/providers/anthropic/response_parser.rs index af00fe595..1fdc529b2 100644 --- a/crates/codegen/vtcode-llm/src/providers/anthropic/response_parser.rs +++ b/crates/codegen/vtcode-llm/src/providers/anthropic/response_parser.rs @@ -255,6 +255,7 @@ pub fn parse_usage(usage_value: &Value) -> Usage { Usage { prompt_tokens: usage_value.get("input_tokens").and_then(|it| it.as_u64()).unwrap_or(0) as u32, completion_tokens: usage_value.get("output_tokens").and_then(|ot| ot.as_u64()).unwrap_or(0) as u32, + reasoning_output_tokens: None, total_tokens: (usage_value.get("input_tokens").and_then(|it| it.as_u64()).unwrap_or(0) + usage_value.get("output_tokens").and_then(|ot| ot.as_u64()).unwrap_or(0)) as u32, cached_prompt_tokens: cache_read_tokens, diff --git a/crates/codegen/vtcode-llm/src/providers/anthropic/stream_decoder.rs b/crates/codegen/vtcode-llm/src/providers/anthropic/stream_decoder.rs index 427ae6494..6f5ef8d9c 100644 --- a/crates/codegen/vtcode-llm/src/providers/anthropic/stream_decoder.rs +++ b/crates/codegen/vtcode-llm/src/providers/anthropic/stream_decoder.rs @@ -71,6 +71,7 @@ pub fn create_stream( aggregator.set_usage(Usage { prompt_tokens: message.usage.input_tokens, completion_tokens: 0, + reasoning_output_tokens: None, total_tokens: message.usage.input_tokens, cached_prompt_tokens: message.usage.cache_read_input_tokens, cache_creation_tokens: message.usage.cache_creation_input_tokens, diff --git a/crates/codegen/vtcode-llm/src/providers/common.rs b/crates/codegen/vtcode-llm/src/providers/common.rs index bac65b446..5924efa0b 100644 --- a/crates/codegen/vtcode-llm/src/providers/common.rs +++ b/crates/codegen/vtcode-llm/src/providers/common.rs @@ -1113,6 +1113,7 @@ pub(crate) fn parse_usage_openai_format( response_json.get("usage").map(|usage_value| crate::provider::Usage { prompt_tokens: usage_value.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32, completion_tokens: usage_value.get("completion_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32, + reasoning_output_tokens: parse_reasoning_tokens_from_usage(usage_value), total_tokens: usage_value.get("total_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32, cached_prompt_tokens: if include_cache_metrics { usage_value @@ -1135,6 +1136,25 @@ pub(crate) fn parse_usage_openai_format( }) } +/// Extracts provider-side reasoning token usage from OpenAI-compatible usage details. +/// +/// Chat Completions providers such as Baseten report this as +/// `completion_tokens_details.reasoning_tokens`, while Responses-compatible +/// providers use the corresponding `output_tokens_details` shape. +#[inline] +pub(crate) fn parse_reasoning_tokens_from_usage(usage_value: &Value) -> Option { + usage_value + .get("completion_tokens_details") + .and_then(|details| details.get("reasoning_tokens")) + .or_else(|| { + usage_value + .get("output_tokens_details") + .and_then(|details| details.get("reasoning_tokens")) + }) + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) +} + #[inline] pub(crate) fn serialize_reasoning_detail_values(details: &[Value]) -> Option> { let normalized = details @@ -1576,10 +1596,45 @@ mod tests { assert_eq!(usage.prompt_tokens, 100); assert_eq!(usage.completion_tokens, 50); assert_eq!(usage.total_tokens, 150); + assert_eq!(usage.reasoning_output_tokens, None); assert_eq!(usage.cached_prompt_tokens, None); assert_eq!(usage.cache_creation_tokens, None); } + #[test] + fn parse_usage_openai_format_extracts_nested_reasoning_tokens() { + let response = json!({ + "usage": { + "prompt_tokens": 100, + "completion_tokens": 50, + "total_tokens": 150, + "completion_tokens_details": { + "reasoning_tokens": 35 + } + } + }); + + let usage = parse_usage_openai_format(&response, false).expect("usage expected"); + assert_eq!(usage.reasoning_output_tokens, Some(35)); + } + + #[test] + fn parse_usage_openai_format_accepts_responses_reasoning_details() { + let response = json!({ + "usage": { + "input_tokens": 100, + "output_tokens": 50, + "total_tokens": 150, + "output_tokens_details": { + "reasoning_tokens": 35 + } + } + }); + + let usage = parse_usage_openai_format(&response, false).expect("usage expected"); + assert_eq!(usage.reasoning_output_tokens, Some(35)); + } + #[test] fn parse_usage_openai_format_includes_cache_metrics_when_enabled() { let response = json!({ diff --git a/crates/codegen/vtcode-llm/src/providers/custom_provider.rs b/crates/codegen/vtcode-llm/src/providers/custom_provider.rs index 6debf765a..639e75bc7 100644 --- a/crates/codegen/vtcode-llm/src/providers/custom_provider.rs +++ b/crates/codegen/vtcode-llm/src/providers/custom_provider.rs @@ -471,6 +471,7 @@ mod tests { model: models::anthropic::DEFAULT_MODEL.to_string(), models: vec![models::anthropic::DEFAULT_MODEL.to_string()], profiles: std::collections::BTreeMap::new(), + pricing: Default::default(), request_policy: Default::default(), }; @@ -614,7 +615,7 @@ mod tests { .insert_header("content-type", "text/event-stream") .set_body_string( "data: {\"id\":\"chatcmpl-baseten\",\"object\":\"chat.completion.chunk\",\"model\":\"baseten/usage\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"answer\"},\"finish_reason\":null}]}\n\n\ - data: {\"id\":\"chatcmpl-baseten\",\"object\":\"chat.completion.chunk\",\"model\":\"baseten/usage\",\"choices\":[],\"usage\":{\"prompt_tokens\":13,\"completion_tokens\":5,\"total_tokens\":18}}\n\n\ + data: {\"id\":\"chatcmpl-baseten\",\"object\":\"chat.completion.chunk\",\"model\":\"baseten/usage\",\"choices\":[],\"usage\":{\"prompt_tokens\":13,\"completion_tokens\":5,\"total_tokens\":18,\"completion_tokens_details\":{\"reasoning_tokens\":3}}}\n\n\ data: [DONE]\n\n", ) }) @@ -655,6 +656,7 @@ mod tests { let response = collect_completed_response(&router, OPTED_IN_MODEL).await; let usage = response.usage.expect("terminal Baseten usage should be retained"); assert_eq!((usage.prompt_tokens, usage.completion_tokens, usage.total_tokens), (13, 5, 18)); + assert_eq!(usage.reasoning_output_tokens, Some(3)); drop(collect_completed_response(&router, OPTED_OUT_MODEL).await); let requests = captured.lock().expect("capture mutex"); diff --git a/crates/codegen/vtcode-llm/src/providers/evolink.rs b/crates/codegen/vtcode-llm/src/providers/evolink.rs index 7fac606e1..69ef2cf96 100644 --- a/crates/codegen/vtcode-llm/src/providers/evolink.rs +++ b/crates/codegen/vtcode-llm/src/providers/evolink.rs @@ -197,6 +197,7 @@ impl EvolinkProvider { crate::provider::Usage { prompt_tokens, completion_tokens, + reasoning_output_tokens: None, total_tokens: prompt_tokens + completion_tokens, cached_prompt_tokens: u.get("cache_read_input_tokens").and_then(|t| t.as_u64()).map(|v| v as u32), cache_creation_tokens: u.get("cache_creation_input_tokens").and_then(|t| t.as_u64()).map(|v| v as u32), diff --git a/crates/codegen/vtcode-llm/src/providers/gemini/helpers.rs b/crates/codegen/vtcode-llm/src/providers/gemini/helpers.rs index d30c6e4c7..f54fd5afd 100644 --- a/crates/codegen/vtcode-llm/src/providers/gemini/helpers.rs +++ b/crates/codegen/vtcode-llm/src/providers/gemini/helpers.rs @@ -626,6 +626,7 @@ impl GeminiProvider { usage: response.usage.map(|usage| vtcode_commons::llm::Usage { prompt_tokens: usage.total_input_tokens.unwrap_or_default(), completion_tokens: usage.total_output_tokens.unwrap_or_default(), + reasoning_output_tokens: None, total_tokens: usage.total_tokens.unwrap_or_default(), cached_prompt_tokens: usage.total_cached_tokens, cache_creation_tokens: None, diff --git a/crates/codegen/vtcode-llm/src/providers/huggingface.rs b/crates/codegen/vtcode-llm/src/providers/huggingface.rs index 826812117..8cc7484bc 100644 --- a/crates/codegen/vtcode-llm/src/providers/huggingface.rs +++ b/crates/codegen/vtcode-llm/src/providers/huggingface.rs @@ -674,6 +674,7 @@ Enable that provider in your HuggingFace Inference Providers settings, or switch .or_else(|| usage_value.get("completion_tokens")) .and_then(|ct| ct.as_u64()) .unwrap_or(0) as u32, + reasoning_output_tokens: crate::providers::common::parse_reasoning_tokens_from_usage(usage_value), total_tokens: usage_value.get("total_tokens").and_then(|tt| tt.as_u64()).unwrap_or(0) as u32, cached_prompt_tokens: None, cache_creation_tokens: None, @@ -1047,6 +1048,7 @@ impl HuggingFaceProvider { aggregator.set_usage(crate::provider::Usage { prompt_tokens: usage_value.get("prompt_tokens").and_then(|pt| pt.as_u64()).unwrap_or(0) as u32, completion_tokens: usage_value.get("completion_tokens").and_then(|ct| ct.as_u64()).unwrap_or(0) as u32, + reasoning_output_tokens: crate::providers::common::parse_reasoning_tokens_from_usage(usage_value), total_tokens: usage_value.get("total_tokens").and_then(|tt| tt.as_u64()).unwrap_or(0) as u32, cached_prompt_tokens: None, cache_creation_tokens: None, diff --git a/crates/codegen/vtcode-llm/src/providers/ollama.rs b/crates/codegen/vtcode-llm/src/providers/ollama.rs index ac786457e..73f5f9608 100644 --- a/crates/codegen/vtcode-llm/src/providers/ollama.rs +++ b/crates/codegen/vtcode-llm/src/providers/ollama.rs @@ -614,6 +614,7 @@ impl OllamaProvider { Some(Usage { prompt_tokens: prompt, completion_tokens: completion, + reasoning_output_tokens: None, total_tokens: prompt + completion, cached_prompt_tokens: None, cache_creation_tokens: None, diff --git a/crates/codegen/vtcode-llm/src/providers/openai/provider/harmony_client.rs b/crates/codegen/vtcode-llm/src/providers/openai/provider/harmony_client.rs index 841a423f8..113c765f3 100644 --- a/crates/codegen/vtcode-llm/src/providers/openai/provider/harmony_client.rs +++ b/crates/codegen/vtcode-llm/src/providers/openai/provider/harmony_client.rs @@ -302,6 +302,7 @@ impl OpenAIProvider { usage: Some(provider::Usage { prompt_tokens: prompt_tokens.len().try_into().unwrap_or(u32::MAX), completion_tokens: completion_tokens.len().try_into().unwrap_or(u32::MAX), + reasoning_output_tokens: None, total_tokens: (prompt_tokens.len() + completion_tokens.len()).try_into().unwrap_or(u32::MAX), cached_prompt_tokens: None, cache_creation_tokens: None, diff --git a/crates/codegen/vtcode-llm/src/providers/openai/response_parser.rs b/crates/codegen/vtcode-llm/src/providers/openai/response_parser.rs index 5b87cf9ad..f18f8f6e0 100644 --- a/crates/codegen/vtcode-llm/src/providers/openai/response_parser.rs +++ b/crates/codegen/vtcode-llm/src/providers/openai/response_parser.rs @@ -2,6 +2,7 @@ use crate::error_display; use crate::provider; +use crate::providers::common::parse_reasoning_tokens_from_usage; use crate::providers::extract_reasoning_trace; use crate::providers::shared::parse_openai_tool_calls; use serde_json::Value; @@ -101,6 +102,7 @@ pub(crate) fn parse_chat_response( .and_then(|ct| ct.as_u64()) .and_then(|v| u32::try_from(v).ok()) .unwrap_or(0), + reasoning_output_tokens: parse_reasoning_tokens_from_usage(usage_value), total_tokens: usage_value .get("total_tokens") .and_then(|tt| tt.as_u64()) @@ -121,3 +123,32 @@ pub(crate) fn parse_chat_response( compaction: None, }) } + +#[cfg(test)] +mod tests { + use super::parse_chat_response; + use serde_json::json; + + #[test] + fn parse_chat_response_preserves_nested_reasoning_token_usage() { + let parsed = parse_chat_response( + json!({ + "choices": [{ + "message": {"content": "answer"}, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 8, + "total_tokens": 18, + "completion_tokens_details": {"reasoning_tokens": 3} + } + }), + "baseten-model".to_string(), + false, + ) + .expect("chat response should parse"); + + assert_eq!(parsed.usage.and_then(|usage| usage.reasoning_output_tokens), Some(3)); + } +} diff --git a/crates/codegen/vtcode-llm/src/providers/openai/responses_api.rs b/crates/codegen/vtcode-llm/src/providers/openai/responses_api.rs index 7255d3c9c..b7b8089df 100644 --- a/crates/codegen/vtcode-llm/src/providers/openai/responses_api.rs +++ b/crates/codegen/vtcode-llm/src/providers/openai/responses_api.rs @@ -3,7 +3,7 @@ use crate::provider::{ AssistantPhase, ContentPart, FinishReason, LLMError, LLMRequest, LLMResponse, MessageContent, MessageRole, ToolCall, Usage, }; -use crate::providers::common::append_normalized_reasoning_detail_items; +use crate::providers::common::{append_normalized_reasoning_detail_items, parse_reasoning_tokens_from_usage}; use crate::providers::openai::types::OpenAIResponsesPayload; use crate::providers::shared::{ collect_tool_references_from_tool_search_output, function_output_value_from_message_content, @@ -418,6 +418,7 @@ pub(crate) fn parse_responses_payload( .and_then(|ct| ct.as_u64()) .and_then(|v| u32::try_from(v).ok()) .unwrap_or(0), + reasoning_output_tokens: parse_reasoning_tokens_from_usage(usage_value), total_tokens: usage_value .get("total_tokens") .and_then(|tt| tt.as_u64()) @@ -1262,6 +1263,26 @@ mod tests { assert_eq!(parsed.usage.and_then(|usage| usage.cached_prompt_tokens), Some(42)); } + #[test] + fn parse_responses_payload_extracts_reasoning_tokens_from_output_details() { + let response = json!({ + "output": [{ + "type": "message", + "content": [{"type": "output_text", "text": "answer"}] + }], + "usage": { + "input_tokens": 100, + "output_tokens": 20, + "total_tokens": 120, + "output_tokens_details": {"reasoning_tokens": 12} + } + }); + + let parsed = parse_responses_payload(response, "gpt-5".to_string(), false).expect("payload should parse"); + + assert_eq!(parsed.usage.and_then(|usage| usage.reasoning_output_tokens), Some(12)); + } + #[test] fn parse_responses_payload_treats_missing_cached_prompt_tokens_as_normal() { let response = json!({ diff --git a/crates/codegen/vtcode-llm/src/providers/openai/stream_decoder.rs b/crates/codegen/vtcode-llm/src/providers/openai/stream_decoder.rs index 54c8ab261..8fa4c26f9 100644 --- a/crates/codegen/vtcode-llm/src/providers/openai/stream_decoder.rs +++ b/crates/codegen/vtcode-llm/src/providers/openai/stream_decoder.rs @@ -68,6 +68,7 @@ fn merge_final_response_metadata( .and_then(Value::as_u64) .and_then(|value| u32::try_from(value).ok()) .unwrap_or(0), + reasoning_output_tokens: crate::providers::common::parse_reasoning_tokens_from_usage(usage_value), total_tokens: usage_value .get("total_tokens") .and_then(Value::as_u64) @@ -259,15 +260,16 @@ pub(crate) fn create_chat_stream( continue; } - let mut payload: Value = serde_json::from_str(trimmed_payload).map_err(|err| { + let payload: Value = serde_json::from_str(trimmed_payload).map_err(|err| { StreamAssemblyError::InvalidPayload(err.to_string()) .into_llm_error("OpenAI") })?; - if let Some(usage_val) = payload.get_mut("usage") - && let Ok(u) = serde_json::from_value::(std::mem::take(usage_val)) { - aggregator.set_usage(u); - } + if payload.get("usage").is_some() + && let Some(usage) = crate::providers::common::parse_usage_openai_format(&payload, false) + { + aggregator.set_usage(usage); + } if let Some(choices) = payload.get("choices").and_then(|v| v.as_array()) && let Some(choice) = choices.first() { @@ -647,6 +649,25 @@ mod tests { assert_eq!(usage.cached_prompt_tokens, Some(9)); } + #[test] + fn responses_final_metadata_preserves_reasoning_tokens() { + let mut response = LLMResponse::default(); + merge_final_response_metadata( + &mut response, + &json!({ + "usage": { + "input_tokens": 12, + "output_tokens": 5, + "total_tokens": 17, + "output_tokens_details": {"reasoning_tokens": 4} + } + }), + false, + ); + + assert_eq!(response.usage.and_then(|usage| usage.reasoning_output_tokens), Some(4)); + } + #[test] fn empty_final_response_can_use_streamed_tool_call_delta() { let response = LLMResponse { diff --git a/crates/codegen/vtcode-llm/src/providers/openrouter/stream_decoder.rs b/crates/codegen/vtcode-llm/src/providers/openrouter/stream_decoder.rs index bb5834853..20433b951 100644 --- a/crates/codegen/vtcode-llm/src/providers/openrouter/stream_decoder.rs +++ b/crates/codegen/vtcode-llm/src/providers/openrouter/stream_decoder.rs @@ -27,6 +27,7 @@ pub(crate) fn parse_usage_value(value: &Value) -> Usage { Usage { prompt_tokens, completion_tokens, + reasoning_output_tokens: crate::providers::common::parse_reasoning_tokens_from_usage(value), total_tokens, cached_prompt_tokens, cache_creation_tokens, diff --git a/crates/codegen/vtcode-llm/src/providers/shared/mod.rs b/crates/codegen/vtcode-llm/src/providers/shared/mod.rs index 3ed8cf0cf..60b35f59e 100644 --- a/crates/codegen/vtcode-llm/src/providers/shared/mod.rs +++ b/crates/codegen/vtcode-llm/src/providers/shared/mod.rs @@ -1641,6 +1641,7 @@ mod tests { "prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150, + "completion_tokens_details": {"reasoning_tokens": 30}, "prompt_cache_hit_tokens": 30, "prompt_cache_miss_tokens": 70 } @@ -1654,6 +1655,7 @@ mod tests { assert_eq!(usage.prompt_tokens, 100); assert_eq!(usage.cached_prompt_tokens, None); assert_eq!(usage.cache_creation_tokens, None); + assert_eq!(usage.reasoning_output_tokens, Some(30)); // With include_cache_metrics = true (DeepSeek behavior) let mut aggregator2 = StreamAggregator::new("test-model".to_string()); @@ -1664,5 +1666,6 @@ mod tests { assert_eq!(usage2.prompt_tokens, 100); assert_eq!(usage2.cached_prompt_tokens, Some(30)); assert_eq!(usage2.cache_creation_tokens, Some(70)); + assert_eq!(usage2.reasoning_output_tokens, Some(30)); } } diff --git a/crates/codegen/vtcode-llm/src/providers/shared/responses_stream.rs b/crates/codegen/vtcode-llm/src/providers/shared/responses_stream.rs index 7dfb4828a..5266c19dd 100644 --- a/crates/codegen/vtcode-llm/src/providers/shared/responses_stream.rs +++ b/crates/codegen/vtcode-llm/src/providers/shared/responses_stream.rs @@ -8,6 +8,7 @@ use hashbrown::{HashMap, HashSet}; use serde_json::{Value, json}; use super::{StreamAggregator, generate_tool_call_id, parse_cached_prompt_tokens_from_usage}; +use crate::providers::common::parse_reasoning_tokens_from_usage; // Retained shared Responses stream processor. // Rig 0.40 can consume SSE, but VTCode needs a provider-agnostic @@ -484,6 +485,7 @@ fn parse_responses_usage( .and_then(Value::as_u64) .and_then(|value| u32::try_from(value).ok()) .unwrap_or(0), + reasoning_output_tokens: parse_reasoning_tokens_from_usage(usage_value), total_tokens: usage_value .get("total_tokens") .and_then(Value::as_u64) diff --git a/crates/codegen/vtcode-llm/src/utils.rs b/crates/codegen/vtcode-llm/src/utils.rs index 9dce9da1f..1e304fba2 100644 --- a/crates/codegen/vtcode-llm/src/utils.rs +++ b/crates/codegen/vtcode-llm/src/utils.rs @@ -65,6 +65,7 @@ pub(crate) fn parse_response_openai_format( usage: Some(crate::provider::Usage { prompt_tokens: u32::try_from(input_tokens).unwrap_or(u32::MAX), completion_tokens: u32::try_from(output_tokens).unwrap_or(u32::MAX), + reasoning_output_tokens: usage.and_then(crate::providers::common::parse_reasoning_tokens_from_usage), total_tokens: u32::try_from(input_tokens.saturating_add(output_tokens)).unwrap_or(u32::MAX), cached_prompt_tokens: if include_cache { response.get("cache_hit").and_then(|c| c.as_bool()).map(|_| 0) diff --git a/crates/common/vtcode-commons/src/llm.rs b/crates/common/vtcode-commons/src/llm.rs index 3e80d962b..88d4b84db 100644 --- a/crates/common/vtcode-commons/src/llm.rs +++ b/crates/common/vtcode-commons/src/llm.rs @@ -34,6 +34,9 @@ pub enum BackendKind { pub struct Usage { pub prompt_tokens: u32, pub completion_tokens: u32, + /// Number of completion tokens spent on provider-side reasoning/thinking. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_output_tokens: Option, pub total_tokens: u32, pub cached_prompt_tokens: Option, pub cache_creation_tokens: Option, @@ -162,6 +165,7 @@ mod usage_tests { let usage = Usage { prompt_tokens: 1_000, completion_tokens: 200, + reasoning_output_tokens: None, total_tokens: 1_200, cached_prompt_tokens: Some(600), cache_creation_tokens: Some(150), @@ -183,6 +187,7 @@ mod usage_tests { let usage = Usage { prompt_tokens: 1_000, completion_tokens: 200, + reasoning_output_tokens: None, total_tokens: 1_200, cached_prompt_tokens: None, cache_creation_tokens: None, diff --git a/docs/acp/ACP_INTEGRATION.md b/docs/acp/ACP_INTEGRATION.md index 40a5311fc..1ed5d09e6 100644 --- a/docs/acp/ACP_INTEGRATION.md +++ b/docs/acp/ACP_INTEGRATION.md @@ -52,10 +52,32 @@ was advertised: - `_lody/subagents/output` returns a bounded output tail for an owned task. Usage is reported with the `_lody/session/usage_update` extension notification. -Its parameters contain `sessionId`, a `usage` object with normalized input, -output, and cache token counts, and `modelUsage`, keyed by model name. The -notification represents the usage delta for one provider response, not a -running total. +Its parameters contain `sessionId`, a `usage` object, and `modelUsage`, keyed by +model name. The notification represents the usage delta for one provider +response, not a running total. The normalized usage object may contain: + +- `inputTokens`, `outputTokens`, `cacheReadInputTokens`, and + `cacheCreationInputTokens`; +- `reasoningOutputTokens`, when the provider reports reasoning usage (including + the nested `completion_tokens_details.reasoning_tokens` field used by + Baseten's OpenAI-compatible responses). `outputTokens` is the visible output + portion after reasoning tokens are split out; +- `contextWindow`, from the resolved provider/model profile; and +- `costUSD`, only when opt-in custom-provider pricing resolves both input and + output rates. Pricing is configured in USD per million tokens, with optional + cache-read and cache-write rates. + +Automatic context compaction is advertised as `_meta.lody.compaction = { +"version": 1 }`. Each compaction is represented by standard ACP +`tool_call`/`tool_call_update` session updates. The updates use a stable tool +call ID and carry `_meta.lody.activity` with the activity kind, token counts, +duration, and any failure reason; no private ACP update type is required. + +VT Code does not advertise `_meta.lody.rateLimits` unless it has trustworthy +quota state. A Baseten HTTP 429 is not treated as a quota window: the response +and any `Retry-After` value are surfaced through warning notices and provider +telemetry instead. This avoids presenting fabricated reset times or usage +percentages. The current server is launched with `vtcode acp` and communicates over stdio; it does not expose the legacy `/messages`, `/metadata`, or `/health` HTTP diff --git a/docs/acp/ACP_QUICK_REFERENCE.md b/docs/acp/ACP_QUICK_REFERENCE.md index 6d5aa7de3..f39617741 100644 --- a/docs/acp/ACP_QUICK_REFERENCE.md +++ b/docs/acp/ACP_QUICK_REFERENCE.md @@ -41,7 +41,11 @@ notification `_lody/session/usage_update`. Its parameters contain: "usage": { "inputTokens": 123, "outputTokens": 45, - "cacheReadInputTokens": 0 + "cacheReadInputTokens": 0, + "cacheCreationInputTokens": 0, + "reasoningOutputTokens": 12, + "contextWindow": 524288, + "costUSD": 0.0000123 }, "modelUsage": { "model-name": { @@ -57,6 +61,21 @@ notification `_lody/session/usage_update`. Its parameters contain: provider response, and no notification is emitted when the provider supplies no usage data. +`reasoningOutputTokens`, `contextWindow`, and `costUSD` are optional. The +visible `outputTokens` value excludes reasoning tokens when the provider +reports them; Baseten's nested +`completion_tokens_details.reasoning_tokens` field is preserved during +normalization. `costUSD` is emitted only when explicitly configured custom +provider pricing supplies both input and output rates (USD per million +tokens). Cache pricing is optional. + +Automatic context compaction is advertised as `_meta.lody.compaction` version +1 and is emitted through standard `tool_call` and `tool_call_update` updates. +The updates carry `_meta.lody.activity` for token counts, duration, and failure +details. VT Code does not advertise `_meta.lody.rateLimits` without trustworthy +quota state; HTTP 429 and `Retry-After` are instead reported in warning notices +and provider telemetry. + ## Legacy REST ACP client reference ## Initialize ACP Client diff --git a/docs/config/CONFIG_FIELD_REFERENCE.md b/docs/config/CONFIG_FIELD_REFERENCE.md index fba88d0b2..8c0504f64 100644 --- a/docs/config/CONFIG_FIELD_REFERENCE.md +++ b/docs/config/CONFIG_FIELD_REFERENCE.md @@ -244,6 +244,11 @@ python3 scripts/generate_config_field_reference.py | `custom_providers[].auth` | `CustomProviderCommandAuthConfig \| null` | no | `-` | Optional command-backed bearer token configuration. | | `custom_providers[].base_url` | `string` | yes | `-` | Base URL of the OpenAI-compatible API endpoint (e.g., ``). | | `custom_providers[].context_window` | `integer \| null` | no | `-` | Optional context window size in tokens for models served by this endpoint. When omitted, the OpenAI-compatible provider uses its default context window size. | +| `custom_providers[].pricing` | `CustomProviderPricingConfig` | no | `-` | Optional explicit pricing for ACP usage cost reporting. Rates are USD per million tokens; `costUSD` is emitted only when both input and output rates resolve. | +| `custom_providers[].pricing.input_per_million_usd` | `number \| null` | no | `-` | Input-token price in USD per million tokens. Must be finite and non-negative. | +| `custom_providers[].pricing.output_per_million_usd` | `number \| null` | no | `-` | Output-token price in USD per million tokens. Must be finite and non-negative. Required with input pricing for ACP `costUSD`. | +| `custom_providers[].pricing.cache_read_per_million_usd` | `number \| null` | no | `-` | Cached-input read price in USD per million tokens. Optional; used when the provider reports cached input tokens. | +| `custom_providers[].pricing.cache_write_per_million_usd` | `number \| null` | no | `-` | Cached-input write price in USD per million tokens. Optional; used when the provider reports cache-creation tokens. | | `custom_providers[].api_format` | `string \| null` | no | `null` | Optional provider-level API format hint. Allowed values: `auto`, `openai-chat`, `openai-responses`, `anthropic-messages`. When omitted, VT Code preserves legacy autodetection behavior. An explicit value is honored and will not silently fallback. | | `custom_providers[].supports_tools` | `boolean \| null` | no | `null` | Provider-level default for tool calling when per-model metadata is unavailable. | | `custom_providers[].supports_reasoning` | `boolean \| null` | no | `null` | Provider-level default for reasoning support when per-model metadata is unavailable. | @@ -258,6 +263,11 @@ python3 scripts/generate_config_field_reference.py | `custom_providers.profiles.""` | `table` | no | `-` | Per-model sparse profile used to override runtime defaults for a specific model identifier. Profiles do NOT add models to the picker; they only alter runtime behavior (capabilities, api_format, context_window, etc.). | | `custom_providers.profiles."".api_format` | `string \| null` | no | `null` | Per-model API format hint. Same allowed values as `custom_providers[].api_format`. Omitted preserves legacy/autodetect behavior for that model. | | `custom_providers.profiles."".context_window` | `integer \| null` | no | `null` | Per-model context window in tokens. When omitted the provider or autodetected model metadata applies. | +| `custom_providers.profiles."".pricing` | `CustomProviderPricingConfig` | no | `-` | Optional per-model pricing override in USD per million tokens. It is merged field-by-field over provider pricing; ACP `costUSD` requires resolved input and output rates. | +| `custom_providers.profiles."".pricing.input_per_million_usd` | `number \| null` | no | `-` | Per-model input-token price in USD per million tokens. | +| `custom_providers.profiles."".pricing.output_per_million_usd` | `number \| null` | no | `-` | Per-model output-token price in USD per million tokens. | +| `custom_providers.profiles."".pricing.cache_read_per_million_usd` | `number \| null` | no | `-` | Per-model cached-input read price in USD per million tokens. | +| `custom_providers.profiles."".pricing.cache_write_per_million_usd` | `number \| null` | no | `-` | Per-model cached-input write price in USD per million tokens. | | `custom_providers.profiles."".supports_tools` | `boolean \| null` | no | `null` | Whether the model supports external tool calls. Explicit `false` is honored. | | `custom_providers.profiles."".supports_reasoning` | `boolean \| null` | no | `null` | Whether the model supports structured reasoning guidance and longer reasoning passes. | | `custom_providers.profiles."".supports_reasoning_effort` | `boolean \| null` | no | `null` | Whether the model supports reasoning-effort controls. Explicit `false` is honored. | diff --git a/docs/config/config.md b/docs/config/config.md index 9df465d46..b776f28fd 100644 --- a/docs/config/config.md +++ b/docs/config/config.md @@ -171,6 +171,15 @@ model = "gpt-5.4" # api_format = "auto" # Optional provider-level API format hint: auto|openai-chat|openai-responses|anthropic-messages # supports_stream_usage = true # Opt in only when this OpenAI-chat endpoint emits a terminal usage chunk +# Optional explicit pricing for ACP usage cost reporting. Values are USD per +# million tokens; costUSD is emitted only when both input and output rates are +# configured (cache rates are optional). +[custom_providers.pricing] +# input_per_million_usd = 0.15 +# output_per_million_usd = 0.50 +# cache_read_per_million_usd = 0.03 +# cache_write_per_million_usd = 0.00 + [custom_providers.request_policy] max_in_flight_requests = 4 queue_timeout_seconds = 120 @@ -213,7 +222,7 @@ Capability defaults and per-model profiles Custom providers may expose a small, conservative set of capability defaults to use when model metadata is absent. These are useful for gateways and aggregators that do not provide per-model descriptors. Set fields such as `supports_tools`, `supports_vision`, `supports_structured_output`, `supports_parallel_tool_calls`, or `supports_stream_usage` directly on the provider entry. `supports_stream_usage` is optional and defaults to `false`; set it to `true` only when an OpenAI-chat endpoint supports a terminal usage chunk in streamed responses. It enables `stream_options.include_usage = true` for that custom provider's OpenAI-chat requests. Native OpenAI provider behaviour is unchanged. -For fine-grained overrides you can declare sparse per-model profiles. Profiles live in `custom_providers.profiles.""` and only modify runtime defaults for that specific model identifier. IMPORTANT: profiles do not add or enable models in the picker — `model` / `models` remain the allowlist/default. A profile only changes how VT Code treats an already-selected model at runtime (capabilities, context window, api_format, etc.). +For fine-grained overrides you can declare sparse per-model profiles. Profiles live in `custom_providers.profiles.""` and only modify runtime defaults for that specific model identifier. IMPORTANT: profiles do not add or enable models in the picker — `model` / `models` remain the allowlist/default. A profile only changes how VT Code treats an already-selected model at runtime (capabilities, context window, api_format, pricing, etc.). Example per-model profile: @@ -229,6 +238,14 @@ supports_context_caching = false supports_responses_compaction = true supports_context_edits = false # supports_stream_usage = true # only for endpoints with a terminal usage chunk + +[custom_providers.profiles."gpt-5.4".pricing] +# Rates are USD per million tokens. Both input and output are required before +# ACP usage updates include costUSD. +input_per_million_usd = 0.15 +output_per_million_usd = 0.50 +cache_read_per_million_usd = 0.03 +cache_write_per_million_usd = 0.00 ``` Precedence and semantics @@ -244,6 +261,7 @@ Additional rules: - An explicit boolean `false` in any overriding layer is honored and prevents a higher-level implicit `true` from taking effect. - Omitting `api_format` preserves legacy autodetection behavior; explicitly setting `api_format` to a value instructs VT Code to use that API shape and not silently fall back. - `supports_stream_usage` follows the same precedence: a profile value overrides the provider default. When `true`, only custom OpenAI-chat streams request `stream_options.include_usage = true`; the endpoint should return usage in the terminal empty-choices chunk. When omitted or `false`, VT Code does not request streamed usage. Native OpenAI requests are unaffected. +- `pricing` follows the same precedence and is opt-in. Rates are configured as USD per million tokens with separate input, output, cache-read, and cache-write fields. ACP `costUSD` is omitted unless both input and output rates resolve; cache rates are used when the provider reports cache token counts. - Profiles do not make a model available in the picker — use `model` or `models` to control availability. Store a custom provider key with the same explicit identity used by the diff --git a/src/agent/runloop/unified/context_manager_tests.rs b/src/agent/runloop/unified/context_manager_tests.rs index 563f25ffd..3e2cfae7c 100644 --- a/src/agent/runloop/unified/context_manager_tests.rs +++ b/src/agent/runloop/unified/context_manager_tests.rs @@ -277,6 +277,7 @@ fn test_update_token_usage_prefers_prompt_pressure() { manager.update_token_usage(&Some(uni::Usage { prompt_tokens: 1000, completion_tokens: 500, + reasoning_output_tokens: None, total_tokens: 1500, cached_prompt_tokens: None, cache_creation_tokens: None, @@ -289,6 +290,7 @@ fn test_update_token_usage_prefers_prompt_pressure() { manager.update_token_usage(&Some(uni::Usage { prompt_tokens: 2500, completion_tokens: 800, + reasoning_output_tokens: None, total_tokens: 3300, cached_prompt_tokens: None, cache_creation_tokens: None, @@ -305,6 +307,7 @@ fn test_update_token_usage_falls_back_when_prompt_missing() { manager.update_token_usage(&Some(uni::Usage { prompt_tokens: 0, completion_tokens: 800, + reasoning_output_tokens: None, total_tokens: 3300, cached_prompt_tokens: None, cache_creation_tokens: None, @@ -334,6 +337,7 @@ async fn build_system_prompt_ignores_token_usage_updates() { manager.update_token_usage(&Some(uni::Usage { prompt_tokens: 180_000, completion_tokens: 2_000, + reasoning_output_tokens: None, total_tokens: 182_000, cached_prompt_tokens: None, cache_creation_tokens: None, diff --git a/src/agent/runloop/unified/turn/turn_loop/tests.rs b/src/agent/runloop/unified/turn/turn_loop/tests.rs index 49526d4ec..90d4b498c 100644 --- a/src/agent/runloop/unified/turn/turn_loop/tests.rs +++ b/src/agent/runloop/unified/turn/turn_loop/tests.rs @@ -595,6 +595,7 @@ fn accumulate_turn_usage_merges_prompt_completion_and_cached_tokens() { &Some(uni::Usage { prompt_tokens: 100, completion_tokens: 20, + reasoning_output_tokens: None, total_tokens: 120, cached_prompt_tokens: Some(15), cache_creation_tokens: None, @@ -608,6 +609,7 @@ fn accumulate_turn_usage_merges_prompt_completion_and_cached_tokens() { &Some(uni::Usage { prompt_tokens: 40, completion_tokens: 10, + reasoning_output_tokens: None, total_tokens: 50, cached_prompt_tokens: None, cache_creation_tokens: None, @@ -632,6 +634,7 @@ fn accumulate_turn_usage_normalizes_anthropic_exclusive_input() { &Some(uni::Usage { prompt_tokens: 100, completion_tokens: 20, + reasoning_output_tokens: None, total_tokens: 120, cached_prompt_tokens: None, cache_creation_tokens: Some(50), diff --git a/vtcode.toml.example b/vtcode.toml.example index 540577f3e..5e4cf9756 100644 --- a/vtcode.toml.example +++ b/vtcode.toml.example @@ -28,6 +28,12 @@ reasoning_effort = "low" # context_window = 256000 # provider capability in tokens # api_format = "auto" # hint: auto|openai-chat|openai-responses|anthropic-messages # supports_stream_usage = true # opt in only when the endpoint emits terminal stream usage +# Optional explicit pricing for ACP usage updates (USD per million tokens). +# [custom_providers.pricing] +# input_per_million_usd = 0.15 +# output_per_million_usd = 0.50 +# cache_read_per_million_usd = 0.03 +# cache_write_per_million_usd = 0.00 # Optional provider-level conservative defaults (applied when model metadata is missing): # supports_tools = true # supports_vision = false