Revert "feat(quota): unify OAuth and API balance lookup (#279)"

This reverts commit d473c8b8f186e82eb63e15d7a3bc54f05810b087.
This commit is contained in:
lzt404 committed 2026-09-20 18:09:32 +08:00
1 parent f58b51b4cc
commit 100f5df737
27 files changed
+151 -2861

No files matched your search

-6
View File
@@ -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();
}
-109
View File
@@ -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<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ApiAccessBalanceEndpointUpdate {
pub(crate) previous_identity: Option<ApiAccessRecordIdentityInput>,
pub(crate) next_identity: Option<ApiAccessRecordIdentityInput>,
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<ApiAccessRecordIdentityInput>,
gui_config_state: tauri::State<'_, GuiConfigState>,
) -> Result<Vec<Option<String>>, 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"
-133
View File
@@ -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<String> {
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<String, String> {
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::<Vec<_>>();
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<bool, St
if config.api_keys != original_api_keys {
changed = true;
}
let original_balance_endpoints = config.api_balance_endpoints.clone();
config.api_balance_endpoints = config
.api_balance_endpoints
.iter()
.filter_map(|entry| {
validate_gui_api_balance_endpoint(entry).ok().map(|_| {
let mut normalized = entry.clone();
normalized.provider_section = normalized.provider_section.trim().to_string();
normalized.record_identity = normalized.record_identity.trim().to_ascii_lowercase();
normalized.balance_url = reqwest::Url::parse(normalized.balance_url.trim())
.ok()
.map(|url| url.to_string())
.unwrap_or_default();
normalized
})
})
.fold(Vec::new(), |mut entries, entry| {
if !entries.iter().any(|existing: &GuiApiBalanceEndpoint| {
existing.provider_section == entry.provider_section
&& existing.record_identity == entry.record_identity
}) {
entries.push(entry);
}
entries
});
if config.api_balance_endpoints != original_balance_endpoints {
changed = true;
}
let proxy_url = if config.proxy_override {
network_proxy::normalize_optional_proxy_url(&config.proxy_url)?
} else {
@@ -1962,25 +1851,6 @@ pub(crate) fn write_gui_config_to_path(
"api-access-remarks",
Item::Value(Value::Array(api_access_remarks)),
);
let mut api_balance_endpoints = Array::new();
for entry in &config.api_balance_endpoints {
let mut table = InlineTable::new();
table.insert(
"provider-section",
Value::from(entry.provider_section.as_str()),
);
table.insert(
"record-identity",
Value::from(entry.record_identity.as_str()),
);
table.insert("balance-url", Value::from(entry.balance_url.as_str()));
api_balance_endpoints.push(Value::InlineTable(table));
}
set_codex_table_item(
root,
"api-balance-endpoints",
Item::Value(Value::Array(api_balance_endpoints)),
);
let content = document.to_string();
toml::from_str::<GuiConfigFile>(&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
-14
View File
@@ -634,7 +634,6 @@ struct GuiConfigFile {
#[serde(deserialize_with = "deserialize_gui_api_keys")]
api_keys: Vec<GuiApiKeyEntry>,
api_access_remarks: Vec<GuiApiAccessRemark>,
api_balance_endpoints: Vec<GuiApiBalanceEndpoint>,
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,
-138
View File
@@ -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::<GuiConfigFile>(&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();
}
-1
View File
@@ -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,
-1
View File
@@ -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,
+4 -14
View File
@@ -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;
+1 -52
View File
@@ -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 認証情報の総クォータを提供していません。',
+1 -52
View File
@@ -372,59 +372,8 @@ export const en: Record<MessageKey, string> = {
'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.',
+1 -52
View File
@@ -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 凭证提供额度总量数据。',
+1
View File
@@ -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'];
+38 -76
View File
@@ -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<ProviderSection, Record<string, unknown>[]> => ({
'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<ProviderSection, Record<string, unknown>[]> => ({
'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<unknown> = managementApi.get,
onProtocolError?: (protocol: ProviderSection, error: unknown) => void,
): Promise<Record<ProviderSection, Record<string, unknown>[]>> => (
await loadApiAccessRecords(getConfig, onProtocolError)
);
const rowFromRecord = (
section: ProviderSection,
record: Record<string, unknown>,
@@ -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<string, unknown>) =>
export const stripResponseFields = (record: Record<string, unknown>) => {
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<ProviderRow | null>(null);
const [dialogDraft, setDialogDraft] = useState<ProviderDraft>(emptyProviderDraft);
const [apiAccessRemarks, setApiAccessRemarks] = useState<Record<string, string>>({});
const [apiBalanceUrls, setApiBalanceUrls] = useState<Record<string, string>>({});
const [healthDialogRow, setHealthDialogRow] = useState<ProviderRow | null>(null);
const [dragOverId, setDragOverId] = useState<string | null>(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)}`);
}
});
setRecords(nextRecords);
if (failures.length > 0) setError(t('apiAccess.error.partialLoad', { errors: failures.join('; ') }));
const recordEntries = (Object.entries(nextRecords) as [ProviderSection, Record<string, unknown>[]][])
.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] ?? '',
])));
return next;
});
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<string, string> = {};
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<string, unknown>[];
let targetIndex = -1;
let currentRecord: Record<string, unknown> | 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({
/>
</label>
<label><span>{t('apiAccess.field.baseUrl')}</span><input value={draft.baseUrl} onChange={(event) => updateTextField('baseUrl', event.currentTarget.value)} placeholder={activeSection === 'codex-api-key' || activeSection === 'openai-compatibility' ? t('apiAccess.baseRequiredPlaceholder') : t('apiAccess.baseOptionalPlaceholder')} /></label>
<label><span>{t('quota.api.balanceUrl.label')}</span><input value={draft.balanceUrl ?? ''} onChange={(event) => updateTextField('balanceUrl', event.currentTarget.value)} placeholder={t('quota.api.balanceUrl.placeholder')} /></label>
<small className="api-balance-url-hint">{t('quota.api.balanceUrl.description')}</small>
{activeCategory === 'openai-compatibility' ? (
<div className="thinking-level-config">
<div className="thinking-level-heading">
+2
View File
@@ -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' ? <OAuthLoginPage /> : null}
{activeSubpage === 'authFiles' ? <AuthFileManagementPage /> : null}
{activeSubpage === 'quota' ? <QuotaPage /> : null}
</div>
</section>
);
+63 -337
View File
@@ -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,52 +45,23 @@ const providerMeta: Record<QuotaProvider, { label: string; icon: string }> = {
};
const providerOrder: QuotaProvider[] = ['claude', 'antigravity', 'codex', 'xai', 'kimi', 'devin'];
const apiProviderOrder: ApiQuotaVendor[] = ['deepseek', 'stepfun', 'siliconflow', 'openrouter', 'novita'];
export const apiAccessIconForQuotaSource = (
source: Pick<ApiQuotaSource, 'protocol' | 'recordName' | 'baseUrl'>,
): 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<AuthFile[]>([]);
const [apiSources, setApiSources] = useState<ApiQuotaSource[]>([]);
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<ApiQuotaSource | null>(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 loadFiles = useCallback(async () => {
setLoading(true);
setError('');
try {
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);
@@ -127,62 +75,27 @@ export function QuotaPage() {
});
return next;
});
} catch (requestError) {
setError(String(requestError));
} finally {
setLoading(false);
}
}, []);
const loadSources = useCallback(async () => {
const revision = ++sourceLoadRevision.current;
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('; ')}`);
}
});
} catch (requestError) {
if (sourceLoadRevision.current === revision) setError(apiQuotaErrorMessage(requestError));
} finally {
if (sourceLoadRevision.current === revision) 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<QuotaProvider, { file: AuthFile; quota: QuotaState }[]>();
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<ApiQuotaVendor | 'unsupported', ApiQuotaSource[]>();
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 (
<section className="page management-page quota-page">
{confirmationDialog}
<header className="management-header quota-page-heading">
<div>
<h1>{t('quota.title')}</h1>
</div>
<header className="management-header">
<div><h1>{t('quota.title')}</h1></div>
<div className="management-heading-actions">
<span className="muted-summary">{t(sourceCount === 1 ? 'quota.queryableCredentials.one' : 'quota.queryableCredentials.other', { count: sourceCount })}</span>
<button type="button" className="secondary-button compact-button" onClick={() => void loadSources()} disabled={loading || refreshing || querying}>
<span className="muted-summary">{t(files.length === 1 ? 'quota.queryableCredentials.one' : 'quota.queryableCredentials.other', { count: files.length })}</span>
<button type="button" className="secondary-button compact-button" onClick={() => void loadFiles()} disabled={loading || refreshing || querying}>
<RefreshCw size={16} />{t('quota.readList')}
</button>
<button type="button" className="primary-button compact-button" onClick={() => void refreshAll()} disabled={refreshing || loading || querying || sourceCount === 0}>
<button type="button" className="secondary-button compact-button" onClick={() => void refreshAll()} disabled={refreshing || loading || querying || files.length === 0}>
<RefreshCw size={16} className={refreshing ? 'spin' : ''} />{t('quota.refreshAll')}
</button>
</div>
@@ -253,30 +158,18 @@ export function QuotaPage() {
{error ? <MessageNotice message={error} /> : null}
{loading ? (
<div className="management-loading"><LoaderCircle size={20} className="spin" />{t('quota.loadingFiles')}</div>
) : oauthCards.length === 0 && apiCards.length === 0 ? (
) : grouped.length === 0 ? (
<div className="management-empty"><AlertCircle size={24} /><strong>{t('quota.empty.title')}</strong><span>{t('quota.empty.description')}</span></div>
) : (
<div className="quota-source-sections">
{oauthCards.length > 0 ? (
<section className="quota-source-section quota-oauth-section">
<div className="quota-source-section-heading"><div className="quota-source-section-icon"><ShieldCheck size={18} aria-hidden="true" /></div><h2>{t('quota.oauth.title')}</h2><span>{t(oauthCount === 1 ? 'quota.credentials.one' : 'quota.credentials.other', { count: oauthCount })}</span></div>
<div className="quota-credential-grid">{oauthCards.map(({ file, quota }) => {
const provider = providerForFile(file);
return <QuotaCard key={quotaKey(file)} file={file} quota={quota} onRefresh={() => void refreshOne(file)} onReset={provider === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} />;
})}</div>
<div className="quota-group-list">
{grouped.map(([provider, items]) => (
<section className="quota-provider-group" key={provider}>
<div className="quota-group-heading"><div><img src={providerMeta[provider].icon} alt="" className={provider === 'devin' ? 'provider-logo devin-logo' : 'provider-logo'} /><h2>{providerMeta[provider].label}</h2></div><span>{t(items.length === 1 ? 'quota.credentials.one' : 'quota.credentials.other', { count: items.length })}</span></div>
<div className="real-quota-grid">{items.map(({ file, quota }) => <QuotaCard key={quotaKey(file)} file={file} quota={quota} onRefresh={() => void refreshOne(file)} onReset={provider === 'codex' ? () => void resetCodexQuota(file, quota) : undefined} />)}</div>
</section>
) : null}
{apiCards.length > 0 ? (
<section className="quota-source-section quota-api-section">
<div className="quota-source-section-heading"><div className="quota-source-section-icon"><KeyRound size={18} aria-hidden="true" /></div><h2>{t('quota.api.sectionTitle')}</h2><span>{t('quota.api.sectionCount', { supported: apiCount, unsupported: apiUnsupportedCount })}</span></div>
<div className="quota-credential-grid">{apiCards.map((source) => <ApiQuotaCard key={apiQuotaCacheKey(source)} source={source} quota={quotas[apiQuotaCacheKey(source)] ?? idleQuota()} onRefresh={() => void refreshApiOne(source)} onConfigure={() => { setBalanceEditorError(''); setBalanceEditorSource(source); }} />)}</div>
</section>
) : null}
))}
</div>
)}
{balanceEditorSource ? (
<ApiBalanceUrlDialog source={balanceEditorSource} busy={balanceEditorSaving} error={balanceEditorError} onClose={() => { if (!balanceEditorSaving) setBalanceEditorSource(null); }} onSave={(value) => void saveBalanceUrl(balanceEditorSource, value)} />
) : null}
</section>
);
}
@@ -291,7 +184,6 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
<article className="panel real-quota-card">
<div className="real-quota-card-header">
<div><strong title={name}>{name}</strong><span>{provider ? providerMeta[provider].label : t('quota.unknownProvider')}{quota.plan ? ' · ' + quota.plan : ''}</span></div>
<span className="quota-source-badge oauth">{t('quota.oauth.badge')}</span>
<div className="quota-card-actions">
{onReset && (quota.resetCredits ?? 0) > 0 ? <button type="button" className="secondary-button compact-button" onClick={onReset} disabled={!canResetCodexQuota(file, quota)} title={t('quota.reset')}>{t('quota.reset')}</button> : null}
<button type="button" className="icon-button quiet" onClick={onRefresh} disabled={disabled || quota.status === 'loading'} title={disabled ? t('quota.fileDisabled') : t('quota.refresh')}><RefreshCw size={16} className={quota.status === 'loading' ? 'spin' : ''} /></button>
@@ -312,180 +204,14 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
<MessageNotice message={quota.resetCreditsError ? name + ': ' + t('quota.resetCreditsWarning', { error: quota.resetCreditsError }) : null} />
</div> : null}
{quota.status === 'success' && provider === 'devin' && quota.subscriptionActiveUntil ? <div className="quota-reset-credit-summary"><span>{t('quota.subscriptionExpiry', { time: formatQuotaTimestamp(quota.subscriptionActiveUntil, locale) })}</span></div> : null}
<QuotaRows quota={quota} now={now} />
</article>
);
}
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<QuotaState['rows'][number]['amount']> }) {
const { t, locale } = useI18n();
return (
<div className="quota-amount-summary">
{amount.remaining !== null ? <span>{t('quota.amount.remaining')}: <strong>{formatQuotaAmount(amount.remaining, amount.unit, locale)}</strong></span> : null}
{amount.used !== null ? <span>{t('quota.amount.used')}: <strong>{formatQuotaAmount(amount.used, amount.unit, locale)}</strong></span> : null}
{amount.total !== null ? <span>{t('quota.amount.total')}: <strong>{formatQuotaAmount(amount.total, amount.unit, locale)}</strong></span> : null}
</div>
);
}
function QuotaRows({ quota, now }: { quota: QuotaState; now: number }) {
const { locale, t } = useI18n();
if (quota.status !== 'success') return null;
return (
<div className="quota-row-list">
{quota.rows.map((row, index) => {
{quota.status === 'success' ? <div className="quota-row-list">{quota.rows.map((row, index) => {
const reset = formatQuotaReset(row.resetAtMs, row.reset, locale, now);
return (
<div className="real-quota-row" key={`${row.label}-${index}`}>
{row.amount ? null : (
<div>
<span>{row.label}</span>
<strong>{row.remainingPercent === null ? '—' : t('quota.remaining', { percent: Math.round(row.remainingPercent) })}</strong>
</div>
)}
{row.amount ? <QuotaAmountSummary amount={row.amount} /> : null}
return <div className="real-quota-row" key={`${row.label}-${index}`}>
<div><span>{row.label}</span><strong>{row.remainingPercent === null ? '—' : t('quota.remaining', { percent: Math.round(row.remainingPercent) })}</strong></div>
{row.remainingPercent !== null ? <div className="real-quota-track"><span style={{ width: `${Math.max(0, Math.min(100, row.remainingPercent))}%` }} /></div> : null}
<small>{[row.detail, reset].filter(Boolean).join(' · ')}</small>
</div>
);
})}
</div>
);
}
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 (
<article className="panel real-quota-card">
<div className="real-quota-card-header">
<img src={icon} alt="" className="provider-logo quota-card-provider-logo" />
<div className="quota-card-copy">
<div className="quota-card-title-line">
<strong title={label}>{label}</strong>
<span className="quota-source-badge api">{t('quota.api.badge')}</span>
</div>
<span>{apiQuotaProtocolLabel(source.protocol)}</span>
</div>
<div className="quota-card-actions">
<button type="button" className="icon-button quiet" onClick={onConfigure} disabled={disabled} title={t('quota.api.balanceUrl.button')} aria-label={t('quota.api.balanceUrl.button')}>
<Settings2 size={16} aria-hidden="true" />
</button>
<button
type="button"
className="icon-button quiet"
onClick={onRefresh}
disabled={disabled || unsupported || quota.status === 'loading'}
title={disabled ? t('quota.fileDisabled') : unsupported ? t('quota.api.unsupported') : t('quota.refresh')}
>
<RefreshCw size={16} className={quota.status === 'loading' ? 'spin' : ''} />
</button>
</div>
</div>
{source.configurationError ? <div className="quota-card-error"><AlertCircle size={18} />{source.configurationError}</div> : null}
{quota.status === 'idle' && !source.configurationError ? (
<div className="quota-card-message">
<span>{disabled ? t('quota.fileDisabled') : unsupported ? t('quota.api.unsupported') : t('quota.notFetched')}</span>
{disabled || unsupported ? null : <button type="button" className="secondary-button compact-button" onClick={onRefresh}>{t('quota.fetch')}</button>}
</div>
) : null}
{quota.status === 'loading' ? <div className="quota-card-message"><LoaderCircle size={18} className="spin" />{t('quota.querying')}</div> : null}
{quota.status === 'error' ? <div className="quota-card-error"><AlertCircle size={18} />{quota.error}</div> : null}
<QuotaRows quota={quota} now={now} />
</div>;
})}</div> : null}
</article>
);
}
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<HTMLElement>(null);
const inputRef = useRef<HTMLInputElement>(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<HTMLElement>('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 = (
<div className="config-dialog-backdrop app-confirm-backdrop quota-balance-url-backdrop" onMouseDown={(event) => event.currentTarget === event.target && !busy && onClose()}>
<section ref={dialogRef} className="config-dialog quota-balance-url-dialog" role="dialog" aria-modal="true" aria-labelledby="quota-balance-url-title">
<div className="config-dialog-heading">
<div><Settings2 size={19} aria-hidden="true" /><h2 id="quota-balance-url-title">{t('quota.api.balanceUrl.configure')}</h2></div>
<button type="button" className="icon-button quiet" onClick={onClose} disabled={busy} title={t('common.close')}><X size={18} /></button>
</div>
<p className="quota-balance-url-source">{label}</p>
<label className="config-dialog-field"><span>{t('quota.api.balanceUrl.label')}</span><input ref={inputRef} className="config-dialog-text-input" type="url" value={value} onChange={(event) => setValue(event.currentTarget.value)} placeholder={t('quota.api.balanceUrl.placeholder')} disabled={busy} /></label>
<p className="quota-balance-url-notice">{t('quota.api.balanceUrl.description')}</p>
{error ? <div className="management-alert error" role="alert">{error}</div> : null}
<div className="config-dialog-actions two-actions">
<button type="button" className="secondary-button" onClick={onClose} disabled={busy}>{t('quota.api.balanceUrl.cancel')}</button>
<button type="button" className="primary-button" onClick={() => onSave(value)} disabled={busy}>{busy ? t('common.saving') : t('quota.api.balanceUrl.save')}</button>
</div>
</section>
</div>
);
return typeof document === 'undefined' ? content : createPortal(content, document.body);
}
-775
View File
@@ -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<string, ApiQuotaAdapter> = {
'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<ApiQuotaVendor, Parameters<typeof translate>[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<ApiQuotaProtocol, Parameters<typeof translate>[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<typeof translate>[1],
variables?: Parameters<typeof translate>[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<string, unknown>,
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<ApiQuotaSource, 'adapter' | 'entryIndex' | 'entryCount' | 'recordName' | 'recordOrdinal' | 'recordCount' | 'baseUrl' | 'protocol'>,
) => {
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<ApiQuotaSource, 'adapter' | 'entryIndex' | 'entryCount' | 'recordName' | 'recordOrdinal' | 'recordCount' | 'baseUrl' | 'protocol'>) =>
sourceLabelFor(source);
/*
* API sources are queryable only when they map to a supported adapter and are enabled.
*/
export const isApiQuotaSourceQueryable = (source: Pick<ApiQuotaSource, 'adapter' | 'disabled' | 'configurationError'>) =>
Boolean(source.adapter) && !source.configurationError && !source.disabled;
export const isApiQuotaSourceUnsupported = (source: Pick<ApiQuotaSource, 'adapter' | 'configurationError'>) =>
!source.adapter || Boolean(source.configurationError);
export const countApiQuotaCards = (sources: Pick<ApiQuotaSource, 'adapter' | 'configurationError'>[]) =>
sources.length;
export const countApiQuotaUnsupported = (sources: Pick<ApiQuotaSource, 'adapter' | 'configurationError'>[]) =>
sources.filter(isApiQuotaSourceUnsupported).length;
export const countApiQuotaSources = (sources: Pick<ApiQuotaSource, 'adapter' | 'disabled' | 'configurationError'>[]) =>
sources.filter(isApiQuotaSourceQueryable).length;
const recordsForEntry = (
protocol: ApiQuotaProtocol,
record: Record<string, unknown>,
): Record<string, unknown>[] => {
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<string, unknown>[],
): ApiQuotaSource[] => {
const recordNames = records.map((record) => readString(record, 'name'));
const recordNameCounts = new Map<string, number>();
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<ApiQuotaSource, 'id' | 'adapter' | 'balanceUrl'>) =>
`${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<ApiQuotaSource, 'protocol' | 'recordName' | 'baseUrl' | 'recordApiKeys'>,
): 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<string, unknown>,
): 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<Record<string, MessageKey>> = {
'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<Array<string | null>> => {
try {
return await invoke<Array<string | null>>('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<void> => {
try {
await invoke('save_api_access_balance_endpoint', {
update: { previousIdentity, nextIdentity, balanceUrl },
});
} catch (error) {
throw new Error(apiQuotaErrorMessage(error));
}
};
export const apiQuotaSourcesFromRecords = (
recordsByProtocol: Partial<Record<ApiQuotaProtocol, Record<string, unknown>[]>>,
): 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<Record<ApiQuotaProtocol, Record<string, unknown>[]>>;
return apiQuotaSourcesFromRecords(records);
};
export async function loadApiAccessRecords(
get: (path: string) => Promise<unknown> = managementApi.get,
onProtocolError?: (protocol: ApiQuotaProtocol, error: unknown) => void,
): Promise<Record<ApiQuotaProtocol, Record<string, unknown>[]>> {
await get('/config');
const records = {} as Record<ApiQuotaProtocol, Record<string, unknown>[]>;
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<unknown> = managementApi.get,
resolveBalanceUrls: (queries: ApiAccessRecordIdentity[]) => Promise<Array<string | null>> = resolveApiAccessBalanceUrls,
): Promise<ApiQuotaDiscovery> {
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<string | null> = [];
const metadataErrors = new Map<string, string>();
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<string, string | null>();
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<ApiQuotaDiscovery>,
loadOAuthSources: () => Promise<void>,
onApiSources?: (discovery: ApiQuotaDiscovery) => void | Promise<void>,
): Promise<ApiQuotaDiscovery> {
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<ApiQuotaSource, 'protocol' | 'recordName' | 'baseUrl' | 'recordApiKeys'>,
balanceUrl: string,
saveBalanceEndpoint: typeof saveApiAccessBalanceEndpoint = saveApiAccessBalanceEndpoint,
): Promise<string> => {
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<unknown> => {
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<Record<string, unknown>>('/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<QuotaState> => {
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(),
};
}
};
-12
View File
@@ -66,18 +66,6 @@ export const managementApi = {
openAuthFilesDirectory: () => invoke<void>('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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
+9 -72
View File
@@ -4,11 +4,8 @@ import type { QuotaState } from './quotaService';
type QuotaCache = Record<string, QuotaState>;
type QuotaCacheUpdater = QuotaCache | ((current: QuotaCache) => QuotaCache);
export const API_QUOTA_CACHE_PREFIX = 'api-quota::';
let cache: QuotaCache = {};
let oauthGeneration = 0;
const namespaceGenerations = new Map<string, number>();
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<string>,
belongsToNamespace: (key: string) => boolean,
namespacePrefix?: string,
) => {
export const pruneQuotaCache = (validKeys: Set<string>) => {
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<string>) => {
pruneQuotaCacheWhere(validKeys, (key) => !key.startsWith(API_QUOTA_CACHE_PREFIX));
};
export const pruneQuotaCacheNamespace = (
namespacePrefix: string,
validKeys: Set<string>,
) => {
pruneQuotaCacheWhere(validKeys, (key) => key.startsWith(namespacePrefix), namespacePrefix);
};
type QuotaRefreshTarget = { key: string; query: () => Promise<QuotaState> };
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);
}
-7
View File
@@ -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[];
+24 -148
View File
@@ -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;
}
-591
View File
@@ -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<string, string>;
};
let get: ReturnType<typeof spyOn>;
let post: ReturnType<typeof spyOn>;
let put: ReturnType<typeof spyOn>;
let calls: ApiCallRequest[];
let handler: (request: ApiCallRequest) => unknown | Promise<unknown>;
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: [] });
});
});
+4 -19
View File
@@ -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',
]);
});
});
+1 -1
View File
@@ -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');
-65
View File
@@ -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',
-37
View File
@@ -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');
});
});
+1 -138
View File
@@ -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(
<I18nProvider>
<ApiQuotaCard
source={source}
quota={{
status: 'success',
rows: [{
label: 'OpenRouter',
remainingPercent: null,
amount: { remaining: 65, used: 35, total: 100, unit: 'USD' },
}],
}}
onRefresh={() => {}}
onConfigure={() => {}}
/>
</I18nProvider>,
);
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 (
<>
<ApiQuotaCard source={source} quota={{ status: 'idle', rows: [] }} onRefresh={() => {}} onConfigure={() => setOpen(true)} />
{open ? (
<ApiBalanceUrlDialog
source={source}
busy={false}
error=""
onClose={() => setOpen(false)}
onSave={(value) => { savedValue = value; setOpen(false); }}
/>
) : null}
</>
);
}
const root = createRoot(container);
await act(async () => {
root.render(<I18nProvider><Harness /></I18nProvider>);
});
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();
});
});
+1 -11
View File
@@ -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[] {