From f48bfcb2ee3374b55256127480d596232f7e57ec Mon Sep 17 00:00:00 2001 From: Hotakus Date: Mon, 10 Aug 2026 00:32:46 +0800 Subject: [PATCH 01/15] =?UTF-8?q?feat(balance):=20=E6=8A=BD=E8=B1=A1?= =?UTF-8?q?=E4=BD=99=E9=A2=9D=E6=9F=A5=E8=AF=A2=E4=B8=BA=E5=8F=AF=E6=8F=92?= =?UTF-8?q?=E6=8B=94=20provider=20=E9=80=82=E9=85=8D=E5=99=A8=E5=B9=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/balance-providers.ts | 62 ++++++++++++++ src/index.tsx | 176 +++++++++++++++++++++++---------------- 2 files changed, 166 insertions(+), 72 deletions(-) create mode 100644 src/balance-providers.ts diff --git a/src/balance-providers.ts b/src/balance-providers.ts new file mode 100644 index 0000000..72e3f14 --- /dev/null +++ b/src/balance-providers.ts @@ -0,0 +1,62 @@ +// --------------------------------------------------------------------------- +// 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 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", + })) + }, +} + +/** 已注册的 provider 列表(按需追加新适配器)。 */ +export const balanceProviders: BalanceProvider[] = [deepseekProvider] + +/** 按 id 取 provider;未知 id 回退到第一个。 */ +export function getBalanceProvider(id: string): BalanceProvider { + return balanceProviders.find((p) => p.id === id) ?? balanceProviders[0] ?? deepseekProvider +} + +/** 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..b87fb12 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -19,6 +19,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, type BalanceEntry } from "./balance-providers" // --------------------------------------------------------------------------- // Helpers @@ -114,7 +115,7 @@ const ZH_T = { secModel: "模型", secSkills: "已加载技能", balTotal: "总余额:", - balNoKey: "未配置 API Key", + balNoKey: "未配置 {p} API Key", balLoading: "查询中...", balError: "查询失败", balErr401: "API Key 无效", @@ -154,7 +155,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 +349,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 +362,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,再换算到目标币种。 @@ -429,9 +403,12 @@ 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 /** Preferred currency code for balance display (CNY / USD / …). Empty = first entry. */ balanceCurrency: () => string setBalanceCurrency: (v: string) => void @@ -488,6 +465,7 @@ function TokenCachePanel(props: { sectionSkills, setSectionSkills, sectionBalance, setSectionBalance, balanceRefresh, + balanceProviderId, setBalanceProviderId, balanceCurrency, setBalanceCurrency, borderVisible, setBorderVisible, } = props.signals @@ -529,8 +507,12 @@ 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()) + const key = props.api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "") if (!key) { setBalanceState({ status: "idle", data: null, lastFetch: 0, error: undefined, key: undefined }); return } const now = Date.now() const prev = balanceState() @@ -542,7 +524,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 }) @@ -763,6 +745,20 @@ 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) + } + // 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 +1111,7 @@ function TokenCachePanel(props: { {"> "} - {t().balNoKey} + {t().balNoKey.replace("{p}", providerName())} @@ -1214,6 +1210,7 @@ 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 [balanceCurrency, setBalanceCurrency] = createSignal("") const [borderVisible, setBorderVisible] = createSignal(true) const [langZH, setLangZH] = createSignal(LANG_ZH) @@ -1229,6 +1226,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { sectionSkills, setSectionSkills, sectionBalance, setSectionBalance, balanceRefresh, setBalanceRefresh, + balanceProviderId, setBalanceProviderId, balanceCurrency, setBalanceCurrency, borderVisible, setBorderVisible, overrideSessionId, setOverrideSessionId, @@ -1387,49 +1385,83 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { }, }, { - title: "Cache: Set DeepSeek API Key", + title: "Cache: Switch Balance Provider", + value: "cache.balance.provider", + description: "Switch the balance provider for display", + slash: { name: "cache-balance-provider" }, + onSelect: (dialog) => { + const zh = langZH() + const current = signals.balanceProviderId() + dialog?.replace(() => ( + ({ + title: p.name + (p.id === current ? " *" : ""), + value: p.id, + }))} + onSelect={(opt) => { + const provider = getBalanceProvider(opt.value) + api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id) + signals.setBalanceProviderId(provider.id) + signals.setBalanceRefresh(signals.balanceRefresh() + 1) + api.ui.toast({ message: zh ? `余额提供商: ${provider.name}` : `Balance provider: ${provider.name}` }) + dialog?.clear() + }} + /> + )) + }, + }, + { + title: "Cache: Set Balance API Key", value: "cache.balance.key", - description: "Set or update the DeepSeek API key for balance display", + description: "Select a provider and set its API key for balance display", slash: { name: "cache-balance-key" }, 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) + // 步骤 1:选择 provider 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..." }) - } else { - api.ui.toast({ message: zh ? "API Key 已清除" : "API Key cleared" }) - } - dialog?.clear() + ({ + title: p.name, + value: p.id, + }))} + onSelect={(opt) => { + const provider = getBalanceProvider(opt.value) + api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id) + signals.setBalanceProviderId(provider.id) + const current = api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "") + const masked = maskKey(current) + // 步骤 2:输入 key(脱敏预填;空清除 / 含 * 保留原 key / 新 key 实时刷新) + 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()} + /> + )) }} - onCancel={() => dialog?.clear()} /> )) }, From 297df6e7cf349e13a327bdf2fd568f9dfbcdb61d Mon Sep 17 00:00:00 2001 From: Hotakus Date: Mon, 10 Aug 2026 00:45:43 +0800 Subject: [PATCH 02/15] =?UTF-8?q?feat(balance):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=8C=89=E4=BC=9A=E8=AF=9D=E6=8F=90=E4=BE=9B=E5=95=86=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=B7=9F=E9=9A=8F=E4=BD=99=E9=A2=9D=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E5=B9=B6=E7=B2=BE=E7=AE=80=E5=88=87=E6=8D=A2=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/balance-providers.ts | 5 +++ src/index.tsx | 67 +++++++++++++++++++++++++++++++--------- 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/src/balance-providers.ts b/src/balance-providers.ts index 72e3f14..ea099b5 100644 --- a/src/balance-providers.ts +++ b/src/balance-providers.ts @@ -54,6 +54,11 @@ export function getBalanceProvider(id: string): BalanceProvider { return balanceProviders.find((p) => p.id === id) ?? balanceProviders[0] ?? deepseekProvider } +/** 按 OpenCode providerID 精确匹配余额 provider;未命中返回 undefined。 */ +export function matchBalanceProvider(providerId: string): BalanceProvider | undefined { + return balanceProviders.find((p) => p.id === providerId) +} + /** key 脱敏:保留头 5 尾 5 字符,中间用 * 填充。 */ export function maskKey(k: string): string { if (!k) return "" diff --git a/src/index.tsx b/src/index.tsx index b87fb12..8099168 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -19,7 +19,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, type BalanceEntry } from "./balance-providers" +import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceEntry } from "./balance-providers" // --------------------------------------------------------------------------- // Helpers @@ -409,6 +409,9 @@ interface PanelSignals { /** Currently selected balance provider id (e.g. "deepseek"). */ balanceProviderId: () => string setBalanceProviderId: (v: string) => void + /** Auto-follow 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 @@ -466,6 +469,7 @@ function TokenCachePanel(props: { sectionBalance, setSectionBalance, balanceRefresh, balanceProviderId, setBalanceProviderId, + autoBalance, setAutoBalance, balanceCurrency, setBalanceCurrency, borderVisible, setBorderVisible, } = props.signals @@ -546,6 +550,16 @@ function TokenCachePanel(props: { untrack(() => { void pollBalance() }) }) + // 自动跟随当前会话的 provider(精确匹配)。手动切换会关闭此行为。 + createEffect(() => { + if (!autoBalance()) return + const hit = matchBalanceProvider(data().providerName) + 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(() => { @@ -750,6 +764,9 @@ function TokenCachePanel(props: { if (typeof savedProvider === "string" && balanceProviders.some((p) => p.id === savedProvider)) { setBalanceProviderId(savedProvider) } + // Restore auto-follow (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) { @@ -1211,6 +1228,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { 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) @@ -1227,6 +1245,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { sectionBalance, setSectionBalance, balanceRefresh, setBalanceRefresh, balanceProviderId, setBalanceProviderId, + autoBalance, setAutoBalance, balanceCurrency, setBalanceCurrency, borderVisible, setBorderVisible, overrideSessionId, setOverrideSessionId, @@ -1386,25 +1405,42 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { }, { title: "Cache: Switch Balance Provider", - value: "cache.balance.provider", - description: "Switch the balance provider for display", - slash: { name: "cache-balance-provider" }, + value: "cache.balance", + description: "Switch balance provider or toggle auto-follow", + slash: { name: "cache-balance" }, onSelect: (dialog) => { const zh = langZH() const current = signals.balanceProviderId() + const auto = signals.autoBalance() + const autoLabel = auto + ? (zh ? "自动跟随 [开]" : "Auto-follow [ON]") + : (zh ? "自动跟随 [关]" : "Auto-follow [OFF]") dialog?.replace(() => ( ({ - title: p.name + (p.id === current ? " *" : ""), - value: p.id, - }))} + title={zh ? "余额提供商 / 自动跟随" : "Balance Provider / Auto-follow"} + options={[ + { title: autoLabel, value: "__auto__" }, + ...balanceProviders.map((p) => ({ + title: p.name + (p.id === current ? " *" : ""), + value: p.id, + })), + ]} onSelect={(opt) => { - const provider = getBalanceProvider(opt.value) - api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id) - signals.setBalanceProviderId(provider.id) - signals.setBalanceRefresh(signals.balanceRefresh() + 1) - api.ui.toast({ message: zh ? `余额提供商: ${provider.name}` : `Balance provider: ${provider.name}` }) + 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-follow balance provider: ${next ? "ON" : "OFF"}` }) + } else { + 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) + signals.setBalanceRefresh(signals.balanceRefresh() + 1) + api.ui.toast({ message: zh ? `余额提供商: ${provider.name}(自动跟随已关闭)` : `Balance provider: ${provider.name} (auto-follow off)` }) + } dialog?.clear() }} /> @@ -1428,8 +1464,11 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { }))} 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) const current = api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "") const masked = maskKey(current) // 步骤 2:输入 key(脱敏预填;空清除 / 含 * 保留原 key / 新 key 实时刷新) From 643ab4257b8cade72bca94c099dab22fc0beb070 Mon Sep 17 00:00:00 2001 From: Hotakus Date: Mon, 10 Aug 2026 01:20:50 +0800 Subject: [PATCH 03/15] =?UTF-8?q?feat(balance):=20=E6=96=B0=E5=A2=9E=20Sil?= =?UTF-8?q?iconFlow=20=E4=BD=99=E9=A2=9D=E9=80=82=E9=85=8D=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/balance-providers.ts | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/balance-providers.ts b/src/balance-providers.ts index ea099b5..c4f1266 100644 --- a/src/balance-providers.ts +++ b/src/balance-providers.ts @@ -19,6 +19,36 @@ export interface BalanceProvider { 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", @@ -47,7 +77,7 @@ const deepseekProvider: BalanceProvider = { } /** 已注册的 provider 列表(按需追加新适配器)。 */ -export const balanceProviders: BalanceProvider[] = [deepseekProvider] +export const balanceProviders: BalanceProvider[] = [deepseekProvider, siliconflowProvider] /** 按 id 取 provider;未知 id 回退到第一个。 */ export function getBalanceProvider(id: string): BalanceProvider { From 5d6e2e130d8a1ae97728dc16391c516d65cca91b Mon Sep 17 00:00:00 2001 From: Hotakus Date: Mon, 10 Aug 2026 01:20:50 +0800 Subject: [PATCH 04/15] =?UTF-8?q?fix(balance):=20/cache-balance=20?= =?UTF-8?q?=E6=97=A0=20key=20=E8=87=AA=E5=8A=A8=E8=BF=9B=E5=85=A5=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=88=87=E6=8D=A2=E5=90=8E?= =?UTF-8?q?=E6=AE=8B=E7=95=99=E6=97=A7=E4=BD=99=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.tsx | 86 +++++++++++++++++++++++++++++++-------------------- 1 file changed, 52 insertions(+), 34 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 8099168..987c394 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,7 +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 } from "./balance-providers" +import { balanceProviders, getBalanceProvider, maskKey, matchBalanceProvider, type BalanceEntry, type BalanceProvider } from "./balance-providers" // --------------------------------------------------------------------------- // Helpers @@ -1255,6 +1256,42 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { // ── slash commands for runtime config ── const KV_PREFIX = "cache_panel" + + /** 弹出指定 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", @@ -1431,6 +1468,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { api.kv.set(`${KV_PREFIX}.balance.auto`, next) signals.setAutoBalance(next) api.ui.toast({ message: zh ? `自动跟随余额提供商: ${next ? "开" : "关"}` : `Auto-follow balance provider: ${next ? "ON" : "OFF"}` }) + dialog?.clear() } else { const provider = getBalanceProvider(opt.value) // 手动切换会关闭自动跟随 @@ -1438,10 +1476,17 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { api.kv.set(`${KV_PREFIX}.balance.auto`, false) signals.setBalanceProviderId(provider.id) signals.setAutoBalance(false) + // 切换后立即按新 provider 刷新显示(无 key 时显示 idle,避免残留上一 provider 余额) signals.setBalanceRefresh(signals.balanceRefresh() + 1) - api.ui.toast({ message: zh ? `余额提供商: ${provider.name}(自动跟随已关闭)` : `Balance provider: ${provider.name} (auto-follow off)` }) + 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-follow off)` }) + dialog?.clear() + } } - dialog?.clear() }} /> )) @@ -1469,37 +1514,10 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { api.kv.set(`${KV_PREFIX}.balance.auto`, false) signals.setBalanceProviderId(provider.id) signals.setAutoBalance(false) - const current = api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "") - const masked = maskKey(current) - // 步骤 2:输入 key(脱敏预填;空清除 / 含 * 保留原 key / 新 key 实时刷新) - 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()} - /> - )) + // 切换后立即刷新显示(防止取消输入时残留上一 provider 的余额) + signals.setBalanceRefresh(signals.balanceRefresh() + 1) + // 步骤 2:输入 key + promptBalanceKey(dialog, provider) }} /> )) From 7c502c6ba8c975744aff599ae74520ed5f9743b6 Mon Sep 17 00:00:00 2001 From: Hotakus Date: Mon, 10 Aug 2026 01:37:29 +0800 Subject: [PATCH 05/15] =?UTF-8?q?feat(balance):=20=E6=96=B0=E5=A2=9E=20Ope?= =?UTF-8?q?nRouter=20=E4=BD=99=E9=A2=9D=E9=80=82=E9=85=8D=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/balance-providers.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/balance-providers.ts b/src/balance-providers.ts index c4f1266..eb780e8 100644 --- a/src/balance-providers.ts +++ b/src/balance-providers.ts @@ -76,8 +76,33 @@ const deepseekProvider: BalanceProvider = { }, } +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) }] + }, +} + /** 已注册的 provider 列表(按需追加新适配器)。 */ -export const balanceProviders: BalanceProvider[] = [deepseekProvider, siliconflowProvider] +export const balanceProviders: BalanceProvider[] = [deepseekProvider, siliconflowProvider, openrouterProvider] /** 按 id 取 provider;未知 id 回退到第一个。 */ export function getBalanceProvider(id: string): BalanceProvider { From f759264ac4eabb98b4e9e16cbb9592699a73baec Mon Sep 17 00:00:00 2001 From: Hotakus Date: Mon, 10 Aug 2026 01:46:27 +0800 Subject: [PATCH 06/15] =?UTF-8?q?feat(balance):=20=E6=96=B0=E5=A2=9E=20Moo?= =?UTF-8?q?nshot=20=E4=BD=99=E9=A2=9D=E9=80=82=E9=85=8D=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/balance-providers.ts | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/balance-providers.ts b/src/balance-providers.ts index eb780e8..fb0cbf1 100644 --- a/src/balance-providers.ts +++ b/src/balance-providers.ts @@ -101,8 +101,32 @@ const openrouterProvider: BalanceProvider = { }, } +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] +export const balanceProviders: BalanceProvider[] = [deepseekProvider, siliconflowProvider, openrouterProvider, moonshotProvider] /** 按 id 取 provider;未知 id 回退到第一个。 */ export function getBalanceProvider(id: string): BalanceProvider { From 9305855791d066ff577f52c3fad5148f4f90eb60 Mon Sep 17 00:00:00 2001 From: Hotakus Date: Mon, 10 Aug 2026 02:00:59 +0800 Subject: [PATCH 07/15] =?UTF-8?q?fix(balance):=20=E4=BD=99=E9=A2=9D?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E5=95=86=E5=8C=B9=E9=85=8D=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=89=8D=E7=BC=80=E5=8F=98=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/balance-providers.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/balance-providers.ts b/src/balance-providers.ts index fb0cbf1..21fc50e 100644 --- a/src/balance-providers.ts +++ b/src/balance-providers.ts @@ -133,9 +133,14 @@ export function getBalanceProvider(id: string): BalanceProvider { return balanceProviders.find((p) => p.id === id) ?? balanceProviders[0] ?? deepseekProvider } -/** 按 OpenCode providerID 精确匹配余额 provider;未命中返回 undefined。 */ +/** + * 按 OpenCode providerID 匹配余额 provider。 + * 先精确匹配,再按前缀匹配(如 moonshotai-cn → moonshot);未命中返回 undefined。 + */ export function matchBalanceProvider(providerId: string): BalanceProvider | undefined { - return balanceProviders.find((p) => p.id === providerId) + const exact = balanceProviders.find((p) => p.id === providerId) + if (exact) return exact + return balanceProviders.find((p) => providerId.startsWith(p.id)) } /** key 脱敏:保留头 5 尾 5 字符,中间用 * 填充。 */ From 0744acbb6447b4d6876d9bde29773fcb6b76e02f Mon Sep 17 00:00:00 2001 From: Hotakus Date: Mon, 10 Aug 2026 02:17:57 +0800 Subject: [PATCH 08/15] =?UTF-8?q?fix(balance):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=B7=9F=E9=9A=8F=E4=B8=8D=E9=9A=8F=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=A8=A1=E5=9E=8B=E5=88=87=E6=8D=A2=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E4=BD=99=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index 987c394..84d0bf8 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -552,9 +552,23 @@ function TokenCachePanel(props: { }) // 自动跟随当前会话的 provider(精确匹配)。手动切换会关闭此行为。 + // 自动跟随当前会话的 provider(前缀匹配)。手动切换会关闭此行为。 + // 直接追踪 messages 取最后一条 assistant 消息的 providerID—— + // 不依赖 session.model 的响应式更新(模型切换时该链路可能不触发重算)。 createEffect(() => { if (!autoBalance()) return - const hit = matchBalanceProvider(data().providerName) + 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 + } + } + if (!pid) return + const hit = matchBalanceProvider(pid) if (hit && hit.id !== balanceProviderId()) { setBalanceProviderId(hit.id) props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1) From 8003cd6a7ff28a35eb4daf15c296c158376684a2 Mon Sep 17 00:00:00 2001 From: Hotakus Date: Mon, 10 Aug 2026 02:30:29 +0800 Subject: [PATCH 09/15] =?UTF-8?q?fix(balance):=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=B7=9F=E9=9A=8F=E6=9B=B4=E5=90=8D=E4=B8=BA=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=88=87=E6=8D=A2=E6=8F=90=E4=BE=9B=E5=95=86=E5=B9=B6=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=8F=9C=E5=8D=95=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.tsx | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 84d0bf8..0e97814 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -410,7 +410,7 @@ interface PanelSignals { /** Currently selected balance provider id (e.g. "deepseek"). */ balanceProviderId: () => string setBalanceProviderId: (v: string) => void - /** Auto-follow the session's provider for balance display. Manual switch disables it. */ + /** 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. */ @@ -551,8 +551,7 @@ function TokenCachePanel(props: { untrack(() => { void pollBalance() }) }) - // 自动跟随当前会话的 provider(精确匹配)。手动切换会关闭此行为。 - // 自动跟随当前会话的 provider(前缀匹配)。手动切换会关闭此行为。 + // 自动切换当前会话的 provider(前缀匹配)。手动切换会关闭此行为。 // 直接追踪 messages 取最后一条 assistant 消息的 providerID—— // 不依赖 session.model 的响应式更新(模型切换时该链路可能不触发重算)。 createEffect(() => { @@ -779,7 +778,7 @@ function TokenCachePanel(props: { if (typeof savedProvider === "string" && balanceProviders.some((p) => p.id === savedProvider)) { setBalanceProviderId(savedProvider) } - // Restore auto-follow (default on) + // 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) @@ -1457,20 +1456,23 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { { title: "Cache: Switch Balance Provider", value: "cache.balance", - description: "Switch balance provider or toggle auto-follow", + description: "切换余额提供商 / 自动切换当前会话提供商 | Switch balance provider / auto-switch session provider", slash: { name: "cache-balance" }, onSelect: (dialog) => { const zh = langZH() const current = signals.balanceProviderId() const auto = signals.autoBalance() const autoLabel = auto - ? (zh ? "自动跟随 [开]" : "Auto-follow [ON]") - : (zh ? "自动跟随 [关]" : "Auto-follow [OFF]") + ? (zh ? "自动切换提供商 [开]" : "Auto-switch provider [ON]") + : (zh ? "自动切换提供商 [关]" : "Auto-switch provider [OFF]") dialog?.replace(() => ( ({ title: p.name + (p.id === current ? " *" : ""), value: p.id, @@ -1481,11 +1483,11 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { const next = !auto api.kv.set(`${KV_PREFIX}.balance.auto`, next) signals.setAutoBalance(next) - api.ui.toast({ message: zh ? `自动跟随余额提供商: ${next ? "开" : "关"}` : `Auto-follow balance provider: ${next ? "ON" : "OFF"}` }) + api.ui.toast({ message: zh ? `自动切换余额提供商: ${next ? "开" : "关"}` : `Auto-switch balance provider: ${next ? "ON" : "OFF"}` }) dialog?.clear() } else { 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) @@ -1497,7 +1499,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { // 未配置 key → 进入设置流程(对话框保持打开等待输入) promptBalanceKey(dialog, provider) } else { - api.ui.toast({ message: zh ? `余额提供商: ${provider.name}(自动跟随已关闭)` : `Balance provider: ${provider.name} (auto-follow off)` }) + api.ui.toast({ message: zh ? `余额提供商: ${provider.name}(自动切换已关闭)` : `Balance provider: ${provider.name} (auto-switch off)` }) dialog?.clear() } } @@ -1523,7 +1525,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { }))} onSelect={(opt) => { const provider = getBalanceProvider(opt.value) - // 手动指定 provider 会关闭自动跟随 + // 手动指定 provider 会关闭自动切换 api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id) api.kv.set(`${KV_PREFIX}.balance.auto`, false) signals.setBalanceProviderId(provider.id) From 3132041ddff52babc589ad9dbeff16b212b2ca57 Mon Sep 17 00:00:00 2001 From: Hotakus Date: Mon, 10 Aug 2026 02:45:39 +0800 Subject: [PATCH 10/15] =?UTF-8?q?fix(balance):=20section=20=E9=80=89?= =?UTF-8?q?=E9=A1=B9=20DS=20Balance=20=E6=9B=B4=E5=90=8D=E4=B8=BA=20Balanc?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index 0e97814..b150548 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1381,7 +1381,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) => { From e4c80419c8a63f304971d807e948aa2f08cc96b7 Mon Sep 17 00:00:00 2001 From: Hotakus Date: Mon, 10 Aug 2026 03:11:13 +0800 Subject: [PATCH 11/15] =?UTF-8?q?docs:=20=E6=96=B0=E5=A2=9E=E4=BD=99?= =?UTF-8?q?=E9=A2=9D=E6=9F=A5=E8=AF=A2=E4=BD=BF=E7=94=A8=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E4=B8=8E=E5=B7=B2=E6=94=AF=E6=8C=81=E6=8F=90=E4=BE=9B=E5=95=86?= =?UTF-8?q?=E8=A1=A8=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 27 ++++++++++++++++++++++++++- README_EN.md | 27 ++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2d3fd41..f9f981d 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 设置 | +| `/cache-balance-key` | 设置余额 API Key | 两步流程:选择提供商 → 输入 API Key |
斜杠命令 @@ -141,11 +144,33 @@ 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 提供商的账户余额。通过 `/cache-balance` 选择提供商并设置 API Key;开启**自动切换**后,余额查询会跟随当前会话正在使用的模型提供商自动切换。 + +已支持余额查询的提供商: + +| 提供商 | 余额查询端点 | 币种 | 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 存储**:API Key 明文保存于插件持久化 KV,请勿在共享设备上使用。 +> +> **自动切换**:默认开启;手动选择提供商后自动关闭,可在 `/cache-balance` 中重新开启。未配置 Key 的提供商不参与自动切换。 +> +> **希望支持**:已调研确认具备可行性的候选提供商,尚未实现。智谱 GLM 仅有社区逆向的非官方端点(无稳定性保障)。 + --- ## 5. 更新 diff --git a/README_EN.md b/README_EN.md index 1c6deb5..ecd6a85 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 / toggle auto-switch; selecting a provider without a key jumps straight into key setup | +| `/cache-balance-key` | Set balance API key | Two-step flow: pick a provider → enter the API key |
Slash command @@ -141,9 +144,31 @@ 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. Use `/cache-balance` to pick a provider and set its API key; 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 storage**: 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`. Providers without a key are skipped by auto-switch. +> +> **Planned**: candidates confirmed feasible by research, not yet implemented. Zhipu GLM only has a community-reversed unofficial endpoint (no stability guarantee). + --- ## 5. Update From a4fc2c965551ac0fb9b9a15dbdf07f082624db54 Mon Sep 17 00:00:00 2001 From: Hotakus Date: Mon, 10 Aug 2026 03:29:59 +0800 Subject: [PATCH 12/15] =?UTF-8?q?feat(balance):=20=E4=BD=99=E9=A2=9D=20key?= =?UTF-8?q?=20=E8=87=AA=E5=8A=A8=E5=A4=8D=E7=94=A8=20OpenCode=20=E5=87=AD?= =?UTF-8?q?=E6=8D=AE=E5=B9=B6=E4=BF=AE=E5=A4=8D=E6=97=A0=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E6=97=B6=E8=87=AA=E5=8A=A8=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.tsx | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/index.tsx b/src/index.tsx index b150548..912ed23 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -374,6 +374,25 @@ 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 } }> + const hit = provs.find((p) => p.id === provider.id) ?? provs.find((p) => p.id.startsWith(provider.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] @@ -517,7 +536,9 @@ function TokenCachePanel(props: { const pollBalance = async () => { 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() @@ -566,6 +587,14 @@ function TokenCachePanel(props: { 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()) { From 28d6f5e95aa3ebaa1650dc478d7517bb94c00c23 Mon Sep 17 00:00:00 2001 From: Hotakus Date: Mon, 10 Aug 2026 03:43:51 +0800 Subject: [PATCH 13/15] =?UTF-8?q?feat(balance):=20=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=A0=87=E6=B3=A8=E6=8F=90=E4=BE=9B=E5=95=86?= =?UTF-8?q?=20key=20=E6=9D=A5=E6=BA=90=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 912ed23..877257a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1299,6 +1299,19 @@ 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() @@ -1503,7 +1516,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { value: "__auto__", }, ...balanceProviders.map((p) => ({ - title: p.name + (p.id === current ? " *" : ""), + title: providerOptionTitle(p, current), value: p.id, })), ]} @@ -1549,7 +1562,7 @@ const tui: TuiPlugin = async (api: TuiPluginApi) => { ({ - title: p.name, + title: providerOptionTitle(p), value: p.id, }))} onSelect={(opt) => { From d0936ba538e205d5ba84dcc6f88880a77dfc2cc4 Mon Sep 17 00:00:00 2001 From: Hotakus Date: Mon, 10 Aug 2026 03:48:08 +0800 Subject: [PATCH 14/15] =?UTF-8?q?docs:=20=E8=A1=A5=E5=85=85=E4=BD=99?= =?UTF-8?q?=E9=A2=9D=20key=20=E8=87=AA=E5=8A=A8=E5=A4=8D=E7=94=A8=E4=B8=8E?= =?UTF-8?q?=E6=9D=A5=E6=BA=90=E6=A0=87=E6=B3=A8=E8=AF=B4=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 10 ++++++---- README_EN.md | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index f9f981d..f8dd350 100644 --- a/README.md +++ b/README.md @@ -109,7 +109,7 @@ npm install -g opencode-visual-cache@latest | `/cache-section` | 开关区块与边框 | 独立控制 Token 明细 / 模型与定价 / 估算 Token 分布 / 已加载技能 / 余额 / 面板边框的显隐 | | `/cache-config` | 查看当前配置 | 弹出当前货币、汇率、区块可见性状态 | | `/cache-lang` | 切换显示语言 | 从列表选择中文或 English,界面即时切换,无需重启 | -| `/cache-balance` | 余额查询设置 | 选择余额提供商 / 开关自动切换;选中未配置 Key 的提供商时直接进入 Key 设置 | +| `/cache-balance` | 余额查询设置 | 选择余额提供商(菜单标注 Key 来源:用户 key / OpenCode / 未配置)/ 开关自动切换 | | `/cache-balance-key` | 设置余额 API Key | 两步流程:选择提供商 → 输入 API Key |
@@ -152,7 +152,7 @@ npm install -g opencode-visual-cache@latest ### 4.4 余额查询 -面板支持显示多家 AI 提供商的账户余额。通过 `/cache-balance` 选择提供商并设置 API Key;开启**自动切换**后,余额查询会跟随当前会话正在使用的模型提供商自动切换。 +面板支持显示多家 AI 提供商的账户余额。开启**自动切换**后,余额查询会跟随当前会话正在使用的模型提供商自动切换。 已支持余额查询的提供商: @@ -165,9 +165,11 @@ npm install -g opencode-visual-cache@latest | 智谱 GLM | 待接入(社区逆向端点,非官方) | CNY | — | ⏳ 希望支持 | | xAI | 待接入(需 Management Key + Team ID) | USD | — | ⏳ 希望支持 | -> **Key 存储**:API Key 明文保存于插件持久化 KV,请勿在共享设备上使用。 +> **Key 来源**:优先使用 `/cache-balance-key` 手动配置的 Key;未手动配置时自动复用 OpenCode 已认证的凭据(`/connect` 配置的 provider)。两者都没有的提供商无法查询余额。 > -> **自动切换**:默认开启;手动选择提供商后自动关闭,可在 `/cache-balance` 中重新开启。未配置 Key 的提供商不参与自动切换。 +> **Key 存储**:手动配置的 API Key 明文保存于插件持久化 KV,请勿在共享设备上使用。 +> +> **自动切换**:默认开启;手动选择提供商后自动关闭,可在 `/cache-balance` 中重新开启。自动切换按当前会话的模型提供商匹配,未配置 Key 的提供商被选中时显示「未配置」提示。 > > **希望支持**:已调研确认具备可行性的候选提供商,尚未实现。智谱 GLM 仅有社区逆向的非官方端点(无稳定性保障)。 diff --git a/README_EN.md b/README_EN.md index ecd6a85..d339cb4 100644 --- a/README_EN.md +++ b/README_EN.md @@ -109,7 +109,7 @@ The plugin supports slash commands and command palette (`Ctrl + P`) for runtime | `/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 / toggle auto-switch; selecting a provider without a key jumps straight into key setup | +| `/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 |
@@ -150,7 +150,7 @@ Toggled via `/cache-section` — takes effect instantly, no restart required. Th ### 4.4 Balance Query -The panel can display account balance from multiple AI providers. Use `/cache-balance` to pick a provider and set its API key; with **auto-switch** enabled, the balance query follows the model provider of the current session automatically. +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: @@ -163,9 +163,11 @@ Supported balance providers: | Zhipu GLM | Pending (community-reversed endpoint, unofficial) | CNY | — | ⏳ Planned | | xAI | Pending (requires Management Key + Team ID) | USD | — | ⏳ Planned | -> **Key storage**: API keys are stored in plaintext in the plugin's persistent KV — avoid using on shared devices. +> **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. > -> **Auto-switch**: enabled by default; picking a provider manually disables it — re-enable anytime via `/cache-balance`. Providers without a key are skipped by auto-switch. +> **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). From 4ecf52df70d68081afb1006985ce1970a8a2523a Mon Sep 17 00:00:00 2001 From: Hotakus Date: Mon, 10 Aug 2026 03:59:06 +0800 Subject: [PATCH 15/15] =?UTF-8?q?fix(balance):=20provider=20=E5=8C=B9?= =?UTF-8?q?=E9=85=8D=E6=94=B9=E4=B8=BA=E5=A4=A7=E5=B0=8F=E5=86=99=E4=B8=8D?= =?UTF-8?q?=E6=95=8F=E6=84=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/balance-providers.ts | 6 ++++-- src/index.tsx | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/balance-providers.ts b/src/balance-providers.ts index 21fc50e..39b5b00 100644 --- a/src/balance-providers.ts +++ b/src/balance-providers.ts @@ -136,11 +136,13 @@ export function getBalanceProvider(id: string): BalanceProvider { /** * 按 OpenCode providerID 匹配余额 provider。 * 先精确匹配,再按前缀匹配(如 moonshotai-cn → moonshot);未命中返回 undefined。 + * 比较不区分大小写,容忍 providerID 的大小写变体。 */ export function matchBalanceProvider(providerId: string): BalanceProvider | undefined { - const exact = balanceProviders.find((p) => p.id === providerId) + const id = providerId.toLowerCase() + const exact = balanceProviders.find((p) => p.id.toLowerCase() === id) if (exact) return exact - return balanceProviders.find((p) => providerId.startsWith(p.id)) + return balanceProviders.find((p) => id.startsWith(p.id.toLowerCase())) } /** key 脱敏:保留头 5 尾 5 字符,中间用 * 填充。 */ diff --git a/src/index.tsx b/src/index.tsx index 877257a..f235f2c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -383,7 +383,9 @@ function convertBalance(target: string, targetRate: number, amount: number, from function findOpencodeKey(api: TuiPluginApi, provider: BalanceProvider): string { try { const provs = api.state.provider as unknown as Array<{ id: string; key?: string; options?: { apiKey?: string } }> - const hit = provs.find((p) => p.id === provider.id) ?? provs.find((p) => p.id.startsWith(provider.id)) + // 大小写不敏感:精确匹配 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