diff --git a/src-tauri/src/agents/backups/tests.rs b/src-tauri/src/agents/backups/tests.rs index 0723fd5..ff0f09a 100644 --- a/src-tauri/src/agents/backups/tests.rs +++ b/src-tauri/src/agents/backups/tests.rs @@ -1106,6 +1106,9 @@ fn linked_configuration_and_backup_directories_are_rejected() { fs::read_to_string(outside.0.join("config.toml")).unwrap(), "custom='outside'" ); + #[cfg(unix)] + fs::remove_file(link).unwrap(); + #[cfg(windows)] fs::remove_dir(link).unwrap(); let data = agent_data_directory(&paths).unwrap(); fs::create_dir_all(&data).unwrap(); @@ -1113,6 +1116,9 @@ fn linked_configuration_and_backup_directories_are_rejected() { assert!(create_backup("codex", &home.0).is_err()); assert!(list_backups("codex", &home.0).is_err()); assert!(delete_backup("codex", &home.0, "1").is_err()); + #[cfg(unix)] + fs::remove_file(data.join("backups")).unwrap(); + #[cfg(windows)] fs::remove_dir(data.join("backups")).unwrap(); } diff --git a/src-tauri/src/app_settings.rs b/src-tauri/src/app_settings.rs index 81e0820..68af397 100644 --- a/src-tauri/src/app_settings.rs +++ b/src-tauri/src/app_settings.rs @@ -1,5 +1,114 @@ use super::*; +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ApiAccessRecordIdentityInput { + pub(crate) provider_section: String, + pub(crate) record_name: String, + pub(crate) base_url: String, + pub(crate) api_keys: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ApiAccessBalanceEndpointUpdate { + pub(crate) previous_identity: Option, + pub(crate) next_identity: Option, + pub(crate) balance_url: String, +} + +fn api_access_record_identity_input( + input: &ApiAccessRecordIdentityInput, +) -> Result<(String, String), String> { + Ok(( + input.provider_section.trim().to_string(), + api_access_record_identity( + &input.provider_section, + &input.record_name, + &input.base_url, + &input.api_keys, + )?, + )) +} + +#[tauri::command] +pub(crate) fn resolve_api_access_balance_urls( + queries: Vec, + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result>, String> { + let config = gui_config_state.snapshot()?; + queries + .iter() + .map(|query| { + let (provider_section, record_identity) = api_access_record_identity_input(query)?; + Ok(config + .api_balance_endpoints + .iter() + .find(|entry| { + entry.provider_section == provider_section + && entry.record_identity == record_identity + }) + .map(|entry| entry.balance_url.clone())) + }) + .collect() +} + +pub(crate) fn apply_api_access_balance_endpoint_update( + config: &mut GuiConfigFile, + update: &ApiAccessBalanceEndpointUpdate, +) -> Result<(), String> { + let previous = update + .previous_identity + .as_ref() + .map(api_access_record_identity_input) + .transpose()?; + let next = update + .next_identity + .as_ref() + .map(api_access_record_identity_input) + .transpose()?; + let balance_url = update.balance_url.trim().to_string(); + if let Some((provider_section, record_identity)) = next.as_ref() { + if !balance_url.is_empty() { + validate_gui_api_balance_endpoint(&GuiApiBalanceEndpoint { + provider_section: provider_section.clone(), + record_identity: record_identity.clone(), + balance_url: balance_url.clone(), + })?; + } + } + config.api_balance_endpoints.retain(|entry| { + let previous_match = previous.as_ref().is_some_and(|(section, identity)| { + entry.provider_section == *section && entry.record_identity == *identity + }); + let next_match = next.as_ref().is_some_and(|(section, identity)| { + entry.provider_section == *section && entry.record_identity == *identity + }); + !previous_match && !next_match + }); + if let Some((provider_section, record_identity)) = next.as_ref() { + if !balance_url.is_empty() { + config.api_balance_endpoints.push(GuiApiBalanceEndpoint { + provider_section: provider_section.clone(), + record_identity: record_identity.clone(), + balance_url: reqwest::Url::parse(&balance_url) + .map_err(|_| API_BALANCE_INVALID_URL.to_string())? + .to_string(), + }); + } + } + Ok(()) +} + +#[tauri::command] +pub(crate) fn save_api_access_balance_endpoint( + update: ApiAccessBalanceEndpointUpdate, + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result<(), String> { + gui_config_state.update(|config| apply_api_access_balance_endpoint_update(config, &update))?; + Ok(()) +} + #[tauri::command] pub(crate) fn health_check() -> &'static str { "EasyCLIProxyAPI Rust backend is ready" diff --git a/src-tauri/src/core_config/settings.rs b/src-tauri/src/core_config/settings.rs index 5d5808b..bef77a6 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -34,11 +34,94 @@ pub(crate) fn validate_api_access_provider_section(section: &str) -> Result<(), } } +pub(crate) const API_BALANCE_INVALID_PROVIDER: &str = "api_balance.invalid_provider"; +pub(crate) const API_BALANCE_INVALID_IDENTITY: &str = "api_balance.invalid_identity"; +pub(crate) const API_BALANCE_INVALID_URL: &str = "api_balance.invalid_url"; +pub(crate) const API_BALANCE_INSECURE_URL: &str = "api_balance.insecure_url"; +pub(crate) const API_BALANCE_INVALID_RECORD_NAME: &str = "api_balance.invalid_record_name"; +pub(crate) const API_BALANCE_INVALID_BASE_URL: &str = "api_balance.invalid_base_url"; + +pub(crate) fn validate_gui_api_balance_endpoint( + endpoint: &GuiApiBalanceEndpoint, +) -> Result<(), String> { + validate_api_access_provider_section(&endpoint.provider_section) + .map_err(|_| API_BALANCE_INVALID_PROVIDER.to_string())?; + if endpoint.record_identity.len() != 64 + || !endpoint + .record_identity + .chars() + .all(|character| character.is_ascii_hexdigit()) + { + return Err(API_BALANCE_INVALID_IDENTITY.to_string()); + } + let value = endpoint.balance_url.trim(); + if value.is_empty() || value.chars().count() > 2048 || value.chars().any(char::is_control) { + return Err(API_BALANCE_INVALID_URL.to_string()); + } + let parsed = reqwest::Url::parse(value).map_err(|_| API_BALANCE_INVALID_URL.to_string())?; + if !matches!(parsed.scheme(), "http" | "https") + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.fragment().is_some() + || (parsed.scheme() == "http" && !is_loopback_host(parsed.host_str().unwrap_or_default())) + { + return Err(API_BALANCE_INSECURE_URL.to_string()); + } + Ok(()) +} + pub(crate) fn api_access_key_hash(value: &str) -> Option { let value = value.trim(); (!value.is_empty()).then(|| sha256_bytes(value.as_bytes())) } +pub(crate) fn api_access_record_identity( + provider_section: &str, + record_name: &str, + base_url: &str, + api_keys: &[String], +) -> Result { + validate_api_access_provider_section(provider_section) + .map_err(|_| API_BALANCE_INVALID_PROVIDER.to_string())?; + let record_name = record_name.trim(); + if record_name.chars().count() > 512 || record_name.chars().any(char::is_control) { + return Err(API_BALANCE_INVALID_RECORD_NAME.to_string()); + } + let base_url = base_url.trim(); + if base_url.chars().count() > 2048 || base_url.chars().any(char::is_control) { + return Err(API_BALANCE_INVALID_BASE_URL.to_string()); + } + let normalized_base_url = if base_url.is_empty() { + String::new() + } else { + let parsed = + reqwest::Url::parse(base_url).map_err(|_| API_BALANCE_INVALID_BASE_URL.to_string())?; + if !matches!(parsed.scheme(), "http" | "https") + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + { + return Err(API_BALANCE_INVALID_BASE_URL.to_string()); + } + parsed.to_string() + }; + let mut key_hashes = api_keys + .iter() + .filter_map(|key| api_access_key_hash(key)) + .collect::>(); + key_hashes.sort_unstable(); + key_hashes.dedup(); + let material = [ + provider_section.trim(), + record_name, + &normalized_base_url, + &key_hashes.join("\u{0}"), + ] + .join("\u{0}"); + Ok(sha256_bytes(material.as_bytes())) +} + pub(crate) fn usage_provider_section(provider: &str) -> Option<&'static str> { match provider.trim().to_ascii_lowercase().as_str() { "codex" => Some("codex-api-key"), @@ -1627,6 +1710,34 @@ pub(crate) fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result(&content) @@ -1882,6 +2012,9 @@ pub(crate) fn validate_gui_config(config: &GuiConfigFile) -> Result<(), String> validate_core_api_key(&entry.key)?; validate_api_key_remark(&entry.remark)?; } + for entry in &config.api_balance_endpoints { + validate_gui_api_balance_endpoint(entry)?; + } for entry in &config.api_access_remarks { validate_api_access_provider_section(&entry.provider_section)?; if entry.api_key_hash.len() != 64 diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fe07775..95026d5 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -634,6 +634,7 @@ struct GuiConfigFile { #[serde(deserialize_with = "deserialize_gui_api_keys")] api_keys: Vec, api_access_remarks: Vec, + api_balance_endpoints: Vec, management_secret_key: String, debug: bool, commercial_mode: bool, @@ -870,6 +871,14 @@ struct GuiApiAccessRemark { remark: String, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +struct GuiApiBalanceEndpoint { + provider_section: String, + record_identity: String, + balance_url: String, +} + #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct ApiAccessRemarkLocator { @@ -939,6 +948,9 @@ impl Default for GuiConfigFile { auth_dir: DEFAULT_AUTH_DIR.to_string(), api_keys: vec![default_api_key_entry()], api_access_remarks: Vec::new(), + api_balance_endpoints: Vec::new(), + // Populated with an OS-generated secret while loading the GUI + // configuration. Core hashes the value written into config.yaml. management_secret_key: String::new(), debug: false, commercial_mode: false, @@ -2590,7 +2602,9 @@ fn main() { get_core_status, get_gui_settings, resolve_api_access_remarks, + resolve_api_access_balance_urls, save_api_access_remark, + save_api_access_balance_endpoint, set_app_locale, resolve_windows_close_request, get_software_settings, diff --git a/src-tauri/src/tests/app_settings.rs b/src-tauri/src/tests/app_settings.rs index d005f8a..cfbe91b 100644 --- a/src-tauri/src/tests/app_settings.rs +++ b/src-tauri/src/tests/app_settings.rs @@ -200,3 +200,141 @@ fn physical_window_size_uses_display_scale_and_ignores_minimized_sizes() { assert!(logical_window_size_from_physical(&tauri::PhysicalSize::new(0, 0), 1.0).is_none()); assert!(logical_window_size_from_physical(&physical_size, 0.0).is_none()); } + +#[test] +fn api_balance_record_identity_is_stable_and_secret_free() { + let first = api_access_record_identity( + "openai-compatibility", + "Shared provider", + "https://custom.example/v1", + &["second-secret".to_string(), "first-secret".to_string()], + ) + .unwrap(); + let reordered = api_access_record_identity( + "openai-compatibility", + "Shared provider", + "https://custom.example/v1", + &["first-secret".to_string(), "second-secret".to_string()], + ) + .unwrap(); + let changed = api_access_record_identity( + "openai-compatibility", + "Shared provider", + "https://other.example/v1", + &["first-secret".to_string(), "second-secret".to_string()], + ) + .unwrap(); + + assert_eq!(first, reordered); + assert_ne!(first, changed); + assert_eq!(first.len(), 64); + assert!(first.chars().all(|character| character.is_ascii_hexdigit())); + assert!(!first.contains("secret")); +} + +#[test] +fn api_balance_validation_returns_stable_error_codes() { + let invalid_url = GuiApiBalanceEndpoint { + provider_section: "openai-compatibility".to_string(), + record_identity: "0".repeat(64), + balance_url: "not-a-url".to_string(), + }; + assert_eq!( + validate_gui_api_balance_endpoint(&invalid_url), + Err(API_BALANCE_INVALID_URL.to_string()) + ); + + let insecure_url = GuiApiBalanceEndpoint { + balance_url: "http://example.com/balance".to_string(), + ..invalid_url + }; + assert_eq!( + validate_gui_api_balance_endpoint(&insecure_url), + Err(API_BALANCE_INSECURE_URL.to_string()) + ); + assert_eq!( + api_access_record_identity("invalid", "name", "https://example.com", &[]), + Err(API_BALANCE_INVALID_PROVIDER.to_string()) + ); +} + +#[test] +fn api_balance_metadata_saves_migrates_clears_and_serializes_without_keys() { + let old_identity = ApiAccessRecordIdentityInput { + provider_section: "openai-compatibility".to_string(), + record_name: "Shared provider".to_string(), + base_url: "https://custom.example/v1".to_string(), + api_keys: vec!["old-secret".to_string()], + }; + let new_identity = ApiAccessRecordIdentityInput { + provider_section: "openai-compatibility".to_string(), + record_name: "Renamed provider".to_string(), + base_url: "https://custom.example/v2".to_string(), + api_keys: vec!["new-secret".to_string()], + }; + let old_hash = api_access_record_identity( + &old_identity.provider_section, + &old_identity.record_name, + &old_identity.base_url, + &old_identity.api_keys, + ) + .unwrap(); + let new_hash = api_access_record_identity( + &new_identity.provider_section, + &new_identity.record_name, + &new_identity.base_url, + &new_identity.api_keys, + ) + .unwrap(); + let mut config = GuiConfigFile { + management_secret_key: "test-management-secret".to_string(), + ..GuiConfigFile::default() + }; + + apply_api_access_balance_endpoint_update( + &mut config, + &ApiAccessBalanceEndpointUpdate { + previous_identity: None, + next_identity: Some(old_identity.clone()), + balance_url: "https://api.deepseek.com/user/balance".to_string(), + }, + ) + .unwrap(); + assert_eq!(config.api_balance_endpoints.len(), 1); + assert_eq!(config.api_balance_endpoints[0].record_identity, old_hash); + + apply_api_access_balance_endpoint_update( + &mut config, + &ApiAccessBalanceEndpointUpdate { + previous_identity: Some(old_identity), + next_identity: Some(new_identity.clone()), + balance_url: "https://api.deepseek.com/user/balance".to_string(), + }, + ) + .unwrap(); + assert_eq!(config.api_balance_endpoints.len(), 1); + assert_eq!(config.api_balance_endpoints[0].record_identity, new_hash); + + let home = agent_test_home("api-balance-metadata"); + let path = home.join("config.toml"); + write_gui_config_to_path(&config, &path).unwrap(); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("api-balance-endpoints")); + assert!(content.contains("https://api.deepseek.com/user/balance")); + assert!(!content.contains("old-secret")); + assert!(!content.contains("new-secret")); + let restored = toml::from_str::(&content).unwrap(); + assert_eq!(restored.api_balance_endpoints, config.api_balance_endpoints); + + apply_api_access_balance_endpoint_update( + &mut config, + &ApiAccessBalanceEndpointUpdate { + previous_identity: Some(new_identity), + next_identity: None, + balance_url: String::new(), + }, + ) + .unwrap(); + assert!(config.api_balance_endpoints.is_empty()); + fs::remove_dir_all(home).unwrap(); +} diff --git a/src-tauri/src/tests/core_config.rs b/src-tauri/src/tests/core_config.rs index d5d9a12..3d57feb 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -1152,6 +1152,7 @@ fn startup_preserves_all_user_owned_yaml_and_only_applies_gui_managed_values() { }, ], api_access_remarks: Vec::new(), + api_balance_endpoints: Vec::new(), management_secret_key: String::new(), debug: true, commercial_mode: true, diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index 21ea7ca..a78e600 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -4802,6 +4802,7 @@ mod tests { auth_dir: String::new(), api_keys: Vec::new(), api_access_remarks: Vec::new(), + api_balance_endpoints: Vec::new(), management_secret_key: "123456".to_string(), debug: false, commercial_mode: false, diff --git a/src/App.tsx b/src/App.tsx index 56d4ddc..4e30dc7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { Check, ChevronUp, ExternalLink, + Gauge, History, House, Languages, @@ -30,6 +31,7 @@ import { OAuthManagementPage } from './pages/ManagementPages'; import { AgentsPage } from './pages/AgentsPage'; import { EasyModePage } from './pages/EasyModePage'; import { UsageRecordsPage } from './pages/UsageRecordsPage'; +import { QuotaPage } from './pages/QuotaPage'; import { languageOptions, useI18n } from './i18n'; import { AppUpdateDialog, AppUpdateProvider, useAppUpdate } from './appUpdate'; import { appUpdateIndicatorState } from './appUpdateModel'; @@ -64,10 +66,10 @@ const pages = [ component: OAuthManagementPage, }, { - id: 'agents', - labelKey: 'app.nav.agents', - icon: Bot, - component: AgentsPage, + id: 'quota', + labelKey: 'app.nav.quota', + icon: Gauge, + component: QuotaPage, }, { id: 'usage-records', @@ -75,6 +77,12 @@ const pages = [ icon: History, component: UsageRecordsPage, }, + { + id: 'agents', + labelKey: 'app.nav.agents', + icon: Bot, + component: AgentsPage, + }, { id: 'config', labelKey: 'app.nav.config', @@ -89,6 +97,8 @@ const pages = [ }, ] as const; +export const appPageIds = pages.map((page) => page.id); + type PageId = (typeof pages)[number]['id']; type WindowsCloseAction = 'exit' | 'minimize-to-tray'; type WindowsCloseBehavior = 'ask' | WindowsCloseAction; diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index fba254c..62fabc9 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -385,8 +385,59 @@ export const jaOverrides = { 'quota.refreshAll': 'すべて更新', 'quota.loadingFiles': '認証ファイルを読み込んでいます', 'quota.empty.title': '照会可能なクォータがありません', - 'quota.empty.description': 'まず認証ファイルにクォータ照会対応の認証情報を追加してください。', + 'quota.empty.description': 'まず認証ファイルまたは API 接続にクォータ照会対応の認証情報を追加してください。', 'quota.unknownProvider': '不明', + 'quota.amount.remaining': '残り', + 'quota.amount.used': '使用済み', + 'quota.amount.total': '合計', + 'quota.api.provider.deepseek': 'DeepSeek', + 'quota.api.provider.stepfun': 'StepFun', + 'quota.api.provider.siliconflow': 'SiliconFlow', + 'quota.api.provider.openrouter': 'OpenRouter', + 'quota.api.provider.novita': 'Novita AI', + 'quota.api.protocol.codex': 'Codex API キー', + 'quota.api.protocol.openaiCompatibility': 'OpenAI 互換 API', + 'quota.api.protocol.claude': 'Claude API キー', + 'quota.api.protocol.gemini': 'Gemini API キー', + 'quota.api.credentialLabel': '{provider} · API 認証情報 {index}', + 'quota.api.namedCredentialLabel': '{name} · API 認証情報 {index}', + 'quota.api.namedRecordOrdinalLabel': '{name}({index})', + 'quota.api.unsupportedCredential': '未対応の API 認証情報', + 'quota.api.unsupportedHostLabel': '未対応の API ホスト:{host}', + 'quota.api.unsupportedCredentialOrdinal': '未対応の API ホスト:{host} · API 認証情報 {index}', + 'quota.api.unsupported': 'この API ソースの残高照会には対応していません', + 'quota.api.missingCredential': 'この認証情報には auth-index または API キーがありません', + 'quota.api.unrecognizedResponse': '残高レスポンスを認識できませんでした', + 'quota.api.balanceUnavailable': 'プロバイダーが現在の残高を利用できないと報告しました', + 'quota.oauth.title': 'OAuth アカウント', + 'quota.api.sectionTitle': 'API 接続', + 'quota.api.sectionCount': '照会可能 {supported} · 未対応 {unsupported}', + 'quota.oauth.badge': 'OAuth', + 'quota.api.badge': 'API キー', + 'quota.api.balanceUrl.label': '残高照会 URL', + 'quota.api.balanceUrl.description': '公式残高エンドポイントの任意の上書きです。照会時、この API 認証情報が指定ホストへ送信されます。', + 'quota.api.balanceUrl.queryNotice': '照会時、この API 認証情報が指定した残高ホストへ送信されます。', + 'quota.api.balanceUrl.button': '残高エンドポイント', + 'quota.api.balanceUrl.placeholder': 'https://api.example.com/balance', + 'quota.api.balanceUrl.configure': '残高 URL を設定', + 'quota.api.balanceUrl.save': '残高 URL を保存', + 'quota.api.balanceUrl.cancel': 'キャンセル', + 'quota.api.balanceUrl.clear': 'プロバイダーの既定値を使用', + 'quota.api.balanceUrl.invalid': '有効な HTTP(S) 残高 URL を入力してください', + 'quota.api.balanceUrl.credentials': '残高 URL に埋め込み認証情報は使用できません', + 'quota.api.balanceUrl.httpsRequired': 'localhost 以外の残高 URL には HTTPS を使用してください', + 'quota.api.balanceUrl.conflict': '残高 URL の対応プロバイダーが推論 URL と異なります', + 'quota.api.balanceUrl.unsupported': '残高 URL は対応プロバイダーのホスト、または対応する推論プロバイダーと組み合わせたループバック URL にしてください', + 'quota.api.error.invalidProvider': 'API 接続タイプが無効です', + 'quota.api.error.managementHttp': '管理 API リクエストに失敗しました(HTTP {status})', + 'quota.api.error.invalidIdentity': 'API 接続レコード識別子が無効です', + 'quota.api.error.invalidUrl': '残高照会 URL が無効です', + 'quota.api.error.insecureUrl': '残高照会 URL は安全な HTTP(S) アドレスである必要があります', + 'quota.api.error.invalidRecordName': 'API 接続レコード名が無効です', + 'quota.api.error.invalidBaseUrl': 'API 接続の Base URL が無効です', + 'quota.api.balanceUrl.stale': '残高 URL の保存前に API プロバイダー設定が変更されました', + 'quota.api.partialLoad': '一部の API 接続ソースを読み込めませんでした', + 'quota.api.loadFailed': 'API 接続ソースを読み込めませんでした', 'common.confirm': '確認', 'quota.service.xaiPaidAccount': '有料 API アカウント', 'quota.service.xaiPaidHealth': '有料 API の会話が利用可能です。xAI は現在、この OAuth 認証情報の総クォータを提供していません。', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 5a68b5a..933cc35 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -372,8 +372,59 @@ export const en: Record = { 'quota.refreshAll': 'Refresh All', 'quota.loadingFiles': 'Loading authentication files', 'quota.empty.title': 'No quota data available', - 'quota.empty.description': 'Add a credential that supports quota queries under Auth Files first.', + 'quota.empty.description': 'Add a credential that supports quota queries under Auth Files or API Access first.', 'quota.unknownProvider': 'Unknown', + 'quota.amount.remaining': 'Remaining', + 'quota.amount.used': 'Used', + 'quota.amount.total': 'Total', + 'quota.api.provider.deepseek': 'DeepSeek', + 'quota.api.provider.stepfun': 'StepFun', + 'quota.api.provider.siliconflow': 'SiliconFlow', + 'quota.api.provider.openrouter': 'OpenRouter', + 'quota.api.provider.novita': 'Novita AI', + 'quota.api.protocol.codex': 'Codex API key', + 'quota.api.protocol.openaiCompatibility': 'OpenAI-compatible API', + 'quota.api.protocol.claude': 'Claude API key', + 'quota.api.protocol.gemini': 'Gemini API key', + 'quota.api.credentialLabel': '{provider} · API credential {index}', + 'quota.api.namedCredentialLabel': '{name} · API credential {index}', + 'quota.api.namedRecordOrdinalLabel': '{name} ({index})', + 'quota.api.unsupportedCredential': 'Unsupported API credential', + 'quota.api.unsupportedHostLabel': 'Unsupported API host: {host}', + 'quota.api.unsupportedCredentialOrdinal': 'Unsupported API host: {host} · API credential {index}', + 'quota.api.unsupported': 'This API source is not supported for balance lookup', + 'quota.api.missingCredential': 'No auth-index or API key is available for this credential', + 'quota.api.unrecognizedResponse': 'The balance response was not recognized', + 'quota.api.balanceUnavailable': 'The provider reported that this balance is unavailable', + 'quota.oauth.title': 'OAuth Accounts', + 'quota.api.sectionTitle': 'API Access', + 'quota.api.sectionCount': '{supported} queryable · {unsupported} unsupported', + 'quota.oauth.badge': 'OAuth', + 'quota.api.badge': 'API Key', + 'quota.api.balanceUrl.label': 'Balance query URL', + 'quota.api.balanceUrl.description': 'Optional override for the official balance endpoint. Querying sends this API credential to the configured host.', + 'quota.api.balanceUrl.queryNotice': 'Querying sends this API credential to the configured balance host.', + 'quota.api.balanceUrl.button': 'Balance endpoint', + 'quota.api.balanceUrl.placeholder': 'https://api.example.com/balance', + 'quota.api.balanceUrl.configure': 'Configure balance URL', + 'quota.api.balanceUrl.save': 'Save Balance URL', + 'quota.api.balanceUrl.cancel': 'Cancel', + 'quota.api.balanceUrl.clear': 'Use provider default', + 'quota.api.balanceUrl.invalid': 'Enter a valid HTTP(S) balance URL', + 'quota.api.balanceUrl.credentials': 'Balance URLs cannot contain embedded credentials', + 'quota.api.balanceUrl.httpsRequired': 'Use HTTPS unless the balance URL points to localhost', + 'quota.api.balanceUrl.conflict': 'The balance URL belongs to a different supported provider than the inference URL', + 'quota.api.balanceUrl.unsupported': 'The balance URL must use a supported provider host or a loopback URL with a supported inference provider', + 'quota.api.error.invalidProvider': 'Invalid API Access provider type', + 'quota.api.error.managementHttp': 'Management API request failed (HTTP {status})', + 'quota.api.error.invalidIdentity': 'Invalid API Access record identifier', + 'quota.api.error.invalidUrl': 'Invalid balance query URL', + 'quota.api.error.insecureUrl': 'The balance query URL must be a secure HTTP(S) address', + 'quota.api.error.invalidRecordName': 'Invalid API Access record name', + 'quota.api.error.invalidBaseUrl': 'Invalid API Access base URL', + 'quota.api.balanceUrl.stale': 'This API provider record changed before the balance URL was saved', + 'quota.api.partialLoad': 'Some API Access sources could not be loaded', + 'quota.api.loadFailed': 'API Access sources could not be loaded', 'common.confirm': 'Confirm', 'quota.service.xaiPaidAccount': 'Paid API account', 'quota.service.xaiPaidHealth': 'Paid API chat is available. xAI does not currently provide total quota data for this OAuth credential.', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index fdaf3c7..bbf53e6 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -371,8 +371,59 @@ export const zhCN = { 'quota.refreshAll': '刷新全部', 'quota.loadingFiles': '读取凭证文件中', 'quota.empty.title': '暂无可查询配额', - 'quota.empty.description': '先在凭证文件中添加支持配额查询的凭据。', + 'quota.empty.description': '先在凭证文件或 API 接入中添加支持额度查询的凭据。', 'quota.unknownProvider': '未知', + 'quota.amount.remaining': '剩余', + 'quota.amount.used': '已用', + 'quota.amount.total': '总量', + 'quota.api.provider.deepseek': 'DeepSeek', + 'quota.api.provider.stepfun': 'StepFun', + 'quota.api.provider.siliconflow': 'SiliconFlow', + 'quota.api.provider.openrouter': 'OpenRouter', + 'quota.api.provider.novita': 'Novita AI', + 'quota.api.protocol.codex': 'Codex API key', + 'quota.api.protocol.openaiCompatibility': 'OpenAI 兼容 API', + 'quota.api.protocol.claude': 'Claude API key', + 'quota.api.protocol.gemini': 'Gemini API key', + 'quota.api.credentialLabel': '{provider} · API 凭据 {index}', + 'quota.api.namedCredentialLabel': '{name} · API 凭据 {index}', + 'quota.api.namedRecordOrdinalLabel': '{name}({index})', + 'quota.api.unsupportedCredential': '不支持的 API 凭据', + 'quota.api.unsupportedHostLabel': '不支持的 API 主机:{host}', + 'quota.api.unsupportedCredentialOrdinal': '不支持的 API 主机:{host} · API 凭据 {index}', + 'quota.api.unsupported': '暂不支持查询此 API 来源的余额', + 'quota.api.missingCredential': '此凭据没有可用的 auth-index 或 API key', + 'quota.api.unrecognizedResponse': '无法识别余额接口的返回数据', + 'quota.api.balanceUnavailable': '提供商报告当前余额不可用', + 'quota.oauth.title': 'OAuth 账户', + 'quota.api.sectionTitle': 'API 接入', + 'quota.api.sectionCount': '{supported} 个可查询 · {unsupported} 个不支持', + 'quota.oauth.badge': 'OAuth', + 'quota.api.badge': 'API Key', + 'quota.api.balanceUrl.label': '余额查询 URL', + 'quota.api.balanceUrl.description': '可选的官方余额接口覆盖地址。查询时会将此 API 凭据发送到配置的主机。', + 'quota.api.balanceUrl.queryNotice': '查询时会将此 API 凭据发送到配置的余额主机。', + 'quota.api.balanceUrl.button': '余额接口', + 'quota.api.balanceUrl.placeholder': 'https://api.example.com/balance', + 'quota.api.balanceUrl.configure': '配置余额 URL', + 'quota.api.balanceUrl.save': '保存余额 URL', + 'quota.api.balanceUrl.cancel': '取消', + 'quota.api.balanceUrl.clear': '使用提供商默认地址', + 'quota.api.balanceUrl.invalid': '请输入有效的 HTTP(S) 余额 URL', + 'quota.api.balanceUrl.credentials': '余额 URL 不能包含嵌入式凭据', + 'quota.api.balanceUrl.httpsRequired': '除 localhost 外,余额 URL 必须使用 HTTPS', + 'quota.api.balanceUrl.conflict': '余额 URL 所属的支持提供商与推理 URL 不同', + 'quota.api.balanceUrl.unsupported': '余额 URL 必须使用支持的提供商主机,或与支持的推理提供商配合使用回环地址', + 'quota.api.error.invalidProvider': 'API 接入类型无效', + 'quota.api.error.managementHttp': '管理 API 请求失败(HTTP {status})', + 'quota.api.error.invalidIdentity': 'API 接入记录标识无效', + 'quota.api.error.invalidUrl': '余额查询 URL 无效', + 'quota.api.error.insecureUrl': '余额查询 URL 必须是安全的 HTTP(S) 地址', + 'quota.api.error.invalidRecordName': 'API 接入记录名称无效', + 'quota.api.error.invalidBaseUrl': 'API 接入 Base URL 无效', + 'quota.api.balanceUrl.stale': '保存余额 URL 前 API 提供商记录已发生变化', + 'quota.api.partialLoad': '部分 API 接入来源读取失败', + 'quota.api.loadFailed': 'API 接入来源读取失败', 'common.confirm': '确认', 'quota.service.xaiPaidAccount': '付费 API 账号', 'quota.service.xaiPaidHealth': '付费 API 对话可用。xAI 暂不为此 OAuth 凭证提供额度总量数据。', diff --git a/src/oauthNavigation.ts b/src/oauthNavigation.ts index 4c8ca9e..c28758c 100644 --- a/src/oauthNavigation.ts +++ b/src/oauthNavigation.ts @@ -1,7 +1,6 @@ export const oauthSubpages = [ { id: 'login', labelKey: 'oauth.title' }, { id: 'authFiles', labelKey: 'authFiles.title' }, - { id: 'quota', labelKey: 'quota.title' }, ] as const; export type OAuthSubpage = (typeof oauthSubpages)[number]['id']; diff --git a/src/pages/ApiAccessPage.tsx b/src/pages/ApiAccessPage.tsx index 42c858e..321840d 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -50,7 +50,6 @@ import { readBoolean, readNumber, readString, - responseList, } from '../services/managementApi'; import { DEEPSEEK_BASE_URL, @@ -76,6 +75,16 @@ import { modelMatchesRule } from '../services/oauthModels'; import { getCurrentLocale, translate, useI18n } from '../i18n'; import type { MessageKey } from '../i18n/resources'; import { MessageNotice, FloatingNotice, useAppNotice } from '../appNotice'; +import { + apiAccessRecordIdentityFromRecord, + apiAccessRecordIdentityFor, + apiAccessRecordIdentityKey, + apiQuotaErrorMessage, + loadApiAccessRecords, + resolveApiAccessBalanceUrls, + saveApiAccessBalanceEndpoint, + validateBalanceUrl, +} from '../services/apiQuota'; export type ProviderSection = | 'gemini-api-key' @@ -105,6 +114,7 @@ type ProviderRow = { apiKey: string; apiKeys: string[]; baseUrl: string; + balanceUrl: string; models: ModelOption[]; disabled: boolean; priority: number | null; @@ -207,6 +217,7 @@ export type ProviderDraft = { apiKey: string; remark: string; baseUrl: string; + balanceUrl?: string; priority: string; models: ModelOption[]; prefix?: string; @@ -247,11 +258,6 @@ const providerDefinitions: ProviderDefinition[] = [ export const providerSectionOrder = providerDefinitions.map((definition) => definition.id); -const providerLoadDefinitions = providerDefinitions.filter( - (definition, index, definitions) => - definitions.findIndex((item) => item.section === definition.section) === index, -); - const emptyRecords = (): Record[]> => ({ 'gemini-api-key': [], 'codex-api-key': [], @@ -290,6 +296,22 @@ export const sectionRecordsFromConfig = (payload: unknown, section: ProviderSect ? payload[section].filter(isRecord) : []; +export const providerRecordsFromConfig = ( + payload: unknown, +): Record[]> => ({ + 'gemini-api-key': sectionRecordsFromConfig(payload, 'gemini-api-key'), + 'codex-api-key': sectionRecordsFromConfig(payload, 'codex-api-key'), + 'claude-api-key': sectionRecordsFromConfig(payload, 'claude-api-key'), + 'openai-compatibility': sectionRecordsFromConfig(payload, 'openai-compatibility'), +}); + +export const loadProviderRecords = async ( + getConfig: (path: string) => Promise = managementApi.get, + onProtocolError?: (protocol: ProviderSection, error: unknown) => void, +): Promise[]>> => ( + await loadApiAccessRecords(getConfig, onProtocolError) +); + const rowFromRecord = ( section: ProviderSection, record: Record, @@ -316,6 +338,7 @@ const rowFromRecord = ( apiKey: entry ? readString(entry, 'api-key', 'apiKey') : singleApiKey, apiKeys: entry ? apiKeys : singleApiKey ? [singleApiKey] : [], baseUrl: readString(record, 'base-url', 'baseUrl'), + balanceUrl: '', models: modelsFromRecord(record.models), disabled: definitionFor(section).openAi ? readBoolean(record, 'disabled') @@ -378,6 +401,8 @@ const providerHeadersFromRecord = (record: Record) => export const stripResponseFields = (record: Record) => { const next = { ...record }; + delete next['balance-url']; + delete next.balanceUrl; delete next['auth-index']; delete next.authIndex; delete next.auth_index; @@ -602,6 +627,7 @@ const draftFromRow = (row: ProviderRow): ProviderDraft => { apiKey: definition.openAi ? row.apiKeys.join('\n') : row.apiKey, remark: row.remark || (definition.openAi && !isDeepSeek ? row.name : ''), baseUrl: row.baseUrl, + balanceUrl: row.balanceUrl, priority: row.priority === null ? '' : String(row.priority), models: row.models, prefix: readString(row.record, 'prefix'), @@ -639,6 +665,7 @@ const emptyProviderDraft = (): ProviderDraft => ({ apiKey: '', remark: '', baseUrl: '', + balanceUrl: '', priority: '', models: [], prefix: '', @@ -954,6 +981,7 @@ export function ApiAccessPage() { const [editingRow, setEditingRow] = useState(null); const [dialogDraft, setDialogDraft] = useState(emptyProviderDraft); const [apiAccessRemarks, setApiAccessRemarks] = useState>({}); + const [apiBalanceUrls, setApiBalanceUrls] = useState>({}); const [healthDialogRow, setHealthDialogRow] = useState(null); const [dragOverId, setDragOverId] = useState(null); const activeDefinition = definitionFor(activeCategory); @@ -967,31 +995,20 @@ export function ApiAccessPage() { if (showLoading) setLoading(true); setError(''); try { - const responses = await Promise.allSettled( - providerLoadDefinitions.map(async (definition) => ({ - section: definition.section, - records: responseList( - await managementApi.get(`/${definition.section}`), - definition.responseKey, - ), - })), - ); const failures: string[] = []; - setRecords((current) => { - const next = { ...current }; - responses.forEach((result, index) => { - const definition = providerLoadDefinitions[index]; - if (result.status === 'fulfilled') { - next[result.value.section] = result.value.records; - } else { - failures.push(`${t(definition.labelKey)}: ${String(result.reason)}`); - } - }); - return next; + const nextRecords = await loadProviderRecords(managementApi.get, (protocol, requestError) => { + failures.push(`${t(definitionFor(protocol).labelKey)}: ${apiQuotaErrorMessage(requestError)}`); }); - if (failures.length > 0) { - setError(t('apiAccess.error.partialLoad', { errors: failures.join('; ') })); - } + setRecords(nextRecords); + if (failures.length > 0) setError(t('apiAccess.error.partialLoad', { errors: failures.join('; ') })); + const recordEntries = (Object.entries(nextRecords) as [ProviderSection, Record[]][]) + .flatMap(([section, items]) => items.map((record) => ({ section, record }))); + const identities = recordEntries.map(({ section, record }) => apiAccessRecordIdentityFromRecord(section, record)); + const urls = await resolveApiAccessBalanceUrls(identities); + setApiBalanceUrls(Object.fromEntries(identities.map((identity, index) => [ + apiAccessRecordIdentityKey(identity), + urls[index] ?? '', + ]))); } catch (requestError) { setError(String(requestError)); } finally { @@ -1036,6 +1053,7 @@ export function ApiAccessPage() { .map((record, index) => rowFromRecord(activeSection, record, index)) .map((row) => ({ ...row, + balanceUrl: apiBalanceUrls[apiAccessRecordIdentityKey(apiAccessRecordIdentityFromRecord(row.section, row.record))] ?? '', name: activeCategory === 'deepseek' ? t('apiAccess.provider.deepseek') : row.name, @@ -1052,7 +1070,7 @@ export function ApiAccessPage() { .toLowerCase() .includes(query); }), - [activeCategory, activeSection, apiAccessRemarks, filter, records, t], + [activeCategory, activeSection, apiAccessRemarks, apiBalanceUrls, filter, records, t], ); const openCreate = () => { @@ -1083,6 +1101,7 @@ export function ApiAccessPage() { ); const preparedDraftForSave = { ...preparedDraft, + balanceUrl: preparedDraft.balanceUrl ?? '', models: preparedDraft.models.filter((model) => model.name.trim()), }; const baseUrlRequired = definition.openAi || definition.section === 'codex-api-key'; @@ -1117,10 +1136,12 @@ export function ApiAccessPage() { return { saved: false, target: 'form', error: t('apiAccess.error.remarkInvalid') }; } let baseUrl = preparedDraft.baseUrl.trim(); + let balanceUrl = preparedDraft.balanceUrl?.trim() ?? ''; let providerHeaders: Record = {}; try { if (baseUrl) baseUrl = normalizeBaseUrl(baseUrl); if (baseUrlRequired && !baseUrl) throw new Error(t('apiAccess.error.baseRequired', { provider: t(definition.labelKey) })); + balanceUrl = validateBalanceUrl(balanceUrl, baseUrl); providerHeaders = parseProviderHeaders(preparedDraft.headersText ?? ''); } catch (requestError) { return { saved: false, target: 'form', error: requestErrorMessage(requestError) }; @@ -1128,7 +1149,7 @@ export function ApiAccessPage() { setBusy(true); setError(''); try { - let draftToSave = { ...preparedDraftForSave, baseUrl }; + let draftToSave: ProviderDraft = { ...preparedDraftForSave, baseUrl, balanceUrl }; if ( definition.openAi && activeCategory !== 'deepseek' @@ -1158,8 +1179,8 @@ export function ApiAccessPage() { models: fetchedModels, }); } - const latestConfig = await managementApi.get('/config'); - const current = sectionRecordsFromConfig(latestConfig, activeSection); + const latestRecords = await loadProviderRecords(); + const current = latestRecords[activeSection]; let nextList: Record[]; let targetIndex = -1; let currentRecord: Record | undefined; @@ -1191,6 +1212,16 @@ export function ApiAccessPage() { : [...current, ...recordsToSave]; await managementApi.put(`/${activeSection}`, nextList.map(stripResponseFields)); + const previousIdentity = editingRow && currentRecord + ? apiAccessRecordIdentityFromRecord(activeSection, currentRecord) + : null; + for (const [index, record] of recordsToSave.entries()) { + await saveApiAccessBalanceEndpoint( + index === 0 ? previousIdentity : null, + apiAccessRecordIdentityFromRecord(activeSection, record), + draftToSave.balanceUrl ?? '', + ); + } await invoke('save_api_access_remark', { update: { providerSection: activeSection, @@ -1226,6 +1257,11 @@ export function ApiAccessPage() { if (targetIndex < 0) throw new Error(t('apiAccess.error.stale')); const remainingRecords = current.filter((_, index) => index !== targetIndex).map(stripResponseFields); await managementApi.put(`/${row.section}`, remainingRecords); + await saveApiAccessBalanceEndpoint( + apiAccessRecordIdentityFromRecord(row.section, row.record), + null, + '', + ); await invoke('save_api_access_remark', { update: { providerSection: row.section, @@ -1251,8 +1287,8 @@ export function ApiAccessPage() { setError(''); setNotice(''); try { - const latestConfig = await managementApi.get('/config'); - const latestRows = sectionRecordsFromConfig(latestConfig, row.section); + const latestRecords = await loadProviderRecords(); + const latestRows = latestRecords[row.section]; const targetIndex = resolveProviderRecordIndex(latestRows, row); if (targetIndex < 0) { throw new Error(t('apiAccess.error.stale')); @@ -1293,8 +1329,8 @@ export function ApiAccessPage() { setError(''); setNotice(''); try { - const latestConfig = await managementApi.get('/config'); - const latestRows = sectionRecordsFromConfig(latestConfig, source.section); + const latestRecords = await loadProviderRecords(); + const latestRows = latestRecords[source.section]; const nextRows = reorderProviderRecords(latestRows, rows, source, target); if (!nextRows) throw new Error(t('apiAccess.error.stale')); await managementApi.put(`/${source.section}`, nextRows); @@ -1804,7 +1840,7 @@ export function ApiProviderDialog({ ), [modelOptions, selectedModelNames]); const updateTextField = ( - field: 'apiKey' | 'remark' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText', + field: 'apiKey' | 'remark' | 'baseUrl' | 'balanceUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText', value: string, ) => { setFormError(''); @@ -2027,6 +2063,8 @@ export function ApiProviderDialog({ /> + + {t('quota.api.balanceUrl.description')} {activeCategory === 'openai-compatibility' ? (
diff --git a/src/pages/ManagementPages.tsx b/src/pages/ManagementPages.tsx index 98f5aea..3ca20b2 100644 --- a/src/pages/ManagementPages.tsx +++ b/src/pages/ManagementPages.tsx @@ -28,7 +28,6 @@ import { shouldShowOAuthLoginStatus, } from '../services/oauthLoginState'; import { AuthFileManagementPage } from './AuthFileManagementPage'; -import { QuotaPage } from './QuotaPage'; import { validateDevinCallback } from '../services/devinOAuth'; type OAuthProviderId = 'codex' | 'claude' | 'antigravity' | 'kimi' | 'xai' | 'devin'; @@ -151,7 +150,6 @@ export function OAuthManagementPage() { > {activeSubpage === 'login' ? : null} {activeSubpage === 'authFiles' ? : null} - {activeSubpage === 'quota' ? : null}
); diff --git a/src/pages/QuotaPage.tsx b/src/pages/QuotaPage.tsx index 27a3c0e..728cb6c 100644 --- a/src/pages/QuotaPage.tsx +++ b/src/pages/QuotaPage.tsx @@ -1,6 +1,7 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { AlertCircle, KeyRound, LoaderCircle, RefreshCw, Settings2, ShieldCheck, X } from 'lucide-react'; import { MessageNotice } from '../appNotice'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { AlertCircle, LoaderCircle, RefreshCw } from 'lucide-react'; import { useConfirmation } from '../components/ConfirmationDialog'; import { QuotaActionFeedback } from '../components/QuotaActionFeedback'; import { canResetCodexQuota } from '../services/quotaActions'; @@ -8,9 +9,30 @@ import { useCodexQuotaReset } from '../components/useCodexQuotaReset'; import antigravityIcon from '../assets/icons/antigravity.svg'; import claudeIcon from '../assets/icons/claude.svg'; import codexIcon from '../assets/icons/codex.svg'; +import deepseekIcon from '../assets/icons/deepseek.svg'; +import geminiIcon from '../assets/icons/gemini.svg'; import grokIcon from '../assets/icons/grok.svg'; import devinIcon from '../assets/icons/devin.svg'; import kimiIcon from '../assets/icons/kimi-light.svg'; +import openaiIcon from '../assets/icons/openai-light.svg'; +import { + apiQuotaCacheKey, + apiQuotaErrorMessage, + apiAccessRecordIdentityFor, + apiAccessRecordIdentityKey, + apiQuotaProtocolLabel, + apiQuotaSourceLabel, + countApiQuotaSources, + countApiQuotaUnsupported, + discoverApiQuotaSources, + loadQuotaSourceStages, + saveApiQuotaBalanceUrl, + isApiQuotaSourceQueryable, + queryApiQuotaSource, + withApiQuotaBalanceUrl, + type ApiQuotaSource, + type ApiQuotaVendor, +} from '../services/apiQuota'; import { managementApi, readBoolean, responseList } from '../services/managementApi'; import { formatQuotaReset, useQuotaClock } from '../services/quotaTime'; import { @@ -25,10 +47,11 @@ import { type QuotaState, } from '../services/quotaService'; import { - captureQuotaCacheGeneration, - commitQuotaCacheIfCurrent, + API_QUOTA_CACHE_PREFIX, getQuotaCacheSnapshot, pruneQuotaCache, + pruneQuotaCacheNamespace, + refreshQuotaCacheEntries, updateQuotaCache, useQuotaCache, } from '../services/quotaCache'; @@ -45,57 +68,121 @@ const providerMeta: Record = { }; const providerOrder: QuotaProvider[] = ['claude', 'antigravity', 'codex', 'xai', 'kimi', 'devin']; -const REFRESH_CONCURRENCY = 4; +const apiProviderOrder: ApiQuotaVendor[] = ['deepseek', 'stepfun', 'siliconflow', 'openrouter', 'novita']; + +export const apiAccessIconForQuotaSource = ( + source: Pick, +): string => { + if (source.protocol === 'codex-api-key') return codexIcon; + if (source.protocol === 'claude-api-key') return claudeIcon; + if (source.protocol === 'gemini-api-key') return geminiIcon; + const deepSeekAccess = source.recordName.toLowerCase().includes('deepseek') + || /^https?:\/\/api\.deepseek\.com(?:\/|$)/i.test(source.baseUrl.trim()); + return deepSeekAccess ? deepseekIcon : openaiIcon; +}; export function QuotaPage() { const { t } = useI18n(); const { askConfirmation, confirmationDialog } = useConfirmation(); const [files, setFiles] = useState([]); + const [apiSources, setApiSources] = useState([]); + const apiSourcesRef = useRef(apiSources); + const sourceLoadRevision = useRef(0); const quotas = useQuotaCache(); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(''); + const [balanceEditorSource, setBalanceEditorSource] = useState(null); + const [balanceEditorError, setBalanceEditorError] = useState(''); + const [balanceEditorSaving, setBalanceEditorSaving] = useState(false); const querying = Object.values(quotas).some((quota) => quota.status === 'loading'); - const loadFiles = useCallback(async () => { + const applyApiSources = useCallback((sources: ApiQuotaSource[]) => { + apiSourcesRef.current = sources; + setApiSources(sources); + pruneQuotaCacheNamespace(API_QUOTA_CACHE_PREFIX, new Set(sources.map(apiQuotaCacheKey))); + updateQuotaCache((current) => { + const next = { ...current }; + sources.forEach((source) => { + const key = apiQuotaCacheKey(source); + if (!next[key]) next[key] = idleQuota(); + }); + return next; + }); + }, []); + + const loadOAuthFiles = useCallback(async (isCurrent: () => boolean) => { + const payload = await managementApi.get('/auth-files'); + if (!isCurrent()) return; + const allFiles = dedupeAuthFiles(responseList(payload, 'files')); + const nextFiles = allFiles.filter((file) => !readBoolean(file, 'disabled') && providerForFile(file)); + setFiles(nextFiles); + const validQuotaKeys = new Set(allFiles.map(quotaKey)); + pruneQuotaCache(validQuotaKeys); + updateQuotaCache((current) => { + const next = { ...current }; + nextFiles.forEach((file) => { + const key = quotaKey(file); + if (!next[key]) next[key] = idleQuota(); + }); + return next; + }); + }, []); + + const loadSources = useCallback(async () => { + const revision = ++sourceLoadRevision.current; setLoading(true); setError(''); try { - const payload = await managementApi.get('/auth-files'); - const allFiles = dedupeAuthFiles(responseList(payload, 'files')); - const nextFiles = allFiles.filter((file) => !readBoolean(file, 'disabled') && providerForFile(file)); - setFiles(nextFiles); - const validQuotaKeys = new Set(allFiles.map(quotaKey)); - pruneQuotaCache(validQuotaKeys); - updateQuotaCache((current) => { - const next = { ...current }; - nextFiles.forEach((file) => { - const key = quotaKey(file); - if (!next[key]) next[key] = idleQuota(); - }); - return next; + await loadQuotaSourceStages(discoverApiQuotaSources, () => loadOAuthFiles(() => sourceLoadRevision.current === revision), (apiResult) => { + if (sourceLoadRevision.current !== revision) return; + applyApiSources(apiResult.sources); + if (apiResult.errors?.length) { + setError(`${t('quota.api.partialLoad')}: ${apiResult.errors.join('; ')}`); + } }); } catch (requestError) { - setError(String(requestError)); + if (sourceLoadRevision.current === revision) setError(apiQuotaErrorMessage(requestError)); } finally { - setLoading(false); + if (sourceLoadRevision.current === revision) setLoading(false); } - }, []); + }, [applyApiSources, loadOAuthFiles, t]); useEffect(() => { - void loadFiles(); - }, [loadFiles]); + void loadSources(); + return () => { sourceLoadRevision.current += 1; }; + }, [loadSources]); const refreshOne = useCallback(async (file: AuthFile) => { const key = quotaKey(file); - if (getQuotaCacheSnapshot()[key]?.status === 'loading') return; - const cacheGeneration = captureQuotaCacheGeneration(); - updateQuotaCache((current) => ({ ...current, [key]: { status: 'loading', rows: [] } })); - const result = await loadQuota(file); - commitQuotaCacheIfCurrent(cacheGeneration, () => { - updateQuotaCache((current) => ({ ...current, [key]: result })); - }); - }, [t]); + await refreshQuotaCacheEntries([{ key, query: () => loadQuota(file) }]); + }, []); + + const refreshApiOne = useCallback(async (source: ApiQuotaSource) => { + const key = apiQuotaCacheKey(source); + await refreshQuotaCacheEntries([{ key, query: () => queryApiQuotaSource(source) }]); + }, []); + + const saveBalanceUrl = useCallback(async (source: ApiQuotaSource, value: string) => { + setBalanceEditorSaving(true); + setBalanceEditorError(''); + try { + const balanceUrl = await saveApiQuotaBalanceUrl(source, value); + const identity = apiAccessRecordIdentityKey(apiAccessRecordIdentityFor(source)); + sourceLoadRevision.current += 1; + applyApiSources(apiSourcesRef.current.map((current) => ( + apiAccessRecordIdentityKey(apiAccessRecordIdentityFor(current)) === identity + ? withApiQuotaBalanceUrl(current, balanceUrl) + : current + ))); + setLoading(false); + setBalanceEditorSource(null); + } catch (saveError) { + setBalanceEditorError(saveError instanceof Error ? saveError.message : String(saveError)); + } finally { + setBalanceEditorSaving(false); + } + }, [applyApiSources]); const resetCodexQuota = useCodexQuotaReset(askConfirmation, setError); @@ -103,29 +190,19 @@ export function QuotaPage() { if (Object.values(getQuotaCacheSnapshot()).some((quota) => quota.status === 'loading')) return; setRefreshing(true); setError(''); - const cacheGeneration = captureQuotaCacheGeneration(); - updateQuotaCache((current) => ({ - ...current, - ...Object.fromEntries(files.map((file) => [quotaKey(file), { - ...current[quotaKey(file)], status: 'loading', rows: [], - }])), - })); + const refreshableApiSources = apiSources.filter(isApiQuotaSourceQueryable); + const targets = [ + ...files.map((file) => ({ key: quotaKey(file), query: () => loadQuota(file) })), + ...refreshableApiSources.map((source) => ({ key: apiQuotaCacheKey(source), query: () => queryApiQuotaSource(source) })), + ]; try { - for (let index = 0; index < files.length; index += REFRESH_CONCURRENCY) { - const batch = files.slice(index, index + REFRESH_CONCURRENCY); - await Promise.all(batch.map(async (file) => { - const result = await loadQuota(file); - commitQuotaCacheIfCurrent(cacheGeneration, () => { - updateQuotaCache((current) => ({ ...current, [quotaKey(file)]: result })); - }); - })); - } + await refreshQuotaCacheEntries(targets); } finally { setRefreshing(false); } - }, [files, t]); + }, [apiSources, files]); - const grouped = useMemo(() => { + const oauthCards = useMemo(() => { const groups = new Map(); files.forEach((file) => { const provider = providerForFile(file); @@ -134,23 +211,41 @@ export function QuotaPage() { items.push({ file, quota: quotas[quotaKey(file)] ?? idleQuota() }); groups.set(provider, items); }); - return providerOrder.flatMap((provider) => { - const items = groups.get(provider); - return items ? [[provider, items] as const] : []; - }); + return providerOrder.flatMap((provider) => groups.get(provider) ?? []); }, [files, quotas]); + const apiCards = useMemo(() => { + const groups = new Map(); + apiSources.forEach((source) => { + const provider = source.adapter?.vendor ?? 'unsupported'; + const items = groups.get(provider) ?? []; + items.push(source); + groups.set(provider, items); + }); + return [ + ...apiProviderOrder.flatMap((provider) => groups.get(provider) ?? []), + ...(groups.get('unsupported') ?? []), + ]; + }, [apiSources]); + + const oauthCount = files.length; + const apiCount = countApiQuotaSources(apiSources); + const apiUnsupportedCount = countApiQuotaUnsupported(apiSources); + const sourceCount = oauthCount + apiCount; + return (
{confirmationDialog} -
-

{t('quota.title')}

+
+
+

{t('quota.title')}

+
- {t(files.length === 1 ? 'quota.queryableCredentials.one' : 'quota.queryableCredentials.other', { count: files.length })} - -
@@ -158,18 +253,30 @@ export function QuotaPage() { {error ? : null} {loading ? (
{t('quota.loadingFiles')}
- ) : grouped.length === 0 ? ( + ) : oauthCards.length === 0 && apiCards.length === 0 ? (
{t('quota.empty.title')}{t('quota.empty.description')}
) : ( -
- {grouped.map(([provider, items]) => ( -
-

{providerMeta[provider].label}

{t(items.length === 1 ? 'quota.credentials.one' : 'quota.credentials.other', { count: items.length })}
-
{items.map(({ file, quota }) => void refreshOne(file)} onReset={provider === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} />)}
+
+ {oauthCards.length > 0 ? ( +
+

{t('quota.oauth.title')}

{t(oauthCount === 1 ? 'quota.credentials.one' : 'quota.credentials.other', { count: oauthCount })}
+
{oauthCards.map(({ file, quota }) => { + const provider = providerForFile(file); + return void refreshOne(file)} onReset={provider === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} />; + })}
- ))} + ) : null} + {apiCards.length > 0 ? ( +
+

{t('quota.api.sectionTitle')}

{t('quota.api.sectionCount', { supported: apiCount, unsupported: apiUnsupportedCount })}
+
{apiCards.map((source) => void refreshApiOne(source)} onConfigure={() => { setBalanceEditorError(''); setBalanceEditorSource(source); }} />)}
+
+ ) : null}
)} + {balanceEditorSource ? ( + { if (!balanceEditorSaving) setBalanceEditorSource(null); }} onSave={(value) => void saveBalanceUrl(balanceEditorSource, value)} /> + ) : null}
); } @@ -184,6 +291,7 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
{name}{provider ? providerMeta[provider].label : t('quota.unknownProvider')}{quota.plan ? ' · ' + quota.plan : ''}
+ {t('quota.oauth.badge')}
{onReset && (quota.resetCredits ?? 0) > 0 ? : null} @@ -204,14 +312,180 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
: null} {quota.status === 'success' && provider === 'devin' && quota.subscriptionActiveUntil ?
{t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })}
: null} - {quota.status === 'success' ?
{quota.rows.map((row, index) => { + +
+ ); +} + +const formatQuotaAmount = (value: number | null, unit: string, locale: string) => { + if (value === null) return '—'; + const formatted = new Intl.NumberFormat(locale, { maximumFractionDigits: 6 }).format(value); + return unit ? `${formatted} ${unit}` : formatted; +}; + +function QuotaAmountSummary({ amount }: { amount: NonNullable }) { + const { t, locale } = useI18n(); + return ( +
+ {amount.remaining !== null ? {t('quota.amount.remaining')}: {formatQuotaAmount(amount.remaining, amount.unit, locale)} : null} + {amount.used !== null ? {t('quota.amount.used')}: {formatQuotaAmount(amount.used, amount.unit, locale)} : null} + {amount.total !== null ? {t('quota.amount.total')}: {formatQuotaAmount(amount.total, amount.unit, locale)} : null} +
+ ); +} + +function QuotaRows({ quota, now }: { quota: QuotaState; now: number }) { + const { locale, t } = useI18n(); + if (quota.status !== 'success') return null; + return ( +
+ {quota.rows.map((row, index) => { const reset = formatQuotaReset(row.resetAtMs, row.reset, locale, now); - return
-
{row.label}{row.remainingPercent === null ? '—' : t('quota.remaining', { percent: Math.round(row.remainingPercent) })}
- {row.remainingPercent !== null ?
: null} - {[row.detail, reset].filter(Boolean).join(' · ')} -
; - })}
: null} + return ( +
+ {row.amount ? null : ( +
+ {row.label} + {row.remainingPercent === null ? '—' : t('quota.remaining', { percent: Math.round(row.remainingPercent) })} +
+ )} + {row.amount ? : null} + {row.remainingPercent !== null ?
: null} + {[row.detail, reset].filter(Boolean).join(' · ')} +
+ ); + })} +
+ ); +} + +export function ApiQuotaCard({ + source, + quota, + onRefresh, + onConfigure, +}: { + source: ApiQuotaSource; + quota: QuotaState; + onRefresh: () => void; + onConfigure: () => void; +}) { + const { t } = useI18n(); + const now = useQuotaClock() + (quota.serverTimeOffsetMs ?? 0); + const unsupported = !source.adapter || Boolean(source.configurationError); + const disabled = source.disabled; + const label = apiQuotaSourceLabel(source); + const icon = apiAccessIconForQuotaSource(source); + return ( +
+
+ +
+
+ {label} + {t('quota.api.badge')} +
+ {apiQuotaProtocolLabel(source.protocol)} +
+
+ + +
+
+ {source.configurationError ?
{source.configurationError}
: null} + {quota.status === 'idle' && !source.configurationError ? ( +
+ {disabled ? t('quota.fileDisabled') : unsupported ? t('quota.api.unsupported') : t('quota.notFetched')} + {disabled || unsupported ? null : } +
+ ) : null} + {quota.status === 'loading' ?
{t('quota.querying')}
: null} + {quota.status === 'error' ?
{quota.error}
: null} +
); } + +export function ApiBalanceUrlDialog({ + source, + busy, + error, + onClose, + onSave, +}: { + source: ApiQuotaSource; + busy: boolean; + error: string; + onClose: () => void; + onSave: (value: string) => void; +}) { + const { t } = useI18n(); + const [value, setValue] = useState(source.balanceUrl); + const dialogRef = useRef(null); + const inputRef = useRef(null); + const onCloseRef = useRef(onClose); + const busyRef = useRef(busy); + onCloseRef.current = onClose; + busyRef.current = busy; + const label = apiQuotaSourceLabel(source); + useEffect(() => { + if (typeof document === 'undefined') return; + const previousFocus = document.activeElement; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + inputRef.current?.focus(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + if (!busyRef.current) onCloseRef.current(); + } else if (event.key === 'Tab') { + const controls = Array.from(dialogRef.current?.querySelectorAll('button:not(:disabled), input:not(:disabled)') ?? []); + const first = controls[0]; + const last = controls[controls.length - 1]; + if (event.shiftKey && (document.activeElement === first || !dialogRef.current?.contains(document.activeElement))) { + event.preventDefault(); + last?.focus(); + } else if (!event.shiftKey && (document.activeElement === last || !dialogRef.current?.contains(document.activeElement))) { + event.preventDefault(); + first?.focus(); + } + } + }; + document.addEventListener('keydown', onKeyDown, true); + return () => { + document.removeEventListener('keydown', onKeyDown, true); + document.body.style.overflow = previousOverflow; + if (previousFocus instanceof HTMLElement && previousFocus.isConnected) previousFocus.focus(); + }; + }, []); + const content = ( +
event.currentTarget === event.target && !busy && onClose()}> +
+
+
+ +
+

