Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 1 addition & 16 deletions crates/goose-cli/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ use goose::agents::extension::{Envs, ExtensionConfig, PLATFORM_EXTENSIONS};
use goose::agents::types::RetryConfig;
use goose::agents::{Agent, SessionConfig, COMPACT_TRIGGERS};
use goose::config::{Config, GooseMode};
use goose::providers::pricing::initialize_pricing_cache;
use goose::session::SessionManager;
use input::InputResult;
use rmcp::model::PromptMessage;
Expand Down Expand Up @@ -1416,19 +1415,6 @@ impl CliSession {
.get_goose_provider()
.unwrap_or_else(|_| "unknown".to_string());

// Do not get costing information if show cost is disabled
// This will prevent the API call to openrouter.ai
// This is useful if for cases where openrouter.ai may be blocked by corporate firewalls
if show_cost {
// Initialize pricing cache on startup
tracing::info!("Initializing pricing cache...");
if let Err(e) = initialize_pricing_cache().await {
tracing::warn!(
"Failed to initialize pricing cache: {e}. Pricing data may not be available."
);
}
}

match self.get_session().await {
Ok(metadata) => {
let total_tokens = metadata.total_tokens.unwrap_or(0) as usize;
Expand All @@ -1443,8 +1429,7 @@ impl CliSession {
&model_config.model_name,
input_tokens,
output_tokens,
)
.await;
);
}
}
Err(_) => {
Expand Down
66 changes: 10 additions & 56 deletions crates/goose-cli/src/session/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,9 @@ use goose::config::Config;
use goose::conversation::message::{
ActionRequiredData, Message, MessageContent, ToolRequest, ToolResponse,
};
use goose::providers::pricing::get_model_pricing;
use goose::providers::pricing::parse_model_id;
use goose::providers::canonical::maybe_get_canonical_model;
use goose::utils::safe_truncate;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use regex::Regex;
use rmcp::model::{CallToolRequestParam, JsonObject, PromptArgument};
use serde_json::Value;
use std::cell::RefCell;
Expand Down Expand Up @@ -795,69 +793,25 @@ pub fn display_context_usage(total_tokens: usize, context_limit: usize) {
);
}

fn normalize_model_name(model: &str) -> String {
let mut result = model.to_string();

// Remove "-latest" suffix
if result.ends_with("-latest") {
result = result.strip_suffix("-latest").unwrap().to_string();
}

// Remove date-like suffixes: -YYYYMMDD
let re_date = Regex::new(r"-\d{8}$").unwrap();
if re_date.is_match(&result) {
result = re_date.replace(&result, "").to_string();
}

// Convert version numbers like -3-7- to -3.7- (e.g., claude-3-7-sonnet -> claude-3.7-sonnet)
let re_version = Regex::new(r"-(\d+)-(\d+)-").unwrap();
if re_version.is_match(&result) {
result = re_version.replace(&result, "-$1.$2-").to_string();
}

result
}

