diff --git a/README.md b/README.md index 2d3fd41..f8dd350 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ - **折叠记忆**:折叠状态持久化,重启后保持 - **语言适配**:自动检测系统语言,支持 `/cache-lang` 运行时切换中/英文,偏好持久化 - **多币种**:通过 `/cache-currency` 切换货币,费用和节省同步换算 +- **余额查询**:查询多家 AI 提供商的账户余额,支持自动切换跟随当前会话提供商 - **斜杠命令**:`/cache-session` `/cache-session-back` `/cache-rate` `/cache-section` `/cache-config` `/cache-lang` 动态配置面板 - **子代理缓存查看**:`/cache-session` 自动扫描并列出子代理,选择一个即可切换面板显示其缓存统计,支持 `/cache-session-back` 返回主会话 - **已加载技能**:检测 session 中 LLM 调用 `skill` tool 的记录,展示已加载技能名及估算 Token 占用 @@ -105,9 +106,11 @@ npm install -g opencode-visual-cache@latest | `/cache-session-back` | 返回主会话统计 | 从子代理缓存视图切回主会话 | | `/cache-currency` | 切换货币单位 | 从列表选择货币(USD / CNY / EUR / JPY / GBP / KRW),自动填入默认汇率 | | `/cache-rate` | 调整汇率乘数 | 输入自定义汇率(如 `7.2`),用于费用换算 | -| `/cache-section` | 开关区块与边框 | 独立控制 Token 明细 / 模型与定价 / 估算 Token 分布 / 已加载技能 / 面板边框的显隐 | +| `/cache-section` | 开关区块与边框 | 独立控制 Token 明细 / 模型与定价 / 估算 Token 分布 / 已加载技能 / 余额 / 面板边框的显隐 | | `/cache-config` | 查看当前配置 | 弹出当前货币、汇率、区块可见性状态 | | `/cache-lang` | 切换显示语言 | 从列表选择中文或 English,界面即时切换,无需重启 | +| `/cache-balance` | 余额查询设置 | 选择余额提供商(菜单标注 Key 来源:用户 key / OpenCode / 未配置)/ 开关自动切换 | +| `/cache-balance-key` | 设置余额 API Key | 两步流程:选择提供商 → 输入 API Key |
斜杠命令 @@ -141,11 +144,35 @@ npm install -g opencode-visual-cache@latest - **模型与定价**:费用 / 提供商 / 模型名 / 单价 - **估算 Token 分布**:按角色拆分的 Token 估算 - **已加载技能**:session 中 LLM 实际调用过的 Skill 名及估算 Token 占用 +- **余额**:当前提供商账户余额(多提供商 + 自动切换) 通过 `/cache-section` 切换后即时生效,无需重启。此外,该命令还可以开关面板的**外边框**——关闭后内容会顶格显示,释放额外空间。 > **关于 Token 分布数值**:分布面板中"总计"为最后一次 API 调用的精确 token 数,"系统提示"/"用户"等分项为字符级 BPE 估算值。分项之和通常小于总计,差值主要来自 OpenCode 运行时注入的系统提示组成部分,包括环境信息、Skill 目录、工具 Schema 定义等(详见 [`system.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/system.ts)、[`tools.ts`](https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/session/tools.ts))。这些内容不在 agent 配置的 `prompt` 字段中,因此插件无法估算,属于预期行为。 +### 4.4 余额查询 + +面板支持显示多家 AI 提供商的账户余额。开启**自动切换**后,余额查询会跟随当前会话正在使用的模型提供商自动切换。 + +已支持余额查询的提供商: + +| 提供商 | 余额查询端点 | 币种 | Key 前缀 | 状态 | +|--------|-------------|------|---------|------| +| DeepSeek | `https://api.deepseek.com/user/balance` | CNY / USD | `sk-` | ✅ 已支持 | +| SiliconFlow | `https://api.siliconflow.cn/v1/user/info` | CNY | `sk-` | ✅ 已支持 | +| OpenRouter | `https://openrouter.ai/api/v1/credits` | USD | `sk-or-` | ✅ 已支持 | +| Moonshot | `https://api.moonshot.cn/v1/users/me/balance` | CNY | `sk-` | ✅ 已支持 | +| 智谱 GLM | 待接入(社区逆向端点,非官方) | CNY | — | ⏳ 希望支持 | +| xAI | 待接入(需 Management Key + Team ID) | USD | — | ⏳ 希望支持 | + +> **Key 来源**:优先使用 `/cache-balance-key` 手动配置的 Key;未手动配置时自动复用 OpenCode 已认证的凭据(`/connect` 配置的 provider)。两者都没有的提供商无法查询余额。 +> +> **Key 存储**:手动配置的 API Key 明文保存于插件持久化 KV,请勿在共享设备上使用。 +> +> **自动切换**:默认开启;手动选择提供商后自动关闭,可在 `/cache-balance` 中重新开启。自动切换按当前会话的模型提供商匹配,未配置 Key 的提供商被选中时显示「未配置」提示。 +> +> **希望支持**:已调研确认具备可行性的候选提供商,尚未实现。智谱 GLM 仅有社区逆向的非官方端点(无稳定性保障)。 + --- ## 5. 更新 diff --git a/README_EN.md b/README_EN.md index 1c6deb5..d339cb4 100644 --- a/README_EN.md +++ b/README_EN.md @@ -50,6 +50,7 @@ Interested in sub-agent monitoring? Check out [opencode-subagent-magazine](https - **Persistent State**: Fold preferences and config remembered across restarts via api.kv - **Language**: Auto-detects system locale, with `/cache-lang` for runtime switching between Chinese and English — preference persisted across restarts - **Multi-currency**: Switch via `/cache-currency` — costs, savings, and per-million rates convert in real time +- **Balance Query**: Query account balance across multiple AI providers, with auto-switch following the current session's provider - **Slash Commands**: `/cache-session` `/cache-session-back` `/cache-rate` `/cache-section` `/cache-config` `/cache-lang` for live panel configuration - **Sub-Agent Cache View**: `/cache-session` auto-scans and lists sub-agents; select one to switch the panel stats. Use `/cache-session-back` to return to the main session - **Loaded Skills**: Detects `skill` tool calls in the session and displays loaded skill names with estimated token footprint @@ -105,9 +106,11 @@ The plugin supports slash commands and command palette (`Ctrl + P`) for runtime | `/cache-session-back` | Return to main session | Switch back to main session from sub-agent cache view | | `/cache-currency` | Switch currency | Pick from a list (USD / CNY / EUR / JPY / GBP / KRW); default exchange rate auto-filled | | `/cache-rate` | Adjust exchange rate | Enter a custom rate (e.g. `7.2` for CNY) | -| `/cache-section` | Toggle sections & border | Independently show/hide Detail, Model & Pricing, Token Distribution, Loaded Skills, or the panel border | +| `/cache-section` | Toggle sections & border | Independently show/hide Detail, Model & Pricing, Token Distribution, Loaded Skills, Balance, or the panel border | | `/cache-config` | View current config | Displays currency, rate, and section visibility | | `/cache-lang` | Switch display language | Pick Chinese or English from the dialog — takes effect immediately, no restart needed | +| `/cache-balance` | Balance query settings | Pick a balance provider (menu shows key source: user key / OpenCode / not set) / toggle auto-switch | +| `/cache-balance-key` | Set balance API key | Two-step flow: pick a provider → enter the API key |
Slash command @@ -141,9 +144,33 @@ Three sub-sections can be toggled independently to save sidebar space: - **Model & Pricing**: cost / provider / model name / per-million rates - **Estimated Token Dist.**: per-role token breakdown - **Loaded Skills**: skill names the LLM actually loaded via the `skill` tool, with estimated token counts +- **Balance**: the selected provider's account balance (multi-provider with auto-switch) Toggled via `/cache-section` — takes effect instantly, no restart required. The same command also toggles the panel **border**; turning it off removes the outline and padding so content fills the full width. +### 4.4 Balance Query + +The panel can display account balance from multiple AI providers. With **auto-switch** enabled, the balance query follows the model provider of the current session automatically. + +Supported balance providers: + +| Provider | Balance endpoint | Currency | Key prefix | Status | +|----------|-----------------|----------|------------|--------| +| DeepSeek | `https://api.deepseek.com/user/balance` | CNY / USD | `sk-` | ✅ Supported | +| SiliconFlow | `https://api.siliconflow.cn/v1/user/info` | CNY | `sk-` | ✅ Supported | +| OpenRouter | `https://openrouter.ai/api/v1/credits` | USD | `sk-or-` | ✅ Supported | +| Moonshot | `https://api.moonshot.cn/v1/users/me/balance` | CNY | `sk-` | ✅ Supported | +| Zhipu GLM | Pending (community-reversed endpoint, unofficial) | CNY | — | ⏳ Planned | +| xAI | Pending (requires Management Key + Team ID) | USD | — | ⏳ Planned | + +> **Key source**: a key set manually via `/cache-balance-key` takes priority; otherwise the plugin reuses the credential OpenCode already authenticated (`/connect`-configured providers). Providers with neither cannot show a balance. +> +> **Key storage**: manually configured API keys are stored in plaintext in the plugin's persistent KV — avoid using on shared devices. +> +> **Auto-switch**: enabled by default; picking a provider manually disables it — re-enable anytime via `/cache-balance`. Auto-switch matches the current session's model provider; a provider without a key shows a "not set" hint when selected. +> +> **Planned**: candidates confirmed feasible by research, not yet implemented. Zhipu GLM only has a community-reversed unofficial endpoint (no stability guarantee). + --- ## 5. Update diff --git a/src/balance-providers.ts b/src/balance-providers.ts new file mode 100644 index 0000000..39b5b00 --- /dev/null +++ b/src/balance-providers.ts @@ -0,0 +1,153 @@ +// --------------------------------------------------------------------------- +// Balance providers — pluggable account-balance query adapters. +// --------------------------------------------------------------------------- + +/** 归一化后的余额条目——显示层与具体 provider 解耦。 */ +export interface BalanceEntry { + currency: string // 原生币种(CNY/USD…),复用现有汇率换算 + total: string // 余额字符串 +} + +/** provider 统一错误:message 即错误码(401/403/EMPTY/…),显示层直接展示。 */ +export class BalanceError extends Error {} + +/** 可插拔的余额 provider 适配器。 */ +export interface BalanceProvider { + id: string // 唯一标识,同时用作 KV key 命名空间 + name: string // 显示名(专有名词,无需 i18n) + keyPlaceholder?: string // key 输入框占位(如 "sk-...") + fetchBalance(apiKey: string, signal?: AbortSignal): Promise +} + +const siliconflowProvider: BalanceProvider = { + id: "siliconflow", + name: "SiliconFlow", + keyPlaceholder: "sk-...", + async fetchBalance(apiKey, signal) { + // 国内站 api.siliconflow.cn(CNY);国际站为 api.siliconflow.com(USD) + const res = await fetch("https://api.siliconflow.cn/v1/user/info", { + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, + signal, + }) + if (!res.ok) { + if (res.status === 401) throw new BalanceError("401") + if (res.status === 403) throw new BalanceError("403") + throw new BalanceError(String(res.status)) + } + const json = await res.json() as { + status?: boolean + data?: { + balance?: string | number + chargeBalance?: string | number + totalBalance?: string | number + } + } + // totalBalance 为总余额(含充值+赠送),缺失时回退 balance + const total = json.data?.totalBalance ?? json.data?.balance + if (typeof total === "undefined" || total === null) throw new BalanceError("EMPTY") + return [{ currency: "CNY", total: String(total) }] + }, +} + +const deepseekProvider: BalanceProvider = { + id: "deepseek", + name: "DeepSeek", + keyPlaceholder: "sk-...", + async fetchBalance(apiKey, signal) { + const res = await fetch("https://api.deepseek.com/user/balance", { + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, + signal, + }) + if (!res.ok) { + if (res.status === 401) throw new BalanceError("401") + if (res.status === 402 || res.status === 403) throw new BalanceError("403") + throw new BalanceError(String(res.status)) + } + const json = await res.json() as { + is_available?: boolean + balance_infos?: { currency: string; total_balance: string; granted_balance: string; topped_up_balance: string }[] + } + const infos = json.balance_infos ?? [] + if (infos.length === 0) throw new BalanceError("EMPTY") + return infos.map((info) => ({ + currency: info.currency ?? "CNY", + total: info.total_balance ?? "0", + })) + }, +} + +const openrouterProvider: BalanceProvider = { + id: "openrouter", + name: "OpenRouter", + keyPlaceholder: "sk-or-...", + async fetchBalance(apiKey, signal) { + // 官方文档标注需 Management key,实测普通 API key 亦可查询账户余额 + const res = await fetch("https://openrouter.ai/api/v1/credits", { + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, + signal, + }) + if (!res.ok) { + if (res.status === 401 || res.status === 403) throw new BalanceError("403") + throw new BalanceError(String(res.status)) + } + const json = await res.json() as { + data?: { total_credits?: number; total_usage?: number } + } + const credits = json.data?.total_credits + const usage = json.data?.total_usage + if (typeof credits !== "number" || typeof usage !== "number") throw new BalanceError("EMPTY") + // 剩余额度 = 充值总额 - 已用 + return [{ currency: "USD", total: (credits - usage).toFixed(2) }] + }, +} + +const moonshotProvider: BalanceProvider = { + id: "moonshot", + name: "Moonshot", + keyPlaceholder: "sk-...", + async fetchBalance(apiKey, signal) { + // 国内站 api.moonshot.cn(CNY);国际站 api.moonshot.ai(USD) + const res = await fetch("https://api.moonshot.cn/v1/users/me/balance", { + headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, + signal, + }) + if (!res.ok) { + if (res.status === 401) throw new BalanceError("401") + if (res.status === 403) throw new BalanceError("403") + throw new BalanceError(String(res.status)) + } + const json = await res.json() as { + data?: { available_balance?: string | number } + } + const balance = json.data?.available_balance + if (typeof balance === "undefined" || balance === null) throw new BalanceError("EMPTY") + return [{ currency: "CNY", total: String(balance) }] + }, +} + +/** 已注册的 provider 列表(按需追加新适配器)。 */ +export const balanceProviders: BalanceProvider[] = [deepseekProvider, siliconflowProvider, openrouterProvider, moonshotProvider] + +/** 按 id 取 provider;未知 id 回退到第一个。 */ +export function getBalanceProvider(id: string): BalanceProvider { + return balanceProviders.find((p) => p.id === id) ?? balanceProviders[0] ?? deepseekProvider +} + +/** + * 按 OpenCode providerID 匹配余额 provider。 + * 先精确匹配,再按前缀匹配(如 moonshotai-cn → moonshot);未命中返回 undefined。 + * 比较不区分大小写,容忍 providerID 的大小写变体。 + */ +export function matchBalanceProvider(providerId: string): BalanceProvider | undefined { + const id = providerId.toLowerCase() + const exact = balanceProviders.find((p) => p.id.toLowerCase() === id) + if (exact) return exact + return balanceProviders.find((p) => id.startsWith(p.id.toLowerCase())) +} + +/** key 脱敏:保留头 5 尾 5 字符,中间用 * 填充。 */ +export function maskKey(k: string): string { + if (!k) return "" + if (k.length <= 10) return k.slice(0, 5) + "*".repeat(Math.max(3, k.length - 5)) + return k.slice(0, 5) + "*".repeat(Math.max(3, k.length - 10)) + k.slice(-5) +} diff --git a/src/index.tsx b/src/index.tsx index 5804106..f235f2c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,6 +8,7 @@ import type { TuiSlotPlugin, TuiPluginModule, TuiThemeCurrent, + TuiDialogStack, } from "@opencode-ai/plugin/tui" import type { UserMessage, AssistantMessage, Message } from "@opencode-ai/sdk" import type { @@ -19,6 +20,7 @@ import type { } from "@opencode-ai/sdk/v2" import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, untrack } from "solid-js" import { PLUGIN_VERSION } from "./_version" +import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceEntry, type BalanceProvider } from "./balance-providers" // --------------------------------------------------------------------------- // Helpers @@ -114,7 +116,7 @@ const ZH_T = { secModel: "模型", secSkills: "已加载技能", balTotal: "总余额:", - balNoKey: "未配置 API Key", + balNoKey: "未配置 {p} API Key", balLoading: "查询中...", balError: "查询失败", balErr401: "API Key 无效", @@ -154,7 +156,7 @@ const EN_T = { secModel: "Model", secSkills: "Loaded Skills", balTotal: "Total:", - balNoKey: "No API Key set", + balNoKey: "{p} API Key not set", balLoading: "Fetching...", balError: "Fetch failed", balErr401: "Invalid API Key", @@ -348,17 +350,12 @@ interface TokenDist { } // --------------------------------------------------------------------------- -// DeepSeek Balance +// Balance state // --------------------------------------------------------------------------- -interface DeepSeekBalance { - currency: string - total: string -} - interface BalanceState { status: "idle" | "loading" | "ok" | "error" - data: DeepSeekBalance[] | null + data: BalanceEntry[] | null lastFetch: number error?: string key?: string // 上次成功/尝试查询所用的 key,用于检测 key 是否更换 @@ -366,28 +363,6 @@ interface BalanceState { const BALANCE_POLL_MS = 5 * 60 * 1000 // 5 minutes -async function fetchDeepSeekBalance(apiKey: string, signal?: AbortSignal): Promise { - const res = await fetch("https://api.deepseek.com/user/balance", { - headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" }, - signal, - }) - if (!res.ok) { - if (res.status === 401) throw new Error("401") - if (res.status === 402 || res.status === 403) throw new Error("403") - throw new Error(String(res.status)) - } - const json = await res.json() as { - is_available?: boolean - balance_infos?: { currency: string; total_balance: string; granted_balance: string; topped_up_balance: string }[] - } - const infos = json.balance_infos ?? [] - if (infos.length === 0) throw new Error("EMPTY") - return infos.map((info) => ({ - currency: info.currency ?? "CNY", - total: info.total_balance ?? "0", - })) -} - /** * 将余额从来源币种换算为目标币种。 * DEFAULT_RATES 以 USD=1 为基准:先折算为 USD,再换算到目标币种。 @@ -399,6 +374,27 @@ function convertBalance(target: string, targetRate: number, amount: number, from return target === "USD" ? usd : usd * targetRate } +/** + * 从 OpenCode 已认证的 provider 读取 API key 作为余额查询的自动兜底。 + * 匹配复用前缀逻辑:先精确匹配 id,再前缀匹配(如 moonshotai-cn → moonshot)。 + * key 来源:auth.json(provider.key)或配置(provider.options.apiKey)。 + * 仅当手动配置的 key 缺失时使用;读取失败或未匹配返回空串。 + */ +function findOpencodeKey(api: TuiPluginApi, provider: BalanceProvider): string { + try { + const provs = api.state.provider as unknown as Array<{ id: string; key?: string; options?: { apiKey?: string } }> + // 大小写不敏感:精确匹配 id,否则前缀匹配(如 moonshotai-cn → moonshot) + const id = provider.id.toLowerCase() + const hit = provs.find((p) => p.id.toLowerCase() === id) ?? provs.find((p) => p.id.toLowerCase().startsWith(id)) + if (!hit) return "" + const k = typeof hit.key === "string" ? hit.key : "" + if (k) return k + return typeof hit.options?.apiKey === "string" ? hit.options.apiKey : "" + } catch { + return "" + } +} + /** 货币符号:优先取 /cache-currency 内置映射,未知币种回退为代码。 */ function balanceSymbol(currency: string): string { const sym = CURRENCIES[currency] @@ -429,9 +425,15 @@ interface PanelSignals { setSectionSkills: (v: boolean) => void sectionBalance: () => boolean setSectionBalance: (v: boolean) => void - /** Increment to force a DeepSeek balance re-fetch. */ + /** Increment to force a balance re-fetch. */ balanceRefresh: () => number setBalanceRefresh: (v: number) => void + /** Currently selected balance provider id (e.g. "deepseek"). */ + balanceProviderId: () => string + setBalanceProviderId: (v: string) => void + /** Auto-switch to the session's provider for balance display. Manual switch disables it. */ + autoBalance: () => boolean + setAutoBalance: (v: boolean) => void /** Preferred currency code for balance display (CNY / USD / …). Empty = first entry. */ balanceCurrency: () => string setBalanceCurrency: (v: string) => void @@ -488,6 +490,8 @@ function TokenCachePanel(props: { sectionSkills, setSectionSkills, sectionBalance, setSectionBalance, balanceRefresh, + balanceProviderId, setBalanceProviderId, + autoBalance, setAutoBalance, balanceCurrency, setBalanceCurrency, borderVisible, setBorderVisible, } = props.signals @@ -529,8 +533,14 @@ function TokenCachePanel(props: { // 请求序号:防止定时轮询与手动刷新并发时,慢的旧请求覆盖新结果 let balanceSeq = 0 + // 当前 provider 显示名 + const providerName = createMemo(() => getBalanceProvider(balanceProviderId()).name) + const pollBalance = async () => { - const key = props.api.kv.get(`${KV_PREFIX}.ds_key`, "") + const provider = getBalanceProvider(balanceProviderId()) + // 手动配置的 key 优先;缺失时自动复用 OpenCode 已认证的 key(auth.json / config) + const key = props.api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "") + || findOpencodeKey(props.api, provider) if (!key) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return } const now = Date.now() const prev = balanceState() @@ -542,7 +552,7 @@ function TokenCachePanel(props: { let timedOut = false const timer = setTimeout(() => { timedOut = true; controller.abort() }, 10_000) try { - const data = await fetchDeepSeekBalance(key, controller.signal) + const data = await provider.fetchBalance(key, controller.signal) clearTimeout(timer) if (seq !== balanceSeq) return // 已被更新的请求取代,丢弃过期结果 setBalanceState({ status: "ok", data, lastFetch: Date.now(), error: undefined, key }) @@ -564,6 +574,37 @@ function TokenCachePanel(props: { untrack(() => { void pollBalance() }) }) + // 自动切换当前会话的 provider(前缀匹配)。手动切换会关闭此行为。 + // 直接追踪 messages 取最后一条 assistant 消息的 providerID—— + // 不依赖 session.model 的响应式更新(模型切换时该链路可能不触发重算)。 + createEffect(() => { + if (!autoBalance()) return + const sid = props.signals.overrideSessionId() ?? props.sessionId + const msgs = props.api.state.session.messages(sid) as Message[] + let pid = "" + for (let i = msgs.length - 1; i >= 0; i--) { + const m = msgs[i] + if (m.role === "assistant" && (m as AssistantMessage).providerID) { + pid = (m as AssistantMessage).providerID + break + } + } + // 会话尚无 assistant 消息(新会话 / 刚切换模型未对话 / 消息未加载) + // → 回退到会话级模型元数据,反映当前正在使用的 provider + if (!pid) { + try { + const session = props.api.state.session.get(sid) + pid = session?.model?.providerID ?? "" + } catch { /* ignore */ } + } + if (!pid) return + const hit = matchBalanceProvider(pid) + if (hit && hit.id !== balanceProviderId()) { + setBalanceProviderId(hit.id) + props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1) + } + }) + // ── auto-clear override when the user navigates to a different main session ── let lastMainSid = props.sessionId createEffect(() => { @@ -763,6 +804,23 @@ function TokenCachePanel(props: { if (typeof rate === "number" && rate > 0) setExchangeRate(rate) const balCur = props.api.kv.get(`${KV_PREFIX}.balance_currency`) if (typeof balCur === "string") setBalanceCurrency(balCur) + // Restore balance provider (fall back to default when unknown) + const savedProvider = props.api.kv.get(`${KV_PREFIX}.balance.provider`) + if (typeof savedProvider === "string" && balanceProviders.some((p) => p.id === savedProvider)) { + setBalanceProviderId(savedProvider) + } + // Restore auto-switch (default on) + const savedAuto = props.api.kv.get(`${KV_PREFIX}.balance.auto`) + if (typeof savedAuto === "boolean") setAutoBalance(savedAuto) + // Migrate legacy DeepSeek key (cache_panel.ds_key → cache_panel.balance.deepseek.key) + const legacyKey = props.api.kv.get(`${KV_PREFIX}.ds_key`, "") + if (legacyKey) { + const dsKey = props.api.kv.get(`${KV_PREFIX}.balance.deepseek.key`, "") + if (!dsKey) props.api.kv.set(`${KV_PREFIX}.balance.deepseek.key`, legacyKey) + props.api.kv.set(`${KV_PREFIX}.ds_key`, "") + } + // 恢复的 provider 可能与默认值不同,强制重新查询 + props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1) setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true))) setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true))) setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true))) @@ -1115,7 +1173,7 @@ function TokenCachePanel(props: { {"> "} - {t().balNoKey} + {t().balNoKey.replace("{p}", providerName())} @@ -1214,6 +1272,8 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { const [sectionSkills, setSectionSkills] = createSignal(true) const [sectionBalance, setSectionBalance] = createSignal(true) const [balanceRefresh, setBalanceRefresh] = createSignal(0) + const [balanceProviderId, setBalanceProviderId] = createSignal("deepseek") + const [autoBalance, setAutoBalance] = createSignal(true) const [balanceCurrency, setBalanceCurrency] = createSignal("") const [borderVisible, setBorderVisible] = createSignal(true) const [langZH, setLangZH] = createSignal(LANG_ZH) @@ -1229,6 +1289,8 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { sectionSkills, setSectionSkills, sectionBalance, setSectionBalance, balanceRefresh, setBalanceRefresh, + balanceProviderId, setBalanceProviderId, + autoBalance, setAutoBalance, balanceCurrency, setBalanceCurrency, borderVisible, setBorderVisible, overrideSessionId, setOverrideSessionId, @@ -1238,6 +1300,55 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { // ── slash commands for runtime config ── const KV_PREFIX = "cache_panel" + + /** 菜单中 provider 选项标题:标注 key 来源(手动配置 / OpenCode 自动复用 / 未配置)。 */ + const providerOptionTitle = (p: BalanceProvider, current?: string) => { + const zh = langZH() + const hasManual = !!api.kv.get(`${KV_PREFIX}.balance.${p.id}.key`, "") + const hasAuto = !hasManual && !!findOpencodeKey(api, p) + const mark = hasManual + ? (zh ? "(用户 key)" : " (user key)") + : hasAuto + ? (zh ? "(OpenCode)" : " (OpenCode)") + : (zh ? "(未配置)" : " (not set)") + return p.name + mark + (current && p.id === current ? " *" : "") + } + + /** 弹出指定 provider 的 API Key 输入框(脱敏预填;空清除 / 含 * 保留原 key / 新 key 实时刷新)。 */ + const promptBalanceKey = (dialog: TuiDialogStack | undefined, provider: BalanceProvider) => { + const zh = langZH() + const current = api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "") + const masked = maskKey(current) + dialog?.replace(() => ( + {zh ? `输入 ${provider.name} API Key 以显示账户余额(留空清除)` : `Enter your ${provider.name} API key to show account balance (leave empty to clear)`}} + placeholder={provider.keyPlaceholder ?? "sk-..."} + value={masked} + onConfirm={(val) => { + const input = val.trim() + let key: string + if (input === "") { + key = "" + } else if (input.includes("*")) { + key = current + } else { + key = input + } + api.kv.set(`${KV_PREFIX}.balance.${provider.id}.key`, key) + setBalanceRefresh(v => v + 1) + if (key) { + api.ui.toast({ message: zh ? "API Key 已保存,正在查询余额..." : "API Key saved, fetching balance..." }) + } else { + api.ui.toast({ message: zh ? "API Key 已清除" : "API Key cleared" }) + } + dialog?.clear() + }} + onCancel={() => dialog?.clear()} + /> + )) + } + api.command?.register(() => [ { title: "Cache: Set Currency", @@ -1314,7 +1425,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { { title: `Model & Pricing [${modelOn ? "ON" : "OFF"}]`, value: "model" }, { title: `Token Dist. [${distOn ? "ON" : "OFF"}]`, value: "dist" }, { title: `Loaded Skills [${skillsOn ? "ON" : "OFF"}]`, value: "skills" }, - { title: `DS Balance [${balanceOn ? "ON" : "OFF"}]`, value: "balance" }, + { title: `Balance [${balanceOn ? "ON" : "OFF"}]`, value: "balance" }, { title: `Panel Border [${borderOn ? "ON" : "OFF"}]`, value: "border" }, ]} onSelect={(opt) => { @@ -1387,49 +1498,87 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { }, }, { - title: "Cache: Set DeepSeek API Key", - value: "cache.balance.key", - description: "Set or update the DeepSeek API key for balance display", - slash: { name: "cache-balance-key" }, + title: "Cache: Switch Balance Provider", + value: "cache.balance", + description: "切换余额提供商 / 自动切换当前会话提供商 | Switch balance provider / auto-switch session provider", + slash: { name: "cache-balance" }, onSelect: (dialog) => { const zh = langZH() - const current = api.kv.get(`${KV_PREFIX}.ds_key`, "") - // 已保存的 key 以脱敏形式预填:保留 "sk-" 前缀 + 头 5 尾 5 字符,中间用 * 填充 - const maskKey = (k: string): string => { - if (!k) return "" - const prefix = k.startsWith("sk-") ? "sk-" : "" - const body = prefix ? k.slice(3) : k - if (body.length <= 10) return prefix + body.slice(0, 5) + "*".repeat(Math.max(3, body.length - 5)) - return prefix + body.slice(0, 5) + "*".repeat(Math.max(3, body.length - 10)) + body.slice(-5) - } - const masked = maskKey(current) + const current = signals.balanceProviderId() + const auto = signals.autoBalance() + const autoLabel = auto + ? (zh ? "自动切换提供商 [开]" : "Auto-switch provider [ON]") + : (zh ? "自动切换提供商 [关]" : "Auto-switch provider [OFF]") dialog?.replace(() => ( - {zh ? "输入 DeepSeek API Key 以显示账户余额(留空清除)" : "Enter your DeepSeek API key to show account balance (leave empty to clear)"}} - placeholder="sk-..." - value={masked} - onConfirm={(val) => { - const input = val.trim() - // 空 → 清除;含 * (脱敏占位符残留)→ 视为未修改,保留原 key;否则为新 key - let key: string - if (input === "") { - key = "" - } else if (input.includes("*")) { - key = current - } else { - key = input - } - api.kv.set(`${KV_PREFIX}.ds_key`, key) - setBalanceRefresh(v => v + 1) - if (key) { - api.ui.toast({ message: zh ? "API Key 已保存,正在查询余额..." : "API Key saved, fetching balance..." }) + ({ + title: providerOptionTitle(p, current), + value: p.id, + })), + ]} + onSelect={(opt) => { + if (opt.value === "__auto__") { + const next = !auto + api.kv.set(`${KV_PREFIX}.balance.auto`, next) + signals.setAutoBalance(next) + api.ui.toast({ message: zh ? `自动切换余额提供商: ${next ? "开" : "关"}` : `Auto-switch balance provider: ${next ? "ON" : "OFF"}` }) + dialog?.clear() } else { - api.ui.toast({ message: zh ? "API Key 已清除" : "API Key cleared" }) + const provider = getBalanceProvider(opt.value) + // 手动切换会关闭自动切换 + api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id) + api.kv.set(`${KV_PREFIX}.balance.auto`, false) + signals.setBalanceProviderId(provider.id) + signals.setAutoBalance(false) + // 切换后立即按新 provider 刷新显示(无 key 时显示 idle,避免残留上一 provider 余额) + signals.setBalanceRefresh(signals.balanceRefresh() + 1) + const hasKey = !!api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "") + if (!hasKey) { + // 未配置 key → 进入设置流程(对话框保持打开等待输入) + promptBalanceKey(dialog, provider) + } else { + api.ui.toast({ message: zh ? `余额提供商: ${provider.name}(自动切换已关闭)` : `Balance provider: ${provider.name} (auto-switch off)` }) + dialog?.clear() + } } - dialog?.clear() }} - onCancel={() => dialog?.clear()} + /> + )) + }, + }, + { + title: "Cache: Set Balance API Key", + value: "cache.balance.key", + description: "Select a provider and set its API key for balance display", + slash: { name: "cache-balance-key" }, + onSelect: (dialog) => { + const zh = langZH() + // 步骤 1:选择 provider + dialog?.replace(() => ( + ({ + title: providerOptionTitle(p), + value: p.id, + }))} + onSelect={(opt) => { + const provider = getBalanceProvider(opt.value) + // 手动指定 provider 会关闭自动切换 + api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id) + api.kv.set(`${KV_PREFIX}.balance.auto`, false) + signals.setBalanceProviderId(provider.id) + signals.setAutoBalance(false) + // 切换后立即刷新显示(防止取消输入时残留上一 provider 的余额) + signals.setBalanceRefresh(signals.balanceRefresh() + 1) + // 步骤 2:输入 key + promptBalanceKey(dialog, provider) + }} /> )) },