opencode-visual-cache 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tui.js CHANGED
@@ -13,7 +13,109 @@ import { createElement as _$createElement } from "@opentui/solid";
13
13
  import { createMemo, createSignal, createEffect, onMount, onCleanup, Show, untrack } from "solid-js";
14
14
 
15
15
  // src/_version.ts
16
- var PLUGIN_VERSION = "1.4.0";
16
+ var PLUGIN_VERSION = "1.5.0";
17
+
18
+ // src/balance-providers.ts
19
+ var BalanceError = class extends Error {
20
+ };
21
+ var siliconflowProvider = {
22
+ id: "siliconflow",
23
+ name: "SiliconFlow",
24
+ keyPlaceholder: "sk-...",
25
+ async fetchBalance(apiKey, signal) {
26
+ const res = await fetch("https://api.siliconflow.cn/v1/user/info", {
27
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
28
+ signal
29
+ });
30
+ if (!res.ok) {
31
+ if (res.status === 401) throw new BalanceError("401");
32
+ if (res.status === 403) throw new BalanceError("403");
33
+ throw new BalanceError(String(res.status));
34
+ }
35
+ const json = await res.json();
36
+ const total = json.data?.totalBalance ?? json.data?.balance;
37
+ if (typeof total === "undefined" || total === null) throw new BalanceError("EMPTY");
38
+ return [{ currency: "CNY", total: String(total) }];
39
+ }
40
+ };
41
+ var deepseekProvider = {
42
+ id: "deepseek",
43
+ name: "DeepSeek",
44
+ keyPlaceholder: "sk-...",
45
+ async fetchBalance(apiKey, signal) {
46
+ const res = await fetch("https://api.deepseek.com/user/balance", {
47
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
48
+ signal
49
+ });
50
+ if (!res.ok) {
51
+ if (res.status === 401) throw new BalanceError("401");
52
+ if (res.status === 402 || res.status === 403) throw new BalanceError("403");
53
+ throw new BalanceError(String(res.status));
54
+ }
55
+ const json = await res.json();
56
+ const infos = json.balance_infos ?? [];
57
+ if (infos.length === 0) throw new BalanceError("EMPTY");
58
+ return infos.map((info) => ({
59
+ currency: info.currency ?? "CNY",
60
+ total: info.total_balance ?? "0"
61
+ }));
62
+ }
63
+ };
64
+ var openrouterProvider = {
65
+ id: "openrouter",
66
+ name: "OpenRouter",
67
+ keyPlaceholder: "sk-or-...",
68
+ async fetchBalance(apiKey, signal) {
69
+ const res = await fetch("https://openrouter.ai/api/v1/credits", {
70
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
71
+ signal
72
+ });
73
+ if (!res.ok) {
74
+ if (res.status === 401 || res.status === 403) throw new BalanceError("403");
75
+ throw new BalanceError(String(res.status));
76
+ }
77
+ const json = await res.json();
78
+ const credits = json.data?.total_credits;
79
+ const usage = json.data?.total_usage;
80
+ if (typeof credits !== "number" || typeof usage !== "number") throw new BalanceError("EMPTY");
81
+ return [{ currency: "USD", total: (credits - usage).toFixed(2) }];
82
+ }
83
+ };
84
+ var moonshotProvider = {
85
+ id: "moonshot",
86
+ name: "Moonshot",
87
+ keyPlaceholder: "sk-...",
88
+ async fetchBalance(apiKey, signal) {
89
+ const res = await fetch("https://api.moonshot.cn/v1/users/me/balance", {
90
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
91
+ signal
92
+ });
93
+ if (!res.ok) {
94
+ if (res.status === 401) throw new BalanceError("401");
95
+ if (res.status === 403) throw new BalanceError("403");
96
+ throw new BalanceError(String(res.status));
97
+ }
98
+ const json = await res.json();
99
+ const balance = json.data?.available_balance;
100
+ if (typeof balance === "undefined" || balance === null) throw new BalanceError("EMPTY");
101
+ return [{ currency: "CNY", total: String(balance) }];
102
+ }
103
+ };
104
+ var balanceProviders = [deepseekProvider, siliconflowProvider, openrouterProvider, moonshotProvider];
105
+ function getBalanceProvider(id) {
106
+ return balanceProviders.find((p) => p.id === id) ?? balanceProviders[0] ?? deepseekProvider;
107
+ }
108
+ function matchBalanceProvider(providerId) {
109
+ const id = providerId.toLowerCase();
110
+ const exact = balanceProviders.find((p) => p.id.toLowerCase() === id);
111
+ if (exact) return exact;
112
+ return balanceProviders.find((p) => id.startsWith(p.id.toLowerCase()));
113
+ }
114
+ function maskKey(k) {
115
+ if (!k) return "";
116
+ if (k.length <= 10) return k.slice(0, 5) + "*".repeat(Math.max(3, k.length - 5));
117
+ return k.slice(0, 5) + "*".repeat(Math.max(3, k.length - 10)) + k.slice(-5);
118
+ }
17
119
 