{label}

+ +

{t('quota.api.balanceUrl.description')}

+ {error ?
{error}
: null} +
+ + +
+
+
+ ); + return typeof document === 'undefined' ? content : createPortal(content, document.body); +} diff --git a/src/services/apiQuota.ts b/src/services/apiQuota.ts new file mode 100644 index 0000000..c998482 --- /dev/null +++ b/src/services/apiQuota.ts @@ -0,0 +1,775 @@ +import { + apiCallErrorMessage, + isRecord, + isManagementAuthenticationError, + managementApi, + managementApiErrorDetails, + readBoolean, + readString, + responseList, +} from './managementApi'; +import { invoke } from '@tauri-apps/api/core'; +import { getCurrentLocale, translate, type AppLocale } from '../i18n'; +import type { MessageKey } from '../i18n/resources'; +import type { QuotaAmount, QuotaRow, QuotaState } from './quotaService'; +import { API_QUOTA_CACHE_PREFIX } from './quotaCache'; + +export type ApiQuotaProtocol = + | 'codex-api-key' + | 'openai-compatibility' + | 'claude-api-key' + | 'gemini-api-key'; + +export type ApiQuotaVendor = 'deepseek' | 'stepfun' | 'siliconflow' | 'openrouter' | 'novita'; + +export type ApiQuotaAdapter = Readonly<{ + vendor: ApiQuotaVendor; + hostname: string; + endpoint: string; + unit: string; +}>; + +export type ApiQuotaSource = { + id: string; + protocol: ApiQuotaProtocol; + recordIndex: number; + entryIndex: number; + entryCount: number; + recordName: string; + recordApiKeys?: string[]; + recordOrdinal: number; + recordCount: number; + baseUrl: string; + balanceUrl: string; + authIndex: string; + apiKey: string; + adapter: ApiQuotaAdapter | null; + configurationError?: string; + label: string; + disabled: boolean; +}; + +export type ApiQuotaDiscovery = { + sources: ApiQuotaSource[]; + failedProtocols: ApiQuotaProtocol[]; + errors?: string[]; +}; + +export type ApiAccessRecordIdentity = { + providerSection: ApiQuotaProtocol; + recordName: string; + baseUrl: string; + apiKeys: string[]; +}; + +type ApiQuotaSectionDefinition = { + protocol: ApiQuotaProtocol; + responseKey: string; +}; + +const apiQuotaSections: ApiQuotaSectionDefinition[] = [ + { protocol: 'codex-api-key', responseKey: 'codex-api-key' }, + { protocol: 'openai-compatibility', responseKey: 'openai-compatibility' }, + { protocol: 'claude-api-key', responseKey: 'claude-api-key' }, + { protocol: 'gemini-api-key', responseKey: 'gemini-api-key' }, +]; + +const adaptersByHostname: Record = { + 'api.deepseek.com': { + vendor: 'deepseek', + hostname: 'api.deepseek.com', + endpoint: 'https://api.deepseek.com/user/balance', + unit: 'CNY', + }, + 'api.stepfun.ai': { + vendor: 'stepfun', + hostname: 'api.stepfun.ai', + endpoint: 'https://api.stepfun.com/v1/accounts', + unit: 'CNY', + }, + 'api.stepfun.com': { + vendor: 'stepfun', + hostname: 'api.stepfun.com', + endpoint: 'https://api.stepfun.com/v1/accounts', + unit: 'CNY', + }, + 'api.siliconflow.cn': { + vendor: 'siliconflow', + hostname: 'api.siliconflow.cn', + endpoint: 'https://api.siliconflow.cn/v1/user/info', + unit: 'CNY', + }, + 'api.siliconflow.com': { + vendor: 'siliconflow', + hostname: 'api.siliconflow.com', + endpoint: 'https://api.siliconflow.com/v1/user/info', + unit: 'USD', + }, + 'openrouter.ai': { + vendor: 'openrouter', + hostname: 'openrouter.ai', + endpoint: 'https://openrouter.ai/api/v1/credits', + unit: 'USD', + }, + 'api.novita.ai': { + vendor: 'novita', + hostname: 'api.novita.ai', + endpoint: 'https://api.novita.ai/v3/user/balance', + unit: 'USD', + }, +}; + +const vendorLabelKeys: Record[1]> = { + deepseek: 'quota.api.provider.deepseek', + stepfun: 'quota.api.provider.stepfun', + siliconflow: 'quota.api.provider.siliconflow', + openrouter: 'quota.api.provider.openrouter', + novita: 'quota.api.provider.novita', +}; + +const protocolLabelKeys: Record[1]> = { + 'codex-api-key': 'quota.api.protocol.codex', + 'openai-compatibility': 'quota.api.protocol.openaiCompatibility', + 'claude-api-key': 'quota.api.protocol.claude', + 'gemini-api-key': 'quota.api.protocol.gemini', +}; + +const apiQuotaText = ( + key: Parameters[1], + variables?: Parameters[2], +) => translate(getCurrentLocale(), key, variables); + +export const apiQuotaVendorLabel = (vendor: ApiQuotaVendor) => + apiQuotaText(vendorLabelKeys[vendor]); + +export const apiQuotaProtocolLabel = (protocol: ApiQuotaProtocol) => + apiQuotaText(protocolLabelKeys[protocol]); + +export const apiQuotaAdapterFor = (baseUrl: string): ApiQuotaAdapter | null => { + try { + const parsed = new URL(baseUrl.trim()); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + if (parsed.username || parsed.password) return null; + return adaptersByHostname[parsed.hostname.toLowerCase()] ?? null; + } catch { + return null; + } +}; + +const isLoopbackHostname = (hostname: string) => { + const normalized = hostname.toLowerCase(); + return normalized === 'localhost' + || normalized === '127.0.0.1' + || normalized === '::1' + || normalized === '[::1]'; +}; + +const parseSafeBalanceUrl = (value: string): URL => { + let parsed: URL; + try { + parsed = new URL(value.trim()); + } catch { + throw new Error(apiQuotaText('quota.api.balanceUrl.invalid')); + } + if (parsed.username || parsed.password) { + throw new Error(apiQuotaText('quota.api.balanceUrl.credentials')); + } + if (parsed.protocol === 'http:' && !isLoopbackHostname(parsed.hostname)) { + throw new Error(apiQuotaText('quota.api.balanceUrl.httpsRequired')); + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + throw new Error(apiQuotaText('quota.api.balanceUrl.invalid')); + } + parsed.hash = ''; + return parsed; +}; + +export const safeBalanceUrlValue = (value: string): string => { + if (!value.trim()) return ''; + return parseSafeBalanceUrl(value).toString(); +}; + +export const resolveApiQuotaAdapter = ( + baseUrl: string, + balanceUrl: string, +): { adapter: ApiQuotaAdapter | null; value: string; error?: string } => { + const inferenceAdapter = apiQuotaAdapterFor(baseUrl); + if (!balanceUrl.trim()) return { adapter: inferenceAdapter, value: '' }; + try { + const parsed = parseSafeBalanceUrl(balanceUrl); + const value = parsed.toString(); + const configuredAdapter = apiQuotaAdapterFor(value); + if (inferenceAdapter && configuredAdapter && inferenceAdapter.vendor !== configuredAdapter.vendor) { + return { adapter: null, value, error: apiQuotaText('quota.api.balanceUrl.conflict') }; + } + if (!configuredAdapter && (!inferenceAdapter || !isLoopbackHostname(parsed.hostname))) { + return { adapter: null, value, error: apiQuotaText('quota.api.balanceUrl.unsupported') }; + } + const selected = configuredAdapter ?? inferenceAdapter; + return { + adapter: selected ? { ...selected, endpoint: value } : null, + value, + }; + } catch (error) { + return { adapter: null, value: balanceUrl.trim(), error: error instanceof Error ? error.message : String(error) }; + } +}; + +export const validateBalanceUrl = (balanceUrl: string, baseUrl: string): string => { + const resolved = resolveApiQuotaAdapter(baseUrl, balanceUrl); + if (resolved.error) throw new Error(resolved.error); + return resolved.value; +}; + +export const detectApiQuotaVendor = (baseUrl: string): ApiQuotaVendor | null => + apiQuotaAdapterFor(baseUrl)?.vendor ?? null; + +export const apiQuotaEndpointFor = (baseUrl: string): string | null => + apiQuotaAdapterFor(baseUrl)?.endpoint ?? null; + +const numberValue = (value: unknown): number | null => { + if (isRecord(value) && 'val' in value) return numberValue(value.val); + if (typeof value !== 'number' && typeof value !== 'string') return null; + if (typeof value === 'string' && !value.trim()) return null; + const parsed = typeof value === 'number' ? value : Number(value); + return Number.isFinite(parsed) ? parsed : null; +}; + +const parseBody = (value: unknown): unknown => { + if (typeof value !== 'string') return value; + try { + return JSON.parse(value); + } catch { + return value; + } +}; + +const monetaryRow = ( + label: string, + amount: QuotaAmount, + detail?: string, +): QuotaRow => ({ + label, + remainingPercent: null, + amount, + detail, +}); + +const parseDeepSeekBalance = (payload: unknown): QuotaRow[] => { + const value = parseBody(payload); + if (!isRecord(value) || !Array.isArray(value.balance_infos)) return []; + const available = value.is_available !== false; + return value.balance_infos.filter(isRecord).flatMap((info) => { + const remaining = numberValue(info.total_balance); + if (remaining === null) return []; + const currency = readString(info, 'currency') || 'CNY'; + return [monetaryRow( + `${apiQuotaVendorLabel('deepseek')} · ${currency}`, + { remaining, used: null, total: null, unit: currency }, + available ? undefined : apiQuotaText('quota.api.balanceUnavailable'), + )]; + }); +}; + +const parseStepFunBalance = (payload: unknown): QuotaRow[] => { + const value = parseBody(payload); + if (!isRecord(value)) return []; + const remaining = numberValue(value.balance); + return remaining === null + ? [] + : [monetaryRow(apiQuotaVendorLabel('stepfun'), { + remaining, + used: null, + total: null, + unit: 'CNY', + })]; +}; + +const parseSiliconFlowBalance = (payload: unknown, adapter: ApiQuotaAdapter): QuotaRow[] => { + const value = parseBody(payload); + if (!isRecord(value) || !isRecord(value.data)) return []; + const remaining = numberValue(value.data.totalBalance) ?? numberValue(value.data.balance); + return remaining === null + ? [] + : [monetaryRow(apiQuotaVendorLabel('siliconflow'), { + remaining, + used: null, + total: null, + unit: adapter.unit, + })]; +}; + +const parseOpenRouterBalance = (payload: unknown): QuotaRow[] => { + const value = parseBody(payload); + if (!isRecord(value)) return []; + const data = isRecord(value.data) ? value.data : value; + const total = numberValue(data.total_credits); + const used = numberValue(data.total_usage); + const remaining = total === null || used === null ? null : total - used; + if (remaining === null && total === null && used === null) return []; + return [monetaryRow(apiQuotaVendorLabel('openrouter'), { + remaining, + used, + total, + unit: 'USD', + })]; +}; + +const parseNovitaBalance = (payload: unknown): QuotaRow[] => { + const value = parseBody(payload); + if (!isRecord(value)) return []; + const available = numberValue(value.availableBalance); + if (available === null) return []; + return [monetaryRow(apiQuotaVendorLabel('novita'), { + remaining: available / 10_000, + used: null, + total: null, + unit: 'USD', + })]; +}; + +export const parseDeepSeekQuota = parseDeepSeekBalance; +export const parseStepFunQuota = parseStepFunBalance; +export const parseSiliconFlowQuota = parseSiliconFlowBalance; +export const parseOpenRouterQuota = parseOpenRouterBalance; +export const parseNovitaQuota = parseNovitaBalance; + +export const parseApiQuotaResponse = ( + adapter: ApiQuotaAdapter, + payload: unknown, +): QuotaRow[] => { + switch (adapter.vendor) { + case 'deepseek': return parseDeepSeekBalance(payload); + case 'stepfun': return parseStepFunBalance(payload); + case 'siliconflow': return parseSiliconFlowBalance(payload, adapter); + case 'openrouter': return parseOpenRouterBalance(payload); + case 'novita': return parseNovitaBalance(payload); + default: return []; + } +}; + +const hashIdentity = (value: string): string => { + let hash = 2166136261; + let secondary = 0x9e3779b9; + for (let index = 0; index < value.length; index += 1) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + secondary ^= value.charCodeAt(index) + index; + secondary = Math.imul(secondary, 2246822519); + } + return `${(hash >>> 0).toString(36)}-${(secondary >>> 0).toString(36)}`; +}; + +const sourceIdFor = ( + protocol: ApiQuotaProtocol, + record: Record, + recordIndex: number, + entryIndex: number, + baseUrl: string, + authIndex: string, + apiKey: string, +) => { + const name = readString(record, 'name'); + const identity = `${protocol}\u0000${name}\u0000${baseUrl}\u0000record:${recordIndex}\u0000entry:${entryIndex}${authIndex ? `\u0000auth:${authIndex}` : `\u0000key:${apiKey}`}`; + return hashIdentity(identity); +}; + +const safeRecordNameLabel = (value: string): string => { + const trimmed = value.trim(); + if (!trimmed) return ''; + try { + const parsed = new URL(trimmed); + if (parsed.username || parsed.password || parsed.search || parsed.hash) return parsed.hostname.toLowerCase(); + return parsed.hostname.toLowerCase() || trimmed; + } catch { + return trimmed; + } +}; + +const sourceLabelFor = ( + source: Pick, +) => { + const entryOrdinal = source.entryCount > 1 ? source.entryIndex + 1 : null; + const safeRecordName = safeRecordNameLabel(source.recordName); + const recordLabel = safeRecordName && source.recordCount > 1 + ? apiQuotaText('quota.api.namedRecordOrdinalLabel', { + name: safeRecordName, + index: source.recordOrdinal, + }) + : safeRecordName; + const ordinal = entryOrdinal ?? (source.recordCount > 1 ? source.recordOrdinal : null); + if (recordLabel) { + return entryOrdinal === null + ? recordLabel + : apiQuotaText('quota.api.namedCredentialLabel', { + name: recordLabel, + index: entryOrdinal, + }); + } + if (source.adapter) { + return ordinal === null + ? apiQuotaVendorLabel(source.adapter.vendor) + : apiQuotaText('quota.api.credentialLabel', { + provider: apiQuotaVendorLabel(source.adapter.vendor), + index: ordinal, + }); + } + const safeHost = safeApiQuotaHostname(source.baseUrl); + const fallback = safeHost || apiQuotaProtocolLabel(source.protocol); + return ordinal === null + ? apiQuotaText('quota.api.unsupportedHostLabel', { host: fallback }) + : apiQuotaText('quota.api.unsupportedCredentialOrdinal', { host: fallback, index: ordinal }); +}; + +export const safeApiQuotaHostname = (baseUrl: string): string => { + try { + const parsed = new URL(baseUrl.trim()); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return ''; + if (parsed.username || parsed.password) return ''; + return parsed.hostname.toLowerCase(); + } catch { + return ''; + } +}; + +export const apiQuotaSourceLabel = (source: Pick) => + sourceLabelFor(source); + +/* + * API sources are queryable only when they map to a supported adapter and are enabled. + */ +export const isApiQuotaSourceQueryable = (source: Pick) => + Boolean(source.adapter) && !source.configurationError && !source.disabled; + +export const isApiQuotaSourceUnsupported = (source: Pick) => + !source.adapter || Boolean(source.configurationError); + +export const countApiQuotaCards = (sources: Pick[]) => + sources.length; + +export const countApiQuotaUnsupported = (sources: Pick[]) => + sources.filter(isApiQuotaSourceUnsupported).length; + +export const countApiQuotaSources = (sources: Pick[]) => + sources.filter(isApiQuotaSourceQueryable).length; + +const recordsForEntry = ( + protocol: ApiQuotaProtocol, + record: Record, +): Record[] => { + if (protocol !== 'openai-compatibility') return [record]; + const entries = Array.isArray(record['api-key-entries']) + ? record['api-key-entries'].filter(isRecord) + : []; + return entries.length > 0 ? entries : [record]; +}; + +export const flattenApiQuotaRecords = ( + protocol: ApiQuotaProtocol, + records: Record[], +): ApiQuotaSource[] => { + const recordNames = records.map((record) => readString(record, 'name')); + const recordNameCounts = new Map(); + recordNames.forEach((name) => recordNameCounts.set(name, (recordNameCounts.get(name) ?? 0) + 1)); + return records.flatMap((record, recordIndex) => ( + recordsForEntry(protocol, record).map((entry, entryIndex, entries) => { + const baseUrl = readString(entry, 'base-url', 'baseUrl') + || readString(record, 'base-url', 'baseUrl'); + const authIndex = readString(entry, 'auth-index', 'authIndex', 'auth_index') + || readString(record, 'auth-index', 'authIndex', 'auth_index'); + const apiKey = readString(entry, 'api-key', 'apiKey') + || (entry === record ? readString(record, 'api-key', 'apiKey') : ''); + const recordApiKeys = protocol === 'openai-compatibility' + ? recordsForEntry(protocol, record).map((item) => readString(item, 'api-key', 'apiKey')).filter(Boolean) + : [readString(record, 'api-key', 'apiKey')].filter(Boolean); + const resolvedAdapter = resolveApiQuotaAdapter(baseUrl, ''); + const adapter = resolvedAdapter.adapter; + const recordName = readString(record, 'name'); + const recordCount = recordName ? recordNameCounts.get(recordName) ?? 1 : 1; + const recordOrdinal = recordCount > 1 + ? records.slice(0, recordIndex + 1).filter((item) => readString(item, 'name') === recordName).length + : recordIndex + 1; + const disabled = readBoolean(entry, 'disabled') + || readBoolean(record, 'disabled') + || (Array.isArray(record['excluded-models']) + && record['excluded-models'].some((item) => String(item).trim() === '*')); + const source: ApiQuotaSource = { + id: sourceIdFor(protocol, record, recordIndex, entryIndex, baseUrl, authIndex, apiKey), + protocol, + recordIndex, + entryIndex, + entryCount: entries.length, + recordName, + recordApiKeys, + recordOrdinal, + recordCount, + baseUrl, + balanceUrl: resolvedAdapter.value, + authIndex, + apiKey, + adapter, + configurationError: resolvedAdapter.error, + label: '', + disabled, + }; + source.label = sourceLabelFor(source); + return source; + }) + )); +}; + +export const apiQuotaCacheKey = (source: Pick) => + `${API_QUOTA_CACHE_PREFIX}${source.id}::${hashIdentity(source.adapter?.endpoint ?? source.balanceUrl)}`; + +export const withApiQuotaBalanceUrl = (source: ApiQuotaSource, balanceUrl: string): ApiQuotaSource => { + const resolved = resolveApiQuotaAdapter(source.baseUrl, balanceUrl); + return { ...source, balanceUrl: resolved.value, adapter: resolved.adapter, configurationError: resolved.error }; +}; + +export const apiAccessRecordIdentityFor = ( + source: Pick, +): ApiAccessRecordIdentity => ({ + providerSection: source.protocol, + recordName: source.recordName, + baseUrl: source.baseUrl, + apiKeys: [...(source.recordApiKeys ?? [])], +}); + +export const apiAccessRecordIdentityKey = (identity: ApiAccessRecordIdentity): string => JSON.stringify([ + identity.providerSection, + identity.recordName, + identity.baseUrl, + [...identity.apiKeys].sort(), +]); + +export const apiAccessRecordIdentityFromRecord = ( + protocol: ApiQuotaProtocol, + record: Record, +): ApiAccessRecordIdentity => ({ + providerSection: protocol, + recordName: readString(record, 'name'), + baseUrl: readString(record, 'base-url', 'baseUrl'), + apiKeys: protocol === 'openai-compatibility' + ? (Array.isArray(record['api-key-entries']) ? record['api-key-entries'].filter(isRecord).map((entry) => readString(entry, 'api-key', 'apiKey')).filter(Boolean) : []) + : [readString(record, 'api-key', 'apiKey')].filter(Boolean), +}); + +const apiQuotaBackendErrorKeys: Partial> = { + 'api_balance.invalid_provider': 'quota.api.error.invalidProvider', + 'api_balance.invalid_identity': 'quota.api.error.invalidIdentity', + 'api_balance.invalid_url': 'quota.api.error.invalidUrl', + 'api_balance.insecure_url': 'quota.api.error.insecureUrl', + 'api_balance.invalid_record_name': 'quota.api.error.invalidRecordName', + 'api_balance.invalid_base_url': 'quota.api.error.invalidBaseUrl', +}; + +export const apiQuotaErrorMessage = (error: unknown, locale: AppLocale = getCurrentLocale()): string => { + const message = error instanceof Error ? error.message : String(error); + const httpError = managementApiErrorDetails(error); + if (httpError) { + return [translate(locale, 'quota.api.error.managementHttp', { status: httpError.status }), httpError.message].filter(Boolean).join(': '); + } + const key = apiQuotaBackendErrorKeys[message]; + return key ? translate(locale, key) : message; +}; + +export const resolveApiAccessBalanceUrls = async ( + queries: ApiAccessRecordIdentity[], +): Promise> => { + try { + return await invoke>('resolve_api_access_balance_urls', { queries }); + } catch (error) { + throw new Error(apiQuotaErrorMessage(error)); + } +}; + +export const saveApiAccessBalanceEndpoint = async ( + previousIdentity: ApiAccessRecordIdentity | null, + nextIdentity: ApiAccessRecordIdentity | null, + balanceUrl: string, +): Promise => { + try { + await invoke('save_api_access_balance_endpoint', { + update: { previousIdentity, nextIdentity, balanceUrl }, + }); + } catch (error) { + throw new Error(apiQuotaErrorMessage(error)); + } +}; + +export const apiQuotaSourcesFromRecords = ( + recordsByProtocol: Partial[]>>, +): ApiQuotaDiscovery => { + const sources: ApiQuotaSource[] = []; + apiQuotaSections.forEach((definition) => { + sources.push(...flattenApiQuotaRecords( + definition.protocol, + recordsByProtocol[definition.protocol] ?? [], + )); + }); + return { sources, failedProtocols: [] }; +}; + +export const apiQuotaSourcesFromConfig = (config: unknown): ApiQuotaDiscovery => { + const records = Object.fromEntries(apiQuotaSections.map((definition) => [ + definition.protocol, + responseList(config, definition.responseKey), + ])) as Partial[]>>; + return apiQuotaSourcesFromRecords(records); +}; + +export async function loadApiAccessRecords( + get: (path: string) => Promise = managementApi.get, + onProtocolError?: (protocol: ApiQuotaProtocol, error: unknown) => void, +): Promise[]>> { + await get('/config'); + const records = {} as Record[]>; + for (const definition of apiQuotaSections) { + try { + const response = await get(`/${definition.protocol}`); + records[definition.protocol] = Array.isArray(response) + ? response.filter(isRecord) + : responseList(response, definition.responseKey); + } catch (error) { + if (!onProtocolError || isManagementAuthenticationError(error)) throw error; + records[definition.protocol] = []; + onProtocolError(definition.protocol, error); + } + } + return records; +} + +export async function discoverApiQuotaSources( + get: (path: string) => Promise = managementApi.get, + resolveBalanceUrls: (queries: ApiAccessRecordIdentity[]) => Promise> = resolveApiAccessBalanceUrls, +): Promise { + const failedProtocols: ApiQuotaProtocol[] = []; + const errors: string[] = []; + const records = await loadApiAccessRecords(get, (protocol, error) => { + failedProtocols.push(protocol); + errors.push(`${apiQuotaProtocolLabel(protocol)}: ${apiQuotaErrorMessage(error)}`); + }); + const discovery = apiQuotaSourcesFromRecords(records); + const queries = discovery.sources.map(apiAccessRecordIdentityFor); + let balanceUrls: Array = []; + const metadataErrors = new Map(); + try { + if (queries.length > 0) balanceUrls = await resolveBalanceUrls(queries); + } catch { + // A malformed record must not hide unrelated credentials or discard their saved endpoints. + const resolvedByIdentity = new Map(); + for (const query of queries) { + const identity = apiAccessRecordIdentityKey(query); + if (resolvedByIdentity.has(identity) || metadataErrors.has(identity)) continue; + try { + resolvedByIdentity.set(identity, (await resolveBalanceUrls([query]))[0] ?? null); + } catch (error) { + const message = apiQuotaErrorMessage(error); + metadataErrors.set(identity, message); + errors.push(message); + } + } + balanceUrls = queries.map((query) => resolvedByIdentity.get(apiAccessRecordIdentityKey(query)) ?? null); + } + return { + ...discovery, + failedProtocols, + errors, + sources: discovery.sources.map((source, index) => { + const resolvedSource = withApiQuotaBalanceUrl(source, balanceUrls[index] ?? ''); + return { + ...resolvedSource, + configurationError: metadataErrors.get(apiAccessRecordIdentityKey(queries[index])) ?? resolvedSource.configurationError, + }; + }), + }; +} + +export async function loadQuotaSourceStages( + loadApiSources: () => Promise, + loadOAuthSources: () => Promise, + onApiSources?: (discovery: ApiQuotaDiscovery) => void | Promise, +): Promise { + let apiSources: ApiQuotaDiscovery; + try { + apiSources = await loadApiSources(); + } catch (error) { + if (isManagementAuthenticationError(error)) throw error; + apiSources = { sources: [], failedProtocols: [], errors: [apiQuotaErrorMessage(error)] }; + } + await onApiSources?.(apiSources); + await loadOAuthSources(); + return apiSources; +} + +export const saveApiQuotaBalanceUrl = async ( + source: Pick, + balanceUrl: string, + saveBalanceEndpoint: typeof saveApiAccessBalanceEndpoint = saveApiAccessBalanceEndpoint, +): Promise => { + const normalizedBalanceUrl = validateBalanceUrl(balanceUrl, source.baseUrl); + await saveBalanceEndpoint( + apiAccessRecordIdentityFor(source), + apiAccessRecordIdentityFor(source), + normalizedBalanceUrl, + ); + return normalizedBalanceUrl; +}; + +const redactedError = (error: unknown, secret: string) => { + const message = error instanceof Error ? error.message : String(error); + const normalizedSecret = secret.trim(); + return normalizedSecret ? message.split(normalizedSecret).join('[redacted]') : message; +}; + +const requestApiQuotaPayload = async (source: ApiQuotaSource): Promise => { + if (!source.adapter) throw new Error(apiQuotaText('quota.api.unsupported')); + if (!source.authIndex && !source.apiKey.trim()) { + throw new Error(apiQuotaText('quota.api.missingCredential')); + } + const token = source.authIndex ? '$TOKEN$' : source.apiKey.trim(); + const response = await managementApi.post>('/api-call', { + authIndex: source.authIndex || undefined, + method: 'GET', + url: source.adapter.endpoint, + header: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + }, { timeoutMs: 15_000 }); + const status = Number(response.status_code ?? response.statusCode ?? 0); + if (status < 200 || status >= 300) { + throw new Error(apiCallErrorMessage(response)); + } + return parseBody(response.body ?? response.bodyText); +}; + +export const queryApiQuotaSource = async (source: ApiQuotaSource): Promise => { + if (source.disabled) return { status: 'idle', rows: [] }; + if (source.configurationError) { + return { status: 'error', rows: [], error: source.configurationError }; + } + if (!source.adapter) { + return { status: 'error', rows: [], error: apiQuotaText('quota.api.unsupported') }; + } + try { + const rows = parseApiQuotaResponse( + source.adapter, + await requestApiQuotaPayload(source), + ); + if (rows.length === 0) throw new Error(apiQuotaText('quota.api.unrecognizedResponse')); + return { + status: 'success', + rows, + plan: apiQuotaVendorLabel(source.adapter.vendor), + fetchedAt: Date.now(), + }; + } catch (error) { + return { + status: 'error', + rows: [], + error: redactedError(error, source.apiKey), + fetchedAt: Date.now(), + }; + } +}; diff --git a/src/services/managementApi.ts b/src/services/managementApi.ts index dd2fddf..b4f1b5b 100644 --- a/src/services/managementApi.ts +++ b/src/services/managementApi.ts @@ -66,6 +66,18 @@ export const managementApi = { openAuthFilesDirectory: () => invoke('open_auth_files_directory'), }; +export const managementApiErrorDetails = (error: unknown): { status: number; message: string } | null => { + const message = error instanceof Error ? error.message : String(error); + // management_request returns format_management_error's HTTP status in this prefix. + const match = /^管理 API 错误 \((\d{3})\)(?::\s*([\s\S]*))?$/.exec(message.trim()); + return match ? { status: Number(match[1]), message: match[2] ?? '' } : null; +}; + +export const isManagementAuthenticationError = (error: unknown): boolean => { + const status = managementApiErrorDetails(error)?.status; + return status === 401 || status === 403; +}; + export function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } diff --git a/src/services/quotaCache.ts b/src/services/quotaCache.ts index ce33cc2..c275284 100644 --- a/src/services/quotaCache.ts +++ b/src/services/quotaCache.ts @@ -4,8 +4,11 @@ import type { QuotaState } from './quotaService'; type QuotaCache = Record; type QuotaCacheUpdater = QuotaCache | ((current: QuotaCache) => QuotaCache); +export const API_QUOTA_CACHE_PREFIX = 'api-quota::'; + let cache: QuotaCache = {}; -let generation = 0; +let oauthGeneration = 0; +const namespaceGenerations = new Map(); const listeners = new Set<() => void>(); const subscribe = (listener: () => void) => { @@ -15,10 +18,19 @@ const subscribe = (listener: () => void) => { const getSnapshot = () => cache; export const getQuotaCacheSnapshot = getSnapshot; -export const captureQuotaCacheGeneration = () => generation; +export const captureQuotaCacheGeneration = (namespacePrefix?: string) => namespacePrefix + ? namespaceGenerations.get(namespacePrefix) ?? 0 + : oauthGeneration; -export const commitQuotaCacheIfCurrent = (expectedGeneration: number, commit: () => void) => { - if (generation !== expectedGeneration) return false; +export const commitQuotaCacheIfCurrent = ( + expectedGeneration: number, + commit: () => void, + namespacePrefix?: string, +) => { + const currentGeneration = namespacePrefix + ? namespaceGenerations.get(namespacePrefix) ?? 0 + : oauthGeneration; + if (currentGeneration !== expectedGeneration) return false; commit(); return true; }; @@ -30,23 +42,74 @@ export const updateQuotaCache = (updater: QuotaCacheUpdater) => { listeners.forEach((listener) => listener()); }; -export const pruneQuotaCache = (validKeys: Set) => { +const pruneQuotaCacheWhere = ( + validKeys: Set, + belongsToNamespace: (key: string) => boolean, + namespacePrefix?: string, +) => { updateQuotaCache((current) => { const next = Object.fromEntries( Object.entries(current) - .filter(([key]) => validKeys.has(key)) + .filter(([key]) => !belongsToNamespace(key) || validKeys.has(key)) .map(([key, value]) => [ key, - value.status === 'loading' ? { status: 'idle', rows: [] } : value, + belongsToNamespace(key) && value.status === 'loading' + ? { status: 'idle', rows: [] } + : value, ]), ) as QuotaCache; - const unchanged = Object.keys(next).length === Object.keys(current).length; + const unchanged = Object.keys(next).length === Object.keys(current).length + && Object.entries(next).every(([key, value]) => value === current[key]); if (unchanged) return current; - generation += 1; + if (namespacePrefix) { + namespaceGenerations.set(namespacePrefix, (namespaceGenerations.get(namespacePrefix) ?? 0) + 1); + } else { + oauthGeneration += 1; + } return next; }); }; +export const pruneQuotaCache = (validKeys: Set) => { + pruneQuotaCacheWhere(validKeys, (key) => !key.startsWith(API_QUOTA_CACHE_PREFIX)); +}; + +export const pruneQuotaCacheNamespace = ( + namespacePrefix: string, + validKeys: Set, +) => { + pruneQuotaCacheWhere(validKeys, (key) => key.startsWith(namespacePrefix), namespacePrefix); +}; + +type QuotaRefreshTarget = { key: string; query: () => Promise }; + +export async function refreshQuotaCacheEntries(targets: QuotaRefreshTarget[], concurrency = 4) { + const pending = targets.filter(({ key }, index) => cache[key]?.status !== 'loading' + && targets.findIndex((target) => target.key === key) === index).map((target) => { + const namespace = target.key.startsWith(API_QUOTA_CACHE_PREFIX) ? API_QUOTA_CACHE_PREFIX : undefined; + return { ...target, namespace, generation: captureQuotaCacheGeneration(namespace) }; + }); + updateQuotaCache((current) => ({ + ...current, + ...Object.fromEntries(pending.map(({ key }) => [key, { ...current[key], status: 'loading', rows: [] }])), + })); + const batchSize = Math.max(1, Math.floor(concurrency)); + for (let index = 0; index < pending.length; index += batchSize) { + await Promise.all(pending.slice(index, index + batchSize).map(async (target) => { + if (captureQuotaCacheGeneration(target.namespace) !== target.generation) return; + let result: QuotaState; + try { + result = await target.query(); + } catch (error) { + result = { status: 'error', rows: [], error: error instanceof Error ? error.message : String(error) }; + } + commitQuotaCacheIfCurrent(target.generation, () => { + updateQuotaCache((current) => ({ ...current, [target.key]: result })); + }, target.namespace); + })); + } +} + export function useQuotaCache() { return useSyncExternalStore(subscribe, getSnapshot, getSnapshot); } diff --git a/src/services/quotaService.ts b/src/services/quotaService.ts index 5036d87..4f8cbe0 100644 --- a/src/services/quotaService.ts +++ b/src/services/quotaService.ts @@ -23,10 +23,17 @@ export type QuotaStatus = 'idle' | 'loading' | 'success' | 'error'; export type QuotaRow = { label: string; remainingPercent: number | null; + amount?: QuotaAmount; reset?: string; resetAtMs?: number; detail?: string; }; +export type QuotaAmount = { + remaining: number | null; + used: number | null; + total: number | null; + unit: string; +}; export type QuotaState = { status: QuotaStatus; rows: QuotaRow[]; diff --git a/src/styles.css b/src/styles.css index f8a6948..b03a7bb 100644 --- a/src/styles.css +++ b/src/styles.css @@ -9998,48 +9998,121 @@ body.table-col-resizing * { font-size: var(--font-size-label); } -.quota-group-list { +.quota-page-heading > div:first-child { display: grid; - gap: 18px; + gap: 5px; } -.quota-provider-group { +.quota-source-sections { display: grid; - gap: 10px; + gap: 26px; + margin-top: 18px; } -.quota-group-heading { - display: flex; - align-items: center; - justify-content: space-between; +.quota-source-section { + display: grid; gap: 12px; - color: var(--theme-7a746c); - font-size: var(--font-size-label); } -.quota-group-heading > div { - display: flex; +.quota-source-section-heading { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; align-items: center; - gap: 8px; + gap: 11px; } -.quota-group-heading h2 { +.quota-source-section-icon { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border-radius: 9px; +} + +.quota-oauth-section .quota-source-section-icon { + background: var(--theme-f1ecfb); + color: var(--theme-654f91); +} + +.quota-api-section .quota-source-section-icon { + background: var(--theme-eef7f0); + color: var(--theme-2f6b3a); +} + +.quota-source-section-heading h2 { margin: 0; color: var(--theme-2d2a26); font-size: var(--font-size-panel-title); } -.quota-group-heading .provider-logo { - width: 24px; - height: 24px; +.quota-source-section-heading > span { + color: var(--theme-7a746c); + font-size: var(--font-size-label); +} + +.quota-source-badge { + flex: 0 0 auto; + padding: 4px 7px; + border-radius: 6px; + font-size: var(--font-size-meta); + white-space: nowrap; +} + +.quota-source-badge.oauth { + background: var(--theme-f5f1ff); + color: var(--theme-654f91); +} + +.quota-source-badge.api { + background: var(--theme-eef7f0); + color: var(--theme-2f6b3a); +} + +.quota-page .real-quota-card { + gap: 12px; + padding: 14px; } -.real-quota-grid { +.quota-credential-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(min(340px, 100%), 1fr)); + grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } +.quota-api-section .real-quota-card { + gap: 8px; + padding: 12px 14px; +} + +.quota-balance-url-dialog { + width: min(560px, 100%); + height: auto; + grid-template-rows: none; + gap: 16px; +} + +.quota-balance-url-source, +.quota-balance-url-notice { + margin: 0; + color: var(--theme-7a746c); + font-size: var(--font-size-label); + line-height: var(--line-height-body); +} + +.quota-balance-url-notice { + padding: 10px 12px; + border-radius: 7px; + background: var(--theme-f4f2ec); +} + +.api-balance-url-hint { + display: block; + margin: -4px 0 10px; + color: var(--theme-7a746c); + font-size: var(--font-size-meta); + line-height: var(--line-height-body); +} + .real-quota-card { display: grid; gap: 16px; @@ -10059,6 +10132,27 @@ body.table-col-resizing * { min-width: 0; } +.real-quota-card-header .quota-card-copy { + flex: 1 1 auto; +} + +.real-quota-card-header .quota-card-title-line { + display: flex; + align-items: center; + gap: 7px; +} + +.real-quota-card-header .quota-source-badge { + padding: 3px 6px; + font-size: var(--font-size-meta); +} + +.quota-card-provider-logo { + width: 30px; + height: 30px; + flex: 0 0 auto; +} + .real-quota-card-header .quota-card-actions { display: flex; align-items: center; @@ -10200,6 +10294,31 @@ body.table-col-resizing * { color: var(--theme-2d2a26); } +.quota-amount-summary { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px 16px; + padding: 10px 12px; + border-radius: 7px; + background: var(--theme-f4f2ec); + color: var(--theme-7a746c); + font-size: var(--font-size-meta); +} + +.quota-amount-summary span { + white-space: nowrap; +} + +.quota-amount-summary strong { + color: var(--theme-2d2a26); +} + +.quota-api-section .quota-amount-summary span:first-child strong { + margin-left: 2px; + font-size: var(--font-size-panel-title); +} + .real-quota-track { height: 8px; overflow: hidden; @@ -10861,12 +10980,17 @@ body.table-col-resizing * { .oauth-grid, .quota-grid, - .quota-summary-grid { + .quota-summary-grid, + .quota-credential-grid { grid-template-columns: 1fr; } - .real-quota-grid { - grid-template-columns: 1fr; + .quota-source-section-heading { + grid-template-columns: 36px minmax(0, 1fr); + } + + .quota-source-section-heading > span { + grid-column: 2; } .real-provider-row { @@ -14471,7 +14595,7 @@ body.table-col-resizing * { .oauth-grid, .quota-grid, .quota-summary-grid, - .real-quota-grid, + .quota-credential-grid, .auth-filter-grid { grid-template-columns: 1fr; } diff --git a/tests/apiQuota.test.ts b/tests/apiQuota.test.ts new file mode 100644 index 0000000..ea4ae8a --- /dev/null +++ b/tests/apiQuota.test.ts @@ -0,0 +1,591 @@ +import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'; +import { apiAccessIconForQuotaSource } from '../src/pages/QuotaPage'; +import { managementApi } from '../src/services/managementApi'; +import { + apiAccessRecordIdentityFor, + apiQuotaAdapterFor, + apiQuotaCacheKey, + apiQuotaErrorMessage, + apiQuotaSourceLabel, + countApiQuotaCards, + countApiQuotaSources, + countApiQuotaUnsupported, + discoverApiQuotaSources, + apiQuotaSourcesFromConfig, + loadQuotaSourceStages, + resolveApiQuotaAdapter, + saveApiQuotaBalanceUrl, + validateBalanceUrl, + flattenApiQuotaRecords, + withApiQuotaBalanceUrl, + parseDeepSeekQuota, + parseNovitaQuota, + parseOpenRouterQuota, + parseSiliconFlowQuota, + parseStepFunQuota, + queryApiQuotaSource, + safeApiQuotaHostname, +} from '../src/services/apiQuota'; +import { + getQuotaCacheSnapshot, + pruneQuotaCache, + pruneQuotaCacheNamespace, + updateQuotaCache, + refreshQuotaCacheEntries, +} from '../src/services/quotaCache'; +import type { QuotaState } from '../src/services/quotaService'; + +const success = (body: unknown) => ({ status_code: 200, body }); + +type ApiCallRequest = { + authIndex?: string; + method: string; + url: string; + header: Record; +}; + +let get: ReturnType; +let post: ReturnType; +let put: ReturnType; +let calls: ApiCallRequest[]; +let handler: (request: ApiCallRequest) => unknown | Promise; + +beforeEach(() => { + calls = []; + handler = () => success({}); + get = spyOn(managementApi, 'get').mockImplementation(async (path) => { + if (path === '/config') return {}; + throw new Error(`Unexpected GET ${path}`); + }); + post = spyOn(managementApi, 'post').mockImplementation(async (path, body) => { + expect(path).toBe('/api-call'); + const request = body as unknown as ApiCallRequest; + calls.push(request); + return await handler(request) as never; + }); + put = spyOn(managementApi, 'put').mockImplementation(async () => success({}) as never); +}); + +afterEach(() => { + get.mockRestore(); + post.mockRestore(); + put.mockRestore(); + updateQuotaCache({}); +}); + +describe('API quota hostname adapters', () => { + it('matches exact supported hostnames and allows base URL paths', () => { + expect(apiQuotaAdapterFor('https://api.deepseek.com/v1')?.vendor).toBe('deepseek'); + expect(apiQuotaAdapterFor('https://openrouter.ai/anthropic')).toMatchObject({ vendor: 'openrouter' }); + expect(apiQuotaAdapterFor('https://api.siliconflow.com/v1')).toMatchObject({ vendor: 'siliconflow' }); + expect(apiQuotaAdapterFor('https://api.stepfun.ai/custom')).toMatchObject({ vendor: 'stepfun' }); + expect(apiQuotaAdapterFor('https://api.novita.ai/v1')).toMatchObject({ vendor: 'novita' }); + }); + + it('reuses the icon of the API Access category that owns each credential', () => { + const iconFor = (protocol: 'codex-api-key' | 'openai-compatibility' | 'claude-api-key' | 'gemini-api-key', recordName = '', baseUrl = '') => ( + apiAccessIconForQuotaSource({ protocol, recordName, baseUrl }) + ); + expect(iconFor('codex-api-key')).toContain('codex'); + expect(iconFor('claude-api-key')).toContain('claude'); + expect(iconFor('gemini-api-key')).toContain('gemini'); + expect(iconFor('openai-compatibility', 'OpenRouter', 'https://openrouter.ai/v1')).toContain('openai'); + expect(iconFor('openai-compatibility', 'DeepSeek', 'https://custom.example/v1')).toContain('deepseek'); + expect(iconFor('openai-compatibility', 'Custom', 'https://api.deepseek.com/v1')).toContain('deepseek'); + }); + + it('rejects malicious suffixes, subdomains, credentials, and unsupported schemes', () => { + expect(apiQuotaAdapterFor('https://api.deepseek.com.evil.example/v1')).toBeNull(); + expect(apiQuotaAdapterFor('https://evil.api.deepseek.com/v1')).toBeNull(); + expect(apiQuotaAdapterFor('https://user:password@api.deepseek.com/v1')).toBeNull(); + expect(apiQuotaAdapterFor('https://openrouter.ai.evil.example')).toBeNull(); + expect(apiQuotaAdapterFor('ftp://api.deepseek.com')).toBeNull(); + expect(apiQuotaAdapterFor('not a URL')).toBeNull(); + }); + + it('localizes backend balance metadata error codes and preserves unknown errors', () => { + expect(apiQuotaErrorMessage('api_balance.invalid_url')).toBe('余额查询 URL 无效'); + expect(apiQuotaErrorMessage(new Error('api_balance.invalid_base_url'))).toBe('API 接入 Base URL 无效'); + expect(apiQuotaErrorMessage('unexpected error')).toBe('unexpected error'); + }); + + it.each([ + ['zh-CN', '管理 API 请求失败(HTTP 500)'], + ['zh-TW', '管理 API 請求失敗(HTTP 500)'], + ['en', 'Management API request failed (HTTP 500)'], + ['ja', '管理 API リクエストに失敗しました(HTTP 500)'], + ] as const)('localizes management failures in %s while preserving the server detail', (locale, expected) => { + expect(apiQuotaErrorMessage(new Error('管理 API 错误 (500): upstream unavailable'), locale)).toBe(`${expected}: upstream unavailable`); + expect(apiQuotaErrorMessage('管理 API 错误 (500)', locale)).toBe(expected); + }); + + it('validates secure balance URLs and rejects conflicting supported vendors', () => { + expect(validateBalanceUrl('https://api.deepseek.com/user/balance', 'https://custom.example/v1')).toBe('https://api.deepseek.com/user/balance'); + expect(validateBalanceUrl('http://127.0.0.1:9000/balance', 'https://api.deepseek.com/v1')).toBe('http://127.0.0.1:9000/balance'); + expect(() => validateBalanceUrl('http://api.deepseek.com/balance', 'https://api.deepseek.com/v1')).toThrow(); + expect(() => validateBalanceUrl('https://openrouter.ai/api/v1/credits', 'https://api.deepseek.com/v1')).toThrow(); + expect(() => validateBalanceUrl('https://unknown.example/balance', 'https://api.deepseek.com/v1')).toThrow(); + expect(resolveApiQuotaAdapter('https://custom.example/v1', 'https://api.openrouter.ai/api/v1/credits')).toMatchObject({ adapter: null }); + }); +}); + +describe('API quota response parsers', () => { + it('parses DeepSeek balances', () => { + expect(parseDeepSeekQuota({ + is_available: true, + balance_infos: [{ currency: 'CNY', total_balance: '12.5' }], + })).toEqual([expect.objectContaining({ + remainingPercent: null, + amount: { remaining: 12.5, used: null, total: null, unit: 'CNY' }, + })]); + }); + + it('parses StepFun balances', () => { + expect(parseStepFunQuota({ balance: '7.25' })[0].amount).toEqual({ + remaining: 7.25, used: null, total: null, unit: 'CNY', + }); + }); + + it('parses SiliconFlow balances', () => { + const adapter = apiQuotaAdapterFor('https://api.siliconflow.cn/v1')!; + expect(parseSiliconFlowQuota({ data: { totalBalance: 31 } }, adapter)[0].amount).toEqual({ + remaining: 31, used: null, total: null, unit: 'CNY', + }); + }); + + it('parses OpenRouter used and total credits without a fabricated percentage', () => { + expect(parseOpenRouterQuota({ data: { total_credits: 100, total_usage: 35 } })[0].amount).toEqual({ + remaining: 65, used: 35, total: 100, unit: 'USD', + }); + expect(parseOpenRouterQuota({ data: { total_credits: 100, total_usage: 35 } })[0].remainingPercent).toBeNull(); + expect(parseOpenRouterQuota({ data: { total_credits: 10, total_usage: 12 } })[0].amount).toEqual({ + remaining: -2, used: 12, total: 10, unit: 'USD', + }); + }); + + it('parses Novita units from ten-thousandths of a dollar', () => { + expect(parseNovitaQuota({ availableBalance: 12500 })[0].amount).toEqual({ + remaining: 1.25, used: null, total: null, unit: 'USD', + }); + }); +}); + +describe('API quota source discovery and requests', () => { + it('flattens each OpenAI-compatible API key entry into an independent source', () => { + const sources = flattenApiQuotaRecords('openai-compatibility', [{ + name: 'Shared provider', + 'base-url': 'https://api.deepseek.com/v1', + 'api-key-entries': [ + { 'api-key': 'first-secret', 'auth-index': 'auth-first' }, + { 'api-key': 'second-secret', 'auth-index': 'auth-second' }, + ], + }]); + expect(sources).toHaveLength(2); + expect(sources[0].id).not.toBe(sources[1].id); + expect(sources[0].authIndex).toBe('auth-first'); + expect(sources[1].authIndex).toBe('auth-second'); + expect(sources[0].id).not.toContain('secret'); + expect(apiQuotaCacheKey(sources[0])).not.toContain('secret'); + }); + + it('invalidates cached quota identity when a direct API key changes', () => { + const [before] = flattenApiQuotaRecords('codex-api-key', [ + { 'base-url': 'https://api.deepseek.com/v1', 'api-key': 'first-secret' }, + ]); + const [after] = flattenApiQuotaRecords('codex-api-key', [ + { 'base-url': 'https://api.deepseek.com/v1', 'api-key': 'second-secret' }, + ]); + expect(after.id).not.toBe(before.id); + }); + + it('prefers record names and distinguishes duplicate records and entries', () => { + const sources = flattenApiQuotaRecords('openai-compatibility', [ + { + name: 'Shared account', + 'base-url': 'https://api.deepseek.com/v1', + 'api-key-entries': [{ 'api-key': 'first-secret' }], + }, + { + name: 'Shared account', + 'base-url': 'https://api.deepseek.com/v1', + 'api-key-entries': [ + { 'api-key': 'second-secret' }, + { 'api-key': 'third-secret' }, + ], + }, + ]); + expect(sources.map(apiQuotaSourceLabel)).toEqual([ + 'Shared account(1)', + 'Shared account(2) · API 凭据 1', + 'Shared account(2) · API 凭据 2', + ]); + expect(sources.map((source) => source.label)).toEqual(sources.map(apiQuotaSourceLabel)); + }); + + it('does not expose query-bearing record URLs in card labels', () => { + const [source] = flattenApiQuotaRecords('openai-compatibility', [{ + name: 'https://custom.example/provider?token=hidden', + 'base-url': 'https://custom.example/v1', + 'api-key': 'secret-key', + }]); + expect(source.label).toBe('custom.example'); + expect(source.label).not.toContain('hidden'); + }); + + it('uses only safe hostnames or protocol fallbacks for unnamed unsupported sources', () => { + const sources = flattenApiQuotaRecords('openai-compatibility', [ + { 'base-url': 'https://custom.example/v1?token=hidden-token', 'api-key': 'first-secret' }, + { 'base-url': 'https://user:password@another.example/v1', 'api-key': 'second-secret' }, + ]); + expect(safeApiQuotaHostname(sources[0].baseUrl)).toBe('custom.example'); + expect(sources[0].label).toContain('custom.example'); + expect(sources[0].label).not.toContain('hidden-token'); + expect(sources[0].label).not.toContain('first-secret'); + expect(sources[1].label).toContain('OpenAI'); + expect(sources[1].label).not.toContain('another.example'); + expect(sources[1].label).not.toContain('password'); + }); + + it('counts queryable credentials separately from displayed API cards and unsupported cards', () => { + const sources = flattenApiQuotaRecords('codex-api-key', [ + { 'base-url': 'https://api.deepseek.com/v1', 'api-key': 'enabled-secret' }, + { 'base-url': 'https://api.stepfun.com/v1', 'api-key': 'disabled-secret', disabled: true }, + { 'base-url': 'https://custom.example/v1', 'api-key': 'unsupported-secret' }, + { 'base-url': 'https://api.deepseek.com/v1', 'api-key': 'another-secret' }, + ]); + expect(countApiQuotaCards(sources)).toBe(4); + expect(countApiQuotaUnsupported(sources)).toBe(1); + expect(countApiQuotaSources(sources)).toBe(2); + }); + + it('applies GUI balance metadata to enriched runtime records', async () => { + const paths: string[] = []; + const discovery = await discoverApiQuotaSources(async (path) => { + paths.push(path); + if (path === '/config') return {}; + if (path === '/openai-compatibility') return { + 'openai-compatibility': [{ + name: 'Custom inference', + 'base-url': 'https://custom.example/v1', + 'api-key-entries': [{ 'api-key': 'configured-secret', 'auth-index': 'runtime-auth' }], + }], + }; + return { [path.slice(1)]: [] }; + }, async (queries) => { + expect(queries).toEqual([{ + providerSection: 'openai-compatibility', + recordName: 'Custom inference', + baseUrl: 'https://custom.example/v1', + apiKeys: ['configured-secret'], + }]); + return ['https://api.deepseek.com/user/balance?source=configured']; + }); + const [source] = discovery.sources; + expect(source.adapter).toMatchObject({ vendor: 'deepseek', endpoint: 'https://api.deepseek.com/user/balance?source=configured' }); + expect(source.authIndex).toBe('runtime-auth'); + expect(paths).toEqual([ + '/config', + '/codex-api-key', + '/openai-compatibility', + '/claude-api-key', + '/gemini-api-key', + ]); + handler = () => success({ balance_infos: [{ total_balance: 2 }] }); + await queryApiQuotaSource(source); + expect(calls[0]?.url).toBe('https://api.deepseek.com/user/balance?source=configured'); + expect(calls[0]?.authIndex).toBe('runtime-auth'); + }); + + it('persists balance metadata in GUI settings without mutating core providers', async () => { + const [source] = flattenApiQuotaRecords('openai-compatibility', [{ + name: 'Shared', + 'base-url': 'https://custom.example/v1', + 'api-key-entries': [{ 'api-key': 'hidden' }], + }]); + const calls: unknown[][] = []; + const result = await saveApiQuotaBalanceUrl( + source, + 'https://api.deepseek.com/user/balance', + async (...args) => { calls.push(args); }, + ); + const identity = apiAccessRecordIdentityFor(source); + expect(result).toBe('https://api.deepseek.com/user/balance'); + expect(calls).toEqual([[ + identity, + identity, + 'https://api.deepseek.com/user/balance', + ]]); + expect(put).not.toHaveBeenCalled(); + expect(post).not.toHaveBeenCalled(); + }); + + it('discovers all supported API Access sections without querying balances', async () => { + const paths: string[] = []; + const result = await discoverApiQuotaSources(async (path) => { + paths.push(path); + if (path === '/config') return {}; + if (path === '/openai-compatibility') return { + 'openai-compatibility': [{ name: 'DeepSeek', 'base-url': 'https://api.deepseek.com/v1', 'api-key-entries': [{ 'api-key': 'secret' }] }], + }; + return { [path.slice(1)]: [] }; + }, async () => [null]); + expect(result.sources).toHaveLength(1); + expect(paths).toEqual([ + '/config', + '/codex-api-key', + '/openai-compatibility', + '/claude-api-key', + '/gemini-api-key', + ]); + expect(post).not.toHaveBeenCalled(); + }); + + it('parses all API Access sections from one config payload', () => { + const result = apiQuotaSourcesFromConfig({ + 'codex-api-key': [{ 'base-url': 'https://api.deepseek.com/v1', 'api-key': 'codex-secret' }], + 'openai-compatibility': [{ 'base-url': 'https://api.stepfun.com/v1', 'api-key-entries': [{ 'api-key': 'openai-secret' }] }], + 'claude-api-key': [{ 'base-url': 'https://openrouter.ai/v1', 'api-key': 'claude-secret' }], + 'gemini-api-key': [{ 'base-url': 'https://api.novita.ai/v1', 'api-key': 'gemini-secret' }], + }); + expect(result.sources).toHaveLength(4); + expect(get).not.toHaveBeenCalled(); + expect(post).not.toHaveBeenCalled(); + }); + + it('short-circuits OAuth loading when API source loading rejects', async () => { + let oauthLoaded = false; + await expect(loadQuotaSourceStages( + async () => { throw new Error('管理 API 错误 (401): invalid management key'); }, + async () => { oauthLoaded = true; }, + )).rejects.toThrow('(401)'); + expect(oauthLoaded).toBe(false); + }); + + it('loads config once before auth-files and never posts during source staging', async () => { + const paths: string[] = []; + get.mockImplementation(async (path) => { + paths.push(path); + if (path === '/config') return {}; + if (path === '/auth-files') return { files: [] }; + return { [String(path).slice(1)]: [] }; + }); + await loadQuotaSourceStages( + discoverApiQuotaSources, + async () => { await managementApi.get('/auth-files'); }, + ); + expect(paths).toEqual([ + '/config', + '/codex-api-key', + '/openai-compatibility', + '/claude-api-key', + '/gemini-api-key', + '/auth-files', + ]); + expect(post).not.toHaveBeenCalled(); + }); + + it('skips auth-files after a management config failure', async () => { + const paths: string[] = []; + get.mockImplementation(async (path) => { + paths.push(path); + if (path === '/config') throw new Error('管理 API 错误 (401): invalid management key'); + return { files: [] }; + }); + await expect(loadQuotaSourceStages( + discoverApiQuotaSources, + async () => { await managementApi.get('/auth-files'); }, + )).rejects.toThrow('(401)'); + expect(paths).toEqual(['/config']); + expect(post).not.toHaveBeenCalled(); + }); + + it('uses auth-index and token placeholder before falling back to the direct key', async () => { + handler = (request) => { + expect(request.method).toBe('GET'); + return success({ balance: 3 }); + }; + const [withAuthIndex] = flattenApiQuotaRecords('codex-api-key', [{ + 'base-url': 'https://api.stepfun.com/v1', + 'api-key': 'direct-secret', + 'auth-index': 'credential-1', + }]); + expect(await queryApiQuotaSource(withAuthIndex)).toMatchObject({ status: 'success' }); + expect(calls[0]).toMatchObject({ + authIndex: 'credential-1', + url: 'https://api.stepfun.com/v1/accounts', + header: { Authorization: 'Bearer $TOKEN$', Accept: 'application/json' }, + }); + + calls = []; + const [direct] = flattenApiQuotaRecords('codex-api-key', [{ + 'base-url': 'https://api.stepfun.com/v1', + 'api-key': 'direct-secret', + }]); + await queryApiQuotaSource(direct); + expect(calls[0]).toMatchObject({ + authIndex: undefined, + header: { Authorization: 'Bearer direct-secret' }, + }); + }); + + it('redacts direct API keys from balance errors', async () => { + handler = () => ({ status_code: 401, body: 'invalid direct-secret' }); + const [source] = flattenApiQuotaRecords('codex-api-key', [{ + 'base-url': 'https://api.deepseek.com/v1', + 'api-key': 'direct-secret', + }]); + const result = await queryApiQuotaSource(source); + expect(result.error).toContain('[redacted]'); + expect(result.error).not.toContain('direct-secret'); + }); + + it('uses the fixed official endpoint for each supported vendor', async () => { + const cases = [ + ['https://api.deepseek.com/v1', { balance_infos: [{ total_balance: 1 }] }, 'https://api.deepseek.com/user/balance'], + ['https://api.stepfun.ai/v1', { balance: 1 }, 'https://api.stepfun.com/v1/accounts'], + ['https://api.siliconflow.cn/v1', { data: { totalBalance: 1 } }, 'https://api.siliconflow.cn/v1/user/info'], + ['https://openrouter.ai/anthropic', { data: { total_credits: 1, total_usage: 0 } }, 'https://openrouter.ai/api/v1/credits'], + ['https://api.novita.ai/v1', { availableBalance: 10000 }, 'https://api.novita.ai/v3/user/balance'], + ] as const; + handler = () => success({}); + for (const [baseUrl, body, endpoint] of cases) { + handler = () => success(body); + const [source] = flattenApiQuotaRecords('codex-api-key', [{ 'base-url': baseUrl, 'api-key': 'direct-secret' }]); + await queryApiQuotaSource(source); + expect(calls.at(-1)?.url).toBe(endpoint); + expect(calls.at(-1)?.header).toEqual({ Authorization: 'Bearer direct-secret', Accept: 'application/json' }); + } + }); +}); + +describe('quota loading failure isolation', () => { + it('keeps healthy API sources and loads OAuth after a provider returns 500', async () => { + const paths: string[] = []; + let oauthLoaded = false; + const result = await loadQuotaSourceStages( + () => discoverApiQuotaSources(async (path) => { + paths.push(path); + if (path === '/config') return {}; + if (path === '/claude-api-key') throw new Error('管理 API 错误 (500): unavailable'); + return { [path.slice(1)]: [{ 'api-key': 'test-key', 'base-url': 'https://api.deepseek.com/v1' }] }; + }, async (queries) => queries.map(() => null)), + async () => { oauthLoaded = true; }, + ); + expect(oauthLoaded).toBe(true); + expect(paths.at(-1)).toBe('/gemini-api-key'); + expect(result.sources).toHaveLength(3); + expect(result.failedProtocols).toEqual(['claude-api-key']); + expect(result.errors?.join(' ')).toContain('HTTP 500'); + }); + + it.each([401, 403])('stops all later reads if authentication expires with %s', async (status) => { + const paths: string[] = []; + let oauthLoaded = false; + await expect(loadQuotaSourceStages( + () => discoverApiQuotaSources(async (path) => { + paths.push(path); + if (path === '/config') return {}; + throw `管理 API 错误 (${status}): denied`; + }, async () => []), + async () => { oauthLoaded = true; }, + )).rejects.toContain(`(${status})`); + expect(oauthLoaded).toBe(false); + expect(paths).toEqual(['/config', '/codex-api-key']); + }); + + it('still loads OAuth when the config endpoint has a non-authentication failure', async () => { + let oauthLoaded = false; + const result = await loadQuotaSourceStages( + async () => { throw new Error('管理 API 错误 (500): unavailable'); }, + async () => { oauthLoaded = true; }, + ); + expect(oauthLoaded).toBe(true); + expect(result.errors?.[0]).toContain('HTTP 500'); + }); + + it('isolates malformed balance metadata while retaining other saved endpoints and OAuth', async () => { + let oauthLoaded = false; + const result = await loadQuotaSourceStages( + () => discoverApiQuotaSources(async (path) => path === '/openai-compatibility' ? { + 'openai-compatibility': [ + { name: 'bad', 'base-url': 'invalid', 'api-key-entries': [{ 'api-key': 'bad-key' }] }, + { name: 'healthy', 'base-url': 'https://custom.example/v1', 'api-key-entries': [{ 'api-key': 'good-key' }] }, + ], + } : {}, async (queries) => { + if (queries.some((query) => query.recordName === 'bad')) throw 'api_balance.invalid_base_url'; + return queries.map(() => 'https://api.deepseek.com/user/balance'); + }), + async () => { oauthLoaded = true; }, + ); + expect(oauthLoaded).toBe(true); + expect(result.sources[0].configurationError).toBeTruthy(); + expect(result.sources[1].configurationError).toBeUndefined(); + expect(result.sources[1].adapter?.endpoint).toBe('https://api.deepseek.com/user/balance'); + expect(result.errors).toHaveLength(1); + }); +}); + +describe('balance endpoint cache invalidation', () => { + const sharedSources = () => flattenApiQuotaRecords('openai-compatibility', [{ + name: 'Shared', 'base-url': 'https://custom.example/v1', + 'api-key-entries': [{ 'api-key': 'first-key' }, { 'api-key': 'second-key' }], + }]); + const oldEndpoint = 'https://api.deepseek.com/user/balance'; + const newEndpoint = 'https://openrouter.ai/api/v1/credits'; + + it('invalidates every key after rereading a record with a changed balance endpoint', async () => { + let endpoint = oldEndpoint; + const discover = () => discoverApiQuotaSources(async (path) => path === '/openai-compatibility' + ? { 'openai-compatibility': [{ name: 'Shared', 'base-url': 'https://custom.example/v1', + 'api-key-entries': [{ 'api-key': 'first-key' }, { 'api-key': 'second-key' }] }] } + : {}, async (queries) => queries.map(() => endpoint)); + const before = (await discover()).sources; + updateQuotaCache(Object.fromEntries(before.map((source) => [apiQuotaCacheKey(source), { status: 'success', rows: [], plan: 'old' }]))); + endpoint = newEndpoint; + const after = (await discover()).sources; + pruneQuotaCacheNamespace('api-quota::', new Set(after.map(apiQuotaCacheKey))); + expect(after.map((source) => source.id)).toEqual(before.map((source) => source.id)); + expect(after.map(apiQuotaCacheKey)).not.toEqual(before.map(apiQuotaCacheKey)); + expect(getQuotaCacheSnapshot()).toEqual({}); + }); + + it('rejects an old response after saving a new endpoint and preserves the new response', async () => { + const source = sharedSources()[0]; + const oldSource = withApiQuotaBalanceUrl(source, oldEndpoint); + const newSource = withApiQuotaBalanceUrl(source, newEndpoint); + let completeOld!: (result: QuotaState) => void; + const pending = refreshQuotaCacheEntries([{ key: apiQuotaCacheKey(oldSource), + query: () => new Promise((resolve) => { completeOld = resolve; }) }]); + pruneQuotaCacheNamespace('api-quota::', new Set([apiQuotaCacheKey(newSource)])); + await refreshQuotaCacheEntries([{ key: apiQuotaCacheKey(newSource), + query: async () => ({ status: 'success', rows: [], plan: 'new' }) }]); + completeOld({ status: 'success', rows: [], plan: 'old' }); + await pending; + expect(getQuotaCacheSnapshot()[apiQuotaCacheKey(oldSource)]).toBeUndefined(); + expect(getQuotaCacheSnapshot()[apiQuotaCacheKey(newSource)]?.plan).toBe('new'); + }); +}); + +describe('API quota cache isolation', () => { + it('prunes only the API namespace and preserves OAuth quota entries', () => { + updateQuotaCache({ + 'oauth-file::one': { status: 'success', rows: [] }, + 'api-quota::keep': { status: 'success', rows: [] }, + 'api-quota::remove': { status: 'success', rows: [] }, + }); + pruneQuotaCacheNamespace('api-quota::', new Set(['api-quota::keep'])); + expect(getQuotaCacheSnapshot()).toEqual({ + 'oauth-file::one': { status: 'success', rows: [] }, + 'api-quota::keep': { status: 'success', rows: [] }, + }); + }); + + it('does not reset an API request when the OAuth namespace is refreshed', () => { + updateQuotaCache({ + 'oauth-file::one': { status: 'success', rows: [] }, + 'api-quota::loading': { status: 'loading', rows: [] }, + }); + pruneQuotaCache(new Set(['oauth-file::one'])); + expect(getQuotaCacheSnapshot()['api-quota::loading']).toEqual({ status: 'loading', rows: [] }); + }); +}); diff --git a/tests/navigationAndUpdate.test.ts b/tests/navigationAndUpdate.test.ts index 7a1999f..f050579 100644 --- a/tests/navigationAndUpdate.test.ts +++ b/tests/navigationAndUpdate.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from 'bun:test'; +import { appPageIds } from '../src/App'; import { appUpdateIndicatorState } from '../src/appUpdateModel'; import { canOpenAppPage, isAlwaysAvailablePage } from '../src/navigation'; import { oauthSubpages } from '../src/oauthNavigation'; @@ -23,16 +24,30 @@ describe('简易模式、首页、配置与版本管理导航', () => { test('内核运行后解锁其他功能页', () => { expect(canOpenAppPage('config', true)).toBe(true); expect(canOpenAppPage('agents', true)).toBe(true); + expect(canOpenAppPage('quota', true)).toBe(true); }); }); -describe('OAuth 子页面导航', () => { - test('认证文件和额度查询收纳在 OAuth 页面内', () => { - expect(oauthSubpages.map((page) => page.id)).toEqual(['login', 'authFiles', 'quota']); +describe('Top-level sidebar navigation', () => { + test('keeps API access, OAuth, quota, usage records, and agents in product order', () => { + const apiIndex = appPageIds.indexOf('api'); + expect(appPageIds.slice(apiIndex, apiIndex + 5)).toEqual([ + 'api', + 'oauth', + 'quota', + 'usage-records', + 'agents', + ]); + + }); +}); + +describe('OAuth subpage navigation', () => { + test('OAuth keeps login and auth file subpages', () => { + expect(oauthSubpages.map((page) => page.id)).toEqual(['login', 'authFiles']); expect(oauthSubpages.map((page) => page.labelKey)).toEqual([ 'oauth.title', 'authFiles.title', - 'quota.title', ]); }); }); diff --git a/tests/operationFeedback.test.ts b/tests/operationFeedback.test.ts index 2b9f037..434ab7e 100644 --- a/tests/operationFeedback.test.ts +++ b/tests/operationFeedback.test.ts @@ -56,7 +56,7 @@ describe('控件自身反馈', () => { visit(source); const attributes = button?.attributes.properties.filter(ts.isJsxAttribute); expect(attributes?.find((attribute) => attribute.name.getText(source) === 'className')?.initializer?.getText(source)) - .toBe("{`${disabled ? 'primary-button' : 'secondary-button'} compact-button`}"); + .toBe("{`${disabled ? 'primary-button' : 'secondary-button'} compact-button auth-card-toggle`}"); expect(attributes?.find((attribute) => attribute.name.getText(source) === 'disabled')?.initializer?.getText(source)) .toBe('{busy}'); const styles = readFileSync(new URL('../src/styles.css', import.meta.url), 'utf8'); diff --git a/tests/providerConfig.test.ts b/tests/providerConfig.test.ts index ff10e29..4502292 100644 --- a/tests/providerConfig.test.ts +++ b/tests/providerConfig.test.ts @@ -8,10 +8,12 @@ import { hasDuplicateProviderRecord, DEEPSEEK_BASE_URL, exclusionsForModelSelection, + loadProviderRecords, modelSelectionForDiscovery, parseProviderHeaders, parseProviderApiKeys, providerCategoryMatchesRecord, + providerRecordsFromConfig, providerDragId, providerRecordWithDisabledState, providerRemarkIdentity, @@ -222,6 +224,69 @@ it('parses multiline API keys into unique trimmed entries', () => { expect(parseProviderApiKeys(' key-a\n\nkey-b\r\nkey-a ')).toEqual(['key-a', 'key-b']); }); +it('keeps balance metadata out of core provider records while preserving extensions', () => { + const current = { + name: 'shared-provider', + 'base-url': 'https://custom.example/v1', + 'balance-url': 'https://api.deepseek.com/user/balance', + 'api-key-entries': [{ 'api-key': 'one' }, { 'api-key': 'two' }], + hidden: { keep: true }, + }; + const next = buildProviderRecord('openai-compatibility', { + name: 'shared-provider', + apiKey: 'one\ntwo', + baseUrl: 'https://custom.example/v1', + balanceUrl: 'https://openrouter.ai/api/v1/credits', + priority: '', + models: [], + }, current); + expect(next['balance-url']).toBeUndefined(); + expect(next.hidden).toEqual({ keep: true }); + expect(next['api-key-entries']).toEqual([{ 'api-key': 'one' }, { 'api-key': 'two' }]); +}); + +it('parses all API Access provider sections from one config payload', () => { + const records = providerRecordsFromConfig({ + 'codex-api-key': [{ 'api-key': 'codex' }], + 'openai-compatibility': [{ name: 'openai' }], + 'claude-api-key': [{ 'api-key': 'claude' }], + 'gemini-api-key': [{ 'api-key': 'gemini' }], + }); + expect(Object.values(records).map((items) => items.length)).toEqual([1, 1, 1, 1]); +}); + +it('authenticates once before loading enriched API Access records sequentially', async () => { + const paths: string[] = []; + const records = await loadProviderRecords(async (path) => { + paths.push(path); + if (path === '/config') return {}; + return { + 'codex-api-key': [{ 'api-key': 'codex' }], + 'openai-compatibility': [{ name: 'openai' }], + 'claude-api-key': [{ 'api-key': 'claude' }], + 'gemini-api-key': [{ 'api-key': 'gemini' }], + }; + }); + + expect(paths).toEqual([ + '/config', + '/codex-api-key', + '/openai-compatibility', + '/claude-api-key', + '/gemini-api-key', + ]); + expect(Object.values(records).map((items) => items.length)).toEqual([1, 1, 1, 1]); +}); + +it('propagates API Access config failures without fallback requests', async () => { + const paths: string[] = []; + await expect(loadProviderRecords(async (path) => { + paths.push(path); + throw new Error('management 401'); + })).rejects.toThrow('management 401'); + expect(paths).toEqual(['/config']); +}); + it('keeps remark identities separate for records that share an API key', () => { const first = apiAccessRemarkLocatorFromRecord('codex-api-key', { 'api-key': 'shared-key', diff --git a/tests/quotaCache.test.ts b/tests/quotaCache.test.ts index b3a84c0..364c54f 100644 --- a/tests/quotaCache.test.ts +++ b/tests/quotaCache.test.ts @@ -4,8 +4,11 @@ import { commitQuotaCacheIfCurrent, getQuotaCacheSnapshot, pruneQuotaCache, + pruneQuotaCacheNamespace, + refreshQuotaCacheEntries, updateQuotaCache, } from '../src/services/quotaCache'; +import type { QuotaState } from '../src/services/quotaService'; describe('额度跨页面缓存', () => { it('保留仍存在的认证文件额度并清理失效项', () => { @@ -35,4 +38,38 @@ describe('额度跨页面缓存', () => { expect(committed).toBe(false); expect(getQuotaCacheSnapshot().retained).toEqual({ status: 'idle', rows: [] }); }); + + it.each(['oauth', 'api'])('keeps the other namespace running when %s is reloaded during refresh all', async (reloaded) => { + updateQuotaCache({}); + let finishOAuth!: (result: QuotaState) => void; + let finishApi!: (result: QuotaState) => void; + const refresh = refreshQuotaCacheEntries([ + { key: 'oauth', query: () => new Promise((resolve) => { finishOAuth = resolve; }) }, + { key: 'api-quota::one', query: () => new Promise((resolve) => { finishApi = resolve; }) }, + ]); + if (reloaded === 'oauth') pruneQuotaCache(new Set(['oauth'])); + else pruneQuotaCacheNamespace('api-quota::', new Set(['api-quota::one'])); + finishOAuth({ status: 'success', rows: [], plan: 'OAuth' }); + finishApi({ status: 'success', rows: [], plan: 'API' }); + await refresh; + expect(getQuotaCacheSnapshot().oauth.status).toBe(reloaded === 'oauth' ? 'idle' : 'success'); + expect(getQuotaCacheSnapshot()['api-quota::one'].status).toBe(reloaded === 'api' ? 'idle' : 'success'); + expect(Object.values(getQuotaCacheSnapshot()).some((quota) => quota.status === 'loading')).toBe(false); + }); + + it('continues queued API requests after OAuth changes and skips obsolete queued OAuth requests', async () => { + updateQuotaCache({}); + let finishFirst!: (result: QuotaState) => void; + const called: string[] = []; + const refresh = refreshQuotaCacheEntries([ + { key: 'oauth-first', query: () => new Promise((resolve) => { finishFirst = resolve; }) }, + { key: 'oauth-next', query: async () => { called.push('OAuth'); return { status: 'success', rows: [] }; } }, + { key: 'api-quota::next', query: async () => { called.push('API'); return { status: 'success', rows: [] }; } }, + ], 1); + pruneQuotaCache(new Set(['oauth-first', 'oauth-next'])); + finishFirst({ status: 'success', rows: [] }); + await refresh; + expect(called).toEqual(['API']); + expect(getQuotaCacheSnapshot()['api-quota::next'].status).toBe('success'); + }); }); diff --git a/tests/quotaRendering.test.tsx b/tests/quotaRendering.test.tsx index e16f2ca..c9ee523 100644 --- a/tests/quotaRendering.test.tsx +++ b/tests/quotaRendering.test.tsx @@ -1,7 +1,10 @@ import { describe, expect, it } from 'bun:test'; +import { act, useState } from 'react'; +import { createRoot } from 'react-dom/client'; import { renderToStaticMarkup } from 'react-dom/server'; import { I18nProvider } from '../src/i18n'; -import { QuotaCard } from '../src/pages/QuotaPage'; +import { ApiBalanceUrlDialog, ApiQuotaCard, QuotaCard } from '../src/pages/QuotaPage'; +import { apiQuotaAdapterFor, type ApiQuotaSource } from '../src/services/apiQuota'; import { quotaRowsFor } from '../src/services/quotaService'; import type { QuotaState } from '../src/services/quotaService'; @@ -39,6 +42,7 @@ describe('quota card rendering', () => { expect(html).not.toContain('real-quota-track'); expect(html).not.toContain('测试可用性'); expect(html).toContain('获取/刷新额度'); + expect(html).toContain('OAuth'); }); it('xAI 探测成功显示可用说明,100% 额度保留刷新按钮', () => { @@ -59,4 +63,137 @@ describe('quota card rendering', () => { expect(html).toContain('重置时间已到,请刷新确认'); expect(html).toContain('剩余 0%'); }); + + it('renders API monetary balances without fabricating a percentage meter', () => { + const source: ApiQuotaSource = { + id: 'safe-source', + protocol: 'openai-compatibility', + recordIndex: 0, + entryIndex: 0, + entryCount: 1, + recordName: 'ignored-record-name', + recordOrdinal: 1, + recordCount: 1, + baseUrl: 'https://openrouter.ai/v1', + balanceUrl: '', + authIndex: 'auth-index', + apiKey: 'secret-key', + adapter: apiQuotaAdapterFor('https://openrouter.ai/v1'), + label: 'ignored-safe-label', + disabled: false, + }; + const html = renderToStaticMarkup( + + {}} + onConfigure={() => {}} + /> + , + ); + expect(html).toContain('剩余'); + expect(html).toContain('已用'); + expect(html).toContain('总量'); + expect(html).toContain('65 USD'); + expect(html).toContain('API Key'); + expect(html).toContain('余额接口'); + expect(html).toContain('provider-logo quota-card-provider-logo'); + expect(html).toContain('openai-light'); + expect(html).not.toContain('real-quota-track'); + expect(html).not.toContain('secret-key'); + }); + + const interactionTest = typeof document === 'undefined' ? it.skip : it; + + interactionTest('opens the balance endpoint dialog and handles close and save actions', async () => { + const source: ApiQuotaSource = { + id: 'interactive-source', + protocol: 'openai-compatibility', + recordIndex: 0, + entryIndex: 0, + entryCount: 1, + recordName: 'Interactive provider', + recordOrdinal: 1, + recordCount: 1, + baseUrl: 'https://custom.example/v1', + balanceUrl: 'https://api.deepseek.com/user/balance?test=1', + authIndex: 'auth-index', + apiKey: 'secret-key', + adapter: apiQuotaAdapterFor('https://custom.example/v1'), + label: 'Interactive provider', + disabled: false, + }; + const container = document.createElement('div'); + document.body.appendChild(container); + window.localStorage.setItem('easy-cli-proxy-api.locale', 'en'); + let savedValue = ''; + + function Harness() { + const [open, setOpen] = useState(false); + return ( + <> + {}} onConfigure={() => setOpen(true)} /> + {open ? ( + setOpen(false)} + onSave={(value) => { savedValue = value; setOpen(false); }} + /> + ) : null} + + ); + } + + const root = createRoot(container); + await act(async () => { + root.render(); + }); + const endpointButton = Array.from(container.querySelectorAll('button')).find((button) => button.textContent === 'Balance endpoint'); + expect(endpointButton).toBeDefined(); + await act(async () => { + endpointButton?.click(); + }); + const dialog = document.body.querySelector('[role="dialog"]'); + const input = dialog?.querySelector('input[type="url"]') as HTMLInputElement | null; + expect(dialog).not.toBeNull(); + expect(input?.className).toBe('config-dialog-text-input'); + expect(input?.parentElement?.className).toBe('config-dialog-field'); + expect(input?.value).toBe(source.balanceUrl); + + const closeButton = dialog?.querySelector('button.icon-button') as HTMLButtonElement | null; + expect(closeButton).not.toBeNull(); + await act(async () => { + closeButton?.click(); + }); + expect(document.body.querySelector('[role="dialog"]')).toBeNull(); + + await act(async () => { + endpointButton?.click(); + }); + const reopenedInput = document.body.querySelector('input[type="url"]') as HTMLInputElement; + await act(async () => { + const setInputValue = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + setInputValue?.call(reopenedInput, 'https://api.deepseek.com/user/balance?updated=1'); + reopenedInput.dispatchEvent(new Event('input', { bubbles: true })); + reopenedInput.dispatchEvent(new Event('change', { bubbles: true })); + }); + const saveButton = document.body.querySelector('button.primary-button') as HTMLButtonElement | null; + await act(async () => { + saveButton?.click(); + }); + expect(savedValue).toBe('https://api.deepseek.com/user/balance?updated=1'); + expect(document.body.querySelector('[role="dialog"]')).toBeNull(); + root.unmount(); + container.remove(); + }); }); diff --git a/tests/uiLocalization.test.ts b/tests/uiLocalization.test.ts index d845aa9..2cc9c21 100644 --- a/tests/uiLocalization.test.ts +++ b/tests/uiLocalization.test.ts @@ -5,7 +5,17 @@ import { fileURLToPath } from 'node:url'; import ts from 'typescript'; const sourceRoot = fileURLToPath(new URL('../src/', import.meta.url)); -const technicalText = new Set(['EasyCLIProxyAPI', 'WebSocket', 'Fast', 'ms', 'auto']); +const technicalText = new Set([ + 'EasyCLIProxyAPI', + 'WebSocket', + 'Fast', + 'HTTP', + 'auto', + 'excluded_models', + 'headers', + 'ms', + 'note', +]); const technicalPlaceholders = new Set(['1h', 'sk-...', 'gpt-5.6-terra', 'https://...']); function componentFiles(directory: string): string[] {