Skip to content
14 changes: 14 additions & 0 deletions crates/goose-provider-types/src/conversation/token_usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ use utoipa::ToSchema;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderUsage {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
pub model: String,
pub usage: Usage,
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -32,12 +34,23 @@ pub struct DraftStats {
impl ProviderUsage {
pub fn new(model: String, usage: Usage) -> Self {
Self {
provider: None,
model,
usage,
stats: None,
}
}

pub fn with_provider(mut self, provider: impl Into<String>) -> Self {
self.provider = Some(provider.into());
self
}

pub fn with_model(mut self, model: impl Into<String>) -> Self {
self.model = model.into();
self
}

pub fn with_stats(mut self, stats: ProviderStats) -> Self {
self.stats = Some(stats);
self
Expand All @@ -47,6 +60,7 @@ impl ProviderUsage {
/// Uses the model from this ProviderUsage
pub fn combine_with(&self, other: &ProviderUsage) -> ProviderUsage {
ProviderUsage {
provider: self.provider.clone().or_else(|| other.provider.clone()),
model: self.model.clone(),
usage: self.usage + other.usage,
stats: self.stats.clone().or_else(|| other.stats.clone()),
Expand Down
44 changes: 43 additions & 1 deletion crates/goose/src/acp/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ use crate::providers::inventory::{
};
use crate::scheduler_trait::SchedulerTrait;
use crate::session::{
EnabledExtensionsState, ExtensionData, ExtensionState, Session, SessionManager, SessionType,
EnabledExtensionsState, ExtensionData, ExtensionState, ProviderUsageSnapshotState, Session,
SessionManager, SessionType,
};
use crate::source_roots::SourceRoot;
use crate::utils::sanitize_unicode_tags;
Expand Down Expand Up @@ -836,6 +837,13 @@ pub(super) struct UsageUpdates {
pub(super) standard: UsageUpdate,
}

fn provider_usage_meta(session: &Session) -> Option<Meta> {
let provider_usage = ProviderUsageSnapshotState::from_extension_data(&session.extension_data)?;
serde_json::json!({ "goose": { "providerUsage": provider_usage } })
.as_object()
.cloned()
}

pub(super) fn build_usage_updates(session: &Session) -> Option<UsageUpdates> {
let used = session.usage.total_tokens.unwrap_or(0).max(0) as u64;
let ctx_limit = session.model_config.as_ref()?.context_limit() as u64;
Expand All @@ -859,6 +867,9 @@ pub(super) fn build_usage_updates(session: &Session) -> Option<UsageUpdates> {
if let Some(amount) = session.accumulated_cost {
standard = standard.cost(Cost::new(amount, "USD"));
}
if let Some(meta) = provider_usage_meta(session) {
standard = standard.meta(meta);
}
standard
},
})
Expand Down Expand Up @@ -3888,6 +3899,37 @@ print(\"hello, world\")
assert_eq!(updates.standard.size, 258_000);
}

#[test]
fn test_build_usage_update_includes_provider_usage_snapshot_meta() {
let mut session = make_session_with_usage(
TokenUsage::new(Some(80), Some(40), Some(120)),
TokenUsage::default(),
);
session.model_config = Some(
goose_providers::model::ModelConfig::new("test-model")
.with_context_limit(Some(258_000)),
);
let mut provider_usage = ProviderUsageSnapshotState::default();
provider_usage.add_usage(
"test-provider",
"test-model",
"2026-07-02T10:00:00Z".to_string(),
TokenUsage::new(Some(10), Some(5), Some(15)),
);
provider_usage
.to_extension_data(&mut session.extension_data)
.unwrap();

let updates = build_usage_updates(&session).expect("usage updates should be present");
let standard = serde_json::to_value(updates.standard).unwrap();
let entry = &standard["_meta"]["goose"]["providerUsage"]["entries"][0];

assert_eq!(entry["providerId"], "test-provider");
assert_eq!(entry["modelId"], "test-model");
assert_eq!(entry["inputTokens"], 10);
assert_eq!(entry["outputTokens"], 5);
}

#[test]
fn test_build_usage_update_requires_model_config() {
let session = make_session_with_usage(
Expand Down
179 changes: 162 additions & 17 deletions crates/goose/src/acp/server/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,47 @@ fn inventory_entry_to_dto(entry: ProviderInventoryEntry) -> ProviderInventoryEnt
}
}

fn provider_price_per_million(price: Option<f64>) -> Option<f64> {
price.map(|price| price * 1_000_000.0)
}

struct ModelPricing {
input: Option<f64>,
output: Option<f64>,
cache_read: Option<f64>,
cache_write: Option<f64>,
currency: String,
using_canonical: bool,
}

fn resolve_model_pricing(
provider: Option<&crate::providers::base::ModelInfo>,
canonical: Option<&crate::providers::canonical::CanonicalModel>,
) -> ModelPricing {
if let Some(provider) = provider
.filter(|model| model.input_token_cost.is_some() || model.output_token_cost.is_some())
{
return ModelPricing {
input: provider_price_per_million(provider.input_token_cost),
output: provider_price_per_million(provider.output_token_cost),
cache_read: None,
cache_write: None,
currency: provider.currency.clone().unwrap_or_else(|| "$".to_string()),
using_canonical: false,
};
}

ModelPricing {
input: canonical.and_then(|model| model.cost.input),
output: canonical.and_then(|model| model.cost.output),
cache_read: canonical.and_then(|model| model.cost.cache_read),
cache_write: canonical.and_then(|model| model.cost.cache_write),
currency: "$".to_string(),
using_canonical: canonical
.is_some_and(|model| model.cost.input.is_some() || model.cost.output.is_some()),
}
}

fn provider_config_key_to_dto(key: crate::providers::base::ConfigKey) -> ProviderConfigKey {
ProviderConfigKey {
name: key.name,
Expand Down Expand Up @@ -1022,24 +1063,128 @@ impl GooseAcpAgent {
) -> Result<CanonicalModelInfoResponse, agent_client_protocol::Error> {
use goose_providers::model::ModelConfig;

let model_info =
crate::providers::canonical::maybe_get_canonical_model(&req.provider, &req.model).map(
|canonical_model| CanonicalModelInfoDto {
provider: req.provider.clone(),
model: req.model.clone(),
context_limit: canonical_model.limit.context,
max_output_tokens: canonical_model.limit.output,
reasoning: canonical_model
.reasoning
.unwrap_or_else(|| ModelConfig::new(&req.model).is_reasoning_model()),
input_token_cost: canonical_model.cost.input,
output_token_cost: canonical_model.cost.output,
cache_read_token_cost: canonical_model.cost.cache_read,
cache_write_token_cost: canonical_model.cost.cache_write,
currency: "$".to_string(),
},
// TODO: Rename this legacy method to provider model info. It now returns
// provider-declared metadata first and falls back to canonical metadata.
let provider_model = crate::providers::get_from_registry(&req.provider)
.await
.ok()
.and_then(|entry| entry.model_info(&req.model));

let canonical_model =
crate::providers::canonical::maybe_get_canonical_model(&req.provider, &req.model);

if provider_model.is_none() && canonical_model.is_none() {
return Ok(CanonicalModelInfoResponse { model_info: None });
}

let provider = provider_model.as_ref();
let canonical = canonical_model.as_ref();
let pricing = resolve_model_pricing(provider, canonical);
if pricing.using_canonical {
warn!(
provider = %req.provider,
model = %req.model,
"Using canonical pricing because provider model pricing is not configured"
);
}

Ok(CanonicalModelInfoResponse {
model_info: Some(CanonicalModelInfoDto {
provider: req.provider.clone(),
model: req.model.clone(),
context_limit: provider
.and_then(|model| (model.context_limit > 0).then_some(model.context_limit))
.or_else(|| canonical.map(|model| model.limit.context))
.unwrap_or_default(),
max_output_tokens: canonical.and_then(|model| model.limit.output),
reasoning: provider.map(|model| model.reasoning).unwrap_or(false)
|| canonical
.and_then(|model| model.reasoning)
.unwrap_or_else(|| ModelConfig::new(&req.model).is_reasoning_model()),
input_token_cost: pricing.input,
output_token_cost: pricing.output,
cache_read_token_cost: pricing.cache_read,
cache_write_token_cost: pricing.cache_write,
currency: pricing.currency,
}),
})
}
}

#[cfg(test)]
mod tests {
use super::{provider_price_per_million, resolve_model_pricing};
use crate::providers::base::ModelInfo;
use crate::providers::canonical::{CanonicalModel, Limit, Modalities, Pricing};

#[test]
fn provider_price_per_million_normalizes_per_token_price() {
assert_eq!(provider_price_per_million(Some(0.000003)), Some(3.0));
assert_eq!(provider_price_per_million(None), None);
}

fn provider_model(input: Option<f64>, output: Option<f64>) -> ModelInfo {
ModelInfo {
name: "provider-model".to_string(),
input_token_cost: input,
output_token_cost: output,
currency: Some("RUB".to_string()),
..ModelInfo::new("provider-model", 128_000)
}
}

fn canonical_model() -> CanonicalModel {
CanonicalModel {
id: "provider/model".to_string(),
name: "Canonical Model".to_string(),
family: None,
attachment: None,
reasoning: None,
thinking_mode: None,
tool_call: false,
temperature: None,
knowledge: None,
release_date: None,
last_updated: None,
modalities: Modalities::default(),
open_weights: None,
cost: Pricing {
input: Some(5.0),
output: Some(15.0),
cache_read: Some(1.0),
cache_write: Some(2.0),
},
limit: Limit::default(),
}
}

#[test]
fn provider_pricing_does_not_mix_canonical_fallback_fields() {
let provider = provider_model(Some(0.0005), None);
let canonical = canonical_model();

let pricing = resolve_model_pricing(Some(&provider), Some(&canonical));

assert_eq!(pricing.input, Some(500.0));
assert_eq!(pricing.output, None);
assert_eq!(pricing.cache_read, None);
assert_eq!(pricing.cache_write, None);
assert_eq!(pricing.currency, "RUB");
assert!(!pricing.using_canonical);
}

#[test]
fn canonical_pricing_is_used_only_when_provider_pricing_is_absent() {
let provider = provider_model(None, None);
let canonical = canonical_model();

let pricing = resolve_model_pricing(Some(&provider), Some(&canonical));

Ok(CanonicalModelInfoResponse { model_info })
assert_eq!(pricing.input, Some(5.0));
assert_eq!(pricing.output, Some(15.0));
assert_eq!(pricing.cache_read, Some(1.0));
assert_eq!(pricing.cache_write, Some(2.0));
assert_eq!(pricing.currency, "$");
assert!(pricing.using_canonical);
}
}
21 changes: 16 additions & 5 deletions crates/goose/src/agents/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1756,8 +1756,10 @@ impl Agent {
);

let compact_model_config = self.model_config_for_session(&session_config.id).await?;
let compact_provider = self.provider().await?;
let compact_provider_name = compact_provider.get_name().to_string();
match compact_messages(
self.provider().await?.as_ref(),
compact_provider.as_ref(),
&compact_model_config,
&session_config.id,
&conversation_to_compact,
Expand All @@ -1766,6 +1768,9 @@ impl Agent {
.await
{
Ok((compacted_conversation, summarization_usage)) => {
let summarization_usage = summarization_usage
.with_provider(compact_provider_name)
.with_model(compact_model_config.model_name.clone());
session_manager.replace_conversation(&session_config.id, &compacted_conversation).await?;
self.update_session_metrics(&session_config.id, session_config.schedule_id.clone(), &summarization_usage, true).await?;

Expand Down Expand Up @@ -1834,7 +1839,7 @@ impl Agent {
.ok()
.and_then(|model_info| model_info.resolved_model)
.map(|resolved_model| InferenceMetadata {
provider: provider_name,
provider: provider_name.clone(),
requested_model,
resolved_model: Some(resolved_model),
});
Expand Down Expand Up @@ -2033,9 +2038,12 @@ impl Agent {
Ok((response, usage)) => {
compaction_attempts = 0;

if let Some(ref usage) = usage {
self.update_session_metrics(&session_config.id, session_config.schedule_id.clone(), usage, false).await?;
yield AgentEvent::Usage(usage.clone());
if let Some(usage) = usage {
let usage = usage
.with_provider(provider_name.clone())
.with_model(model_config.model_name.clone());
self.update_session_metrics(&session_config.id, session_config.schedule_id.clone(), &usage, false).await?;
yield AgentEvent::Usage(usage);
}

if let Some(response) = response {
Expand Down Expand Up @@ -2421,6 +2429,9 @@ impl Agent {
.await
{
Ok((compacted_conversation, usage)) => {
let usage = usage
.with_provider(provider_name.clone())
.with_model(model_config.model_name.clone());
session_manager.replace_conversation(&session_config.id, &compacted_conversation).await?;
self.update_session_metrics(&session_config.id, session_config.schedule_id.clone(), &usage, true).await?;
conversation = compacted_conversation;
Expand Down
Loading
Loading