feat: 合并 Kimi Coding 用量展示,按 provider 判断 + 2 秒轮询实时切换
- 新增 kimi-coding provider 支持:KIMI_CODING_API_KEY 查询 api.kimi.com/coding/v1/usages, 展示 5小时/7天 已用百分比 + 重置倒计时 + 刷新时间(对齐 cc-switch SubscriptionQuotaFooter) - 判断依据从 model 前缀改为 agentDefaultModel.currentSelection(),切换模型/对话实时更新 - client 轮询从 60s 降到 2s;余额/用量 host 端按 provider 缓存 5 分钟,切走再切回强制刷新 - 非 deepseek/kimi-coding provider 不再展示,避免 opencode-go 等网关模型误显示官方余额
This commit is contained in:
@@ -1,9 +1,19 @@
|
||||
# deepseek-balance
|
||||
|
||||
DeepSeek Harness web 插件:在**输入框下方状态栏**展示 DeepSeek 官方账户余额与本会话估算花费,并区分非 DeepSeek 模型。
|
||||
DeepSeek Harness web 插件:在**输入框下方状态栏**展示当前供应商的余额/用量,按 **provider** 实时切换。
|
||||
|
||||
- DeepSeek 模型时:`● 本会话 ¥X.XX · ● 余额 ¥465.xx`(每 60 秒自动刷新)
|
||||
- 非 DeepSeek 模型时:`● 非 DeepSeek 供应商`(不查余额、不算花费)
|
||||
| 当前模型 provider | 展示内容 | 数据源 |
|
||||
| --- | --- | --- |
|
||||
| `deepseek`(DeepSeek 官方) | `● 本会话 ¥X.XX · ● 余额 ¥465.xx` | `DEEPSEEK_API_KEY` → `GET api.deepseek.com/user/balance` |
|
||||
| `kimi-coding`(Kimi For Coding) | `5小时 3% 4h15m · 7天 31% 6d0h · 5分钟前` | `KIMI_CODING_API_KEY` → `GET api.kimi.com/coding/v1/usages` |
|
||||
| 其他 provider | 不显示(返回 null) | — |
|
||||
|
||||
## 实时性(v2+ 优化)
|
||||
|
||||
- **2 秒轮询**:切换模型/切换对话后最多 2 秒更新,不再等 60 秒。
|
||||
- **按 provider 判断**:用 `agentDefaultModel.currentSelection()`(当前选中,切换即更新),不再用「最近一次请求的模型」,避免切了模型但没发消息时读数停留在旧模型。
|
||||
- **5 分钟缓存**:余额/用量实际查询每 5 分钟一次(host 端按 provider 缓存),切走再切回强制刷新一次。
|
||||
- 切换对话:client 按 `sessionId` 变化重新加载,DeepSeek 的本会话花费按 session 分别累计。
|
||||
|
||||
## 一键安装
|
||||
|
||||
@@ -36,27 +46,29 @@ pm2 restart dsh-web # 若用 pm2 托管;否则用你的启动方式重启
|
||||
## 前置条件
|
||||
|
||||
- DeepSeek Harness 已初始化 web profile(`~/.dsh/profiles/web` 存在)。
|
||||
- `~/.dsh/.credentials.yaml` 里配置了 `DEEPSEEK_API_KEY`(本插件复用 harness 自身的 DeepSeek 密钥,不额外索取)。
|
||||
- `~/.dsh/.credentials.yaml` 里配置对应供应商的密钥:
|
||||
- DeepSeek:`DEEPSEEK_API_KEY`
|
||||
- Kimi Coding:`KIMI_CODING_API_KEY`(也兼容 `KIMI_CODE_API_KEY` / `KIMI_API_KEY`,可选 `KIMI_CODE_BASE_URL` 覆盖)
|
||||
- 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) |
|
||||
| Host | 监听 `llm/stream` 按 session 累计 DeepSeek 官方模型的 token;经 `credentials` 读密钥,按 provider 查对应接口(DeepSeek `/user/balance`、Kimi `/coding/v1/usages`);5 分钟缓存;注册 `/deepseek-balance/status` HTTP route |
|
||||
| Client | 在 `conversation.composer.dock` 槽位渲染状态栏,`fetch` 轮询该 route(2s),按 provider 分发渲染 |
|
||||
|
||||
### 计费口径(重要)
|
||||
### DeepSeek 计费口径(重要)
|
||||
|
||||
- DeepSeek 官方 API **不返回金额**,只返回 token 数。金额是 `token × 单价` 的**估算**,不是账单。
|
||||
- 单价来自第三方 [models.dev](https://models.dev)(USD/百万 token),按模型前缀匹配;拉取失败回落到内置单价。
|
||||
- 汇率固定 7.2 换算成人民币。
|
||||
- 误差来源:DeepSeek 峰谷定价(分时段计价)、汇率、单价维护。
|
||||
|
||||
### 数据生命周期
|
||||
|
||||
- 单价来自第三方 [models.dev](https://models.dev)(USD/百万 token),按模型前缀匹配;拉取失败回落到内置单价;汇率固定 7.2。
|
||||
- 本会话花费是**内存态**,从插件加载后的下一次模型调用开始累计,重启清零,不持久化。
|
||||
- 余额每次轮询实时查询官方接口。
|
||||
|
||||
### Kimi Coding 用量口径(重要)
|
||||
|
||||
- Kimi Code 是订阅制,「余额」= 每周请求配额与 5 小时滚动窗口的已用百分比,接口不返回金额。
|
||||
- 展示格式对齐 [cc-switch](https://github.com/GeekRicardo/cc-switch) 的 `SubscriptionQuotaFooter`:`5小时 X% 倒计时 · 7天 Y% 倒计时 · N分钟前`,百分比 <70% 绿 / 70-90% 橙 / ≥90% 红。
|
||||
- 仅当 provider 为 `kimi-coding`(`api.kimi.com/coding`)时展示;通过 opencode-go 等网关跑的 kimi 模型不属于此账户,不展示。
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+175
-62
@@ -11,12 +11,17 @@ window.__ModuleLoader__.load({
|
||||
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__item { display: inline-flex; align-items: center; gap: 4px; } " +
|
||||
".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); }";
|
||||
".dsb-readout--error { color: var(--dsw-alias-state-error-primary); } " +
|
||||
".dsb-readout__pct { font-weight: 600; font-variant-numeric: tabular-nums; } " +
|
||||
".dsb-pct-ok { color: var(--dsw-alias-state-success-primary); } " +
|
||||
".dsb-pct-warn { color: #d97706; } " +
|
||||
".dsb-pct-crit { color: var(--dsw-alias-state-error-primary); } " +
|
||||
".dsb-readout__ago { color: var(--dsw-alias-label-secondary); }";
|
||||
|
||||
function ensureStyle() {
|
||||
if (document.getElementById(STYLE_ID) !== null) return;
|
||||
@@ -40,62 +45,9 @@ window.__ModuleLoader__.load({
|
||||
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 供应商",
|
||||
);
|
||||
}
|
||||
// ── DeepSeek 读数 ────────────────────────────────────────
|
||||
|
||||
function DeepSeekReadout(res) {
|
||||
var items = [];
|
||||
var pricingTitle = res.pricingSource === "live" ? "定价来自 models.dev(实时)" : "定价来自内置兜底";
|
||||
|
||||
@@ -125,10 +77,7 @@ window.__ModuleLoader__.load({
|
||||
React.createElement(
|
||||
"span",
|
||||
{ key: "balance", className: "dsb-readout__item", title: "DeepSeek 账户余额" },
|
||||
React.createElement(
|
||||
"span",
|
||||
{ className: "dsb-readout__dot" + (available ? "" : " dsb-readout__dot--off") },
|
||||
),
|
||||
React.createElement("span", { className: "dsb-readout__dot" + (available ? "" : " dsb-readout__dot--off") }),
|
||||
"余额 " + (parts.length ? parts.join(" · ") : "—"),
|
||||
),
|
||||
);
|
||||
@@ -145,11 +94,175 @@ window.__ModuleLoader__.load({
|
||||
return React.createElement("span", { className: "dsb-readout" }, items);
|
||||
}
|
||||
|
||||
// ── Kimi Coding 读数(对齐 cc-switch SubscriptionQuotaFooter)──
|
||||
|
||||
function countdownStr(resetsAt) {
|
||||
if (!resetsAt) return null;
|
||||
var t = Date.parse(resetsAt);
|
||||
if (t !== t) return null;
|
||||
var diffMs = t - Date.now();
|
||||
if (diffMs <= 0) return null;
|
||||
var hours = Math.floor(diffMs / 3600000);
|
||||
var minutes = Math.floor((diffMs % 3600000) / 60000);
|
||||
if (hours > 24) return Math.floor(hours / 24) + "d" + (hours % 24) + "h";
|
||||
if (hours > 0) return hours + "h" + minutes + "m";
|
||||
return minutes + "m";
|
||||
}
|
||||
|
||||
function agoStr(queriedAt) {
|
||||
if (!queriedAt) return null;
|
||||
var diff = Math.floor((Date.now() - queriedAt) / 1000);
|
||||
if (diff < 0) return "刚刚";
|
||||
if (diff < 60) return "刚刚";
|
||||
if (diff < 3600) return Math.floor(diff / 60) + "分钟前";
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + "小时前";
|
||||
return Math.floor(diff / 86400) + "天前";
|
||||
}
|
||||
|
||||
function pctClass(u) {
|
||||
var v = Number(u);
|
||||
if (v !== v) return "dsb-pct-ok";
|
||||
if (v >= 90) return "dsb-pct-crit";
|
||||
if (v >= 70) return "dsb-pct-warn";
|
||||
return "dsb-pct-ok";
|
||||
}
|
||||
|
||||
function fmtPct(u) {
|
||||
var v = Number(u);
|
||||
return v === v ? Math.round(v) + "%" : "—";
|
||||
}
|
||||
|
||||
function fmtInt(n) {
|
||||
var v = Number(n);
|
||||
return v === v ? String(Math.round(v)) : "—";
|
||||
}
|
||||
|
||||
function fmtTime(iso) {
|
||||
try {
|
||||
var d = new Date(iso);
|
||||
return d.getTime() !== d.getTime() ? iso : d.toLocaleString();
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function membershipName(level) {
|
||||
if (typeof level !== "string") return null;
|
||||
return level.indexOf("LEVEL_") === 0 ? level.slice(6) : level;
|
||||
}
|
||||
|
||||
function KimiReadout(res) {
|
||||
var fh = res.fiveHour;
|
||||
var wk = res.weekly;
|
||||
|
||||
if (!fh && !wk) {
|
||||
return React.createElement(
|
||||
"span",
|
||||
{ className: "dsb-readout dsb-readout--error", title: res.balanceError || "用量不可用" },
|
||||
"余额不可用",
|
||||
);
|
||||
}
|
||||
|
||||
var tip = [];
|
||||
if (fh) {
|
||||
tip.push(
|
||||
"5小时:已用 " + fmtInt(fh.used) + "/" + fmtInt(fh.limit) + "(剩 " + fmtInt(fh.remaining) + ")" +
|
||||
(fh.resetsAt ? ",重置 " + fmtTime(fh.resetsAt) : ""),
|
||||
);
|
||||
}
|
||||
if (wk) {
|
||||
tip.push(
|
||||
"7天:已用 " + fmtInt(wk.used) + "/" + fmtInt(wk.limit) + "(剩 " + fmtInt(wk.remaining) + ")" +
|
||||
(wk.resetsAt ? ",重置 " + fmtTime(wk.resetsAt) : ""),
|
||||
);
|
||||
}
|
||||
var mem = membershipName(res.membership);
|
||||
if (mem) tip.push("会员 " + mem);
|
||||
|
||||
var items = [];
|
||||
if (fh) {
|
||||
var fhCd = countdownStr(fh.resetsAt);
|
||||
items.push(
|
||||
React.createElement(
|
||||
"span",
|
||||
{ key: "fh", className: "dsb-readout__item" },
|
||||
"5小时 ",
|
||||
React.createElement("span", { className: "dsb-readout__pct " + pctClass(fh.utilization) }, fmtPct(fh.utilization)),
|
||||
fhCd ? " " + fhCd : "",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (wk) {
|
||||
var wkCd = countdownStr(wk.resetsAt);
|
||||
items.push(
|
||||
React.createElement(
|
||||
"span",
|
||||
{ key: "wk", className: "dsb-readout__item" },
|
||||
"7天 ",
|
||||
React.createElement("span", { className: "dsb-readout__pct " + pctClass(wk.utilization) }, fmtPct(wk.utilization)),
|
||||
wkCd ? " " + wkCd : "",
|
||||
),
|
||||
);
|
||||
}
|
||||
var ago = agoStr(res.queriedAt);
|
||||
if (ago) items.push(React.createElement("span", { key: "ago", className: "dsb-readout__item dsb-readout__ago" }, ago));
|
||||
|
||||
return React.createElement("span", { className: "dsb-readout", title: tip.join("\n") }, items);
|
||||
}
|
||||
|
||||
// ── 统一读数(按 provider 分发)──────────────────────────
|
||||
|
||||
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" });
|
||||
})
|
||||
.catch(function () {
|
||||
if (!alive) return;
|
||||
setView({ phase: "error" });
|
||||
});
|
||||
}
|
||||
load();
|
||||
// 2 秒轮询:切换 provider/对话后最多 2 秒更新;余额/用量实际 5 分钟才重新查(host 端缓存)。
|
||||
var timer = setInterval(load, 2000);
|
||||
return function () {
|
||||
alive = false;
|
||||
clearInterval(timer);
|
||||
};
|
||||
},
|
||||
[sessionId],
|
||||
);
|
||||
|
||||
// 加载中 / 异常 / 不支持的 provider 一律不渲染。
|
||||
if (view.phase !== "ok") return null;
|
||||
var res = view.res;
|
||||
if (!res.isSupported) return null;
|
||||
|
||||
if (res.provider === "deepseek") return DeepSeekReadout(res);
|
||||
if (res.provider === "kimi-coding") return KimiReadout(res);
|
||||
return null;
|
||||
}
|
||||
|
||||
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 余额" },
|
||||
{ name: "conversation.composer.dock", id: "deepseek-balance", order: 10, label: "余额" },
|
||||
function (props) {
|
||||
return React.createElement(StatusReadout, { sessionId: props && props.sessionId });
|
||||
},
|
||||
|
||||
+219
-31
@@ -1,18 +1,27 @@
|
||||
// deepseek-balance — host face
|
||||
// deepseek-balance — host face(DeepSeek + Kimi Coding 双供应商)
|
||||
//
|
||||
// 职责(全部进程内、可逆):
|
||||
// 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 轮询。
|
||||
// 1. 监听 `llm/stream` 瀑布事件,按 session 累计 DeepSeek 官方模型的 token 用量。
|
||||
// 2. 当前 provider 判断一律用 `agentDefaultModel.currentSelection()`(切换即更新),
|
||||
// 不再依赖"最近一次请求的模型",解决切换模型/切换对话后读数不及时的问题。
|
||||
// 3. provider = deepseek → 展示 DeepSeek 官方余额(DEEPSEEK_API_KEY)+ 本会话估算花费;
|
||||
// provider = kimi-coding → 展示 Kimi Coding 订阅用量(KIMI_CODING_API_KEY,
|
||||
// GET https://api.kimi.com/coding/v1/usages,5 小时窗口 / 每周配额 / 刷新时间)。
|
||||
// 4. 两类余额/用量都按 provider 做 5 分钟内存缓存;切走再切回强制刷新一次。
|
||||
// 5. 注册 /deepseek-balance/status HTTP route 供 client 2 秒轮询。
|
||||
|
||||
const inject = ["webServer", "credentials", "subprocess"];
|
||||
|
||||
const CNY_PER_USD = 7.2;
|
||||
const PRICING_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
const MODELS_DEV_URL = "https://models.dev/api.json";
|
||||
|
||||
const PROVIDER_DEEPSEEK = "deepseek";
|
||||
const PROVIDER_KIMI = "kimi-coding";
|
||||
const KIMI_DEFAULT_BASE = "https://api.kimi.com/coding";
|
||||
const KIMI_USAGE_PATH = "/v1/usages";
|
||||
|
||||
// models.dev 拉取失败时的兜底单价,USD / 百万 token。
|
||||
const FALLBACK_PRICING = [
|
||||
{ prefix: "deepseek-v4-pro", input: 0.435, output: 0.87, cacheRead: 0.003625 },
|
||||
@@ -29,6 +38,11 @@ function round2(n) {
|
||||
return Math.round(n * 100) / 100;
|
||||
}
|
||||
|
||||
function toNum(v) {
|
||||
const n = Number(v);
|
||||
return n === n ? n : null;
|
||||
}
|
||||
|
||||
function writeJson(res, status, body) {
|
||||
const payload = JSON.stringify(body);
|
||||
res.writeHead(status, {
|
||||
@@ -68,11 +82,14 @@ function apply(ctx) {
|
||||
|
||||
const pricing = { entries: null, fetchedAt: 0, source: "fallback" };
|
||||
let pricingPromise = null;
|
||||
const state = { lastModel: null };
|
||||
const tokensBySession = new Map();
|
||||
// 余额/用量缓存:key 记录上次的 provider,切走再切回时强制刷新一次。
|
||||
const caches = {
|
||||
deepseek: { key: null, fetchedAt: 0, balance: null, balanceError: null },
|
||||
kimi: { key: null, fetchedAt: 0, status: null },
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -87,9 +104,8 @@ function apply(ctx) {
|
||||
tokensBySession.set(key, cur);
|
||||
}
|
||||
|
||||
// 透传每个 chunk,流结束后把 usage 记入本会话累计;不改变下游语义。
|
||||
// 透传每个 chunk,流结束后把 DeepSeek 官方模型的 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;
|
||||
@@ -99,7 +115,7 @@ function apply(ctx) {
|
||||
yield chunk;
|
||||
}
|
||||
} finally {
|
||||
if (usage) {
|
||||
if (usage && isDeepSeekModel(options && options.model)) {
|
||||
try {
|
||||
recordUsage(options, usage);
|
||||
} catch (error) {
|
||||
@@ -110,16 +126,23 @@ function apply(ctx) {
|
||||
})();
|
||||
});
|
||||
|
||||
function defaultModel() {
|
||||
// 当前选中 provider/model(切换即更新),是本插件判断依据的权威源。
|
||||
function currentSelection() {
|
||||
if (!agentDefaultModel) return null;
|
||||
try {
|
||||
const sel = agentDefaultModel.currentSelection();
|
||||
return sel && typeof sel.model === "string" ? sel.model : null;
|
||||
if (!sel || typeof sel !== "object") return null;
|
||||
return {
|
||||
provider: typeof sel.provider === "string" ? sel.provider : null,
|
||||
model: typeof sel.model === "string" ? sel.model : null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── DeepSeek:定价 + 花费 + 余额 ──────────────────────────
|
||||
|
||||
function httpGet(url) {
|
||||
const handle = subprocess.spawn({
|
||||
argv: ["curl", "-sS", "--max-time", "20", url],
|
||||
@@ -188,7 +211,16 @@ function apply(ctx) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function queryBalance() {
|
||||
function computeSpend(model, tokens) {
|
||||
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);
|
||||
return round2(usd * CNY_PER_USD);
|
||||
}
|
||||
|
||||
async function queryDeepSeekBalance() {
|
||||
let resolved;
|
||||
try {
|
||||
resolved = await credentials.resolve("DEEPSEEK_API_KEY");
|
||||
@@ -197,7 +229,6 @@ function apply(ctx) {
|
||||
}
|
||||
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';
|
||||
@@ -236,9 +267,10 @@ function apply(ctx) {
|
||||
return { ok: true, body };
|
||||
}
|
||||
|
||||
async function getStatus(sessionId) {
|
||||
async function deepseekStatus(sessionId) {
|
||||
const sel = currentSelection();
|
||||
const model = sel && sel.model ? sel.model : null;
|
||||
const key = sessionId ? String(sessionId) : "__global__";
|
||||
const model = state.lastModel || defaultModel();
|
||||
const tokens = tokensBySession.get(key) || {
|
||||
inputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
@@ -246,31 +278,187 @@ function apply(ctx) {
|
||||
requests: 0,
|
||||
};
|
||||
|
||||
if (!isDeepSeekModel(model)) {
|
||||
return { ok: true, isDeepSeek: false, model: model || null, requests: tokens.requests };
|
||||
await ensurePricing();
|
||||
const spendCny = computeSpend(model, tokens);
|
||||
|
||||
const cache = caches.deepseek;
|
||||
const now = Date.now();
|
||||
let balance = null;
|
||||
let balanceError = null;
|
||||
const switched = cache.key !== PROVIDER_DEEPSEEK;
|
||||
if (!switched && now - cache.fetchedAt < CACHE_TTL_MS && (cache.balance !== null || cache.balanceError !== null)) {
|
||||
balance = cache.balance;
|
||||
balanceError = cache.balanceError;
|
||||
} else {
|
||||
const result = await queryDeepSeekBalance();
|
||||
balance = result.ok ? result.body : null;
|
||||
balanceError = result.ok ? null : result.error;
|
||||
cache.key = PROVIDER_DEEPSEEK;
|
||||
cache.fetchedAt = now;
|
||||
cache.balance = balance;
|
||||
cache.balanceError = balanceError;
|
||||
}
|
||||
|
||||
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,
|
||||
isSupported: true,
|
||||
provider: PROVIDER_DEEPSEEK,
|
||||
model: model,
|
||||
spendCny: spendCny,
|
||||
requests: tokens.requests,
|
||||
pricingSource: pricing.source,
|
||||
balance: balance.ok ? balance.body : null,
|
||||
balanceError: balance.ok ? null : balance.error,
|
||||
balance: balance,
|
||||
balanceError: balanceError,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Kimi Coding:订阅用量 ─────────────────────────────────
|
||||
|
||||
async function resolveCredential(names) {
|
||||
for (const name of names) {
|
||||
try {
|
||||
const resolved = await credentials.resolve(name);
|
||||
if (resolved && resolved.value) return resolved.value;
|
||||
} catch {
|
||||
// 尝试下一个候选名
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 对齐 cc-switch:utilization = (limit - remaining) / limit * 100(已用百分比)
|
||||
function makeTier(limitRaw, remainingRaw, resetTime) {
|
||||
const limit = toNum(limitRaw);
|
||||
const remaining = toNum(remainingRaw);
|
||||
const used = limit !== null && remaining !== null ? Math.max(limit - remaining, 0) : null;
|
||||
const utilization = limit !== null && limit > 0 && used !== null ? (used / limit) * 100 : null;
|
||||
return {
|
||||
limit: limit,
|
||||
remaining: remaining,
|
||||
used: used,
|
||||
utilization: utilization,
|
||||
resetsAt: typeof resetTime === "string" ? resetTime : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function queryKimiUsage() {
|
||||
let key;
|
||||
try {
|
||||
key = await resolveCredential(["KIMI_CODING_API_KEY", "KIMI_CODE_API_KEY", "KIMI_API_KEY"]);
|
||||
} catch {
|
||||
return { ok: false, error: "读取凭据失败" };
|
||||
}
|
||||
if (!key) return { ok: false, error: "未配置 KIMI_CODING_API_KEY" };
|
||||
|
||||
let baseUrl = KIMI_DEFAULT_BASE;
|
||||
try {
|
||||
const override = await resolveCredential(["KIMI_CODE_BASE_URL"]);
|
||||
if (override && typeof override === "string" && override.length > 0) baseUrl = override.trim();
|
||||
} catch {
|
||||
// 使用默认
|
||||
}
|
||||
|
||||
const config =
|
||||
'header = "Authorization: Bearer ' + key + '"\n' +
|
||||
'header = "Accept: application/json"\n' +
|
||||
'header = "User-Agent: KimiCLI/1.5"\n';
|
||||
|
||||
let handle;
|
||||
try {
|
||||
handle = subprocess.spawn({
|
||||
argv: ["curl", "-sS", "--max-time", "15", "--config", "-", baseUrl + KIMI_USAGE_PATH],
|
||||
cwd: "/",
|
||||
stdio: { stdin: { data: config }, stdout: { maxBytes: 262144 }, 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" };
|
||||
}
|
||||
|
||||
if (body && body.error) {
|
||||
return { ok: false, error: String(body.error.message || body.error.type || "接口返回错误") };
|
||||
}
|
||||
|
||||
// 5 小时窗口:limits[].detail(取第一个,对齐 cc-switch)
|
||||
const firstLimit = Array.isArray(body.limits) && body.limits.length ? body.limits[0] : null;
|
||||
const rateDetail = firstLimit && firstLimit.detail ? firstLimit.detail : null;
|
||||
// 周限额:usage
|
||||
const usage = body && body.usage ? body.usage : null;
|
||||
|
||||
if (!rateDetail && !usage) return { ok: false, error: "响应缺少 usage/limits 字段" };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
fiveHour: rateDetail ? makeTier(rateDetail.limit, rateDetail.remaining, rateDetail.resetTime) : null,
|
||||
weekly: usage ? makeTier(usage.limit, usage.remaining, usage.resetTime) : null,
|
||||
membership:
|
||||
body.user && body.user.membership && typeof body.user.membership.level === "string"
|
||||
? body.user.membership.level
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function kimiStatus() {
|
||||
const sel = currentSelection();
|
||||
const model = sel && sel.model ? sel.model : null;
|
||||
const cache = caches.kimi;
|
||||
const now = Date.now();
|
||||
const switched = cache.key !== PROVIDER_KIMI;
|
||||
if (!switched && cache.status && now - cache.fetchedAt < CACHE_TTL_MS) {
|
||||
return { ok: true, ...cache.status };
|
||||
}
|
||||
|
||||
const result = await queryKimiUsage();
|
||||
const status = {
|
||||
isSupported: true,
|
||||
provider: PROVIDER_KIMI,
|
||||
model: model,
|
||||
fiveHour: result.ok ? result.fiveHour : null,
|
||||
weekly: result.ok ? result.weekly : null,
|
||||
membership: result.ok ? result.membership : null,
|
||||
queriedAt: result.ok ? now : null,
|
||||
balanceError: result.ok ? null : result.error,
|
||||
};
|
||||
cache.key = PROVIDER_KIMI;
|
||||
cache.fetchedAt = now;
|
||||
cache.status = status;
|
||||
return { ok: true, ...status };
|
||||
}
|
||||
|
||||
// ── 统一入口 ──────────────────────────────────────────────
|
||||
|
||||
async function getStatus(sessionId) {
|
||||
const sel = currentSelection();
|
||||
const provider = sel ? sel.provider : null;
|
||||
const model = sel ? sel.model : null;
|
||||
|
||||
if (provider === PROVIDER_DEEPSEEK) return deepseekStatus(sessionId);
|
||||
if (provider === PROVIDER_KIMI) return kimiStatus();
|
||||
|
||||
// 其他 provider:不展示(client 返回 null),但带出当前 provider/model 便于诊断。
|
||||
return { ok: true, isSupported: false, provider: provider, model: model };
|
||||
}
|
||||
|
||||
ctx.effect(
|
||||
() =>
|
||||
ctx.webServer.register({
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "deepseek-balance",
|
||||
"version": "1.0.0",
|
||||
"description": "DeepSeek Harness web 插件:在输入框下方状态栏展示 DeepSeek 官方账户余额与本会话估算花费(token × models.dev 实时单价),并区分非 DeepSeek 模型。",
|
||||
"description": "DeepSeek Harness web 插件:输入框下方状态栏展示当前供应商的余额/用量 —— DeepSeek 官方账户余额 + 本会话估算花费,或 Kimi Coding 订阅用量(5小时/7天/刷新时间)。按 provider 判断、2 秒轮询实时切换。",
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"exports": {
|
||||
|
||||
Reference in New Issue
Block a user