From 100f5df737abc73356b4cb07ee870099159a6543 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 20 Sep 2026 12:34:02 +0800 Subject: [PATCH] Revert "feat(quota): unify OAuth and API balance lookup (#279)" This reverts commit d473c8b8f186e82eb63e15d7a3bc54f05810b087. --- src-tauri/src/agents/backups/tests.rs | 6 - src-tauri/src/app_settings.rs | 109 ---- src-tauri/src/core_config/settings.rs | 133 ----- src-tauri/src/main.rs | 14 - src-tauri/src/tests/app_settings.rs | 138 ----- src-tauri/src/tests/core_config.rs | 1 - src-tauri/src/usage.rs | 1 - src/App.tsx | 18 +- src/i18n/ja.ts | 53 +- src/i18n/locales/en.ts | 53 +- src/i18n/locales/zh-CN.ts | 53 +- src/oauthNavigation.ts | 1 + src/pages/ApiAccessPage.tsx | 114 ++-- src/pages/ManagementPages.tsx | 2 + src/pages/QuotaPage.tsx | 420 +++----------- src/services/apiQuota.ts | 775 -------------------------- src/services/managementApi.ts | 12 - src/services/quotaCache.ts | 81 +-- src/services/quotaService.ts | 7 - src/styles.css | 172 +----- tests/apiQuota.test.ts | 591 -------------------- tests/navigationAndUpdate.test.ts | 23 +- tests/operationFeedback.test.ts | 2 +- tests/providerConfig.test.ts | 65 --- tests/quotaCache.test.ts | 37 -- tests/quotaRendering.test.tsx | 139 +---- tests/uiLocalization.test.ts | 12 +- 27 files changed, 161 insertions(+), 2871 deletions(-) delete mode 100644 src/services/apiQuota.ts delete mode 100644 tests/apiQuota.test.ts diff --git a/src-tauri/src/agents/backups/tests.rs b/src-tauri/src/agents/backups/tests.rs index ff0f09a..0723fd5 100644 --- a/src-tauri/src/agents/backups/tests.rs +++ b/src-tauri/src/agents/backups/tests.rs @@ -1106,9 +1106,6 @@ 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(); @@ -1116,9 +1113,6 @@ 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 68af397..81e0820 100644 --- a/src-tauri/src/app_settings.rs +++ b/src-tauri/src/app_settings.rs @@ -1,114 +1,5 @@ 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 bef77a6..5d5808b 100644 --- a/src-tauri/src/core_config/settings.rs +++ b/src-tauri/src/core_config/settings.rs @@ -34,94 +34,11 @@ 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"), @@ -1710,34 +1627,6 @@ pub(crate) fn sanitize_gui_config(config: &mut GuiConfigFile) -> Result(&content) @@ -2012,9 +1882,6 @@ 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 95026d5..fe07775 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -634,7 +634,6 @@ 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, @@ -871,14 +870,6 @@ 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 { @@ -948,9 +939,6 @@ 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, @@ -2602,9 +2590,7 @@ 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 cfbe91b..d005f8a 100644 --- a/src-tauri/src/tests/app_settings.rs +++ b/src-tauri/src/tests/app_settings.rs @@ -200,141 +200,3 @@ 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 3d57feb..d5d9a12 100644 --- a/src-tauri/src/tests/core_config.rs +++ b/src-tauri/src/tests/core_config.rs @@ -1152,7 +1152,6 @@ 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 a78e600..21ea7ca 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -4802,7 +4802,6 @@ 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 4e30dc7..56d4ddc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,7 +7,6 @@ import { Check, ChevronUp, ExternalLink, - Gauge, History, House, Languages, @@ -31,7 +30,6 @@ 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'; @@ -66,10 +64,10 @@ const pages = [ component: OAuthManagementPage, }, { - id: 'quota', - labelKey: 'app.nav.quota', - icon: Gauge, - component: QuotaPage, + id: 'agents', + labelKey: 'app.nav.agents', + icon: Bot, + component: AgentsPage, }, { id: 'usage-records', @@ -77,12 +75,6 @@ const pages = [ icon: History, component: UsageRecordsPage, }, - { - id: 'agents', - labelKey: 'app.nav.agents', - icon: Bot, - component: AgentsPage, - }, { id: 'config', labelKey: 'app.nav.config', @@ -97,8 +89,6 @@ 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 62fabc9..fba254c 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -385,59 +385,8 @@ export const jaOverrides = { 'quota.refreshAll': 'すべて更新', 'quota.loadingFiles': '認証ファイルを読み込んでいます', 'quota.empty.title': '照会可能なクォータがありません', - 'quota.empty.description': 'まず認証ファイルまたは API 接続にクォータ照会対応の認証情報を追加してください。', + 'quota.empty.description': 'まず認証ファイルにクォータ照会対応の認証情報を追加してください。', '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 933cc35..5a68b5a 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -372,59 +372,8 @@ 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 or API Access first.', + 'quota.empty.description': 'Add a credential that supports quota queries under Auth Files 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 bbf53e6..fdaf3c7 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -371,59 +371,8 @@ export const zhCN = { 'quota.refreshAll': '刷新全部', 'quota.loadingFiles': '读取凭证文件中', 'quota.empty.title': '暂无可查询配额', - 'quota.empty.description': '先在凭证文件或 API 接入中添加支持额度查询的凭据。', + 'quota.empty.description': '先在凭证文件中添加支持配额查询的凭据。', '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 c28758c..4c8ca9e 100644 --- a/src/oauthNavigation.ts +++ b/src/oauthNavigation.ts @@ -1,6 +1,7 @@ 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 321840d..42c858e 100644 --- a/src/pages/ApiAccessPage.tsx +++ b/src/pages/ApiAccessPage.tsx @@ -50,6 +50,7 @@ import { readBoolean, readNumber, readString, + responseList, } from '../services/managementApi'; import { DEEPSEEK_BASE_URL, @@ -75,16 +76,6 @@ 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' @@ -114,7 +105,6 @@ type ProviderRow = { apiKey: string; apiKeys: string[]; baseUrl: string; - balanceUrl: string; models: ModelOption[]; disabled: boolean; priority: number | null; @@ -217,7 +207,6 @@ export type ProviderDraft = { apiKey: string; remark: string; baseUrl: string; - balanceUrl?: string; priority: string; models: ModelOption[]; prefix?: string; @@ -258,6 +247,11 @@ 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': [], @@ -296,22 +290,6 @@ 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, @@ -338,7 +316,6 @@ 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') @@ -401,8 +378,6 @@ 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; @@ -627,7 +602,6 @@ 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'), @@ -665,7 +639,6 @@ const emptyProviderDraft = (): ProviderDraft => ({ apiKey: '', remark: '', baseUrl: '', - balanceUrl: '', priority: '', models: [], prefix: '', @@ -981,7 +954,6 @@ 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); @@ -995,20 +967,31 @@ 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[] = []; - const nextRecords = await loadProviderRecords(managementApi.get, (protocol, requestError) => { - failures.push(`${t(definitionFor(protocol).labelKey)}: ${apiQuotaErrorMessage(requestError)}`); + 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; }); - 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] ?? '', - ]))); + if (failures.length > 0) { + setError(t('apiAccess.error.partialLoad', { errors: failures.join('; ') })); + } } catch (requestError) { setError(String(requestError)); } finally { @@ -1053,7 +1036,6 @@ 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, @@ -1070,7 +1052,7 @@ export function ApiAccessPage() { .toLowerCase() .includes(query); }), - [activeCategory, activeSection, apiAccessRemarks, apiBalanceUrls, filter, records, t], + [activeCategory, activeSection, apiAccessRemarks, filter, records, t], ); const openCreate = () => { @@ -1101,7 +1083,6 @@ 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'; @@ -1136,12 +1117,10 @@ 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) }; @@ -1149,7 +1128,7 @@ export function ApiAccessPage() { setBusy(true); setError(''); try { - let draftToSave: ProviderDraft = { ...preparedDraftForSave, baseUrl, balanceUrl }; + let draftToSave = { ...preparedDraftForSave, baseUrl }; if ( definition.openAi && activeCategory !== 'deepseek' @@ -1179,8 +1158,8 @@ export function ApiAccessPage() { models: fetchedModels, }); } - const latestRecords = await loadProviderRecords(); - const current = latestRecords[activeSection]; + const latestConfig = await managementApi.get('/config'); + const current = sectionRecordsFromConfig(latestConfig, activeSection); let nextList: Record[]; let targetIndex = -1; let currentRecord: Record | undefined; @@ -1212,16 +1191,6 @@ 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, @@ -1257,11 +1226,6 @@ 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, @@ -1287,8 +1251,8 @@ export function ApiAccessPage() { setError(''); setNotice(''); try { - const latestRecords = await loadProviderRecords(); - const latestRows = latestRecords[row.section]; + const latestConfig = await managementApi.get('/config'); + const latestRows = sectionRecordsFromConfig(latestConfig, row.section); const targetIndex = resolveProviderRecordIndex(latestRows, row); if (targetIndex < 0) { throw new Error(t('apiAccess.error.stale')); @@ -1329,8 +1293,8 @@ export function ApiAccessPage() { setError(''); setNotice(''); try { - const latestRecords = await loadProviderRecords(); - const latestRows = latestRecords[source.section]; + const latestConfig = await managementApi.get('/config'); + const latestRows = sectionRecordsFromConfig(latestConfig, source.section); const nextRows = reorderProviderRecords(latestRows, rows, source, target); if (!nextRows) throw new Error(t('apiAccess.error.stale')); await managementApi.put(`/${source.section}`, nextRows); @@ -1840,7 +1804,7 @@ export function ApiProviderDialog({ ), [modelOptions, selectedModelNames]); const updateTextField = ( - field: 'apiKey' | 'remark' | 'baseUrl' | 'balanceUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText', + field: 'apiKey' | 'remark' | 'baseUrl' | 'priority' | 'prefix' | 'headersText' | 'excludedModelsText' | 'testModel' | 'cloakMode' | 'cloakSensitiveWordsText', value: string, ) => { setFormError(''); @@ -2063,8 +2027,6 @@ export function ApiProviderDialog({ /> - - {t('quota.api.balanceUrl.description')} {activeCategory === 'openai-compatibility' ? (
diff --git a/src/pages/ManagementPages.tsx b/src/pages/ManagementPages.tsx index 3ca20b2..98f5aea 100644 --- a/src/pages/ManagementPages.tsx +++ b/src/pages/ManagementPages.tsx @@ -28,6 +28,7 @@ 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'; @@ -150,6 +151,7 @@ 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 728cb6c..27a3c0e 100644 --- a/src/pages/QuotaPage.tsx +++ b/src/pages/QuotaPage.tsx @@ -1,7 +1,6 @@ -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'; @@ -9,30 +8,9 @@ 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 { @@ -47,11 +25,10 @@ import { type QuotaState, } from '../services/quotaService'; import { - API_QUOTA_CACHE_PREFIX, + captureQuotaCacheGeneration, + commitQuotaCacheIfCurrent, getQuotaCacheSnapshot, pruneQuotaCache, - pruneQuotaCacheNamespace, - refreshQuotaCacheEntries, updateQuotaCache, useQuotaCache, } from '../services/quotaCache'; @@ -68,121 +45,57 @@ const providerMeta: Record = { }; const providerOrder: QuotaProvider[] = ['claude', 'antigravity', 'codex', 'xai', 'kimi', 'devin']; -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; -}; +const REFRESH_CONCURRENCY = 4; 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 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; + const loadFiles = useCallback(async () => { setLoading(true); setError(''); try { - 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('; ')}`); - } + 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; }); } catch (requestError) { - if (sourceLoadRevision.current === revision) setError(apiQuotaErrorMessage(requestError)); + setError(String(requestError)); } finally { - if (sourceLoadRevision.current === revision) setLoading(false); + setLoading(false); } - }, [applyApiSources, loadOAuthFiles, t]); + }, []); useEffect(() => { - void loadSources(); - return () => { sourceLoadRevision.current += 1; }; - }, [loadSources]); + void loadFiles(); + }, [loadFiles]); const refreshOne = useCallback(async (file: AuthFile) => { const key = quotaKey(file); - 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]); + 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]); const resetCodexQuota = useCodexQuotaReset(askConfirmation, setError); @@ -190,19 +103,29 @@ export function QuotaPage() { if (Object.values(getQuotaCacheSnapshot()).some((quota) => quota.status === 'loading')) return; setRefreshing(true); setError(''); - 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) })), - ]; + const cacheGeneration = captureQuotaCacheGeneration(); + updateQuotaCache((current) => ({ + ...current, + ...Object.fromEntries(files.map((file) => [quotaKey(file), { + ...current[quotaKey(file)], status: 'loading', rows: [], + }])), + })); try { - await refreshQuotaCacheEntries(targets); + 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 })); + }); + })); + } } finally { setRefreshing(false); } - }, [apiSources, files]); + }, [files, t]); - const oauthCards = useMemo(() => { + const grouped = useMemo(() => { const groups = new Map(); files.forEach((file) => { const provider = providerForFile(file); @@ -211,41 +134,23 @@ export function QuotaPage() { items.push({ file, quota: quotas[quotaKey(file)] ?? idleQuota() }); groups.set(provider, items); }); - 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 providerOrder.flatMap((provider) => { + const items = groups.get(provider); + return items ? [[provider, items] as const] : []; }); - 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; + }, [files, quotas]); return (
{confirmationDialog} -
-
-

{t('quota.title')}

-
+
+

{t('quota.title')}

- {t(sourceCount === 1 ? 'quota.queryableCredentials.one' : 'quota.queryableCredentials.other', { count: sourceCount })} - -
@@ -253,30 +158,18 @@ export function QuotaPage() { {error ? : null} {loading ? (
{t('quota.loadingFiles')}
- ) : oauthCards.length === 0 && apiCards.length === 0 ? ( + ) : grouped.length === 0 ? (
{t('quota.empty.title')}{t('quota.empty.description')}
) : ( -
- {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} />; - })}
+
+ {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} />)}
- ) : 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}
); } @@ -291,7 +184,6 @@ 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} @@ -312,180 +204,14 @@ 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} - -
- ); -} - -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) => { + {quota.status === 'success' ?
{quota.rows.map((row, index) => { const reset = formatQuotaReset(row.resetAtMs, row.reset, locale, now); - 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} - + 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} ); } - -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 deleted file mode 100644 index c998482..0000000 --- a/src/services/apiQuota.ts +++ /dev/null @@ -1,775 +0,0 @@ -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 b4f1b5b..dd2fddf 100644 --- a/src/services/managementApi.ts +++ b/src/services/managementApi.ts @@ -66,18 +66,6 @@ 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 c275284..ce33cc2 100644 --- a/src/services/quotaCache.ts +++ b/src/services/quotaCache.ts @@ -4,11 +4,8 @@ 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 oauthGeneration = 0; -const namespaceGenerations = new Map(); +let generation = 0; const listeners = new Set<() => void>(); const subscribe = (listener: () => void) => { @@ -18,19 +15,10 @@ const subscribe = (listener: () => void) => { const getSnapshot = () => cache; export const getQuotaCacheSnapshot = getSnapshot; -export const captureQuotaCacheGeneration = (namespacePrefix?: string) => namespacePrefix - ? namespaceGenerations.get(namespacePrefix) ?? 0 - : oauthGeneration; +export const captureQuotaCacheGeneration = () => generation; -export const commitQuotaCacheIfCurrent = ( - expectedGeneration: number, - commit: () => void, - namespacePrefix?: string, -) => { - const currentGeneration = namespacePrefix - ? namespaceGenerations.get(namespacePrefix) ?? 0 - : oauthGeneration; - if (currentGeneration !== expectedGeneration) return false; +export const commitQuotaCacheIfCurrent = (expectedGeneration: number, commit: () => void) => { + if (generation !== expectedGeneration) return false; commit(); return true; }; @@ -42,74 +30,23 @@ export const updateQuotaCache = (updater: QuotaCacheUpdater) => { listeners.forEach((listener) => listener()); }; -const pruneQuotaCacheWhere = ( - validKeys: Set, - belongsToNamespace: (key: string) => boolean, - namespacePrefix?: string, -) => { +export const pruneQuotaCache = (validKeys: Set) => { updateQuotaCache((current) => { const next = Object.fromEntries( Object.entries(current) - .filter(([key]) => !belongsToNamespace(key) || validKeys.has(key)) + .filter(([key]) => validKeys.has(key)) .map(([key, value]) => [ key, - belongsToNamespace(key) && value.status === 'loading' - ? { status: 'idle', rows: [] } - : value, + value.status === 'loading' ? { status: 'idle', rows: [] } : value, ]), ) as QuotaCache; - const unchanged = Object.keys(next).length === Object.keys(current).length - && Object.entries(next).every(([key, value]) => value === current[key]); + const unchanged = Object.keys(next).length === Object.keys(current).length; if (unchanged) return current; - if (namespacePrefix) { - namespaceGenerations.set(namespacePrefix, (namespaceGenerations.get(namespacePrefix) ?? 0) + 1); - } else { - oauthGeneration += 1; - } + generation += 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 4f8cbe0..5036d87 100644 --- a/src/services/quotaService.ts +++ b/src/services/quotaService.ts @@ -23,17 +23,10 @@ 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 6976303..5159e57 100644 --- a/src/styles.css +++ b/src/styles.css @@ -10024,121 +10024,48 @@ body.table-col-resizing * { font-size: var(--font-size-label); } -.quota-page-heading > div:first-child { +.quota-group-list { display: grid; - gap: 5px; + gap: 18px; } -.quota-source-sections { +.quota-provider-group { display: grid; - gap: 26px; - margin-top: 18px; + gap: 10px; } -.quota-source-section { - display: grid; - gap: 12px; -} - -.quota-source-section-heading { - display: grid; - grid-template-columns: 36px minmax(0, 1fr) auto; +.quota-group-heading { + display: flex; align-items: center; - gap: 11px; + justify-content: space-between; + gap: 12px; + color: var(--theme-7a746c); + font-size: var(--font-size-label); } -.quota-source-section-icon { - display: grid; - width: 36px; - height: 36px; - place-items: center; - border-radius: 9px; +.quota-group-heading > div { + display: flex; + align-items: center; + gap: 8px; } -.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 { +.quota-group-heading h2 { margin: 0; color: var(--theme-2d2a26); font-size: var(--font-size-panel-title); } -.quota-source-section-heading > span { - color: var(--theme-7a746c); - font-size: var(--font-size-label); +.quota-group-heading .provider-logo { + width: 24px; + height: 24px; } -.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; -} - -.quota-credential-grid { +.real-quota-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(340px, 100%), 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; @@ -10158,27 +10085,6 @@ 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; @@ -10320,31 +10226,6 @@ 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; @@ -11006,17 +10887,12 @@ body.table-col-resizing * { .oauth-grid, .quota-grid, - .quota-summary-grid, - .quota-credential-grid { + .quota-summary-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-quota-grid { + grid-template-columns: 1fr; } .real-provider-row { @@ -14621,7 +14497,7 @@ body.table-col-resizing * { .oauth-grid, .quota-grid, .quota-summary-grid, - .quota-credential-grid, + .real-quota-grid, .auth-filter-grid { grid-template-columns: 1fr; } diff --git a/tests/apiQuota.test.ts b/tests/apiQuota.test.ts deleted file mode 100644 index ea4ae8a..0000000 --- a/tests/apiQuota.test.ts +++ /dev/null @@ -1,591 +0,0 @@ -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 f050579..7a1999f 100644 --- a/tests/navigationAndUpdate.test.ts +++ b/tests/navigationAndUpdate.test.ts @@ -1,5 +1,4 @@ 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'; @@ -24,30 +23,16 @@ describe('简易模式、首页、配置与版本管理导航', () => { test('内核运行后解锁其他功能页', () => { expect(canOpenAppPage('config', true)).toBe(true); expect(canOpenAppPage('agents', true)).toBe(true); - expect(canOpenAppPage('quota', true)).toBe(true); }); }); -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']); +describe('OAuth 子页面导航', () => { + test('认证文件和额度查询收纳在 OAuth 页面内', () => { + expect(oauthSubpages.map((page) => page.id)).toEqual(['login', 'authFiles', 'quota']); 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 434ab7e..2b9f037 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 auth-card-toggle`}"); + .toBe("{`${disabled ? 'primary-button' : 'secondary-button'} compact-button`}"); 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 4502292..ff10e29 100644 --- a/tests/providerConfig.test.ts +++ b/tests/providerConfig.test.ts @@ -8,12 +8,10 @@ import { hasDuplicateProviderRecord, DEEPSEEK_BASE_URL, exclusionsForModelSelection, - loadProviderRecords, modelSelectionForDiscovery, parseProviderHeaders, parseProviderApiKeys, providerCategoryMatchesRecord, - providerRecordsFromConfig, providerDragId, providerRecordWithDisabledState, providerRemarkIdentity, @@ -224,69 +222,6 @@ 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 364c54f..b3a84c0 100644 --- a/tests/quotaCache.test.ts +++ b/tests/quotaCache.test.ts @@ -4,11 +4,8 @@ import { commitQuotaCacheIfCurrent, getQuotaCacheSnapshot, pruneQuotaCache, - pruneQuotaCacheNamespace, - refreshQuotaCacheEntries, updateQuotaCache, } from '../src/services/quotaCache'; -import type { QuotaState } from '../src/services/quotaService'; describe('额度跨页面缓存', () => { it('保留仍存在的认证文件额度并清理失效项', () => { @@ -38,38 +35,4 @@ 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 c9ee523..e16f2ca 100644 --- a/tests/quotaRendering.test.tsx +++ b/tests/quotaRendering.test.tsx @@ -1,10 +1,7 @@ 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 { ApiBalanceUrlDialog, ApiQuotaCard, QuotaCard } from '../src/pages/QuotaPage'; -import { apiQuotaAdapterFor, type ApiQuotaSource } from '../src/services/apiQuota'; +import { QuotaCard } from '../src/pages/QuotaPage'; import { quotaRowsFor } from '../src/services/quotaService'; import type { QuotaState } from '../src/services/quotaService'; @@ -42,7 +39,6 @@ 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% 额度保留刷新按钮', () => { @@ -63,137 +59,4 @@ 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 2cc9c21..d845aa9 100644 --- a/tests/uiLocalization.test.ts +++ b/tests/uiLocalization.test.ts @@ -5,17 +5,7 @@ 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', - 'HTTP', - 'auto', - 'excluded_models', - 'headers', - 'ms', - 'note', -]); +const technicalText = new Set(['EasyCLIProxyAPI', 'WebSocket', 'Fast', 'ms', 'auto']); const technicalPlaceholders = new Set(['1h', 'sk-...', 'gpt-5.6-terra', 'https://...']); function componentFiles(directory: string): string[] {