diff --git a/src-tauri/src/commands/proxy.rs b/src-tauri/src/commands/proxy.rs index 56f587842..c0daa601c 100644 --- a/src-tauri/src/commands/proxy.rs +++ b/src-tauri/src/commands/proxy.rs @@ -2,6 +2,7 @@ //! //! 提供前端调用的 API 接口 +use crate::error::AppError; use crate::proxy::types::*; use crate::proxy::{CircuitBreakerConfig, CircuitBreakerStats}; use crate::store::AppState; @@ -119,6 +120,120 @@ pub async fn update_proxy_config_for_app( .map_err(|e| e.to_string()) } +async fn get_default_cost_multiplier_internal( + state: &AppState, + app_type: &str, +) -> Result { + let db = &state.db; + db.get_default_cost_multiplier(app_type).await +} + +#[cfg_attr(not(feature = "test-hooks"), doc(hidden))] +pub async fn get_default_cost_multiplier_test_hook( + state: &AppState, + app_type: &str, +) -> Result { + get_default_cost_multiplier_internal(state, app_type).await +} + +/// 获取默认成本倍率 +#[tauri::command] +pub async fn get_default_cost_multiplier( + state: tauri::State<'_, AppState>, + app_type: String, +) -> Result { + get_default_cost_multiplier_internal(&state, &app_type) + .await + .map_err(|e| e.to_string()) +} + +async fn set_default_cost_multiplier_internal( + state: &AppState, + app_type: &str, + value: &str, +) -> Result<(), AppError> { + let db = &state.db; + db.set_default_cost_multiplier(app_type, value).await +} + +#[cfg_attr(not(feature = "test-hooks"), doc(hidden))] +pub async fn set_default_cost_multiplier_test_hook( + state: &AppState, + app_type: &str, + value: &str, +) -> Result<(), AppError> { + set_default_cost_multiplier_internal(state, app_type, value).await +} + +/// 设置默认成本倍率 +#[tauri::command] +pub async fn set_default_cost_multiplier( + state: tauri::State<'_, AppState>, + app_type: String, + value: String, +) -> Result<(), String> { + set_default_cost_multiplier_internal(&state, &app_type, &value) + .await + .map_err(|e| e.to_string()) +} + +async fn get_pricing_model_source_internal( + state: &AppState, + app_type: &str, +) -> Result { + let db = &state.db; + db.get_pricing_model_source(app_type).await +} + +#[cfg_attr(not(feature = "test-hooks"), doc(hidden))] +pub async fn get_pricing_model_source_test_hook( + state: &AppState, + app_type: &str, +) -> Result { + get_pricing_model_source_internal(state, app_type).await +} + +/// 获取计费模式来源 +#[tauri::command] +pub async fn get_pricing_model_source( + state: tauri::State<'_, AppState>, + app_type: String, +) -> Result { + get_pricing_model_source_internal(&state, &app_type) + .await + .map_err(|e| e.to_string()) +} + +async fn set_pricing_model_source_internal( + state: &AppState, + app_type: &str, + value: &str, +) -> Result<(), AppError> { + let db = &state.db; + db.set_pricing_model_source(app_type, value).await +} + +#[cfg_attr(not(feature = "test-hooks"), doc(hidden))] +pub async fn set_pricing_model_source_test_hook( + state: &AppState, + app_type: &str, + value: &str, +) -> Result<(), AppError> { + set_pricing_model_source_internal(state, app_type, value).await +} + +/// 设置计费模式来源 +#[tauri::command] +pub async fn set_pricing_model_source( + state: tauri::State<'_, AppState>, + app_type: String, + value: String, +) -> Result<(), String> { + set_pricing_model_source_internal(&state, &app_type, &value) + .await + .map_err(|e| e.to_string()) +} + /// 检查代理服务器是否正在运行 #[tauri::command] pub async fn is_proxy_running(state: tauri::State<'_, AppState>) -> Result { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 06ee64564..560748b16 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -877,6 +877,10 @@ pub fn run() { commands::update_global_proxy_config, commands::get_proxy_config_for_app, commands::update_proxy_config_for_app, + commands::get_default_cost_multiplier, + commands::set_default_cost_multiplier, + commands::get_pricing_model_source, + commands::set_pricing_model_source, commands::is_proxy_running, commands::is_live_takeover_active, commands::switch_proxy_provider, diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index d13020737..7f48d340f 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -215,6 +215,12 @@ pub struct ProviderMeta { /// 成本倍数(用于计算实际成本) #[serde(rename = "costMultiplier", skip_serializing_if = "Option::is_none")] pub cost_multiplier: Option, + /// 计费模式来源(response/request) + #[serde( + rename = "pricingModelSource", + skip_serializing_if = "Option::is_none" + )] + pub pricing_model_source: Option, /// 每日消费限额(USD) #[serde(rename = "limitDailyUsd", skip_serializing_if = "Option::is_none")] pub limit_daily_usd: Option, @@ -614,3 +620,271 @@ pub struct OpenCodeModelLimit { #[serde(skip_serializing_if = "Option::is_none")] pub output: Option, } + +#[cfg(test)] +mod tests { + use super::{ + ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, OpenCodeProviderConfig, Provider, + ProviderManager, ProviderMeta, UniversalProvider, + }; + use serde_json::json; + + #[test] + fn provider_meta_serializes_pricing_model_source() { + let mut meta = ProviderMeta::default(); + meta.pricing_model_source = Some("response".to_string()); + + let value = serde_json::to_value(&meta).expect("serialize ProviderMeta"); + + assert_eq!( + value + .get("pricingModelSource") + .and_then(|item| item.as_str()), + Some("response") + ); + assert!(value.get("pricing_model_source").is_none()); + } + + #[test] + fn provider_meta_omits_pricing_model_source_when_none() { + let meta = ProviderMeta::default(); + let value = serde_json::to_value(&meta).expect("serialize ProviderMeta"); + + assert!(value.get("pricingModelSource").is_none()); + } + + #[test] + fn provider_with_id_populates_defaults() { + let settings_config = json!({ + "env": { "API_KEY": "test" } + }); + let provider = Provider::with_id( + "provider-1".to_string(), + "Provider".to_string(), + settings_config.clone(), + Some("https://example.com".to_string()), + ); + + assert_eq!(provider.id, "provider-1"); + assert_eq!(provider.name, "Provider"); + assert_eq!(provider.settings_config, settings_config); + assert_eq!(provider.website_url.as_deref(), Some("https://example.com")); + assert!(provider.category.is_none()); + assert!(provider.created_at.is_none()); + assert!(provider.sort_index.is_none()); + assert!(provider.notes.is_none()); + assert!(provider.meta.is_none()); + assert!(provider.icon.is_none()); + assert!(provider.icon_color.is_none()); + assert!(!provider.in_failover_queue); + } + + #[test] + fn provider_manager_get_all_providers_returns_map() { + let mut manager = ProviderManager::default(); + let provider = Provider::with_id( + "provider-1".to_string(), + "Provider".to_string(), + json!({ "env": {} }), + None, + ); + manager + .providers + .insert("provider-1".to_string(), provider); + + assert_eq!(manager.get_all_providers().len(), 1); + assert!(manager.get_all_providers().contains_key("provider-1")); + } + + #[test] + fn universal_provider_to_claude_provider_uses_models() { + let mut universal = UniversalProvider::new( + "u1".to_string(), + "Universal".to_string(), + "newapi".to_string(), + "https://api.example.com".to_string(), + "api-key".to_string(), + ); + universal.apps.claude = true; + universal.models.claude = Some(ClaudeModelConfig { + model: Some("claude-main".to_string()), + haiku_model: Some("claude-haiku".to_string()), + sonnet_model: Some("claude-sonnet".to_string()), + opus_model: Some("claude-opus".to_string()), + }); + + let provider = universal + .to_claude_provider() + .expect("claude provider"); + + assert_eq!(provider.id, "universal-claude-u1"); + assert_eq!(provider.name, "Universal"); + assert_eq!(provider.category.as_deref(), Some("aggregator")); + assert_eq!( + provider + .settings_config + .pointer("/env/ANTHROPIC_MODEL") + .and_then(|item| item.as_str()), + Some("claude-main") + ); + assert_eq!( + provider + .settings_config + .pointer("/env/ANTHROPIC_DEFAULT_HAIKU_MODEL") + .and_then(|item| item.as_str()), + Some("claude-haiku") + ); + assert_eq!( + provider + .settings_config + .pointer("/env/ANTHROPIC_DEFAULT_SONNET_MODEL") + .and_then(|item| item.as_str()), + Some("claude-sonnet") + ); + assert_eq!( + provider + .settings_config + .pointer("/env/ANTHROPIC_DEFAULT_OPUS_MODEL") + .and_then(|item| item.as_str()), + Some("claude-opus") + ); + } + + #[test] + fn universal_provider_to_claude_provider_disabled_returns_none() { + let universal = UniversalProvider::new( + "u1".to_string(), + "Universal".to_string(), + "newapi".to_string(), + "https://api.example.com".to_string(), + "api-key".to_string(), + ); + + assert!(universal.to_claude_provider().is_none()); + } + + #[test] + fn universal_provider_to_codex_provider_appends_v1() { + let mut universal = UniversalProvider::new( + "u1".to_string(), + "Universal".to_string(), + "newapi".to_string(), + "https://api.example.com".to_string(), + "api-key".to_string(), + ); + universal.apps.codex = true; + universal.models.codex = Some(CodexModelConfig { + model: Some("gpt-4o-mini".to_string()), + reasoning_effort: Some("low".to_string()), + }); + + let provider = universal.to_codex_provider().expect("codex provider"); + let config = provider + .settings_config + .get("config") + .and_then(|item| item.as_str()) + .expect("config toml"); + + assert!(config.contains("base_url = \"https://api.example.com/v1\"")); + assert_eq!( + provider + .settings_config + .pointer("/auth/OPENAI_API_KEY") + .and_then(|item| item.as_str()), + Some("api-key") + ); + } + + #[test] + fn universal_provider_to_codex_provider_keeps_v1_suffix() { + let mut universal = UniversalProvider::new( + "u1".to_string(), + "Universal".to_string(), + "newapi".to_string(), + "https://api.example.com/v1".to_string(), + "api-key".to_string(), + ); + universal.apps.codex = true; + + let provider = universal.to_codex_provider().expect("codex provider"); + let config = provider + .settings_config + .get("config") + .and_then(|item| item.as_str()) + .expect("config toml"); + + assert!(config.contains("base_url = \"https://api.example.com/v1\"")); + } + + #[test] + fn universal_provider_to_codex_provider_disabled_returns_none() { + let universal = UniversalProvider::new( + "u1".to_string(), + "Universal".to_string(), + "newapi".to_string(), + "https://api.example.com".to_string(), + "api-key".to_string(), + ); + + assert!(universal.to_codex_provider().is_none()); + } + + #[test] + fn universal_provider_to_gemini_provider_defaults_model() { + let mut universal = UniversalProvider::new( + "u1".to_string(), + "Universal".to_string(), + "newapi".to_string(), + "https://api.example.com".to_string(), + "api-key".to_string(), + ); + universal.apps.gemini = true; + + let provider = universal.to_gemini_provider().expect("gemini provider"); + + assert_eq!( + provider + .settings_config + .pointer("/env/GEMINI_MODEL") + .and_then(|item| item.as_str()), + Some("gemini-2.5-pro") + ); + } + + #[test] + fn universal_provider_to_gemini_provider_uses_model() { + let mut universal = UniversalProvider::new( + "u1".to_string(), + "Universal".to_string(), + "newapi".to_string(), + "https://api.example.com".to_string(), + "api-key".to_string(), + ); + universal.apps.gemini = true; + universal.models.gemini = Some(GeminiModelConfig { + model: Some("gemini-custom".to_string()), + }); + + let provider = universal.to_gemini_provider().expect("gemini provider"); + + assert_eq!( + provider + .settings_config + .pointer("/env/GEMINI_MODEL") + .and_then(|item| item.as_str()), + Some("gemini-custom") + ); + } + + #[test] + fn opencode_provider_config_defaults() { + let config = OpenCodeProviderConfig::default(); + assert_eq!(config.npm, "@ai-sdk/openai-compatible"); + assert!(config.name.is_none()); + assert!(config.models.is_empty()); + assert!(config.options.base_url.is_none()); + assert!(config.options.api_key.is_none()); + assert!(config.options.headers.is_none()); + assert!(config.options.extra.is_empty()); + } +}