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
+161 -2871

No files matched your search

+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)}`);
}
});
return next;
});
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] ?? '',
])));
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>
);
+73 -347
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,121 +45,57 @@ 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 payload = await managementApi.get('/auth-files');
if (!isCurrent()) return;
const allFiles = dedupeAuthFiles(responseList(payload, 'files'));
const nextFiles = allFiles.filter((file) => !readBoolean(file, 'disabled') && providerForFile(file));
setFiles(nextFiles);
const validQuotaKeys = new Set(allFiles.map(quotaKey));
pruneQuotaCache(validQuotaKeys);
updateQuotaCache((current) => {
const next = { ...current };
nextFiles.forEach((file) => {
const key = quotaKey(file);
if (!next[key]) next[key] = idleQuota();
});
return next;
});
}, []);
const loadSources = useCallback(async () => {
const revision = ++sourceLoadRevision.current;
const loadFiles = useCallback(async () => {
setLoading(true);
setError('');
try {
await loadQuotaSourceStages(discoverApiQuotaSources, () => loadOAuthFiles(() => sourceLoadRevision.current === revision), (apiResult) => {
if (sourceLoadRevision.current !== revision) return;
applyApiSources(apiResult.sources);
if (apiResult.errors?.length) {
setError(`${t('quota.api.partialLoad')}: ${apiResult.errors.join('; ')}`);
}
const payload = await managementApi.get('/auth-files');
const allFiles = dedupeAuthFiles(responseList(payload, 'files'));
const nextFiles = allFiles.filter((file) => !readBoolean(file, 'disabled') && providerForFile(file));
setFiles(nextFiles);
const validQuotaKeys = new Set(allFiles.map(quotaKey));
pruneQuotaCache(validQuotaKeys);
updateQuotaCache((current) => {
const next = { ...current };
nextFiles.forEach((file) => {
const key = quotaKey(file);
if (!next[key]) next[key] = idleQuota();
});
return next;
});
} catch (requestError) {
if (sourceLoadRevision.current === revision) setError(apiQuotaErrorMessage(requestError));
setError(String(requestError));
} finally {
if (sourceLoadRevision.current === revision) setLoading(false);
setLoading(false);
}
}, [applyApiSources, loadOAuthFiles, t]);
}, []);
useEffect(() => {
void loadSources();
return () => { sourceLoadRevision.current += 1; };
}, [loadSources]);
void loadFiles();
}, [loadFiles]);
const refreshOne = useCallback(async (file: AuthFile) => {
const key = quotaKey(file);
await refreshQuotaCacheEntries([{ key, query: () => loadQuota(file) }]);
}, []);
const refreshApiOne = useCallback(async (source: ApiQuotaSource) => {
const key = apiQuotaCacheKey(source);
await refreshQuotaCacheEntries([{ key, query: () => queryApiQuotaSource(source) }]);
}, []);
const saveBalanceUrl = useCallback(async (source: ApiQuotaSource, value: string) => {
setBalanceEditorSaving(true);
setBalanceEditorError('');
try {
const balanceUrl = await saveApiQuotaBalanceUrl(source, value);
const identity = apiAccessRecordIdentityKey(apiAccessRecordIdentityFor(source));
sourceLoadRevision.current += 1;
applyApiSources(apiSourcesRef.current.map((current) => (
apiAccessRecordIdentityKey(apiAccessRecordIdentityFor(current)) === identity
? withApiQuotaBalanceUrl(current, balanceUrl)
: current
)));
setLoading(false);
setBalanceEditorSource(null);
} catch (saveError) {
setBalanceEditorError(saveError instanceof Error ? saveError.message : String(saveError));
} finally {
setBalanceEditorSaving(false);
}
}, [applyApiSources]);
if (getQuotaCacheSnapshot()[key]?.status === 'loading') return;
const cacheGeneration = captureQuotaCacheGeneration();
updateQuotaCache((current) => ({ ...current, [key]: { status: 'loading', rows: [] } }));
const result = await loadQuota(file);
commitQuotaCacheIfCurrent(cacheGeneration, () => {
updateQuotaCache((current) => ({ ...current, [key]: result }));
});
}, [t]);
const resetCodexQuota = useCodexQuotaReset(askConfirmation, setError);
@@ -190,19 +103,29 @@ export function QuotaPage() {
if (Object.values(getQuotaCacheSnapshot()).some((quota) => quota.status === 'loading')) return;
setRefreshing(true);
setError('');
const refreshableApiSources = apiSources.filter(isApiQuotaSourceQueryable);
const targets = [
...files.map((file) => ({ key: quotaKey(file), query: () => loadQuota(file) })),
...refreshableApiSources.map((source) => ({ key: apiQuotaCacheKey(source), query: () => queryApiQuotaSource(source) })),
];
const cacheGeneration = captureQuotaCacheGeneration();
updateQuotaCache((current) => ({
...current,
...Object.fromEntries(files.map((file) => [quotaKey(file), {
...current[quotaKey(file)], status: 'loading', rows: [],
}])),
}));
try {
await refreshQuotaCacheEntries(targets);
for (let index = 0; index < files.length; index += REFRESH_CONCURRENCY) {
const batch = files.slice(index, index + REFRESH_CONCURRENCY);
await Promise.all(batch.map(async (file) => {
const result = await loadQuota(file);
commitQuotaCacheIfCurrent(cacheGeneration, () => {
updateQuotaCache((current) => ({ ...current, [quotaKey(file)]: result }));
});
}));
}
} finally {
setRefreshing(false);
}
}, [apiSources, files]);
}, [files, t]);
const oauthCards = useMemo(() => {
const grouped = useMemo(() => {
const groups = new Map<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}
{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} />
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> : 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;
}