From efc113f795fbc220d022d11699dad99c53b45718 Mon Sep 17 00:00:00 2001 From: shiranzby <2958779577@qq.com> Date: Sat, 3 Oct 2026 00:46:04 +0800 Subject: [PATCH] feat(quota): plugin provider quota cards, OAuth entries, credential import/export The bundled cpa-multi-plugins providers (workbuddy / trae / qoder / zcode / mimo) keep their own quota numbers. The core cannot be asked for them: no credential carries supports_quota/quota_provider, all five plugins report supports_quota: false, and POST /v0/management/quota/fetch answers 501 "no quota provider available for credential". - management_endpoint(): pass v0/ paths through. Plugin panel routes and the plugin quota API only exist on the v0 mux, so the client could not reach them while the prefix was hardcoded to /v8/management. - quotaService: a `plugin` quota provider resolved through POST /v0/management/plugins//refresh. The payload is shared by the bundled providers; each credential is matched back to its own account so a card shows that account's packages rather than the pool total, with the pool total kept as a trailing row. - QuotaPage: a Plugins group for those rows. - OAuth page: WorkBuddy / Trae / Qoder / ZCode / MiMo entry cards, plus the credential icon map so unknown providers stop falling back to the Gemini icon. - Credentials: per-card export (save dialog), and the import handler now accepts third-party exports - a JSON array of flat snake_case accounts is split and reshaped into one nested credential per account. Single CPA-format files take the previous path unchanged. - i18n: quota.plugin.* and authFiles.export* in zh-CN / en / ja. Additive: the six existing quota providers are untouched. --- src-tauri/capabilities/default.json | 3 +- src-tauri/src/management_api.rs | 36 ++++++++- src/assets/icons/mimo.svg | 1 + src/assets/icons/plugin.svg | 1 + src/assets/icons/qoder.svg | 1 + src/assets/icons/trae.svg | 1 + src/i18n/ja.ts | 11 +++ src/i18n/locales/en.ts | 11 +++ src/i18n/locales/zh-CN.ts | 11 +++ src/pages/AuthFileManagementPage.tsx | 116 ++++++++++++++++++++++++++- src/pages/ManagementPages.tsx | 23 +++++- src/pages/QuotaPage.tsx | 4 +- src/services/managementApi.ts | 6 ++ src/services/quotaService.ts | 98 +++++++++++++++++++++- 14 files changed, 316 insertions(+), 7 deletions(-) create mode 100644 src/assets/icons/mimo.svg create mode 100644 src/assets/icons/plugin.svg create mode 100644 src/assets/icons/qoder.svg create mode 100644 src/assets/icons/trae.svg diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 3ddf21323..da27db797 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -10,6 +10,7 @@ "core:window:allow-theme", "core:window:allow-set-background-color", "core:webview:allow-set-webview-background-color", - "dialog:allow-open" + "dialog:allow-open", + "dialog:allow-save" ] } diff --git a/src-tauri/src/management_api.rs b/src-tauri/src/management_api.rs index 93b85b8f8..473941ca5 100644 --- a/src-tauri/src/management_api.rs +++ b/src-tauri/src/management_api.rs @@ -10,7 +10,7 @@ use std::{ collections::HashMap, error::Error, fs, - path::Path, + path::{Path, PathBuf}, process::{Command, Stdio}, sync::LazyLock, time::Duration, @@ -127,6 +127,36 @@ pub(crate) async fn upload_auth_file( read_management_value(response).await } +#[tauri::command] +pub(crate) fn export_auth_file(source: String, target: String) -> Result<(), String> { + let install_dir = core_install_dir()?; + let candidate = PathBuf::from(source.trim()); + // The credentials list reports paths relative to the core install directory. + let source_path = if candidate.is_absolute() { + candidate + } else { + install_dir.join(candidate) + }; + if !source_path.is_file() { + return Err(format!( + "Credential file not found: {}", + path_to_string(&source_path) + )); + } + let target_path = PathBuf::from(target.trim()); + if target_path.as_os_str().is_empty() { + return Err("Credential export target is empty".to_string()); + } + fs::copy(&source_path, &target_path).map_err(|error| { + format!( + "Failed to copy {} to {}: {error}", + path_to_string(&source_path), + path_to_string(&target_path) + ) + })?; + Ok(()) +} + #[tauri::command] pub(crate) fn open_auth_files_directory( gui_config_state: tauri::State<'_, GuiConfigState>, @@ -352,6 +382,10 @@ pub(crate) fn management_endpoint(config: &GuiConfigFile, path: &str) -> Result< config.port, current_core_tls_settings()?.enabled, ); + // Plugin panel routes and the plugin quota API are only served on the v0 mux. + if let Some(rest) = path.strip_prefix("v0/") { + return Ok(format!("{origin}/v0/{rest}")); + } Ok(format!("{origin}/v8/management/{path}")) } diff --git a/src/assets/icons/mimo.svg b/src/assets/icons/mimo.svg new file mode 100644 index 000000000..71d0dc023 --- /dev/null +++ b/src/assets/icons/mimo.svg @@ -0,0 +1 @@ +MiMo diff --git a/src/assets/icons/plugin.svg b/src/assets/icons/plugin.svg new file mode 100644 index 000000000..8f1402ff3 --- /dev/null +++ b/src/assets/icons/plugin.svg @@ -0,0 +1 @@ +Plugins diff --git a/src/assets/icons/qoder.svg b/src/assets/icons/qoder.svg new file mode 100644 index 000000000..1e9716952 --- /dev/null +++ b/src/assets/icons/qoder.svg @@ -0,0 +1 @@ +Qoder diff --git a/src/assets/icons/trae.svg b/src/assets/icons/trae.svg new file mode 100644 index 000000000..a64bd4846 --- /dev/null +++ b/src/assets/icons/trae.svg @@ -0,0 +1 @@ +Trae diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 305223775..85fc2ad58 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -1699,4 +1699,15 @@ export const jaOverrides = { 'quota.service.error.unrecognized': '上流からクォータデータが返されましたが、形式を認識できません', 'quota.service.error.codexResetOnly': 'リセットクォータを使用できるのは Codex 認証情報のみです', 'quota.service.error.missingConsumeAuthIndex': 'auth-index がないためリセットクォータを使用できません', + 'authFiles.export': 'エクスポート', + 'authFiles.exported': '{name} をエクスポートしました', + 'authFiles.exportFailed': 'エクスポートに失敗: {error}', + 'quota.plugin.pool': '全アカウント合計', + 'quota.plugin.poolDetail': '{count} アカウント · 残り {remain} / {size}', + 'quota.plugin.packages': 'パッケージ枠', + 'quota.plugin.packagesDetail': '残り {remain} / {size} · {count} パッケージ', + 'quota.plugin.regionDetail': '残り {remain} / {size}', + 'quota.plugin.packCount': '{count} パッケージ', + 'quota.plugin.checkin': 'チェックイン積分', + 'quota.plugin.checkinDetail': '本日 +{today} · 累計 {total} · {streak} 日連続', } satisfies Record; diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 62dc5101d..b48e70348 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1699,4 +1699,15 @@ export const en: Record = { 'quota.service.error.unrecognized': 'Upstream returned quota data in an unrecognized format', 'quota.service.error.codexResetOnly': 'Only Codex credentials support reset credits', 'quota.service.error.missingConsumeAuthIndex': 'Missing auth-index; the reset credit cannot be used', + 'authFiles.export': 'Export', + 'authFiles.exported': 'Exported {name}', + 'authFiles.exportFailed': 'Export failed: {error}', + 'quota.plugin.pool': 'All accounts', + 'quota.plugin.poolDetail': '{count} accounts · {remain} of {size} left', + 'quota.plugin.packages': 'Package quota', + 'quota.plugin.packagesDetail': '{remain} of {size} left · {count} packages', + 'quota.plugin.regionDetail': '{remain} of {size} left', + 'quota.plugin.packCount': '{count} packages', + 'quota.plugin.checkin': 'Check-in credits', + 'quota.plugin.checkinDetail': '+{today} today · {total} total · {streak} day streak', }; diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index c58df9f8f..912a9c8e4 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -1698,6 +1698,17 @@ export const zhCN = { 'quota.service.error.unrecognized': '上游返回了配额数据,但无法识别', 'quota.service.error.codexResetOnly': '只有 Codex 凭据支持使用重置额度', 'quota.service.error.missingConsumeAuthIndex': '缺少 auth-index,无法使用重置额度', + 'authFiles.export': '导出', + 'authFiles.exported': '已导出 {name}', + 'authFiles.exportFailed': '导出失败:{error}', + 'quota.plugin.pool': '账号池合计', + 'quota.plugin.poolDetail': '{count} 个账号 · 剩余 {remain} / 共 {size}', + 'quota.plugin.packages': '套餐额度', + 'quota.plugin.packagesDetail': '剩余 {remain} / 共 {size} · {count} 个套餐包', + 'quota.plugin.regionDetail': '剩余 {remain} / 共 {size}', + 'quota.plugin.packCount': '{count} 个套餐包', + 'quota.plugin.checkin': '签到积分', + 'quota.plugin.checkinDetail': '今日 +{today} · 累计 {total} · 连续 {streak} 天', } as const; export type MessageKey = keyof typeof zhCN; diff --git a/src/pages/AuthFileManagementPage.tsx b/src/pages/AuthFileManagementPage.tsx index 50c4d0899..08333a1eb 100644 --- a/src/pages/AuthFileManagementPage.tsx +++ b/src/pages/AuthFileManagementPage.tsx @@ -1,4 +1,5 @@ import { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { save } from '@tauri-apps/plugin-dialog'; import { useConfirmation } from '../components/ConfirmationDialog'; import { QuotaActionFeedback } from '../components/QuotaActionFeedback'; import { AuthFileQuotaPanel } from '../components/AuthFileQuotaPanel'; @@ -30,9 +31,15 @@ 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 workbuddyIcon from '../assets/icons/workbuddy.png'; +import traeIcon from '../assets/icons/trae.svg'; +import qoderIcon from '../assets/icons/qoder.svg'; +import zcodeIcon from '../assets/icons/zcode.png'; +import mimoIcon from '../assets/icons/mimo.svg'; import vertexIcon from '../assets/icons/vertex.svg'; import { formatDate, + isRecord, managementApi, readBoolean, readNumber, @@ -88,6 +95,86 @@ const providerIcons: Record = { vertex: vertexIcon, xai: grokIcon, devin: devinIcon, + workbuddy: workbuddyIcon, + trae: traeIcon, + qoder: qoderIcon, + zcode: zcodeIcon, + mimo: mimoIcon, +}; + +// Third-party exports (for example cockpit-style `workbuddy_accounts_*.json`) ship a +// JSON array of flat snake_case accounts, while CPA stores one nested camelCase file +// per credential. Split the array and reshape every entry before uploading it. +const REGION_BY_DOMAIN: Record = { + 'copilot.tencent.com': 'cn', + 'codebuddy.cn': 'cn', + 'www.codebuddy.cn': 'cn', + 'workbuddy.ai': 'global', + 'www.workbuddy.ai': 'global', + 'codebuddy.ai': 'intl', + 'www.codebuddy.ai': 'intl', +}; + +const normalizeImportedCredential = ( + value: Record, + fallbackProvider: string, +): Record => { + if (isRecord(value.auth) || isRecord(value.account)) return value; + const accessToken = readString(value, 'accessToken', 'access_token'); + if (!accessToken) return value; + const domain = readString(value, 'domain') || 'www.codebuddy.cn'; + const provider = readString(value, 'provider', 'type', 'account_type') || fallbackProvider; + const rawExpiry = value.expiresAt ?? value.expires_at; + let expiresAt = 0; + if (typeof rawExpiry === 'number' && Number.isFinite(rawExpiry)) { + expiresAt = rawExpiry > 10 ** 11 ? Math.floor(rawExpiry / 1000) : Math.floor(rawExpiry); + } + return { + account: { + enterpriseId: '', + nickname: readString(value, 'nickname', 'name'), + uid: readString(value, 'uid', 'user_id'), + }, + auth: { + accessToken, + refreshToken: readString(value, 'refreshToken', 'refresh_token'), + expiresAt, + domain, + region: readString(value, 'region') || REGION_BY_DOMAIN[domain] || 'cn', + }, + auth_kind: 'oauth', + disabled: false, + provider, + type: provider, + }; +}; + +const importCredentialFile = async (file: File): Promise => { + let parsed: unknown = null; + try { + parsed = JSON.parse(await file.text()); + } catch { + parsed = null; + } + const entries = (Array.isArray(parsed) ? parsed : [parsed]).filter(isRecord); + const alreadyCredential = + entries.length === 1 && (isRecord(entries[0].auth) || isRecord(entries[0].account)); + if (parsed === null || entries.length === 0 || alreadyCredential) { + await managementApi.uploadAuthFile(file); + return 1; + } + const fallbackProvider = file.name.split(/[_.-]/)[0].toLowerCase(); + let uploaded = 0; + for (const entry of entries) { + const normalized = normalizeImportedCredential(entry, fallbackProvider); + const provider = + readString(normalized, 'provider', 'type', 'account_type') || fallbackProvider || 'credential'; + const uid = isRecord(normalized.account) ? readString(normalized.account, 'uid') : ''; + const name = uid ? `${provider}-${uid}.json` : file.name; + await managementApi.uploadAuthFileContent(name, JSON.stringify(normalized)); + uploaded += 1; + } + return uploaded; }; const providerName = (file: AuthFile) => { @@ -294,8 +381,7 @@ export function AuthFileManagementPage() { const failures: string[] = []; for (const file of selected) { try { - await managementApi.uploadAuthFile(file); - uploaded += 1; + uploaded += await importCredentialFile(file); } catch (requestError) { failures.push(`${file.name}:${String(requestError)}`); } @@ -311,6 +397,31 @@ export function AuthFileManagementPage() { } }; + const exportFile = async (file: AuthFile) => { + feedback.clearNotice(); + setError(''); + const name = fileName(file); + const source = readString(file, 'path'); + if (!source) { + setError(t('authFiles.exportFailed', { error: t('authFiles.fileOnly') })); + return; + } + setBusy(true); + try { + const target = await save({ + defaultPath: name, + filters: [{ name: 'JSON', extensions: ['json'] }], + }); + if (!target) return; + await managementApi.exportAuthFile(source, target); + showNotice({ key: 'authFiles.exported', variables: { name } }); + } catch (requestError) { + setError(t('authFiles.exportFailed', { error: String(requestError) })); + } finally { + setBusy(false); + } + }; + const toggleStatus = async (file: AuthFile) => { feedback.clearNotice(); setBusy(true); @@ -453,6 +564,7 @@ export function AuthFileManagementPage() { {providerKey(file) ? : null} + diff --git a/src/pages/ManagementPages.tsx b/src/pages/ManagementPages.tsx index fefaefbcb..98e5067bf 100644 --- a/src/pages/ManagementPages.tsx +++ b/src/pages/ManagementPages.tsx @@ -14,6 +14,11 @@ import codexIcon from '../assets/icons/codex.svg'; import grokIcon from '../assets/icons/grok.svg'; import devinIcon from '../assets/icons/devin.svg'; import kimiIcon from '../assets/icons/kimi-light.svg'; +import workbuddyIcon from '../assets/icons/workbuddy.png'; +import traeIcon from '../assets/icons/trae.svg'; +import qoderIcon from '../assets/icons/qoder.svg'; +import zcodeIcon from '../assets/icons/zcode.png'; +import mimoIcon from '../assets/icons/mimo.svg'; import { useI18n } from '../i18n'; import { FloatingNotice, useAppNotice } from '../appNotice'; import { oauthSubpages, type OAuthSubpage } from '../oauthNavigation'; @@ -32,7 +37,18 @@ import { QuotaPage } from './QuotaPage'; import { validateDevinCallback } from '../services/devinOAuth'; import { handleHorizontalTabKey } from '../components/tabKeyboardNavigation'; -type OAuthProviderId = 'codex' | 'claude' | 'antigravity' | 'kimi' | 'xai' | 'devin'; +type OAuthProviderId = + | 'codex' + | 'claude' + | 'antigravity' + | 'kimi' + | 'xai' + | 'devin' + | 'workbuddy' + | 'trae' + | 'qoder' + | 'zcode' + | 'mimo'; type OAuthFlowStatus = 'idle' | 'waiting' | 'success' | 'error'; type OAuthProviderState = { @@ -72,6 +88,11 @@ const oauthProviders = [ { id: 'kimi' as const, name: 'Kimi OAuth', icon: kimiIcon }, { id: 'xai' as const, name: 'xAI OAuth', icon: grokIcon }, { id: 'devin' as const, name: 'Devin OAuth', icon: devinIcon }, + { id: 'workbuddy' as const, name: 'WorkBuddy OAuth', icon: workbuddyIcon }, + { id: 'trae' as const, name: 'Trae OAuth', icon: traeIcon }, + { id: 'qoder' as const, name: 'Qoder OAuth', icon: qoderIcon }, + { id: 'zcode' as const, name: 'ZCode OAuth', icon: zcodeIcon }, + { id: 'mimo' as const, name: 'MiMo OAuth', icon: mimoIcon }, ]; const OAUTH_CALLBACK_SUPPORTED = new Set([ diff --git a/src/pages/QuotaPage.tsx b/src/pages/QuotaPage.tsx index 861e343ac..d8366be9b 100644 --- a/src/pages/QuotaPage.tsx +++ b/src/pages/QuotaPage.tsx @@ -11,6 +11,7 @@ import codexIcon from '../assets/icons/codex.svg'; import grokIcon from '../assets/icons/grok.svg'; import devinIcon from '../assets/icons/devin.svg'; import kimiIcon from '../assets/icons/kimi-light.svg'; +import pluginIcon from '../assets/icons/plugin.svg'; import { managementApi, readBoolean, responseList } from '../services/managementApi'; import { formatQuotaReset, useQuotaClock } from '../services/quotaTime'; import { @@ -42,9 +43,10 @@ const providerMeta: Record = { xai: { label: 'xAI', icon: grokIcon }, devin: { label: 'Devin', icon: devinIcon }, antigravity: { label: 'Antigravity', icon: antigravityIcon }, + plugin: { label: 'Plugins', icon: pluginIcon }, }; -const providerOrder: QuotaProvider[] = ['claude', 'antigravity', 'codex', 'xai', 'kimi', 'devin']; +const providerOrder: QuotaProvider[] = ['claude', 'antigravity', 'codex', 'xai', 'kimi', 'devin', 'plugin']; const REFRESH_CONCURRENCY = 4; export function QuotaPage() { diff --git a/src/services/managementApi.ts b/src/services/managementApi.ts index fb7dc5798..c5fde0b1a 100644 --- a/src/services/managementApi.ts +++ b/src/services/managementApi.ts @@ -253,6 +253,12 @@ export const managementApi = { data, }); }, + uploadAuthFileContent: async (name: string, content: string) => { + const data = Array.from(new TextEncoder().encode(content)); + return invoke('upload_auth_file', { name, data }); + }, + exportAuthFile: (source: string, target: string) => + invoke('export_auth_file', { source, target }), openAuthFilesDirectory: () => invoke('open_auth_files_directory'), }; diff --git a/src/services/quotaService.ts b/src/services/quotaService.ts index c9b1745a4..86049b330 100644 --- a/src/services/quotaService.ts +++ b/src/services/quotaService.ts @@ -18,7 +18,14 @@ const quotaText = ( ) => translate(getCurrentLocale(), key, variables); export type AuthFile = Record; -export type QuotaProvider = 'claude' | 'codex' | 'kimi' | 'xai' | 'antigravity' | 'devin'; +export type QuotaProvider = + | 'claude' + | 'codex' + | 'kimi' + | 'xai' + | 'antigravity' + | 'devin' + | 'plugin'; export type QuotaStatus = 'idle' | 'loading' | 'success' | 'error'; export type QuotaRow = { label: string; @@ -52,6 +59,8 @@ const endpointByProvider: Record = { kimi: 'https://api.kimi.com/coding/v1/usages', xai: 'https://cli-chat-proxy.grok.com/v1/billing', antigravity: 'https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary', + // Plugin providers are answered by the plugin management API; there is no upstream endpoint. + plugin: '', }; const CLAUDE_PROFILE_URL = 'https://api.anthropic.com/api/oauth/profile'; @@ -88,10 +97,16 @@ const headersByProvider: Record> = { 'Content-Type': 'application/json', 'User-Agent': 'antigravity/cli/1.0.13 (aidev_client; os_type=darwin; arch=arm64)', }, + // The plugin API injects the credential server-side; the client only sends the auth index. + plugin: {}, }; +// cpa-multi-plugins providers that expose a quota summary through their plugin API. +const PLUGIN_QUOTA_PROVIDERS = ['workbuddy', 'trae', 'qoder', 'zcode', 'mimo']; + export const providerForFile = (file: AuthFile): QuotaProvider | null => { const value = readString(file, 'provider', 'type', 'account_type').toLowerCase().replace(/_/g, '-'); + if (PLUGIN_QUOTA_PROVIDERS.includes(value)) return 'plugin'; if (value === 'x-ai' || value === 'grok') return 'xai'; if (value === 'cognition') return 'devin'; if (value === 'anthropic') return 'claude'; @@ -836,6 +851,86 @@ const callCodexResetCredits = async ( return codexResetCreditDetailsFor(payload); }; +const pluginNumber = (value: unknown): number | null => + (typeof value === 'number' && Number.isFinite(value) ? value : null); + +// Plugin quota is resolved by the plugin's own management API. The bundled +// cpa-multi-plugins providers share one payload shape: `summary` carries the +// per-region and total pack numbers, `accounts[].checkin` the sign-in credits. +async function loadPluginQuota(file: AuthFile): Promise { + const pluginId = readString(file, 'provider', 'type', 'account_type').toLowerCase().replace(/_/g, '-'); + if (!/^[a-z0-9-]+$/.test(pluginId)) throw new Error(quotaText('quota.service.error.unrecognized')); + const payload = await managementApi.post>( + `/v0/management/plugins/${pluginId}/refresh`, + undefined, + { timeoutMs: 30_000 }, + ); + if (!isRecord(payload)) throw new Error(quotaText('quota.service.error.noResponse')); + const summary = isRecord(payload.summary) ? payload.summary : {}; + const accounts = Array.isArray(payload.accounts) ? payload.accounts.filter(isRecord) : []; + const authIndex = normalizeAuthIndex(file.auth_index ?? file.authIndex); + const credentialName = readString(file, 'name', 'auth_id'); + const own = accounts.find((account) => { + const index = normalizeAuthIndex(account.auth_index ?? account.authIndex); + if (authIndex && index) return index === authIndex; + const id = readString(account, 'auth_id', 'name'); + return Boolean(credentialName && id && id === credentialName); + }) ?? (accounts.length === 1 ? accounts[0] : null); + const credits = isRecord(own?.credits) ? (own?.credits as Record) : null; + const totalSize = pluginNumber(credits?.total_size) ?? 0; + const totalRemain = pluginNumber(credits?.total_remain) ?? 0; + const packCount = pluginNumber(credits?.pack_count) ?? 0; + const rows: QuotaRow[] = []; + if (totalSize > 0) { + rows.push({ + label: quotaText('quota.plugin.packages'), + remainingPercent: Math.max(0, Math.min(100, (totalRemain / totalSize) * 100)), + detail: quotaText('quota.plugin.packagesDetail', { + remain: totalRemain, size: totalSize, count: packCount, + }), + }); + } + const checkin = isRecord(own?.checkin) ? (own?.checkin as Record) : null; + if (checkin) { + const totalCredits = pluginNumber(checkin.total_credits) ?? 0; + if (totalCredits > 0) { + rows.push({ + label: quotaText('quota.plugin.checkin'), + remainingPercent: null, + detail: quotaText('quota.plugin.checkinDetail', { + today: pluginNumber(checkin.today_credit) ?? 0, + total: totalCredits, + streak: pluginNumber(checkin.streak_days) ?? 0, + }), + }); + } + } + // The plugin answers with every account it owns; keep the pooled totals as a + // trailing row so a multi-account pool stays visible from any credential. + const poolSize = pluginNumber(summary.total_size) ?? 0; + if (poolSize > 0 && accounts.length > 1) { + const poolRemain = pluginNumber(summary.total_remain) ?? 0; + rows.push({ + label: quotaText('quota.plugin.pool'), + remainingPercent: Math.max(0, Math.min(100, (poolRemain / poolSize) * 100)), + detail: quotaText('quota.plugin.poolDetail', { + count: accounts.length, remain: poolRemain, size: poolSize, + }), + }); + } + if (rows.length === 0) throw new Error(quotaText('quota.service.error.unrecognized')); + return { + status: 'success', + rows, + plan: own && readString(own, 'nickname') + ? readString(own, 'nickname') + : packCount > 0 + ? quotaText('quota.plugin.packCount', { count: packCount }) + : undefined, + fetchedAt: Date.now(), + }; +} + async function loadQuotaSnapshot(file: AuthFile): Promise { const provider = providerForFile(file); if (!provider) { @@ -847,6 +942,7 @@ async function loadQuotaSnapshot(file: AuthFile): Promise { } try { if (booleanValue(file.disabled) === true) throw new Error(quotaText('quota.fileDisabled')); + if (provider === 'plugin') return await loadPluginQuota(file); const codexMetadata = provider === 'codex' ? codexMetadataFor(file) : undefined; const codexAccountId = codexMetadata?.accountId || ''; const responseClock: { serverTimeOffsetMs?: number } = {};