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 |

@@ -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)
+ }}
/>
))
},