From 717ae61e96da9db6ea1f296d1891a31c0eda6f94 Mon Sep 17 00:00:00 2001 From: GeekRicardo Date: Fri, 14 Aug 2026 17:51:15 +0800 Subject: [PATCH] feat: DeepSeek balance + session spend status bar plugin for DSH --- .gitignore | 3 + LICENSE | 21 ++++ README.md | 63 ++++++++++ cordis.patch.yml | 7 ++ dsh.plugin.json | 12 ++ install.sh | 138 ++++++++++++++++++++++ lib/client.js | 164 ++++++++++++++++++++++++++ lib/index.js | 297 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 57 +++++++++ 9 files changed, 762 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 cordis.patch.yml create mode 100644 dsh.plugin.json create mode 100755 install.sh create mode 100644 lib/client.js create mode 100644 lib/index.js create mode 100644 package.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..80004ac --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +lib/*.map +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0b1c3c0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 GeekRicardo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..371527d --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# deepseek-balance + +DeepSeek Harness web 插件:在**输入框下方状态栏**展示 DeepSeek 官方账户余额与本会话估算花费,并区分非 DeepSeek 模型。 + +- DeepSeek 模型时:`● 本会话 ¥X.XX · ● 余额 ¥465.xx`(每 60 秒自动刷新) +- 非 DeepSeek 模型时:`● 非 DeepSeek 供应商`(不查余额、不算花费) + +## 一键安装 + +```bash +curl -fsSL https://raw.githubusercontent.com/GeekRicardo/deepseek-balance/main/install.sh | bash +``` + +脚本做的事(可先 `--dry-run` 预览): + +1. 在 `~/.dsh/profiles/web/package.json` 写入依赖 `"deepseek-balance": "github:GeekRicardo/deepseek-balance"`; +2. 把 `deepseek-balance` 追加进 `dsh.profile.bundles`; +3. `cd ~/.dsh/profiles/web && pnpm install`; +4. 校验 bundles 已注册,提示重启。 + +重启 DSH 并硬刷新页面后生效: + +```bash +pm2 restart dsh-web # 若用 pm2 托管;否则用你的启动方式重启 +``` + +## 卸载 + +```bash +# 1. 从 ~/.dsh/profiles/web/package.json 的 dsh.profile.bundles 移除 "deepseek-balance" +# 2. 移除 dependencies 里的 "deepseek-balance" +# 3. cd ~/.dsh/profiles/web && pnpm install +# 4. 重启 DSH +``` + +## 前置条件 + +- DeepSeek Harness 已初始化 web profile(`~/.dsh/profiles/web` 存在)。 +- `~/.dsh/.credentials.yaml` 里配置了 `DEEPSEEK_API_KEY`(本插件复用 harness 自身的 DeepSeek 密钥,不额外索取)。 +- Node.js ≥ 20、pnpm 可用。 + +## 工作原理 + +| 半区 | 职责 | +| --- | --- | +| Host | 监听 `llm/stream` 按 session 累计 DeepSeek token;从 models.dev 拉单价(内存缓存 24h + 硬编码兜底)换算人民币估算花费;经 `credentials.resolve('DEEPSEEK_API_KEY')` 读密钥,curl 官方 `/user/balance` 查余额;注册 `/deepseek-balance/status` HTTP route | +| Client | 在 `conversation.composer.dock` 槽位渲染状态栏,`fetch` 轮询该 route(60s) | + +### 计费口径(重要) + +- DeepSeek 官方 API **不返回金额**,只返回 token 数。金额是 `token × 单价` 的**估算**,不是账单。 +- 单价来自第三方 [models.dev](https://models.dev)(USD/百万 token),按模型前缀匹配;拉取失败回落到内置单价。 +- 汇率固定 7.2 换算成人民币。 +- 误差来源:DeepSeek 峰谷定价(分时段计价)、汇率、单价维护。 + +### 数据生命周期 + +- 本会话花费是**内存态**,从插件加载后的下一次模型调用开始累计,重启清零,不持久化。 +- 余额每次轮询实时查询官方接口。 + +## License + +MIT diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..15d150e --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,7 @@ +# deepseek-balance bundle patch +# +# 安装后(dependencies + dsh.profile.bundles 均含 deepseek-balance), +# 启动时 profile boot 会合并本 patch,把插件行插入 host composition。 +- insert: + - id: deepseek-balance + name: 'deepseek-balance' diff --git a/dsh.plugin.json b/dsh.plugin.json new file mode 100644 index 0000000..de7f826 --- /dev/null +++ b/dsh.plugin.json @@ -0,0 +1,12 @@ +{ + "name": "deepseek-balance", + "description": "DeepSeek Harness web 插件:状态栏展示 DeepSeek 余额与本会话估算花费", + "version": "1.0.0", + "entry": { + "name": "deepseek-balance", + "inject": [] + }, + "client": { + "platform": "web" + } +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..f328b60 --- /dev/null +++ b/install.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# ============================================================================= +# deepseek-balance 一键安装脚本(macOS / Linux / Windows Git Bash) +# +# 包只在 GitHub、不发 npm,因此本脚本直接把 github 依赖写进 profile 的 +# package.json(dependencies + dsh.profile.bundles),再 pnpm install 拉取。 +# 下次启动 DSH 时 profile boot 会读取包内 cordis.patch.yml 自动挂载插件行。 +# +# 用法: +# curl -fsSL https://raw.githubusercontent.com/GeekRicardo/deepseek-balance/main/install.sh | bash +# +# 或下载后本地运行:bash install.sh [--dry-run] [--restart] +# +# --dry-run 只打印将要执行的操作,不写任何文件。 +# --restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示)。 +# -h/--help 打印本帮助。 +# +# 环境变量(均可省略):DSH_HOME(默认 ~/.dsh) +# ============================================================================= +set -euo pipefail + +for arg in "$@"; do + if [ "$arg" = "-h" ] || [ "$arg" = "--help" ]; then + cat <<'EOF' +deepseek-balance 一键安装脚本 + +用法: + curl -fsSL https://raw.githubusercontent.com/GeekRicardo/deepseek-balance/main/install.sh | bash + 或:bash install.sh [--dry-run] [--restart] + + --dry-run 只打印将要执行的操作,不写任何文件 + --restart 装完后尝试 `pm2 restart dsh-web`(无 pm2 时仅提示) + +环境变量(可省略):DSH_HOME(默认 ~/.dsh) +EOF + exit 0 + fi +done + +DSH_HOME="${DSH_HOME:-${HOME:-${USERPROFILE:-}}/.dsh}" +PROFILE_DIR="$DSH_HOME/profiles/web" +PKG_JSON="$PROFILE_DIR/package.json" +PKG="deepseek-balance" +GH_DEP="github:GeekRicardo/deepseek-balance" + +DRY_RUN=false +RESTART=false +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=true ;; + --restart) RESTART=true ;; + -h|--help) : ;; + *) echo "未知参数: ${arg}(用 -h 查看用法)" >&2; exit 2 ;; + esac +done + +say() { printf '\033[32m[install]\033[0m %s\n' "$*"; } +warn() { printf '\033[33m[warn]\033[0m %s\n' "$*" >&2; } +die() { printf '\033[31m[error]\033[0m %s\n' "$*" >&2; exit 1; } + +command -v node >/dev/null 2>&1 || die "未找到 node(DSH 需要 Node.js ≥ 20)" +command -v pnpm >/dev/null 2>&1 || die "未找到 pnpm" +[ -d "$PROFILE_DIR" ] || die "找不到 profile 目录:${PROFILE_DIR}(请先安装并运行过一次 dsh web)" +[ -f "$PKG_JSON" ] || die "找不到 ${PKG_JSON}" + +if [ "$DRY_RUN" = true ]; then + say "[dry-run] 步骤 1:在 ${PKG_JSON} 写入 dependencies[\"${PKG}\"]=\"${GH_DEP}\"" + say "[dry-run] 步骤 2:在 dsh.profile.bundles 追加 \"${PKG}\"" + say "[dry-run] 步骤 3:cd ${PROFILE_DIR} && pnpm install" + say "[dry-run] 步骤 4:校验 dsh.profile.bundles 含 ${PKG}" + [ "$RESTART" = true ] && say "[dry-run] 步骤 5:pm2 restart dsh-web" || say "[dry-run] 步骤 5:提示用户重启 DSH" + exit 0 +fi + +say "目标 profile:${PROFILE_DIR}" + +# 步骤 1+2:幂等写 dependencies + bundles +UPDATE_RESULT="$(node -e ' +const fs = require("fs"); +const p = process.argv[1]; +const dep = process.argv[2]; +const pkg = process.argv[3]; +const json = JSON.parse(fs.readFileSync(p, "utf8")); +let changed = false; +json.dependencies = json.dependencies || {}; +if (json.dependencies[pkg] !== dep) { + json.dependencies[pkg] = dep; + changed = true; +} +json.dsh = json.dsh || {}; +json.dsh.profile = json.dsh.profile || {}; +json.dsh.profile.bundles = Array.isArray(json.dsh.profile.bundles) ? json.dsh.profile.bundles : []; +if (!json.dsh.profile.bundles.includes(pkg)) { + json.dsh.profile.bundles.push(pkg); + changed = true; +} +if (changed) { + fs.writeFileSync(p, JSON.stringify(json, null, 2) + "\n"); + console.log("updated"); +} else { + console.log("unchanged"); +} +' "$PKG_JSON" "$GH_DEP" "$PKG")" +[ "$UPDATE_RESULT" = "updated" ] \ + && say "已写入 dependencies + dsh.profile.bundles(${PKG} = ${GH_DEP})" \ + || say "dependencies + bundles 已就绪,跳过" + +# 步骤 3:安装依赖 +say "执行 pnpm install(拉取 GitHub 包,可能耗时)..." +( cd "$PROFILE_DIR" && pnpm install ) + +# 步骤 4:校验 bundles 已注册 +if ! node -e ' + const fs = require("fs"); + const p = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const bundles = p.dsh?.profile?.bundles ?? []; + process.exit(bundles.includes(process.argv[2]) ? 0 : 1); +' "$PKG_JSON" "$PKG"; then + die "deepseek-balance 未出现在 dsh.profile.bundles 中,挂载未注册,请检查 pnpm install 输出。" +fi +say "bundle 已注册:dsh.profile.bundles 包含 ${PKG}(下次启动自动挂载)" + +say "安装完成:${PKG}" + +# 步骤 5:重启提示 +if [ "$RESTART" = true ]; then + if command -v pm2 >/dev/null 2>&1; then + say "重启 dsh-web(pm2)..." + pm2 restart dsh-web || warn "pm2 restart 失败,请手动重启 DSH" + else + warn "未找到 pm2,请手动重启 DSH" + fi +else + say "下一步:重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)使插件生效。" + if command -v pm2 >/dev/null 2>&1; then + say "本机可用:pm2 restart dsh-web(会短暂断开当前页面会话)" + fi +fi diff --git a/lib/client.js b/lib/client.js new file mode 100644 index 0000000..59a58fb --- /dev/null +++ b/lib/client.js @@ -0,0 +1,164 @@ +window.__ModuleLoader__.load({ + id: "deepseek-balance", + factory: (require) => { + var module = { exports: {} }; + var exports = module.exports; + var React = require("react"); + + var inject = ["slots"]; + + var STYLE_ID = "deepseek-balance-style"; + var cssText = + ".dsb-readout { display: inline-flex; align-items: center; gap: 10px; font-size: 11px; line-height: 1.5; " + + "color: var(--dsw-alias-label-secondary); white-space: nowrap; user-select: none; } " + + ".dsb-readout__item { display: inline-flex; align-items: center; } " + + ".dsb-readout__dot { width: 6px; height: 6px; border-radius: 50%; margin-right: 5px; flex: none; " + + "background: var(--dsw-alias-state-success-primary); } " + + ".dsb-readout__dot--off { background: var(--dsw-alias-state-error-primary); } " + + ".dsb-readout__dot--muted { background: var(--dsw-alias-label-secondary); } " + + ".dsb-readout--error { color: var(--dsw-alias-state-error-primary); }"; + + function ensureStyle() { + if (document.getElementById(STYLE_ID) !== null) return; + var style = document.createElement("style"); + style.id = STYLE_ID; + style.textContent = cssText; + document.head.appendChild(style); + } + + function fmtMoney(cny) { + var n = Number(cny); + var val = n === n ? n : 0; + if (val > 0 && val < 0.01) return "¥" + val.toFixed(4); + return "¥" + val.toFixed(2); + } + + function currencySymbol(code) { + if (code === "CNY") return "¥"; + if (code === "USD") return "$"; + if (code === "EUR") return "€"; + return code + " "; + } + + function StatusReadout(props) { + var sessionId = props && props.sessionId; + var viewState = React.useState({ phase: "loading" }); + var view = viewState[0]; + var setView = viewState[1]; + + React.useEffect( + function () { + var alive = true; + function load() { + var qs = sessionId ? "?sessionId=" + encodeURIComponent(String(sessionId)) : ""; + fetch("/deepseek-balance/status" + qs) + .then(function (r) { + return r.json(); + }) + .then(function (res) { + if (!alive) return; + if (res && res.ok) setView({ phase: "ok", res: res }); + else setView({ phase: "error", message: (res && res.error) || "查询失败" }); + }) + .catch(function (err) { + if (!alive) return; + setView({ phase: "error", message: String((err && err.message) || err) }); + }); + } + load(); + var timer = setInterval(load, 60000); + return function () { + alive = false; + clearInterval(timer); + }; + }, + [sessionId], + ); + + if (view.phase === "loading") { + return React.createElement("span", { className: "dsb-readout" }, "DeepSeek ···"); + } + if (view.phase === "error") { + return React.createElement("span", { className: "dsb-readout dsb-readout--error" }, "DeepSeek 状态不可用"); + } + + var res = view.res; + + if (!res.isDeepSeek) { + return React.createElement( + "span", + { + className: "dsb-readout", + title: "当前模型:" + (res.model || "未知") + ",非 DeepSeek 官方,不展示余额/花费", + }, + React.createElement("span", { className: "dsb-readout__dot dsb-readout__dot--muted" }), + "非 DeepSeek 供应商", + ); + } + + var items = []; + var pricingTitle = res.pricingSource === "live" ? "定价来自 models.dev(实时)" : "定价来自内置兜底"; + + items.push( + React.createElement( + "span", + { + key: "spend", + className: "dsb-readout__item", + title: "本会话估算花费(" + res.requests + " 次请求," + pricingTitle + ")", + }, + React.createElement("span", { className: "dsb-readout__dot" }), + "本会话 " + fmtMoney(res.spendCny), + ), + ); + + if (res.balance) { + var infos = Array.isArray(res.balance.balance_infos) ? res.balance.balance_infos : []; + var parts = infos.map(function (info) { + var sym = currencySymbol(info.currency || "CNY"); + var n = Number(info.total_balance); + var text = n === n ? sym + n.toFixed(2) : String(info.total_balance); + return (info.currency || "") + " " + text; + }); + var available = res.balance.is_available !== false; + items.push( + React.createElement( + "span", + { key: "balance", className: "dsb-readout__item", title: "DeepSeek 账户余额" }, + React.createElement( + "span", + { className: "dsb-readout__dot" + (available ? "" : " dsb-readout__dot--off") }, + ), + "余额 " + (parts.length ? parts.join(" · ") : "—"), + ), + ); + } else { + items.push( + React.createElement( + "span", + { key: "balance", className: "dsb-readout__item dsb-readout--error", title: res.balanceError || "余额不可用" }, + "余额不可用", + ), + ); + } + + return React.createElement("span", { className: "dsb-readout" }, items); + } + + function apply(ctx) { + ensureStyle(); + ctx.slots.inject("conversation.composer.dock", function () { + return ctx.slots.register( + { name: "conversation.composer.dock", id: "deepseek-balance", order: 10, label: "DeepSeek 余额" }, + function (props) { + return React.createElement(StatusReadout, { sessionId: props && props.sessionId }); + }, + ); + }); + } + + exports.apply = apply; + exports.inject = inject; + return module.exports; + }, +}); diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..ed59df6 --- /dev/null +++ b/lib/index.js @@ -0,0 +1,297 @@ +// deepseek-balance — host face +// +// 职责(全部进程内、可逆): +// 1. 监听 `llm/stream` 瀑布事件,按 session 累计 DeepSeek 模型的 token 用量。 +// 2. 从 models.dev 拉取 DeepSeek 单价(内存缓存 24h + 硬编码兜底), +// 展示时用最新单价把累计 token 换算成人民币估算花费。 +// 3. 通过 credentials 服务读取 DEEPSEEK_API_KEY,curl 官方 /user/balance 查余额。 +// 4. 注册 /deepseek-balance/status HTTP route 供 client 轮询。 + +const inject = ["webServer", "credentials", "subprocess"]; + +const CNY_PER_USD = 7.2; +const PRICING_TTL_MS = 24 * 60 * 60 * 1000; +const MODELS_DEV_URL = "https://models.dev/api.json"; + +// models.dev 拉取失败时的兜底单价,USD / 百万 token。 +const FALLBACK_PRICING = [ + { prefix: "deepseek-v4-pro", input: 0.435, output: 0.87, cacheRead: 0.003625 }, + { prefix: "deepseek-v4-flash", input: 0.14, output: 0.28, cacheRead: 0.0028 }, + { prefix: "deepseek-chat", input: 0.14, output: 0.28, cacheRead: 0.0028 }, + { prefix: "deepseek-reasoner", input: 0.14, output: 0.28, cacheRead: 0.0028 }, +]; + +function isDeepSeekModel(model) { + return typeof model === "string" && model.indexOf("deepseek-") === 0; +} + +function round2(n) { + return Math.round(n * 100) / 100; +} + +function writeJson(res, status, body) { + const payload = JSON.stringify(body); + res.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "cache-control": "no-store", + }); + res.end(payload); +} + +function isLoopbackHostname(hostname) { + if (hostname === "localhost" || hostname === "::1" || hostname === "[::1]") return true; + const parts = hostname.split("."); + if (parts.length !== 4 || parts[0] !== "127") return false; + return parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255); +} + +// 同源/本机请求防护:拒绝跨站读取余额,只放行 loopback 同源请求。 +function isTrusted(req) { + const host = req.headers.host; + if (!host) return false; + const hostname = host.split(":")[0]; + if (!isLoopbackHostname(hostname)) return false; + if (req.headers["sec-fetch-site"] === "cross-site") return false; + const origin = req.headers.origin; + if (origin === undefined) return true; + try { + return new URL(origin).host === host; + } catch { + return false; + } +} + +function apply(ctx) { + const credentials = ctx.credentials; + const subprocess = ctx.subprocess; + const agentDefaultModel = ctx.get("agentDefaultModel"); + + const pricing = { entries: null, fetchedAt: 0, source: "fallback" }; + let pricingPromise = null; + const state = { lastModel: null }; + const tokensBySession = new Map(); + + function recordUsage(options, usage) { + if (!usage || !isDeepSeekModel(options && options.model)) return; + const key = options.sessionId ? String(options.sessionId) : "__global__"; + const cur = tokensBySession.get(key) || { + inputTokens: 0, + cacheReadTokens: 0, + outputTokens: 0, + requests: 0, + }; + cur.inputTokens += usage.inputTokens || 0; + cur.cacheReadTokens += usage.cacheReadTokens || 0; + cur.outputTokens += usage.outputTokens || 0; + cur.requests += 1; + tokensBySession.set(key, cur); + } + + // 透传每个 chunk,流结束后把 usage 记入本会话累计;不改变下游语义。 + ctx.on("llm/stream", function (options, next) { + if (options && typeof options.model === "string") state.lastModel = options.model; + const upstream = next(); + return (async function* () { + let usage = null; + try { + for await (const chunk of upstream) { + if (chunk && chunk.type === "usage" && chunk.usage) usage = chunk.usage; + yield chunk; + } + } finally { + if (usage) { + try { + recordUsage(options, usage); + } catch (error) { + console.error("deepseek-balance: record usage failed", error); + } + } + } + })(); + }); + + function defaultModel() { + if (!agentDefaultModel) return null; + try { + const sel = agentDefaultModel.currentSelection(); + return sel && typeof sel.model === "string" ? sel.model : null; + } catch { + return null; + } + } + + function httpGet(url) { + const handle = subprocess.spawn({ + argv: ["curl", "-sS", "--max-time", "20", url], + cwd: "/", + stdio: { stdin: "ignore", stdout: { maxBytes: 8388608 }, stderr: { maxBytes: 16384 } }, + graceMs: 5000, + }); + return handle.done.then(function (outcome) { + const out = handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ""; + const err = handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : ""; + if (outcome.exitCode !== 0) { + throw new Error((err && err.trim()) ? err.trim() : "curl exit " + outcome.exitCode); + } + return out; + }); + } + + function parseDeepSeekPricing(body) { + const data = JSON.parse(body); + const models = data && data.deepseek && data.deepseek.models; + if (!models || typeof models !== "object") return null; + const entries = []; + for (const id in models) { + const cost = models[id] && models[id].cost; + if (!cost || typeof cost !== "object") continue; + const input = typeof cost.input === "number" ? cost.input : 0; + const output = typeof cost.output === "number" ? cost.output : 0; + const cacheRead = typeof cost.cache_read === "number" ? cost.cache_read : 0; + if (!(input > 0) && !(output > 0)) continue; + entries.push({ prefix: id, input, output, cacheRead }); + } + if (!entries.length) return null; + entries.sort(function (a, b) { + return b.prefix.length - a.prefix.length; + }); + return entries; + } + + function ensurePricing() { + const now = Date.now(); + if (pricing.entries && now - pricing.fetchedAt < PRICING_TTL_MS) return Promise.resolve(); + if (pricingPromise) return pricingPromise; + pricingPromise = (async function () { + try { + const body = await httpGet(MODELS_DEV_URL); + const entries = parseDeepSeekPricing(body); + if (entries) { + pricing.entries = entries; + pricing.fetchedAt = Date.now(); + pricing.source = "live"; + } + } catch (error) { + console.error("deepseek-balance: fetch pricing failed, using fallback", error); + } finally { + pricingPromise = null; + } + })(); + return pricingPromise; + } + + function matchPricing(model) { + const entries = pricing.entries || FALLBACK_PRICING; + for (let i = 0; i < entries.length; i++) { + if (model.indexOf(entries[i].prefix) === 0) return entries[i]; + } + return null; + } + + async function queryBalance() { + let resolved; + try { + resolved = await credentials.resolve("DEEPSEEK_API_KEY"); + } catch { + return { ok: false, error: "读取凭据失败" }; + } + if (!resolved || !resolved.value) return { ok: false, error: "未配置 DEEPSEEK_API_KEY" }; + + // 密钥经 curl 的 stdin config 注入,避免出现在 argv(ps 可见)。 + const config = + 'header = "Authorization: Bearer ' + resolved.value + '"\n' + + 'header = "Accept: application/json"\n'; + + let handle; + try { + handle = subprocess.spawn({ + argv: ["curl", "-sS", "--max-time", "15", "--config", "-", "https://api.deepseek.com/user/balance"], + cwd: "/", + stdio: { stdin: { data: config }, stdout: { maxBytes: 65536 }, stderr: { maxBytes: 16384 } }, + graceMs: 5000, + }); + } catch { + return { ok: false, error: "启动 curl 失败" }; + } + + let outcome; + try { + outcome = await handle.done; + } catch { + return { ok: false, error: "curl 执行异常" }; + } + + const out = handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ""; + const err = handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : ""; + if (outcome.exitCode !== 0) { + return { ok: false, error: (err && err.trim()) ? err.trim() : "curl exit " + outcome.exitCode }; + } + + let body; + try { + body = JSON.parse(out); + } catch { + return { ok: false, error: "响应不是合法 JSON" }; + } + return { ok: true, body }; + } + + async function getStatus(sessionId) { + const key = sessionId ? String(sessionId) : "__global__"; + const model = state.lastModel || defaultModel(); + const tokens = tokensBySession.get(key) || { + inputTokens: 0, + cacheReadTokens: 0, + outputTokens: 0, + requests: 0, + }; + + if (!isDeepSeekModel(model)) { + return { ok: true, isDeepSeek: false, model: model || null, requests: tokens.requests }; + } + + await ensurePricing(); + const p = matchPricing(model); + const usd = + (p ? p.input : 0) * (tokens.inputTokens / 1000000) + + (p ? p.cacheRead : 0) * (tokens.cacheReadTokens / 1000000) + + (p ? p.output : 0) * (tokens.outputTokens / 1000000); + const spendCny = round2(usd * CNY_PER_USD); + + const balance = await queryBalance(); + return { + ok: true, + isDeepSeek: true, + model: model, + spendCny: spendCny, + requests: tokens.requests, + pricingSource: pricing.source, + balance: balance.ok ? balance.body : null, + balanceError: balance.ok ? null : balance.error, + }; + } + + ctx.effect( + () => + ctx.webServer.register({ + kind: "exact", + path: "/deepseek-balance/status", + handler: async (req, res) => { + if (!isTrusted(req)) { + writeJson(res, 403, { ok: false, error: "forbidden" }); + return; + } + try { + const url = new URL(req.url ?? "/", "http://dsh.internal"); + const sessionId = url.searchParams.get("sessionId"); + writeJson(res, 200, await getStatus(sessionId)); + } catch (error) { + writeJson(res, 500, { ok: false, error: error && error.message ? error.message : String(error) }); + } + }, + }), + "deepseek-balance: /deepseek-balance/status route", + ); +} + +export { apply, inject }; diff --git a/package.json b/package.json new file mode 100644 index 0000000..969d8bb --- /dev/null +++ b/package.json @@ -0,0 +1,57 @@ +{ + "name": "deepseek-balance", + "version": "1.0.0", + "description": "DeepSeek Harness web 插件:在输入框下方状态栏展示 DeepSeek 官方账户余额与本会话估算花费(token × models.dev 实时单价),并区分非 DeepSeek 模型。", + "type": "module", + "main": "lib/index.js", + "exports": { + ".": "./lib/index.js", + "./client": "./lib/client.js", + "./package.json": "./package.json" + }, + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + }, + "client": { + "platform": "web", + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-slots" + ] + } + }, + "files": [ + "lib/index.js", + "lib/client.js", + "cordis.patch.yml", + "dsh.plugin.json", + "README.md", + "LICENSE", + "install.sh" + ], + "license": "MIT", + "engines": { + "node": ">=20" + }, + "repository": { + "type": "git", + "url": "https://github.com/GeekRicardo/deepseek-balance" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "*", + "@deepseek-ai/dsh-host-webserver": "*", + "@deepseek-ai/dsh-credentials": "*", + "@deepseek-ai/dsh-subprocess": "*", + "@deepseek-ai/dsh-client-ui-slots": "*", + "react": "^18.2.0" + }, + "peerDependenciesMeta": { + "@deepseek-ai/cordis": { "optional": true }, + "@deepseek-ai/dsh-host-webserver": { "optional": true }, + "@deepseek-ai/dsh-credentials": { "optional": true }, + "@deepseek-ai/dsh-subprocess": { "optional": true }, + "@deepseek-ai/dsh-client-ui-slots": { "optional": true }, + "react": { "optional": true } + } +}