feat: DeepSeek balance + session spend status bar plugin for DSH
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
lib/*.map
|
||||
.DS_Store
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "deepseek-balance",
|
||||
"description": "DeepSeek Harness web 插件:状态栏展示 DeepSeek 余额与本会话估算花费",
|
||||
"version": "1.0.0",
|
||||
"entry": {
|
||||
"name": "deepseek-balance",
|
||||
"inject": []
|
||||
},
|
||||
"client": {
|
||||
"platform": "web"
|
||||
}
|
||||
}
|
||||
Executable
+138
@@ -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
|
||||
+164
@@ -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;
|
||||
},
|
||||
});
|
||||
+297
@@ -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 };
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user