Revert "feat(quota): unify OAuth and API balance lookup (#279)"
This reverts commit d473c8b8f186e82eb63e15d7a3bc54f05810b087.
This commit is contained in:
1 parent
f58b51b4cc
commit
100f5df737
27 files changed
+161
-2871
No files matched your search
+4
-14
@@ -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
@@ -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
@@ -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.',
|
||||
|
||||
@@ -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,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
@@ -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">
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user