async fn estimate_cost_usd(
fn estimate_cost_usd(
provider: &str,
model: &str,
input_tokens: usize,
output_tokens: usize,
) -> Option<f64> {
// For OpenRouter, parse the model name to extract real provider/model
let openrouter_data = if provider == "openrouter" {
parse_model_id(model)
} else {
None
};

let (provider_to_use, model_to_use) = match &openrouter_data {
Some((real_provider, real_model)) => (real_provider.as_str(), real_model.as_str()),
None => (provider, model),
};
let canonical_model = maybe_get_canonical_model(provider, model)?;

// Use the pricing module's get_model_pricing which handles model name mapping internally
let cleaned_model = normalize_model_name(model_to_use);
let pricing_info = get_model_pricing(provider_to_use, &cleaned_model).await;
let input_cost_per_token = canonical_model.pricing.prompt?;
let output_cost_per_token = canonical_model.pricing.completion?;

match pricing_info {
Some(pricing) => {
let input_cost = pricing.input_cost * input_tokens as f64;
let output_cost = pricing.output_cost * output_tokens as f64;
Some(input_cost + output_cost)
}
None => None,
}
let input_cost = input_cost_per_token * input_tokens as f64;
let output_cost = output_cost_per_token * output_tokens as f64;
Some(input_cost + output_cost)
}

/// Display cost information, if price data is available.
pub async fn display_cost_usage(
provider: &str,
model: &str,
input_tokens: usize,
output_tokens: usize,
) {
if let Some(cost) = estimate_cost_usd(provider, model, input_tokens, output_tokens).await {
pub fn display_cost_usage(provider: &str, model: &str, input_tokens: usize, output_tokens: usize) {
if let Some(cost) = estimate_cost_usd(provider, model, input_tokens, output_tokens) {
use console::style;
eprintln!(
"Cost: {} USD ({} tokens: in {}, out {})",
Expand Down
9 changes: 0 additions & 9 deletions crates/goose-server/src/commands/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ use goose_server::auth::check_token;
use tower_http::cors::{Any, CorsLayer};
use tracing::info;

use goose::providers::pricing::initialize_pricing_cache;

// Graceful shutdown signal
#[cfg(unix)]
async fn shutdown_signal() {
Expand All @@ -32,13 +30,6 @@ pub async fn run() -> Result<()> {

let settings = configuration::Settings::new()?;

if let Err(e) = initialize_pricing_cache().await {
tracing::warn!(
"Failed to initialize pricing cache: {}. Pricing data may not be available.",
e
);
}

let secret_key =
std::env::var("GOOSE_SERVER__SECRET_KEY").unwrap_or_else(|_| "test".to_string());

Expand Down
4 changes: 4 additions & 0 deletions crates/goose-server/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ derive_utoipa!(Icon as IconSchema);
super::routes::config_management::remove_custom_provider,
super::routes::config_management::check_provider,
super::routes::config_management::set_config_provider,
super::routes::config_management::get_pricing,
super::routes::agent::start_agent,
super::routes::agent::resume_agent,
super::routes::agent::get_tools,
Expand Down Expand Up @@ -417,6 +418,9 @@ derive_utoipa!(Icon as IconSchema);
super::routes::config_management::UpdateCustomProviderRequest,
super::routes::config_management::CheckProviderRequest,
super::routes::config_management::SetProviderRequest,
super::routes::config_management::PricingQuery,
super::routes::config_management::PricingResponse,
super::routes::config_management::PricingData,
super::routes::action_required::ConfirmToolActionRequest,
super::routes::reply::ChatRequest,
super::routes::session::ImportSessionRequest,
Expand Down
93 changes: 18 additions & 75 deletions crates/goose-server/src/routes/config_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,8 @@ use goose::config::{Config, ConfigError};
use goose::model::ModelConfig;
use goose::providers::auto_detect::detect_provider_from_api_key;
use goose::providers::base::{ProviderMetadata, ProviderType};
use goose::providers::canonical::maybe_get_canonical_model;
use goose::providers::create_with_default_model;
use goose::providers::pricing::{
get_all_pricing, get_model_pricing, parse_model_id, refresh_pricing,
};
use goose::providers::providers as get_providers;
use goose::{
agents::execute_commands, agents::ExtensionConfig, config::permission::PermissionLevel,
Expand Down Expand Up @@ -470,7 +468,8 @@ pub struct PricingResponse {

#[derive(Deserialize, ToSchema)]
pub struct PricingQuery {
pub configured_only: bool,
pub provider: String,
pub model: String,
}

#[utoipa::path(
Expand All @@ -484,84 +483,28 @@ pub struct PricingQuery {
pub async fn get_pricing(
Json(query): Json<PricingQuery>,
) -> Result<Json<PricingResponse>, StatusCode> {
let configured_only = query.configured_only;

// If refresh requested (configured_only = false), refresh the cache
if !configured_only {
if let Err(e) = refresh_pricing().await {
tracing::error!("Failed to refresh pricing data: {}", e);
}
}
let canonical_model =
maybe_get_canonical_model(&query.provider, &query.model).ok_or(StatusCode::NOT_FOUND)?;

let mut pricing_data = Vec::new();

if !configured_only {
// Get ALL pricing data from the cache
let all_pricing = get_all_pricing().await;

for (provider, models) in all_pricing {
for (model, pricing) in models {
pricing_data.push(PricingData {
provider: provider.clone(),
model: model.clone(),
input_token_cost: pricing.input_cost,
output_token_cost: pricing.output_cost,
currency: "$".to_string(),
context_length: pricing.context_length,
});
}
}
} else {
for (metadata, provider_type) in get_providers().await {
// Skip unconfigured providers if filtering
if !check_provider_configured(&metadata, provider_type) {
continue;
}

for model_info in &metadata.known_models {
// Handle OpenRouter models specially - they store full provider/model names
let (lookup_provider, lookup_model) = if metadata.name == "openrouter" {
// For OpenRouter, parse the model name to extract real provider/model
if let Some((provider, model)) = parse_model_id(&model_info.name) {
(provider, model)
} else {
// Fallback if parsing fails
(metadata.name.clone(), model_info.name.clone())
}
} else {
// For other providers, use names as-is
(metadata.name.clone(), model_info.name.clone())
};

// Only get pricing from OpenRouter cache
if let Some(pricing) = get_model_pricing(&lookup_provider, &lookup_model).await {
pricing_data.push(PricingData {
provider: metadata.name.clone(),
model: model_info.name.clone(),
input_token_cost: pricing.input_cost,
output_token_cost: pricing.output_cost,
currency: "$".to_string(),
context_length: pricing.context_length,
});
}
// No fallback to hardcoded prices
}
}
if let (Some(input_cost), Some(output_cost)) = (
canonical_model.pricing.prompt,
canonical_model.pricing.completion,
) {
pricing_data.push(PricingData {
provider: query.provider.clone(),
model: query.model.clone(),
input_token_cost: input_cost,
output_token_cost: output_cost,
currency: "$".to_string(),
context_length: Some(canonical_model.context_length as u32),
Comment on lines +495 to +501
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cast from usize to u32 could overflow on 64-bit systems if context_length exceeds u32::MAX. Consider using try_into() with proper error handling or validate that the value fits within u32 range.

Suggested change
pricing_data.push(PricingData {
provider: query.provider.clone(),
model: query.model.clone(),
input_token_cost: input_cost,
output_token_cost: output_cost,
currency: "$".to_string(),
context_length: Some(canonical_model.context_length as u32),
let context_length = u32::try_from(canonical_model.context_length)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
pricing_data.push(PricingData {
provider: query.provider.clone(),
model: query.model.clone(),
input_token_cost: input_cost,
output_token_cost: output_cost,
currency: "$".to_string(),
context_length: Some(context_length),

Copilot uses AI. Check for mistakes.
});
}
Comment on lines +491 to 503
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a model is found but has no pricing data (prompt or completion is None), the endpoint returns a 200 OK with an empty pricing array. The frontend at line 20 of pricing.ts expects pricing?.[0] ?? null, which correctly handles this. However, returning 404 NOT_FOUND when pricing data is unavailable would be more semantically correct and make error handling clearer.

Copilot uses AI. Check for mistakes.

tracing::debug!(
"Returning pricing for {} models{}",
pricing_data.len(),
if configured_only {
" (configured providers only)"
} else {
" (all cached models)"
}
);

Ok(Json(PricingResponse {
pricing: pricing_data,
source: "openrouter".to_string(),
source: "canonical".to_string(),
}))
}
Comment on lines 483 to 509
Copy link

Copilot AI Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The API can return 200 OK with an empty pricing array when a canonical model exists but has no pricing data (prompt or completion is None). This differs from returning 404 when the model isn't found. Consider returning 404 or a different status when pricing is unavailable to distinguish between "model not found" and "model found but no pricing".

Copilot uses AI. Check for mistakes.

Expand Down
6 changes: 6 additions & 0 deletions crates/goose/src/providers/canonical/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,9 @@ impl ModelMapping {
}
}
}

pub fn maybe_get_canonical_model(provider: &str, model: &str) -> Option<CanonicalModel> {
let registry = CanonicalModelRegistry::bundled().ok()?;
let canonical_id = map_to_canonical_model(provider, model, registry)?;
registry.get(&canonical_id).cloned()
Comment on lines +24 to +27
Copy link

Copilot AI Dec 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function calls CanonicalModelRegistry::bundled() twice - once on line 25 and implicitly again on line 27 when using the result. The registry should be stored in a variable after the first call to avoid the potential overhead of multiple lazy initialization checks and error conversions.

Copilot uses AI. Check for mistakes.
}
1 change: 0 additions & 1 deletion crates/goose/src/providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ pub mod oauth;
pub mod ollama;
pub mod openai;
pub mod openrouter;
pub mod pricing;
pub mod provider_registry;
pub mod provider_test;
mod retry;
Expand Down
Loading
Loading