18
120
  // src/index.tsx
19
121
  function charColumns(c) {
@@ -91,7 +193,7 @@ var ZH_T = {
91
193
  secModel: "\u6A21\u578B",
92
194
  secSkills: "\u5DF2\u52A0\u8F7D\u6280\u80FD",
93
195
  balTotal: "\u603B\u4F59\u989D:",
94
- balNoKey: "\u672A\u914D\u7F6E API Key",
196
+ balNoKey: "\u672A\u914D\u7F6E {p} API Key",
95
197
  balLoading: "\u67E5\u8BE2\u4E2D...",
96
198
  balError: "\u67E5\u8BE2\u5931\u8D25",
97
199
  balErr401: "API Key \u65E0\u6548",
@@ -130,7 +232,7 @@ var EN_T = {
130
232
  secModel: "Model",
131
233
  secSkills: "Loaded Skills",
132
234
  balTotal: "Total:",
133
- balNoKey: "No API Key set",
235
+ balNoKey: "{p} API Key not set",
134
236
  balLoading: "Fetching...",
135
237
  balError: "Fetch failed",
136
238
  balErr401: "Invalid API Key",
@@ -249,33 +351,25 @@ function estimateTokens(text) {
249
351
  return Math.max(1, Math.ceil(ascii / asciiPerToken + cjk / 1));
250
352
  }
251
353
  var BALANCE_POLL_MS = 5 * 60 * 1e3;
252
- async function fetchDeepSeekBalance(apiKey, signal) {
253
- const res = await fetch("https://api.deepseek.com/user/balance", {
254
- headers: {
255
- Authorization: `Bearer ${apiKey}`,
256
- Accept: "application/json"
257
- },
258
- signal
259
- });
260
- if (!res.ok) {
261
- if (res.status === 401) throw new Error("401");
262
- if (res.status === 402 || res.status === 403) throw new Error("403");
263
- throw new Error(String(res.status));
264
- }
265
- const json = await res.json();
266
- const infos = json.balance_infos ?? [];
267
- if (infos.length === 0) throw new Error("EMPTY");
268
- return infos.map((info) => ({
269
- currency: info.currency ?? "CNY",
270
- total: info.total_balance ?? "0"
271
- }));
272
- }
273
354
  function convertBalance(target, targetRate, amount, from) {
274
355
  if (from === target) return amount;
275
356
  const fromRate = DEFAULT_RATES[from] ?? 1;
276
357
  const usd = from === "USD" ? amount : amount / fromRate;
277
358
  return target === "USD" ? usd : usd * targetRate;
278
359
  }
360
+ function findOpencodeKey(api, provider) {
361
+ try {
362
+ const provs = api.state.provider;
363
+ const id = provider.id.toLowerCase();
364
+ const hit = provs.find((p) => p.id.toLowerCase() === id) ?? provs.find((p) => p.id.toLowerCase().startsWith(id));
365
+ if (!hit) return "";
366
+ const k = typeof hit.key === "string" ? hit.key : "";
367
+ if (k) return k;
368
+ return typeof hit.options?.apiKey === "string" ? hit.options.apiKey : "";
369
+ } catch {
370
+ return "";
371
+ }
372
+ }
279
373
  function balanceSymbol(currency) {
280
374
  const sym = CURRENCIES[currency];
281
375
  return sym ?? currency + " ";
@@ -330,6 +424,10 @@ function TokenCachePanel(props) {
330
424
  sectionBalance,
331
425
  setSectionBalance,
332
426
  balanceRefresh,
427
+ balanceProviderId,
428
+ setBalanceProviderId,
429
+ autoBalance,
430
+ setAutoBalance,
333
431
  balanceCurrency,
334
432
  setBalanceCurrency,
335
433
  borderVisible,
@@ -388,8 +486,10 @@ function TokenCachePanel(props) {
388
486
  lastFetch: 0
389
487
  });
390
488
  let balanceSeq = 0;
489
+ const providerName = createMemo(() => getBalanceProvider(balanceProviderId()).name);
391
490
  const pollBalance = async () => {
392
- const key = props.api.kv.get(`${KV_PREFIX}.ds_key`, "");
491
+ const provider = getBalanceProvider(balanceProviderId());
492
+ const key = props.api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "") || findOpencodeKey(props.api, provider);
393
493
  if (!key) {
394
494
  setBalanceState({
395
495
  status: "idle",
@@ -417,7 +517,7 @@ function TokenCachePanel(props) {
417
517
  controller.abort();
418
518
  }, 1e4);
419
519
  try {
420
- const data2 = await fetchDeepSeekBalance(key, controller.signal);
520
+ const data2 = await provider.fetchBalance(key, controller.signal);
421
521
  clearTimeout(timer);
422
522
  if (seq !== balanceSeq) return;
423
523
  setBalanceState({
@@ -446,6 +546,32 @@ function TokenCachePanel(props) {
446
546
  void pollBalance();
447
547
  });
448
548
  });
549
+ createEffect(() => {
550
+ if (!autoBalance()) return;
551
+ const sid = props.signals.overrideSessionId() ?? props.sessionId;
552
+ const msgs = props.api.state.session.messages(sid);
553
+ let pid = "";
554
+ for (let i = msgs.length - 1; i >= 0; i--) {
555
+ const m = msgs[i];
556
+ if (m.role === "assistant" && m.providerID) {
557
+ pid = m.providerID;
558
+ break;
559
+ }
560
+ }
561
+ if (!pid) {
562
+ try {
563
+ const session = props.api.state.session.get(sid);
564
+ pid = session?.model?.providerID ?? "";
565
+ } catch {
566
+ }
567
+ }
568
+ if (!pid) return;
569
+ const hit = matchBalanceProvider(pid);
570
+ if (hit && hit.id !== balanceProviderId()) {
571
+ setBalanceProviderId(hit.id);
572
+ props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1);
573
+ }
574
+ });
449
575
  let lastMainSid = props.sessionId;
450
576
  createEffect(() => {
451
577
  const sid = props.sessionId;
@@ -512,7 +638,7 @@ function TokenCachePanel(props) {
512
638
  const freshTotal = input + read, sessionHitRate = freshTotal > 0 ? read / freshTotal * 100 : 0;
513
639
  const model = mid.split("/").pop() ?? mid, hasPricing = inputRate > 0 || cacheReadRate > 0 || cacheWriteRate > 0;
514
640
  const hasTrendData = prevMsgHitRate >= 0 && lastMsgHitRate >= 0;
515
- const trend = hasTrendData ? lastMsgHitRate - prevMsgHitRate : 0, providerName = pid || "";
641
+ const trend = hasTrendData ? lastMsgHitRate - prevMsgHitRate : 0, providerName2 = pid || "";
516
642
  const distData = untrack(() => {
517
643
  let dist = {
518
644
  system: 0,
@@ -637,7 +763,7 @@ function TokenCachePanel(props) {
637
763
  hasData: read > 0 || write > 0 || input > 0 || output > 0 || cost > 0,
638
764
  trend,
639
765
  hasTrendData,
640
- providerName,
766
+ providerName: providerName2,
641
767
  sessionHitRate,
642
768
  dist: distData.finalDist,
643
769
  hasDistData: distData.finalHasDist,
@@ -689,6 +815,19 @@ function TokenCachePanel(props) {
689
815
  if (typeof rate === "number" && rate > 0) setExchangeRate(rate);
690
816
  const balCur = props.api.kv.get(`${KV_PREFIX}.balance_currency`);
691
817
  if (typeof balCur === "string") setBalanceCurrency(balCur);
818
+ const savedProvider = props.api.kv.get(`${KV_PREFIX}.balance.provider`);
819
+ if (typeof savedProvider === "string" && balanceProviders.some((p) => p.id === savedProvider)) {
820
+ setBalanceProviderId(savedProvider);
821
+ }
822
+ const savedAuto = props.api.kv.get(`${KV_PREFIX}.balance.auto`);
823
+ if (typeof savedAuto === "boolean") setAutoBalance(savedAuto);
824
+ const legacyKey = props.api.kv.get(`${KV_PREFIX}.ds_key`, "");
825
+ if (legacyKey) {
826
+ const dsKey = props.api.kv.get(`${KV_PREFIX}.balance.deepseek.key`, "");
827
+ if (!dsKey) props.api.kv.set(`${KV_PREFIX}.balance.deepseek.key`, legacyKey);
828
+ props.api.kv.set(`${KV_PREFIX}.ds_key`, "");
829
+ }
830
+ props.signals.setBalanceRefresh(props.signals.balanceRefresh() + 1);
692
831
  setSectionDetail(Boolean(props.api.kv.get(`${KV_PREFIX}.section.detail`, true)));
693
832
  setSectionModel(Boolean(props.api.kv.get(`${KV_PREFIX}.section.model`, true)));
694
833
  setSectionDist(Boolean(props.api.kv.get(`${KV_PREFIX}.section.dist`, true)));
@@ -1428,7 +1567,7 @@ function TokenCachePanel(props) {
1428
1567
  _$insertNode(_el$52, _el$53);
1429
1568
  _$insertNode(_el$52, _el$55);
1430
1569
  _$insertNode(_el$53, _$createTextNode(`> `));
1431
- _$insert(_el$55, () => t().balNoKey);
1570
+ _$insert(_el$55, () => t().balNoKey.replace("{p}", providerName()));
1432
1571
  _$effect((_p$) => {
1433
1572
  var _v$9 = pal().muted, _v$0 = {
1434
1573
  fg: pal().muted
@@ -1585,6 +1724,8 @@ var tui = async (api) => {
1585
1724
  const [sectionSkills, setSectionSkills] = createSignal(true);
1586
1725
  const [sectionBalance, setSectionBalance] = createSignal(true);
1587
1726
  const [balanceRefresh, setBalanceRefresh] = createSignal(0);
1727
+ const [balanceProviderId, setBalanceProviderId] = createSignal("deepseek");
1728
+ const [autoBalance, setAutoBalance] = createSignal(true);
1588
1729
  const [balanceCurrency, setBalanceCurrency] = createSignal("");
1589
1730
  const [borderVisible, setBorderVisible] = createSignal(true);
1590
1731
  const [langZH, setLangZH] = createSignal(LANG_ZH);
@@ -1608,6 +1749,10 @@ var tui = async (api) => {
1608
1749
  setSectionBalance,
1609
1750
  balanceRefresh,
1610
1751
  setBalanceRefresh,
1752
+ balanceProviderId,
1753
+ setBalanceProviderId,
1754
+ autoBalance,
1755
+ setAutoBalance,
1611
1756
  balanceCurrency,
1612
1757
  setBalanceCurrency,
1613
1758
  borderVisible,
@@ -1617,6 +1762,56 @@ var tui = async (api) => {
1617
1762
  };
1618
1763
  api.slots.register(createSidebarSlot(api, signals));
1619
1764
  const KV_PREFIX = "cache_panel";
1765
+ const providerOptionTitle = (p, current) => {
1766
+ const zh = langZH();
1767
+ const hasManual = !!api.kv.get(`${KV_PREFIX}.balance.${p.id}.key`, "");
1768
+ const hasAuto = !hasManual && !!findOpencodeKey(api, p);
1769
+ const mark = hasManual ? zh ? "\uFF08\u7528\u6237 key\uFF09" : " (user key)" : hasAuto ? zh ? "\uFF08OpenCode\uFF09" : " (OpenCode)" : zh ? "\uFF08\u672A\u914D\u7F6E\uFF09" : " (not set)";
1770
+ return p.name + mark + (current && p.id === current ? " *" : "");
1771
+ };
1772
+ const promptBalanceKey = (dialog, provider) => {
1773
+ const zh = langZH();
1774
+ const current = api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "");
1775
+ const masked = maskKey(current);
1776
+ dialog?.replace(() => _$createComponent(api.ui.DialogPrompt, {
1777
+ get title() {
1778
+ return provider.name;
1779
+ },
1780
+ description: () => (() => {
1781
+ var _el$93 = _$createElement("text");
1782
+ _$insert(_el$93, () => zh ? `\u8F93\u5165 ${provider.name} API Key \u4EE5\u663E\u793A\u8D26\u6237\u4F59\u989D\uFF08\u7559\u7A7A\u6E05\u9664\uFF09` : `Enter your ${provider.name} API key to show account balance (leave empty to clear)`);
1783
+ return _el$93;
1784
+ })(),
1785
+ get placeholder() {
1786
+ return provider.keyPlaceholder ?? "sk-...";
1787
+ },
1788
+ value: masked,
1789
+ onConfirm: (val) => {
1790
+ const input = val.trim();
1791
+ let key;
1792
+ if (input === "") {
1793
+ key = "";
1794
+ } else if (input.includes("*")) {
1795
+ key = current;
1796
+ } else {
1797
+ key = input;
1798
+ }
1799
+ api.kv.set(`${KV_PREFIX}.balance.${provider.id}.key`, key);
1800
+ setBalanceRefresh((v) => v + 1);
1801
+ if (key) {
1802
+ api.ui.toast({
1803
+ message: zh ? "API Key \u5DF2\u4FDD\u5B58\uFF0C\u6B63\u5728\u67E5\u8BE2\u4F59\u989D..." : "API Key saved, fetching balance..."
1804
+ });
1805
+ } else {
1806
+ api.ui.toast({
1807
+ message: zh ? "API Key \u5DF2\u6E05\u9664" : "API Key cleared"
1808
+ });
1809
+ }
1810
+ dialog?.clear();
1811
+ },
1812
+ onCancel: () => dialog?.clear()
1813
+ }));
1814
+ };
1620
1815
  api.command?.register(() => [{
1621
1816
  title: "Cache: Set Currency",
1622
1817
  value: "cache.currency",
@@ -1660,9 +1855,9 @@ var tui = async (api) => {
1660
1855
  dialog?.replace(() => _$createComponent(api.ui.DialogPrompt, {
1661
1856
  title: "Exchange Rate",
1662
1857
  description: () => (() => {
1663
- var _el$93 = _$createElement("text");
1664
- _$insertNode(_el$93, _$createTextNode(`Enter the exchange rate from USD to your currency (e.g. 7.2 for CNY)`));
1665
- return _el$93;
1858
+ var _el$94 = _$createElement("text");
1859
+ _$insertNode(_el$94, _$createTextNode(`Enter the exchange rate from USD to your currency (e.g. 7.2 for CNY)`));
1860
+ return _el$94;
1666
1861
  })(),
1667
1862
  placeholder: "1.0",
1668
1863
  get value() {
@@ -1710,7 +1905,7 @@ var tui = async (api) => {
1710
1905
  title: `Loaded Skills [${skillsOn ? "ON" : "OFF"}]`,
1711
1906
  value: "skills"
1712
1907
  }, {
1713
- title: `DS Balance [${balanceOn ? "ON" : "OFF"}]`,
1908
+ title: `Balance [${balanceOn ? "ON" : "OFF"}]`,
1714
1909
  value: "balance"
1715
1910
  }, {
1716
1911
  title: `Panel Border [${borderOn ? "ON" : "OFF"}]`,
@@ -1793,56 +1988,83 @@ var tui = async (api) => {
1793
1988
  }));
1794
1989
  }
1795
1990
  }, {
1796
- title: "Cache: Set DeepSeek API Key",
1797
- value: "cache.balance.key",
1798
- description: "Set or update the DeepSeek API key for balance display",
1991
+ title: "Cache: Switch Balance Provider",
1992
+ value: "cache.balance",
1993
+ description: "\u5207\u6362\u4F59\u989D\u63D0\u4F9B\u5546 / \u81EA\u52A8\u5207\u6362\u5F53\u524D\u4F1A\u8BDD\u63D0\u4F9B\u5546 | Switch balance provider / auto-switch session provider",
1799
1994
  slash: {
1800
- name: "cache-balance-key"
1995
+ name: "cache-balance"
1801
1996
  },
1802
1997
  onSelect: (dialog) => {
1803
1998
  const zh = langZH();
1804
- const current = api.kv.get(`${KV_PREFIX}.ds_key`, "");
1805
- const maskKey = (k) => {
1806
- if (!k) return "";
1807
- const prefix = k.startsWith("sk-") ? "sk-" : "";
1808
- const body = prefix ? k.slice(3) : k;
1809
- if (body.length <= 10) return prefix + body.slice(0, 5) + "*".repeat(Math.max(3, body.length - 5));
1810
- return prefix + body.slice(0, 5) + "*".repeat(Math.max(3, body.length - 10)) + body.slice(-5);
1811
- };
1812
- const masked = maskKey(current);
1813
- dialog?.replace(() => _$createComponent(api.ui.DialogPrompt, {
1814
- title: zh ? "DeepSeek API Key" : "DeepSeek API Key",
1815
- description: () => (() => {
1816
- var _el$95 = _$createElement("text");
1817
- _$insert(_el$95, zh ? "\u8F93\u5165 DeepSeek API Key \u4EE5\u663E\u793A\u8D26\u6237\u4F59\u989D\uFF08\u7559\u7A7A\u6E05\u9664\uFF09" : "Enter your DeepSeek API key to show account balance (leave empty to clear)");
1818
- return _el$95;
1819
- })(),
1820
- placeholder: "sk-...",
1821
- value: masked,
1822
- onConfirm: (val) => {
1823
- const input = val.trim();
1824
- let key;
1825
- if (input === "") {
1826
- key = "";
1827
- } else if (input.includes("*")) {
1828
- key = current;
1829
- } else {
1830
- key = input;
1831
- }
1832
- api.kv.set(`${KV_PREFIX}.ds_key`, key);
1833
- setBalanceRefresh((v) => v + 1);
1834
- if (key) {
1999
+ const current = signals.balanceProviderId();
2000
+ const auto = signals.autoBalance();
2001
+ const autoLabel = auto ? zh ? "\u81EA\u52A8\u5207\u6362\u63D0\u4F9B\u5546 [\u5F00]" : "Auto-switch provider [ON]" : zh ? "\u81EA\u52A8\u5207\u6362\u63D0\u4F9B\u5546 [\u5173]" : "Auto-switch provider [OFF]";
2002
+ dialog?.replace(() => _$createComponent(api.ui.DialogSelect, {
2003
+ title: zh ? "\u4F59\u989D\u63D0\u4F9B\u5546 / \u81EA\u52A8\u5207\u6362" : "Balance Provider / Auto-switch",
2004
+ get options() {
2005
+ return [{
2006
+ title: autoLabel,
2007
+ value: "__auto__"
2008
+ }, ...balanceProviders.map((p) => ({
2009
+ title: providerOptionTitle(p, current),
2010
+ value: p.id
2011
+ }))];
2012
+ },
2013
+ onSelect: (opt) => {
2014
+ if (opt.value === "__auto__") {
2015
+ const next = !auto;
2016
+ api.kv.set(`${KV_PREFIX}.balance.auto`, next);
2017
+ signals.setAutoBalance(next);
1835
2018
  api.ui.toast({
1836
- message: zh ? "API Key \u5DF2\u4FDD\u5B58\uFF0C\u6B63\u5728\u67E5\u8BE2\u4F59\u989D..." : "API Key saved, fetching balance..."
2019
+ message: zh ? `\u81EA\u52A8\u5207\u6362\u4F59\u989D\u63D0\u4F9B\u5546: ${next ? "\u5F00" : "\u5173"}` : `Auto-switch balance provider: ${next ? "ON" : "OFF"}`
1837
2020
  });
2021
+ dialog?.clear();
1838
2022
  } else {
1839
- api.ui.toast({
1840
- message: zh ? "API Key \u5DF2\u6E05\u9664" : "API Key cleared"
1841
- });
2023
+ const provider = getBalanceProvider(opt.value);
2024
+ api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id);
2025
+ api.kv.set(`${KV_PREFIX}.balance.auto`, false);
2026
+ signals.setBalanceProviderId(provider.id);
2027
+ signals.setAutoBalance(false);
2028
+ signals.setBalanceRefresh(signals.balanceRefresh() + 1);
2029
+ const hasKey = !!api.kv.get(`${KV_PREFIX}.balance.${provider.id}.key`, "");
2030
+ if (!hasKey) {
2031
+ promptBalanceKey(dialog, provider);
2032
+ } else {
2033
+ api.ui.toast({
2034
+ message: zh ? `\u4F59\u989D\u63D0\u4F9B\u5546: ${provider.name}\uFF08\u81EA\u52A8\u5207\u6362\u5DF2\u5173\u95ED\uFF09` : `Balance provider: ${provider.name} (auto-switch off)`
2035
+ });
2036
+ dialog?.clear();
2037
+ }
1842
2038
  }
1843
- dialog?.clear();
2039
+ }
2040
+ }));
2041
+ }
2042
+ }, {
2043
+ title: "Cache: Set Balance API Key",
2044
+ value: "cache.balance.key",
2045
+ description: "Select a provider and set its API key for balance display",
2046
+ slash: {
2047
+ name: "cache-balance-key"
2048
+ },
2049
+ onSelect: (dialog) => {
2050
+ const zh = langZH();
2051
+ dialog?.replace(() => _$createComponent(api.ui.DialogSelect, {
2052
+ title: zh ? "\u9009\u62E9\u4F59\u989D\u63D0\u4F9B\u5546" : "Select Balance Provider",
2053
+ get options() {
2054
+ return balanceProviders.map((p) => ({
2055
+ title: providerOptionTitle(p),
2056
+ value: p.id
2057
+ }));
1844
2058
  },
1845
- onCancel: () => dialog?.clear()
2059
+ onSelect: (opt) => {
2060
+ const provider = getBalanceProvider(opt.value);
2061
+ api.kv.set(`${KV_PREFIX}.balance.provider`, provider.id);
2062
+ api.kv.set(`${KV_PREFIX}.balance.auto`, false);
2063
+ signals.setBalanceProviderId(provider.id);
2064
+ signals.setAutoBalance(false);
2065
+ signals.setBalanceRefresh(signals.balanceRefresh() + 1);
2066
+ promptBalanceKey(dialog, provider);
2067
+ }
1846
2068
  }));
1847
2069
  }
1848
2070
  }, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-visual-cache",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "OpenCode TUI plugin displaying real-time token cache hit rate in the sidebar",
5
5
  "type": "module",
6
6
  "types": "dist/index.d.ts",
package/src/_version.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  // auto-generated
2
- export const PLUGIN_VERSION="1.4.0";
2
+ export const PLUGIN_VERSION="1.5.0";
@@ -0,0 +1,153 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Balance providers — pluggable account-balance query adapters.
3
+ // ---------------------------------------------------------------------------
4
+
5
+ /** 归一化后的余额条目——显示层与具体 provider 解耦。 */
6
+ export interface BalanceEntry {
7
+ currency: string // 原生币种(CNY/USD…),复用现有汇率换算
8
+ total: string // 余额字符串
9
+ }
10
+
11
+ /** provider 统一错误:message 即错误码(401/403/EMPTY/…),显示层直接展示。 */
12
+ export class BalanceError extends Error {}
13
+
14
+ /** 可插拔的余额 provider 适配器。 */
15
+ export interface BalanceProvider {
16
+ id: string // 唯一标识,同时用作 KV key 命名空间
17
+ name: string // 显示名(专有名词,无需 i18n)
18
+ keyPlaceholder?: string // key 输入框占位(如 "sk-...")
19
+ fetchBalance(apiKey: string, signal?: AbortSignal): Promise<BalanceEntry[]>
20
+ }
21
+
22
+ const siliconflowProvider: BalanceProvider = {
23
+ id: "siliconflow",
24
+ name: "SiliconFlow",
25
+ keyPlaceholder: "sk-...",
26
+ async fetchBalance(apiKey, signal) {
27
+ // 国内站 api.siliconflow.cn(CNY);国际站为 api.siliconflow.com(USD)
28
+ const res = await fetch("https://api.siliconflow.cn/v1/user/info", {
29
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
30
+ signal,
31
+ })
32
+ if (!res.ok) {
33
+ if (res.status === 401) throw new BalanceError("401")
34
+ if (res.status === 403) throw new BalanceError("403")
35
+ throw new BalanceError(String(res.status))
36
+ }
37
+ const json = await res.json() as {
38
+ status?: boolean
39
+ data?: {
40
+ balance?: string | number
41
+ chargeBalance?: string | number
42
+ totalBalance?: string | number
43
+ }
44
+ }
45
+ // totalBalance 为总余额(含充值+赠送),缺失时回退 balance
46
+ const total = json.data?.totalBalance ?? json.data?.balance
47
+ if (typeof total === "undefined" || total === null) throw new BalanceError("EMPTY")
48
+ return [{ currency: "CNY", total: String(total) }]
49
+ },
50
+ }
51
+
52
+ const deepseekProvider: BalanceProvider = {
53
+ id: "deepseek",
54
+ name: "DeepSeek",
55
+ keyPlaceholder: "sk-...",
56
+ async fetchBalance(apiKey, signal) {
57
+ const res = await fetch("https://api.deepseek.com/user/balance", {
58
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
59
+ signal,
60
+ })
61
+ if (!res.ok) {
62
+ if (res.status === 401) throw new BalanceError("401")
63
+ if (res.status === 402 || res.status === 403) throw new BalanceError("403")
64
+ throw new BalanceError(String(res.status))
65
+ }
66
+ const json = await res.json() as {
67
+ is_available?: boolean
68
+ balance_infos?: { currency: string; total_balance: string; granted_balance: string; topped_up_balance: string }[]
69
+ }
70
+ const infos = json.balance_infos ?? []
71
+ if (infos.length === 0) throw new BalanceError("EMPTY")
72
+ return infos.map((info) => ({
73
+ currency: info.currency ?? "CNY",
74
+ total: info.total_balance ?? "0",
75
+ }))
76
+ },
77
+ }
78
+
79
+ const openrouterProvider: BalanceProvider = {
80
+ id: "openrouter",
81
+ name: "OpenRouter",
82
+ keyPlaceholder: "sk-or-...",
83
+ async fetchBalance(apiKey, signal) {
84
+ // 官方文档标注需 Management key,实测普通 API key 亦可查询账户余额
85
+ const res = await fetch("https://openrouter.ai/api/v1/credits", {
86
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
87
+ signal,
88
+ })
89
+ if (!res.ok) {
90
+ if (res.status === 401 || res.status === 403) throw new BalanceError("403")
91
+ throw new BalanceError(String(res.status))
92
+ }
93
+ const json = await res.json() as {
94
+ data?: { total_credits?: number; total_usage?: number }
95
+ }
96
+ const credits = json.data?.total_credits
97
+ const usage = json.data?.total_usage
98
+ if (typeof credits !== "number" || typeof usage !== "number") throw new BalanceError("EMPTY")
99
+ // 剩余额度 = 充值总额 - 已用
100
+ return [{ currency: "USD", total: (credits - usage).toFixed(2) }]
101
+ },
102
+ }
103
+
104
+ const moonshotProvider: BalanceProvider = {
105
+ id: "moonshot",
106
+ name: "Moonshot",
107
+ keyPlaceholder: "sk-...",
108
+ async fetchBalance(apiKey, signal) {
109
+ // 国内站 api.moonshot.cn(CNY);国际站 api.moonshot.ai(USD)
110
+ const res = await fetch("https://api.moonshot.cn/v1/users/me/balance", {
111
+ headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
112
+ signal,
113
+ })
114
+ if (!res.ok) {
115
+ if (res.status === 401) throw new BalanceError("401")
116
+ if (res.status === 403) throw new BalanceError("403")
117
+ throw new BalanceError(String(res.status))
118
+ }
119
+ const json = await res.json() as {
120
+ data?: { available_balance?: string | number }
121
+ }
122
+ const balance = json.data?.available_balance
123
+ if (typeof balance === "undefined" || balance === null) throw new BalanceError("EMPTY")
124
+ return [{ currency: "CNY", total: String(balance) }]
125
+ },
126
+ }
127
+
128
+ /** 已注册的 provider 列表(按需追加新适配器)。 */
129
+ export const balanceProviders: BalanceProvider[] = [deepseekProvider, siliconflowProvider, openrouterProvider, moonshotProvider]
130
+
131
+ /** 按 id 取 provider;未知 id 回退到第一个。 */
132
+ export function getBalanceProvider(id: string): BalanceProvider {
133
+ return balanceProviders.find((p) => p.id === id) ?? balanceProviders[0] ?? deepseekProvider
134
+ }
135
+
136
+ /**
137
+ * 按 OpenCode providerID 匹配余额 provider。
138
+ * 先精确匹配,再按前缀匹配(如 moonshotai-cn → moonshot);未命中返回 undefined。
139
+ * 比较不区分大小写,容忍 providerID 的大小写变体。
140
+ */
141
+ export function matchBalanceProvider(providerId: string): BalanceProvider | undefined {
142
+ const id = providerId.toLowerCase()
143
+ const exact = balanceProviders.find((p) => p.id.toLowerCase() === id)
144
+ if (exact) return exact
145
+ return balanceProviders.find((p) => id.startsWith(p.id.toLowerCase()))
146
+ }
147
+
148
+ /** key 脱敏:保留头 5 尾 5 字符,中间用 * 填充。 */
149
+ export function maskKey(k: string): string {
150
+ if (!k) return ""
151
+ if (k.length <= 10) return k.slice(0, 5) + "*".repeat(Math.max(3, k.length - 5))
152
+ return k.slice(0, 5) + "*".repeat(Math.max(3, k.length - 10)) + k.slice(-5)
153
+ }