Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 占用
Expand Down Expand Up @@ -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 |

<div align="center">
<img src="https://raw.githubusercontent.com/Hotakus/opencode-visual-cache/master/assets/splash_cmd.png" alt="斜杠命令" width="49%"></img>
Expand Down Expand Up @@ -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. 更新
Expand Down
29 changes: 28 additions & 1 deletion README_EN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |

<div align="center">
<img src="https://raw.githubusercontent.com/Hotakus/opencode-visual-cache/master/assets/splash_cmd.png" alt="Slash command" width="49%"></img>
Expand Down Expand Up @@ -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
Expand Down
153 changes: 153 additions & 0 deletions src/balance-providers.ts
Original file line number Diff line number Diff line change
@@ -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<BalanceEntry[]>
}

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)
}
Loading
Loading