Integrate pricing with canonical model (#6130)
This commit is contained in:
@@ -33,7 +33,6 @@ use goose::agents::extension::{Envs, ExtensionConfig, PLATFORM_EXTENSIONS};
|
|||||||
use goose::agents::types::RetryConfig;
|
use goose::agents::types::RetryConfig;
|
||||||
use goose::agents::{Agent, SessionConfig, COMPACT_TRIGGERS};
|
use goose::agents::{Agent, SessionConfig, COMPACT_TRIGGERS};
|
||||||
use goose::config::{Config, GooseMode};
|
use goose::config::{Config, GooseMode};
|
||||||
use goose::providers::pricing::initialize_pricing_cache;
|
|
||||||
use goose::session::SessionManager;
|
use goose::session::SessionManager;
|
||||||
use input::InputResult;
|
use input::InputResult;
|
||||||
use rmcp::model::PromptMessage;
|
use rmcp::model::PromptMessage;
|
||||||
@@ -1416,19 +1415,6 @@ impl CliSession {
|
|||||||
.get_goose_provider()
|
.get_goose_provider()
|
||||||
.unwrap_or_else(|_| "unknown".to_string());
|
.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 {
|
match self.get_session().await {
|
||||||
Ok(metadata) => {
|
Ok(metadata) => {
|
||||||
let total_tokens = metadata.total_tokens.unwrap_or(0) as usize;
|
let total_tokens = metadata.total_tokens.unwrap_or(0) as usize;
|
||||||
@@ -1443,8 +1429,7 @@ impl CliSession {
|
|||||||
&model_config.model_name,
|
&model_config.model_name,
|
||||||
input_tokens,
|
input_tokens,
|
||||||
output_tokens,
|
output_tokens,
|
||||||
)
|
);
|
||||||
.await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ use goose::config::Config;
|
|||||||
use goose::conversation::message::{
|
use goose::conversation::message::{
|
||||||
ActionRequiredData, Message, MessageContent, ToolRequest, ToolResponse,
|
ActionRequiredData, Message, MessageContent, ToolRequest, ToolResponse,
|
||||||
};
|
};
|
||||||
use goose::providers::pricing::get_model_pricing;
|
use goose::providers::canonical::maybe_get_canonical_model;
|
||||||
use goose::providers::pricing::parse_model_id;
|
|
||||||
use goose::utils::safe_truncate;
|
use goose::utils::safe_truncate;
|
||||||
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
|
||||||
use regex::Regex;
|
|
||||||
use rmcp::model::{CallToolRequestParam, JsonObject, PromptArgument};
|
use rmcp::model::{CallToolRequestParam, JsonObject, PromptArgument};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
@@ -795,69 +793,25 @@ pub fn display_context_usage(total_tokens: usize, context_limit: usize) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_model_name(model: &str) -> String {
|
fn estimate_cost_usd(
|
||||||
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(
|
|
||||||
provider: &str,
|
provider: &str,
|
||||||
model: &str,
|
model: &str,
|
||||||
input_tokens: usize,
|
input_tokens: usize,
|
||||||
output_tokens: usize,
|
output_tokens: usize,
|
||||||
) -> Option<f64> {
|
) -> Option<f64> {
|
||||||
// For OpenRouter, parse the model name to extract real provider/model
|
let canonical_model = maybe_get_canonical_model(provider, model)?;
|
||||||
let openrouter_data = if provider == "openrouter" {
|
|
||||||
parse_model_id(model)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
let (provider_to_use, model_to_use) = match &openrouter_data {
|
let input_cost_per_token = canonical_model.pricing.prompt?;
|
||||||
Some((real_provider, real_model)) => (real_provider.as_str(), real_model.as_str()),
|
let output_cost_per_token = canonical_model.pricing.completion?;
|
||||||
None => (provider, model),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use the pricing module's get_model_pricing which handles model name mapping internally
|
let input_cost = input_cost_per_token * input_tokens as f64;
|
||||||
let cleaned_model = normalize_model_name(model_to_use);
|
let output_cost = output_cost_per_token * output_tokens as f64;
|
||||||
let pricing_info = get_model_pricing(provider_to_use, &cleaned_model).await;
|
Some(input_cost + output_cost)
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Display cost information, if price data is available.
|
/// Display cost information, if price data is available.
|
||||||
pub async fn display_cost_usage(
|
pub fn display_cost_usage(provider: &str, model: &str, input_tokens: usize, output_tokens: usize) {
|
||||||
provider: &str,
|
if let Some(cost) = estimate_cost_usd(provider, model, input_tokens, output_tokens) {
|
||||||
model: &str,
|
|
||||||
input_tokens: usize,
|
|
||||||
output_tokens: usize,
|
|
||||||
) {
|
|
||||||
if let Some(cost) = estimate_cost_usd(provider, model, input_tokens, output_tokens).await {
|
|
||||||
use console::style;
|
use console::style;
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Cost: {} USD ({} tokens: in {}, out {})",
|
"Cost: {} USD ({} tokens: in {}, out {})",
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ use goose_server::auth::check_token;
|
|||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use goose::providers::pricing::initialize_pricing_cache;
|
|
||||||
|
|
||||||
// Graceful shutdown signal
|
// Graceful shutdown signal
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
async fn shutdown_signal() {
|
async fn shutdown_signal() {
|
||||||
@@ -32,13 +30,6 @@ pub async fn run() -> Result<()> {
|
|||||||
|
|
||||||
let settings = configuration::Settings::new()?;
|
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 =
|
let secret_key =
|
||||||
std::env::var("GOOSE_SERVER__SECRET_KEY").unwrap_or_else(|_| "test".to_string());
|
std::env::var("GOOSE_SERVER__SECRET_KEY").unwrap_or_else(|_| "test".to_string());
|
||||||
|
|
||||||
|
|||||||
@@ -351,6 +351,7 @@ derive_utoipa!(Icon as IconSchema);
|
|||||||
super::routes::config_management::remove_custom_provider,
|
super::routes::config_management::remove_custom_provider,
|
||||||
super::routes::config_management::check_provider,
|
super::routes::config_management::check_provider,
|
||||||
super::routes::config_management::set_config_provider,
|
super::routes::config_management::set_config_provider,
|
||||||
|
super::routes::config_management::get_pricing,
|
||||||
super::routes::agent::start_agent,
|
super::routes::agent::start_agent,
|
||||||
super::routes::agent::resume_agent,
|
super::routes::agent::resume_agent,
|
||||||
super::routes::agent::get_tools,
|
super::routes::agent::get_tools,
|
||||||
@@ -417,6 +418,9 @@ derive_utoipa!(Icon as IconSchema);
|
|||||||
super::routes::config_management::UpdateCustomProviderRequest,
|
super::routes::config_management::UpdateCustomProviderRequest,
|
||||||
super::routes::config_management::CheckProviderRequest,
|
super::routes::config_management::CheckProviderRequest,
|
||||||
super::routes::config_management::SetProviderRequest,
|
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::action_required::ConfirmToolActionRequest,
|
||||||
super::routes::reply::ChatRequest,
|
super::routes::reply::ChatRequest,
|
||||||
super::routes::session::ImportSessionRequest,
|
super::routes::session::ImportSessionRequest,
|
||||||
|
|||||||
@@ -13,10 +13,8 @@ use goose::config::{Config, ConfigError};
|
|||||||
use goose::model::ModelConfig;
|
use goose::model::ModelConfig;
|
||||||
use goose::providers::auto_detect::detect_provider_from_api_key;
|
use goose::providers::auto_detect::detect_provider_from_api_key;
|
||||||
use goose::providers::base::{ProviderMetadata, ProviderType};
|
use goose::providers::base::{ProviderMetadata, ProviderType};
|
||||||
|
use goose::providers::canonical::maybe_get_canonical_model;
|
||||||
use goose::providers::create_with_default_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::providers::providers as get_providers;
|
||||||
use goose::{
|
use goose::{
|
||||||
agents::execute_commands, agents::ExtensionConfig, config::permission::PermissionLevel,
|
agents::execute_commands, agents::ExtensionConfig, config::permission::PermissionLevel,
|
||||||
@@ -470,7 +468,8 @@ pub struct PricingResponse {
|
|||||||
|
|
||||||
#[derive(Deserialize, ToSchema)]
|
#[derive(Deserialize, ToSchema)]
|
||||||
pub struct PricingQuery {
|
pub struct PricingQuery {
|
||||||
pub configured_only: bool,
|
pub provider: String,
|
||||||
|
pub model: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -484,84 +483,28 @@ pub struct PricingQuery {
|
|||||||
pub async fn get_pricing(
|
pub async fn get_pricing(
|
||||||
Json(query): Json<PricingQuery>,
|
Json(query): Json<PricingQuery>,
|
||||||
) -> Result<Json<PricingResponse>, StatusCode> {
|
) -> Result<Json<PricingResponse>, StatusCode> {
|
||||||
let configured_only = query.configured_only;
|
let canonical_model =
|
||||||
|
maybe_get_canonical_model(&query.provider, &query.model).ok_or(StatusCode::NOT_FOUND)?;
|
||||||
// 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 mut pricing_data = Vec::new();
|
let mut pricing_data = Vec::new();
|
||||||
|
|
||||||
if !configured_only {
|
if let (Some(input_cost), Some(output_cost)) = (
|
||||||
// Get ALL pricing data from the cache
|
canonical_model.pricing.prompt,
|
||||||
let all_pricing = get_all_pricing().await;
|
canonical_model.pricing.completion,
|
||||||
|
) {
|
||||||
for (provider, models) in all_pricing {
|
pricing_data.push(PricingData {
|
||||||
for (model, pricing) in models {
|
provider: query.provider.clone(),
|
||||||
pricing_data.push(PricingData {
|
model: query.model.clone(),
|
||||||
provider: provider.clone(),
|
input_token_cost: input_cost,
|
||||||
model: model.clone(),
|
output_token_cost: output_cost,
|
||||||
input_token_cost: pricing.input_cost,
|
currency: "$".to_string(),
|
||||||
output_token_cost: pricing.output_cost,
|
context_length: Some(canonical_model.context_length as u32),
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::debug!(
|
|
||||||
"Returning pricing for {} models{}",
|
|
||||||
pricing_data.len(),
|
|
||||||
if configured_only {
|
|
||||||
" (configured providers only)"
|
|
||||||
} else {
|
|
||||||
" (all cached models)"
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(Json(PricingResponse {
|
Ok(Json(PricingResponse {
|
||||||
pricing: pricing_data,
|
pricing: pricing_data,
|
||||||
source: "openrouter".to_string(),
|
source: "canonical".to_string(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ pub mod oauth;
|
|||||||
pub mod ollama;
|
pub mod ollama;
|
||||||
pub mod openai;
|
pub mod openai;
|
||||||
pub mod openrouter;
|
pub mod openrouter;
|
||||||
pub mod pricing;
|
|
||||||
pub mod provider_registry;
|
pub mod provider_registry;
|
||||||
pub mod provider_test;
|
pub mod provider_test;
|
||||||
mod retry;
|
mod retry;
|
||||||
|
|||||||
@@ -1,408 +0,0 @@
|
|||||||
use anyhow::{anyhow, Result};
|
|
||||||
use reqwest::Client;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::collections::HashMap;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
|
||||||
/// Disk cache configuration
|
|
||||||
const CACHE_FILE_NAME: &str = "pricing_cache.json";
|
|
||||||
const CACHE_TTL_DAYS: u64 = 7; // Cache for 7 days
|
|
||||||
|
|
||||||
/// Get the cache directory path
|
|
||||||
fn get_cache_dir() -> Result<PathBuf> {
|
|
||||||
let cache_dir = if let Ok(goose_dir) = std::env::var("GOOSE_CACHE_DIR") {
|
|
||||||
PathBuf::from(goose_dir)
|
|
||||||
} else {
|
|
||||||
dirs::cache_dir()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("Could not determine cache directory"))?
|
|
||||||
.join("goose")
|
|
||||||
};
|
|
||||||
Ok(cache_dir)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cached pricing data structure for disk storage
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct CachedPricingData {
|
|
||||||
/// Nested HashMap: provider -> model -> pricing info
|
|
||||||
pub pricing: HashMap<String, HashMap<String, PricingInfo>>,
|
|
||||||
/// Unix timestamp when data was fetched
|
|
||||||
pub fetched_at: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Simplified pricing info for efficient storage
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct PricingInfo {
|
|
||||||
pub input_cost: f64, // Cost per token
|
|
||||||
pub output_cost: f64, // Cost per token
|
|
||||||
pub context_length: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cache for OpenRouter pricing data with disk persistence
|
|
||||||
pub struct PricingCache {
|
|
||||||
/// In-memory cache
|
|
||||||
memory_cache: Arc<RwLock<Option<CachedPricingData>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PricingCache {
|
|
||||||
pub fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
memory_cache: Arc::new(RwLock::new(None)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load pricing from disk cache
|
|
||||||
async fn load_from_disk(&self) -> Result<Option<CachedPricingData>> {
|
|
||||||
let cache_path = get_cache_dir()?.join(CACHE_FILE_NAME);
|
|
||||||
|
|
||||||
if !cache_path.exists() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
match tokio::fs::read(&cache_path).await {
|
|
||||||
Ok(data) => {
|
|
||||||
match serde_json::from_slice::<CachedPricingData>(&data) {
|
|
||||||
Ok(cached) => {
|
|
||||||
// Check if cache is still valid
|
|
||||||
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
|
|
||||||
let age_days = (now - cached.fetched_at) / (24 * 60 * 60);
|
|
||||||
|
|
||||||
if age_days < CACHE_TTL_DAYS {
|
|
||||||
Ok(Some(cached))
|
|
||||||
} else {
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("Failed to parse pricing cache: {}", e);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!("Failed to read pricing cache: {}", e);
|
|
||||||
Ok(None)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Save pricing data to disk
|
|
||||||
async fn save_to_disk(&self, data: &CachedPricingData) -> Result<()> {
|
|
||||||
let cache_dir = get_cache_dir()?;
|
|
||||||
tokio::fs::create_dir_all(&cache_dir).await?;
|
|
||||||
|
|
||||||
let cache_path = cache_dir.join(CACHE_FILE_NAME);
|
|
||||||
let json_data = serde_json::to_vec_pretty(data)?;
|
|
||||||
tokio::fs::write(&cache_path, json_data).await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get pricing for a specific model
|
|
||||||
pub async fn get_model_pricing(&self, provider: &str, model: &str) -> Option<PricingInfo> {
|
|
||||||
// Try memory cache first
|
|
||||||
{
|
|
||||||
let cache = self.memory_cache.read().await;
|
|
||||||
if let Some(cached) = &*cache {
|
|
||||||
return cached
|
|
||||||
.pricing
|
|
||||||
.get(&provider.to_lowercase())
|
|
||||||
.and_then(|models| models.get(model))
|
|
||||||
.cloned();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try loading from disk
|
|
||||||
if let Ok(Some(disk_cache)) = self.load_from_disk().await {
|
|
||||||
// Update memory cache
|
|
||||||
{
|
|
||||||
let mut cache = self.memory_cache.write().await;
|
|
||||||
*cache = Some(disk_cache.clone());
|
|
||||||
}
|
|
||||||
|
|
||||||
return disk_cache
|
|
||||||
.pricing
|
|
||||||
.get(&provider.to_lowercase())
|
|
||||||
.and_then(|models| models.get(model))
|
|
||||||
.cloned();
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Force refresh pricing data from OpenRouter
|
|
||||||
pub async fn refresh(&self) -> Result<()> {
|
|
||||||
let pricing = fetch_openrouter_pricing_internal().await?;
|
|
||||||
|
|
||||||
// Convert to our efficient structure
|
|
||||||
let mut structured_pricing: HashMap<String, HashMap<String, PricingInfo>> = HashMap::new();
|
|
||||||
|
|
||||||
for (model_id, model) in pricing {
|
|
||||||
if let Some((provider, model_name)) = parse_model_id(&model_id) {
|
|
||||||
if let (Some(input_cost), Some(output_cost)) = (
|
|
||||||
convert_pricing(&model.pricing.prompt),
|
|
||||||
convert_pricing(&model.pricing.completion),
|
|
||||||
) {
|
|
||||||
let provider_lower = provider.to_lowercase();
|
|
||||||
let provider_models = structured_pricing.entry(provider_lower).or_default();
|
|
||||||
|
|
||||||
provider_models.insert(
|
|
||||||
model_name,
|
|
||||||
PricingInfo {
|
|
||||||
input_cost,
|
|
||||||
output_cost,
|
|
||||||
context_length: model.context_length,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let cached_data = CachedPricingData {
|
|
||||||
pricing: structured_pricing,
|
|
||||||
fetched_at: SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(),
|
|
||||||
};
|
|
||||||
|
|
||||||
self.save_to_disk(&cached_data).await?;
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut cache = self.memory_cache.write().await;
|
|
||||||
*cache = Some(cached_data);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initialize cache (load from disk or fetch if needed)
|
|
||||||
pub async fn initialize(&self) -> Result<()> {
|
|
||||||
// Try loading from disk first
|
|
||||||
if let Ok(Some(cached)) = self.load_from_disk().await {
|
|
||||||
{
|
|
||||||
let mut cache = self.memory_cache.write().await;
|
|
||||||
*cache = Some(cached);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
self.refresh().await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for PricingCache {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Global cache instance
|
|
||||||
lazy_static::lazy_static! {
|
|
||||||
static ref PRICING_CACHE: PricingCache = PricingCache::new();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_http_client() -> Result<Client> {
|
|
||||||
Client::builder()
|
|
||||||
.timeout(Duration::from_secs(30))
|
|
||||||
.pool_idle_timeout(Duration::from_secs(90))
|
|
||||||
.pool_max_idle_per_host(10)
|
|
||||||
.build()
|
|
||||||
.map_err(|e| anyhow!(e))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// OpenRouter model pricing information
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct OpenRouterModel {
|
|
||||||
pub id: String,
|
|
||||||
pub name: String,
|
|
||||||
pub pricing: OpenRouterPricing,
|
|
||||||
pub context_length: Option<u32>,
|
|
||||||
pub architecture: Option<Architecture>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct OpenRouterPricing {
|
|
||||||
pub prompt: String, // Cost per token for input (in USD)
|
|
||||||
pub completion: String, // Cost per token for output (in USD)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Architecture {
|
|
||||||
pub modality: String,
|
|
||||||
pub tokenizer: String,
|
|
||||||
pub instruct_type: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Response from OpenRouter models endpoint
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
pub struct OpenRouterModelsResponse {
|
|
||||||
pub data: Vec<OpenRouterModel>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Internal function to fetch pricing data
|
|
||||||
async fn fetch_openrouter_pricing_internal() -> Result<HashMap<String, OpenRouterModel>> {
|
|
||||||
let client = create_http_client()?;
|
|
||||||
let response = client
|
|
||||||
.get("https://openrouter.ai/api/v1/models")
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !response.status().is_success() {
|
|
||||||
anyhow::bail!(
|
|
||||||
"Failed to fetch OpenRouter models: HTTP {}",
|
|
||||||
response.status()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let models_response: OpenRouterModelsResponse = response.json().await?;
|
|
||||||
|
|
||||||
// Create a map for easy lookup
|
|
||||||
let mut pricing_map = HashMap::new();
|
|
||||||
for model in models_response.data {
|
|
||||||
pricing_map.insert(model.id.clone(), model);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(pricing_map)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Initialize pricing cache on startup
|
|
||||||
pub async fn initialize_pricing_cache() -> Result<()> {
|
|
||||||
PRICING_CACHE.initialize().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get pricing for a specific model
|
|
||||||
pub async fn get_model_pricing(provider: &str, model: &str) -> Option<PricingInfo> {
|
|
||||||
PRICING_CACHE.get_model_pricing(provider, model).await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Force refresh pricing data
|
|
||||||
pub async fn refresh_pricing() -> Result<()> {
|
|
||||||
PRICING_CACHE.refresh().await
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all cached pricing data
|
|
||||||
pub async fn get_all_pricing() -> HashMap<String, HashMap<String, PricingInfo>> {
|
|
||||||
let cache = PRICING_CACHE.memory_cache.read().await;
|
|
||||||
if let Some(cached) = &*cache {
|
|
||||||
cached.pricing.clone()
|
|
||||||
} else {
|
|
||||||
// Try loading from disk
|
|
||||||
if let Ok(Some(disk_cache)) = PRICING_CACHE.load_from_disk().await {
|
|
||||||
// Update memory cache
|
|
||||||
drop(cache);
|
|
||||||
let mut write_cache = PRICING_CACHE.memory_cache.write().await;
|
|
||||||
*write_cache = Some(disk_cache.clone());
|
|
||||||
disk_cache.pricing
|
|
||||||
} else {
|
|
||||||
HashMap::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert OpenRouter model ID to provider/model format
|
|
||||||
/// e.g., "anthropic/claude-sonnet-4-20250514" -> ("anthropic", "claude-sonnet-4-20250514")
|
|
||||||
pub fn parse_model_id(model_id: &str) -> Option<(String, String)> {
|
|
||||||
let parts: Vec<&str> = model_id.splitn(2, '/').collect();
|
|
||||||
if parts.len() == 2 {
|
|
||||||
// Normalize provider names to match our internal naming
|
|
||||||
let provider = match parts[0] {
|
|
||||||
"openai" => "openai",
|
|
||||||
"anthropic" => "anthropic",
|
|
||||||
"google" => "google",
|
|
||||||
"meta-llama" => "ollama", // Meta models often run via Ollama
|
|
||||||
"mistralai" => "mistral",
|
|
||||||
"cohere" => "cohere",
|
|
||||||
"perplexity" => "perplexity",
|
|
||||||
"deepseek" => "deepseek",
|
|
||||||
"groq" => "groq",
|
|
||||||
"nvidia" => "nvidia",
|
|
||||||
"microsoft" => "azure",
|
|
||||||
"replicate" => "replicate",
|
|
||||||
"huggingface" => "huggingface",
|
|
||||||
_ => parts[0],
|
|
||||||
};
|
|
||||||
Some((provider.to_string(), parts[1].to_string()))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convert OpenRouter pricing to cost per token (already in that format)
|
|
||||||
pub fn convert_pricing(price_str: &str) -> Option<f64> {
|
|
||||||
// OpenRouter prices are already in USD per token
|
|
||||||
price_str.parse::<f64>().ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_model_id() {
|
|
||||||
assert_eq!(
|
|
||||||
parse_model_id("anthropic/claude-sonnet-4-20250514"),
|
|
||||||
Some((
|
|
||||||
"anthropic".to_string(),
|
|
||||||
"claude-sonnet-4-20250514".to_string()
|
|
||||||
))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
parse_model_id("openai/gpt-4"),
|
|
||||||
Some(("openai".to_string(), "gpt-4".to_string()))
|
|
||||||
);
|
|
||||||
assert_eq!(parse_model_id("invalid-format"), None);
|
|
||||||
|
|
||||||
// Test the specific model causing issues
|
|
||||||
assert_eq!(
|
|
||||||
parse_model_id("anthropic/claude-sonnet-4-20250514"),
|
|
||||||
Some((
|
|
||||||
"anthropic".to_string(),
|
|
||||||
"claude-sonnet-4-20250514".to_string()
|
|
||||||
))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_convert_pricing() {
|
|
||||||
assert_eq!(convert_pricing("0.000003"), Some(0.000003));
|
|
||||||
assert_eq!(convert_pricing("0.015"), Some(0.015));
|
|
||||||
assert_eq!(convert_pricing("invalid"), None);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_claude_sonnet_4_pricing_lookup() {
|
|
||||||
// Initialize the cache to load from disk
|
|
||||||
if let Err(e) = initialize_pricing_cache().await {
|
|
||||||
println!("Failed to initialize pricing cache: {}", e);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test lookup for the specific model (use the name that actually exists in cache)
|
|
||||||
let pricing = get_model_pricing("anthropic", "claude-sonnet-4").await;
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"Pricing lookup result for anthropic/claude-sonnet-4: {:?}",
|
|
||||||
pricing
|
|
||||||
);
|
|
||||||
|
|
||||||
// Should find pricing data
|
|
||||||
if let Some(pricing_info) = pricing {
|
|
||||||
assert!(pricing_info.input_cost > 0.0);
|
|
||||||
assert!(pricing_info.output_cost > 0.0);
|
|
||||||
println!(
|
|
||||||
"Found pricing: input={}, output={}",
|
|
||||||
pricing_info.input_cost, pricing_info.output_cost
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Print debug info
|
|
||||||
let all_pricing = get_all_pricing().await;
|
|
||||||
if let Some(anthropic_models) = all_pricing.get("anthropic") {
|
|
||||||
println!("Available anthropic models in cache:");
|
|
||||||
for model_name in anthropic_models.keys() {
|
|
||||||
println!(" {}", model_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
panic!("Expected to find pricing for anthropic/claude-sonnet-4");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -854,6 +854,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/config/pricing": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"super::routes::config_management"
|
||||||
|
],
|
||||||
|
"operationId": "get_pricing",
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PricingQuery"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Model pricing data retrieved successfully",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/PricingResponse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/config/providers": {
|
"/config/providers": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -4223,6 +4253,74 @@
|
|||||||
"never_allow"
|
"never_allow"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"PricingData": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"provider",
|
||||||
|
"model",
|
||||||
|
"input_token_cost",
|
||||||
|
"output_token_cost",
|
||||||
|
"currency"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"context_length": {
|
||||||
|
"type": "integer",
|
||||||
|
"format": "int32",
|
||||||
|
"nullable": true,
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"currency": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"input_token_cost": {
|
||||||
|
"type": "number",
|
||||||
|
"format": "double"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"output_token_cost": {
|
||||||
|
"type": "number",
|
||||||
|
"format": "double"
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PricingQuery": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"provider",
|
||||||
|
"model"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"model": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"provider": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"PricingResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"pricing",
|
||||||
|
"source"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"pricing": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/PricingData"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"source": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"PrincipalType": {
|
"PrincipalType": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { Client, Options as Options2, TDataShape } from './client';
|
import type { Client, Options as Options2, TDataShape } from './client';
|
||||||
import { client } from './client.gen';
|
import { client } from './client.gen';
|
||||||
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
|
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CallToolData, CallToolErrors, CallToolResponses, CheckProviderData, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetPricingData, GetPricingResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SendTelemetryEventData, SendTelemetryEventResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StartTunnelData, StartTunnelErrors, StartTunnelResponses, StatusData, StatusResponses, StopTunnelData, StopTunnelErrors, StopTunnelResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
|
||||||
|
|
||||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
|
||||||
/**
|
/**
|
||||||
@@ -178,6 +178,15 @@ export const upsertPermissions = <ThrowOnError extends boolean = false>(options:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getPricing = <ThrowOnError extends boolean = false>(options: Options<GetPricingData, ThrowOnError>) => (options.client ?? client).post<GetPricingResponses, unknown, ThrowOnError>({
|
||||||
|
url: '/config/pricing',
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...options.headers
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const providers = <ThrowOnError extends boolean = false>(options?: Options<ProvidersData, ThrowOnError>) => (options?.client ?? client).get<ProvidersResponses, unknown, ThrowOnError>({ url: '/config/providers', ...options });
|
export const providers = <ThrowOnError extends boolean = false>(options?: Options<ProvidersData, ThrowOnError>) => (options?.client ?? client).get<ProvidersResponses, unknown, ThrowOnError>({ url: '/config/providers', ...options });
|
||||||
|
|
||||||
export const getProviderModels = <ThrowOnError extends boolean = false>(options: Options<GetProviderModelsData, ThrowOnError>) => (options.client ?? client).get<GetProviderModelsResponses, GetProviderModelsErrors, ThrowOnError>({ url: '/config/providers/{name}/models', ...options });
|
export const getProviderModels = <ThrowOnError extends boolean = false>(options: Options<GetProviderModelsData, ThrowOnError>) => (options.client ?? client).get<GetProviderModelsResponses, GetProviderModelsErrors, ThrowOnError>({ url: '/config/providers/{name}/models', ...options });
|
||||||
|
|||||||
@@ -527,6 +527,25 @@ export type ParseRecipeResponse = {
|
|||||||
*/
|
*/
|
||||||
export type PermissionLevel = 'always_allow' | 'ask_before' | 'never_allow';
|
export type PermissionLevel = 'always_allow' | 'ask_before' | 'never_allow';
|
||||||
|
|
||||||
|
export type PricingData = {
|
||||||
|
context_length?: number | null;
|
||||||
|
currency: string;
|
||||||
|
input_token_cost: number;
|
||||||
|
model: string;
|
||||||
|
output_token_cost: number;
|
||||||
|
provider: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PricingQuery = {
|
||||||
|
model: string;
|
||||||
|
provider: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PricingResponse = {
|
||||||
|
pricing: Array<PricingData>;
|
||||||
|
source: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type PrincipalType = 'Extension' | 'Tool';
|
export type PrincipalType = 'Extension' | 'Tool';
|
||||||
|
|
||||||
export type ProviderDetails = {
|
export type ProviderDetails = {
|
||||||
@@ -1718,6 +1737,22 @@ export type UpsertPermissionsResponses = {
|
|||||||
|
|
||||||
export type UpsertPermissionsResponse = UpsertPermissionsResponses[keyof UpsertPermissionsResponses];
|
export type UpsertPermissionsResponse = UpsertPermissionsResponses[keyof UpsertPermissionsResponses];
|
||||||
|
|
||||||
|
export type GetPricingData = {
|
||||||
|
body: PricingQuery;
|
||||||
|
path?: never;
|
||||||
|
query?: never;
|
||||||
|
url: '/config/pricing';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetPricingResponses = {
|
||||||
|
/**
|
||||||
|
* Model pricing data retrieved successfully
|
||||||
|
*/
|
||||||
|
200: PricingResponse;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GetPricingResponse = GetPricingResponses[keyof GetPricingResponses];
|
||||||
|
|
||||||
export type ProvidersData = {
|
export type ProvidersData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useModelAndProvider } from '../ModelAndProviderContext';
|
import { useModelAndProvider } from '../ModelAndProviderContext';
|
||||||
import { useConfig } from '../ConfigContext';
|
|
||||||
import { CoinIcon } from '../icons';
|
import { CoinIcon } from '../icons';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
|
||||||
import {
|
import { fetchModelPricing } from '../../utils/pricing';
|
||||||
getCostForModel,
|
import { PricingData } from '../../api';
|
||||||
initializeCostDatabase,
|
|
||||||
updateAllModelCosts,
|
|
||||||
fetchAndCachePricing,
|
|
||||||
} from '../../utils/costDatabase';
|
|
||||||
|
|
||||||
interface CostTrackerProps {
|
interface CostTrackerProps {
|
||||||
inputTokens?: number;
|
inputTokens?: number;
|
||||||
@@ -24,18 +19,10 @@ interface CostTrackerProps {
|
|||||||
|
|
||||||
export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }: CostTrackerProps) {
|
export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }: CostTrackerProps) {
|
||||||
const { currentModel, currentProvider } = useModelAndProvider();
|
const { currentModel, currentProvider } = useModelAndProvider();
|
||||||
const { getProviders } = useConfig();
|
const [costInfo, setCostInfo] = useState<PricingData | null>(null);
|
||||||
const [costInfo, setCostInfo] = useState<{
|
|
||||||
input_token_cost?: number;
|
|
||||||
output_token_cost?: number;
|
|
||||||
currency?: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [showPricing, setShowPricing] = useState(true);
|
const [showPricing, setShowPricing] = useState(true);
|
||||||
const [pricingFailed, setPricingFailed] = useState(false);
|
const [pricingFailed, setPricingFailed] = useState(false);
|
||||||
const [modelNotFound, setModelNotFound] = useState(false);
|
|
||||||
const [hasAttemptedFetch, setHasAttemptedFetch] = useState(false);
|
|
||||||
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
|
||||||
|
|
||||||
// Check if pricing is enabled
|
// Check if pricing is enabled
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -44,33 +31,11 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
|
|||||||
setShowPricing(stored !== 'false');
|
setShowPricing(stored !== 'false');
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check on mount
|
|
||||||
checkPricingSetting();
|
checkPricingSetting();
|
||||||
|
|
||||||
// Listen for storage changes
|
|
||||||
window.addEventListener('storage', checkPricingSetting);
|
window.addEventListener('storage', checkPricingSetting);
|
||||||
return () => window.removeEventListener('storage', checkPricingSetting);
|
return () => window.removeEventListener('storage', checkPricingSetting);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Set initial load complete after a short delay
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setInitialLoadComplete(true);
|
|
||||||
}, 3000); // Give 3 seconds for initial load
|
|
||||||
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Debug log props removed
|
|
||||||
|
|
||||||
// Initialize cost database on mount
|
|
||||||
useEffect(() => {
|
|
||||||
initializeCostDatabase();
|
|
||||||
|
|
||||||
// Update costs for all models in background
|
|
||||||
updateAllModelCosts().catch(() => {});
|
|
||||||
}, [getProviders]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCostInfo = async () => {
|
const loadCostInfo = async () => {
|
||||||
if (!currentModel || !currentProvider) {
|
if (!currentModel || !currentProvider) {
|
||||||
@@ -78,49 +43,20 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
// First check sync cache
|
const costData = await fetchModelPricing(currentProvider, currentModel);
|
||||||
let costData = getCostForModel(currentProvider, currentModel);
|
|
||||||
|
|
||||||
if (costData) {
|
if (costData) {
|
||||||
// We have cached data
|
|
||||||
setCostInfo(costData);
|
setCostInfo(costData);
|
||||||
setPricingFailed(false);
|
setPricingFailed(false);
|
||||||
setModelNotFound(false);
|
|
||||||
setIsLoading(false);
|
|
||||||
setHasAttemptedFetch(true);
|
|
||||||
} else {
|
} else {
|
||||||
// Need to fetch from backend
|
setPricingFailed(true);
|
||||||
setIsLoading(true);
|
setCostInfo(null);
|
||||||
const result = await fetchAndCachePricing(currentProvider, currentModel);
|
|
||||||
setHasAttemptedFetch(true);
|
|
||||||
|
|
||||||
if (result && result.costInfo) {
|
|
||||||
setCostInfo(result.costInfo);
|
|
||||||
setPricingFailed(false);
|
|
||||||
setModelNotFound(false);
|
|
||||||
} else if (result && result.error === 'model_not_found') {
|
|
||||||
// Model not found in pricing database, but API call succeeded
|
|
||||||
setModelNotFound(true);
|
|
||||||
setPricingFailed(false);
|
|
||||||
} else {
|
|
||||||
// API call failed or other error
|
|
||||||
const freeProviders = ['ollama', 'local', 'localhost'];
|
|
||||||
if (!freeProviders.includes(currentProvider.toLowerCase())) {
|
|
||||||
setPricingFailed(true);
|
|
||||||
setModelNotFound(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setHasAttemptedFetch(true);
|
setPricingFailed(true);
|
||||||
// Only set pricing failed if we're not dealing with a known free provider
|
setCostInfo(null);
|
||||||
const freeProviders = ['ollama', 'local', 'localhost'];
|
} finally {
|
||||||
if (!freeProviders.includes(currentProvider.toLowerCase())) {
|
|
||||||
setPricingFailed(true);
|
|
||||||
setModelNotFound(false);
|
|
||||||
}
|
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -221,10 +157,9 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
|
|||||||
|
|
||||||
// Otherwise show as unavailable
|
// Otherwise show as unavailable
|
||||||
const getUnavailableTooltip = () => {
|
const getUnavailableTooltip = () => {
|
||||||
if (pricingFailed && hasAttemptedFetch && initialLoadComplete) {
|
if (pricingFailed) {
|
||||||
return `Pricing data unavailable - OpenRouter connection failed. Click refresh in settings to retry.`;
|
return `Pricing data unavailable for ${currentModel}`;
|
||||||
}
|
}
|
||||||
// If we reach here, it must be modelNotFound (since we only get here after attempting fetch)
|
|
||||||
return `Cost data not available for ${currentModel} (${inputTokens.toLocaleString()} input, ${outputTokens.toLocaleString()} output tokens)`;
|
return `Cost data not available for ${currentModel} (${inputTokens.toLocaleString()} input, ${outputTokens.toLocaleString()} output tokens)`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -249,12 +184,8 @@ export function CostTracker({ inputTokens = 0, outputTokens = 0, sessionCosts }:
|
|||||||
// Build tooltip content
|
// Build tooltip content
|
||||||
const getTooltipContent = (): string => {
|
const getTooltipContent = (): string => {
|
||||||
// Handle error states first
|
// Handle error states first
|
||||||
if (pricingFailed && hasAttemptedFetch && initialLoadComplete) {
|
if (pricingFailed) {
|
||||||
return `Pricing data unavailable - OpenRouter connection failed. Click refresh in settings to retry.`;
|
return `Pricing data unavailable for ${currentProvider}/${currentModel}`;
|
||||||
}
|
|
||||||
|
|
||||||
if (modelNotFound && hasAttemptedFetch && initialLoadComplete) {
|
|
||||||
return `Pricing not available for ${currentProvider}/${currentModel}. This model may not be supported by the pricing service.`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle session costs
|
// Handle session costs
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Switch } from '../../ui/switch';
|
import { Switch } from '../../ui/switch';
|
||||||
import { Button } from '../../ui/button';
|
import { Button } from '../../ui/button';
|
||||||
import { Settings, RefreshCw, ExternalLink } from 'lucide-react';
|
import { Settings } from 'lucide-react';
|
||||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog';
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../../ui/dialog';
|
||||||
import UpdateSection from './UpdateSection';
|
import UpdateSection from './UpdateSection';
|
||||||
import TunnelSection from '../tunnel/TunnelSection';
|
import TunnelSection from '../tunnel/TunnelSection';
|
||||||
|
|
||||||
import { COST_TRACKING_ENABLED, UPDATES_ENABLED } from '../../../updates';
|
import { COST_TRACKING_ENABLED, UPDATES_ENABLED } from '../../../updates';
|
||||||
import { getApiUrl } from '../../../config';
|
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../ui/card';
|
||||||
import ThemeSelector from '../../GooseSidebar/ThemeSelector';
|
import ThemeSelector from '../../GooseSidebar/ThemeSelector';
|
||||||
import BlockLogoBlack from './icons/block-lockup_black.png';
|
import BlockLogoBlack from './icons/block-lockup_black.png';
|
||||||
@@ -26,9 +25,6 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
|
|||||||
const [isMacOS, setIsMacOS] = useState(false);
|
const [isMacOS, setIsMacOS] = useState(false);
|
||||||
const [isDockSwitchDisabled, setIsDockSwitchDisabled] = useState(false);
|
const [isDockSwitchDisabled, setIsDockSwitchDisabled] = useState(false);
|
||||||
const [showNotificationModal, setShowNotificationModal] = useState(false);
|
const [showNotificationModal, setShowNotificationModal] = useState(false);
|
||||||
const [pricingStatus, setPricingStatus] = useState<'loading' | 'success' | 'error'>('loading');
|
|
||||||
const [lastFetchTime, setLastFetchTime] = useState<Date | null>(null);
|
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
const [showPricing, setShowPricing] = useState(true);
|
const [showPricing, setShowPricing] = useState(true);
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||||
const updateSectionRef = useRef<HTMLDivElement>(null);
|
const updateSectionRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -66,71 +62,6 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
|
|||||||
setShowPricing(stored !== 'false');
|
setShowPricing(stored !== 'false');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Check pricing status on mount
|
|
||||||
useEffect(() => {
|
|
||||||
checkPricingStatus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const checkPricingStatus = async () => {
|
|
||||||
try {
|
|
||||||
const apiUrl = getApiUrl('/config/pricing');
|
|
||||||
const secretKey = await window.electron.getSecretKey();
|
|
||||||
|
|
||||||
const headers: HeadersInit = { 'Content-Type': 'application/json' };
|
|
||||||
if (secretKey) {
|
|
||||||
headers['X-Secret-Key'] = secretKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(apiUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({ configured_only: true }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
await response.json();
|
|
||||||
setPricingStatus('success');
|
|
||||||
setLastFetchTime(new Date());
|
|
||||||
} else {
|
|
||||||
setPricingStatus('error');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setPricingStatus('error');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefreshPricing = async () => {
|
|
||||||
setIsRefreshing(true);
|
|
||||||
try {
|
|
||||||
const apiUrl = getApiUrl('/config/pricing');
|
|
||||||
const secretKey = await window.electron.getSecretKey();
|
|
||||||
|
|
||||||
const headers: HeadersInit = { 'Content-Type': 'application/json' };
|
|
||||||
if (secretKey) {
|
|
||||||
headers['X-Secret-Key'] = secretKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(apiUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({ configured_only: false }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setPricingStatus('success');
|
|
||||||
setLastFetchTime(new Date());
|
|
||||||
// Trigger a reload of the cost database
|
|
||||||
window.dispatchEvent(new CustomEvent('pricing-updated'));
|
|
||||||
} else {
|
|
||||||
setPricingStatus('error');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setPricingStatus('error');
|
|
||||||
} finally {
|
|
||||||
setIsRefreshing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle scrolling to update section
|
// Handle scrolling to update section
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollToSection === 'update' && updateSectionRef.current) {
|
if (scrollToSection === 'update' && updateSectionRef.current) {
|
||||||
@@ -326,69 +257,6 @@ export default function AppSettingsSection({ scrollToSection }: AppSettingsSecti
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pricing Status - only show if cost tracking is enabled */}
|
|
||||||
{COST_TRACKING_ENABLED && showPricing && (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-between text-xs mb-2 px-4">
|
|
||||||
<span className="text-textSubtle">Pricing Source:</span>
|
|
||||||
<a
|
|
||||||
href="https://openrouter.ai/docs#models"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
|
|
||||||
>
|
|
||||||
OpenRouter Docs
|
|
||||||
<ExternalLink size={10} />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-xs mb-2 px-4">
|
|
||||||
<span className="text-textSubtle">Status:</span>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`font-medium ${
|
|
||||||
pricingStatus === 'success'
|
|
||||||
? 'text-green-600 dark:text-green-400'
|
|
||||||
: pricingStatus === 'error'
|
|
||||||
? 'text-red-600 dark:text-red-400'
|
|
||||||
: 'text-textSubtle'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{pricingStatus === 'success'
|
|
||||||
? '✓ Connected'
|
|
||||||
: pricingStatus === 'error'
|
|
||||||
? '✗ Failed'
|
|
||||||
: '... Checking'}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
className="p-0.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors disabled:opacity-50"
|
|
||||||
onClick={handleRefreshPricing}
|
|
||||||
disabled={isRefreshing}
|
|
||||||
title="Refresh pricing data"
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
size={8}
|
|
||||||
className={`text-textSubtle hover:text-textStandard ${isRefreshing ? 'animate-spin-fast' : ''}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{lastFetchTime && (
|
|
||||||
<div className="flex items-center justify-between text-xs mb-2 px-4">
|
|
||||||
<span className="text-textSubtle">Last updated:</span>
|
|
||||||
<span className="text-textSubtle">{lastFetchTime.toLocaleTimeString()}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{pricingStatus === 'error' && (
|
|
||||||
<p className="text-xs text-red-600 dark:text-red-400 px-4">
|
|
||||||
Unable to fetch pricing data. Costs will not be displayed.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useCallback, useRef, useState } from 'react';
|
|||||||
import { useConfig } from '../components/ConfigContext';
|
import { useConfig } from '../components/ConfigContext';
|
||||||
import { ChatType } from '../types/chat';
|
import { ChatType } from '../types/chat';
|
||||||
import { initializeSystem } from '../utils/providerUtils';
|
import { initializeSystem } from '../utils/providerUtils';
|
||||||
import { initializeCostDatabase } from '../utils/costDatabase';
|
|
||||||
import {
|
import {
|
||||||
backupConfig,
|
backupConfig,
|
||||||
initConfig,
|
initConfig,
|
||||||
@@ -13,7 +12,6 @@ import {
|
|||||||
startAgent,
|
startAgent,
|
||||||
validateConfig,
|
validateConfig,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import { COST_TRACKING_ENABLED } from '../updates';
|
|
||||||
|
|
||||||
export enum AgentState {
|
export enum AgentState {
|
||||||
UNINITIALIZED = 'uninitialized',
|
UNINITIALIZED = 'uninitialized',
|
||||||
@@ -235,14 +233,6 @@ export function useAgent(): UseAgentReturn {
|
|||||||
recipe: recipeForInit,
|
recipe: recipeForInit,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (COST_TRACKING_ENABLED) {
|
|
||||||
try {
|
|
||||||
await initializeCostDatabase();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to initialize cost database:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const recipe = initContext.recipe || agentSession.recipe;
|
const recipe = initContext.recipe || agentSession.recipe;
|
||||||
const conversation = agentSession.conversation || [];
|
const conversation = agentSession.conversation || [];
|
||||||
// If we're loading a recipe from initContext (new recipe load), start with empty messages
|
// If we're loading a recipe from initContext (new recipe load), start with empty messages
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useModelAndProvider } from '../components/ModelAndProviderContext';
|
import { useModelAndProvider } from '../components/ModelAndProviderContext';
|
||||||
import { getCostForModel } from '../utils/costDatabase';
|
import { fetchModelPricing } from '../utils/pricing';
|
||||||
import { Session } from '../api';
|
import { Session } from '../api';
|
||||||
|
|
||||||
interface UseCostTrackingProps {
|
interface UseCostTrackingProps {
|
||||||
@@ -32,46 +32,53 @@ export const useCostTracking = ({
|
|||||||
|
|
||||||
// Handle model changes and accumulate costs
|
// Handle model changes and accumulate costs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
const handleModelChange = async () => {
|
||||||
prevModelRef.current !== undefined &&
|
if (
|
||||||
prevProviderRef.current !== undefined &&
|
prevModelRef.current !== undefined &&
|
||||||
(prevModelRef.current !== currentModel || prevProviderRef.current !== currentProvider)
|
prevProviderRef.current !== undefined &&
|
||||||
) {
|
(prevModelRef.current !== currentModel || prevProviderRef.current !== currentProvider)
|
||||||
// Model/provider has changed, save the costs for the previous model
|
) {
|
||||||
const prevKey = `${prevProviderRef.current}/${prevModelRef.current}`;
|
// Model/provider has changed, save the costs for the previous model
|
||||||
|
const prevKey = `${prevProviderRef.current}/${prevModelRef.current}`;
|
||||||
|
|
||||||
// Get pricing info for the previous model
|
// Get pricing info for the previous model
|
||||||
const prevCostInfo = getCostForModel(prevProviderRef.current, prevModelRef.current);
|
const prevCostInfo = await fetchModelPricing(
|
||||||
|
prevProviderRef.current,
|
||||||
|
prevModelRef.current
|
||||||
|
);
|
||||||
|
|
||||||
if (prevCostInfo) {
|
if (prevCostInfo) {
|
||||||
const prevInputCost =
|
const prevInputCost =
|
||||||
(sessionInputTokens || localInputTokens) * (prevCostInfo.input_token_cost || 0);
|
(sessionInputTokens || localInputTokens) * (prevCostInfo.input_token_cost || 0);
|
||||||
const prevOutputCost =
|
const prevOutputCost =
|
||||||
(sessionOutputTokens || localOutputTokens) * (prevCostInfo.output_token_cost || 0);
|
(sessionOutputTokens || localOutputTokens) * (prevCostInfo.output_token_cost || 0);
|
||||||
const prevTotalCost = prevInputCost + prevOutputCost;
|
const prevTotalCost = prevInputCost + prevOutputCost;
|
||||||
|
|
||||||
// Save the accumulated costs for this model
|
// Save the accumulated costs for this model
|
||||||
setSessionCosts((prev) => ({
|
setSessionCosts((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[prevKey]: {
|
[prevKey]: {
|
||||||
inputTokens: sessionInputTokens || localInputTokens,
|
inputTokens: sessionInputTokens || localInputTokens,
|
||||||
outputTokens: sessionOutputTokens || localOutputTokens,
|
outputTokens: sessionOutputTokens || localOutputTokens,
|
||||||
totalCost: prevTotalCost,
|
totalCost: prevTotalCost,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'Model changed from',
|
||||||
|
`${prevProviderRef.current}/${prevModelRef.current}`,
|
||||||
|
'to',
|
||||||
|
`${currentProvider}/${currentModel}`,
|
||||||
|
'- saved costs and restored session token counters'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
prevModelRef.current = currentModel || undefined;
|
||||||
'Model changed from',
|
prevProviderRef.current = currentProvider || undefined;
|
||||||
`${prevProviderRef.current}/${prevModelRef.current}`,
|
};
|
||||||
'to',
|
|
||||||
`${currentProvider}/${currentModel}`,
|
|
||||||
'- saved costs and restored session token counters'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
prevModelRef.current = currentModel || undefined;
|
handleModelChange();
|
||||||
prevProviderRef.current = currentProvider || undefined;
|
|
||||||
}, [
|
}, [
|
||||||
currentModel,
|
currentModel,
|
||||||
currentProvider,
|
currentProvider,
|
||||||
|
|||||||
@@ -1,207 +0,0 @@
|
|||||||
import { getApiUrl } from '../config';
|
|
||||||
import { safeJsonParse } from './conversionUtils';
|
|
||||||
|
|
||||||
export interface ModelCostInfo {
|
|
||||||
input_token_cost: number; // Cost per token for input (in USD)
|
|
||||||
output_token_cost: number; // Cost per token for output (in USD)
|
|
||||||
currency: string; // Currency symbol
|
|
||||||
}
|
|
||||||
|
|
||||||
// In-memory cache for current session only
|
|
||||||
const sessionPricingCache = new Map<string, ModelCostInfo | null>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch pricing data from backend for specific provider/model
|
|
||||||
*/
|
|
||||||
async function fetchPricingForModel(
|
|
||||||
provider: string,
|
|
||||||
model: string
|
|
||||||
): Promise<ModelCostInfo | null> {
|
|
||||||
// For OpenRouter models, we need to use the parsed provider and model for the API lookup
|
|
||||||
let lookupProvider = provider;
|
|
||||||
let lookupModel = model;
|
|
||||||
|
|
||||||
if (provider.toLowerCase() === 'openrouter') {
|
|
||||||
const parsed = parseOpenRouterModel(model);
|
|
||||||
if (parsed) {
|
|
||||||
lookupProvider = parsed[0];
|
|
||||||
lookupModel = parsed[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiUrl = getApiUrl('/config/pricing');
|
|
||||||
const secretKey = await window.electron.getSecretKey();
|
|
||||||
|
|
||||||
const headers: HeadersInit = { 'Content-Type': 'application/json' };
|
|
||||||
if (secretKey) {
|
|
||||||
headers['X-Secret-Key'] = secretKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(apiUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers,
|
|
||||||
body: JSON.stringify({ configured_only: false }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`API request failed with status ${response.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await safeJsonParse<{
|
|
||||||
pricing: Array<{
|
|
||||||
provider: string;
|
|
||||||
model: string;
|
|
||||||
input_token_cost: number;
|
|
||||||
output_token_cost: number;
|
|
||||||
currency: string;
|
|
||||||
}>;
|
|
||||||
}>(response, 'Failed to parse pricing data');
|
|
||||||
|
|
||||||
// Find the specific model pricing using the lookup provider/model
|
|
||||||
const pricing = data.pricing?.find(
|
|
||||||
(p: {
|
|
||||||
provider: string;
|
|
||||||
model: string;
|
|
||||||
input_token_cost: number;
|
|
||||||
output_token_cost: number;
|
|
||||||
currency: string;
|
|
||||||
}) => {
|
|
||||||
const providerMatch = p.provider.toLowerCase() === lookupProvider.toLowerCase();
|
|
||||||
|
|
||||||
// More flexible model matching - handle versioned models
|
|
||||||
let modelMatch = p.model === lookupModel;
|
|
||||||
|
|
||||||
// If exact match fails, try matching without version suffix
|
|
||||||
if (!modelMatch && lookupModel.includes('-20')) {
|
|
||||||
// Remove date suffix like -20241022
|
|
||||||
const modelWithoutDate = lookupModel.replace(/-20\d{6}$/, '');
|
|
||||||
modelMatch = p.model === modelWithoutDate;
|
|
||||||
|
|
||||||
// Also try with dots instead of dashes (claude-3-5-sonnet vs claude-3.5-sonnet)
|
|
||||||
if (!modelMatch) {
|
|
||||||
const modelWithDots = modelWithoutDate.replace(/-(\d)-/g, '.$1.');
|
|
||||||
modelMatch = p.model === modelWithDots;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return providerMatch && modelMatch;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (pricing) {
|
|
||||||
return {
|
|
||||||
input_token_cost: pricing.input_token_cost,
|
|
||||||
output_token_cost: pricing.output_token_cost,
|
|
||||||
currency: pricing.currency || '$',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// API call succeeded but model not found in pricing data
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize the cost database - no-op since we fetch on demand now
|
|
||||||
*/
|
|
||||||
export async function initializeCostDatabase(): Promise<void> {
|
|
||||||
// Clear session cache on init
|
|
||||||
sessionPricingCache.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update model costs from providers - no-op since we fetch on demand
|
|
||||||
*/
|
|
||||||
export async function updateAllModelCosts(): Promise<void> {
|
|
||||||
// No-op - we fetch on demand now
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse OpenRouter model ID to extract provider and model
|
|
||||||
* e.g., "anthropic/claude-sonnet-4" -> ["anthropic", "claude-sonnet-4"]
|
|
||||||
*/
|
|
||||||
function parseOpenRouterModel(modelId: string): [string, string] | null {
|
|
||||||
const parts = modelId.split('/');
|
|
||||||
if (parts.length === 2) {
|
|
||||||
return [parts[0], parts[1]];
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get cost information for a specific model with session caching
|
|
||||||
*/
|
|
||||||
export function getCostForModel(provider: string, model: string): ModelCostInfo | null {
|
|
||||||
const cacheKey = `${provider}/${model}`;
|
|
||||||
|
|
||||||
// Check session cache first
|
|
||||||
if (sessionPricingCache.has(cacheKey)) {
|
|
||||||
return sessionPricingCache.get(cacheKey) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// For OpenRouter models, also check if we have cached data under the parsed provider/model
|
|
||||||
if (provider.toLowerCase() === 'openrouter') {
|
|
||||||
const parsed = parseOpenRouterModel(model);
|
|
||||||
if (parsed) {
|
|
||||||
const [parsedProvider, parsedModel] = parsed;
|
|
||||||
const parsedCacheKey = `${parsedProvider}/${parsedModel}`;
|
|
||||||
if (sessionPricingCache.has(parsedCacheKey)) {
|
|
||||||
const cachedData = sessionPricingCache.get(parsedCacheKey) || null;
|
|
||||||
// Also cache it under the original OpenRouter key for future lookups
|
|
||||||
sessionPricingCache.set(cacheKey, cachedData);
|
|
||||||
return cachedData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// For local/free providers, return zero cost immediately
|
|
||||||
const freeProviders = ['ollama', 'local', 'localhost'];
|
|
||||||
if (freeProviders.includes(provider.toLowerCase())) {
|
|
||||||
const zeroCost = {
|
|
||||||
input_token_cost: 0,
|
|
||||||
output_token_cost: 0,
|
|
||||||
currency: '$',
|
|
||||||
};
|
|
||||||
sessionPricingCache.set(cacheKey, zeroCost);
|
|
||||||
return zeroCost;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Need to fetch - return null and let component handle async fetch
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch and cache pricing for a model
|
|
||||||
*/
|
|
||||||
export async function fetchAndCachePricing(
|
|
||||||
provider: string,
|
|
||||||
model: string
|
|
||||||
): Promise<{ costInfo: ModelCostInfo | null; error?: string } | null> {
|
|
||||||
try {
|
|
||||||
const cacheKey = `${provider}/${model}`;
|
|
||||||
const costInfo = await fetchPricingForModel(provider, model);
|
|
||||||
|
|
||||||
// Cache the result in session cache under the original key
|
|
||||||
sessionPricingCache.set(cacheKey, costInfo);
|
|
||||||
|
|
||||||
// For OpenRouter models, also cache under the parsed provider/model key
|
|
||||||
// This helps with cross-referencing between frontend requests and backend responses
|
|
||||||
if (provider.toLowerCase() === 'openrouter') {
|
|
||||||
const parsed = parseOpenRouterModel(model);
|
|
||||||
if (parsed) {
|
|
||||||
const [parsedProvider, parsedModel] = parsed;
|
|
||||||
const parsedCacheKey = `${parsedProvider}/${parsedModel}`;
|
|
||||||
sessionPricingCache.set(parsedCacheKey, costInfo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (costInfo) {
|
|
||||||
return { costInfo };
|
|
||||||
} else {
|
|
||||||
// Model not found in pricing data
|
|
||||||
return { costInfo: null, error: 'model_not_found' };
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// This is a real API/network error
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { getPricing, PricingData } from '../api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch pricing for a specific provider/model from the backend
|
||||||
|
*/
|
||||||
|
export async function fetchModelPricing(
|
||||||
|
provider: string,
|
||||||
|
model: string
|
||||||
|
): Promise<PricingData | null> {
|
||||||
|
try {
|
||||||
|
const response = await getPricing({
|
||||||
|
body: { provider, model },
|
||||||
|
throwOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.pricing?.[0] ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user