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:
GeekRicardo
2026-08-18 18:27:29 +08:00
parent f005541b10
commit 947c64a447
4 changed files with 421 additions and 108 deletions
+26 -14
View File
@@ -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` 轮询该 route60s |
| 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` 轮询该 route2s),按 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
View File
@@ -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
View File
@@ -1,18 +1,27 @@
// deepseek-balance — host face
// deepseek-balance — host faceDeepSeek + Kimi Coding 双供应商)
//
// 职责(全部进程内、可逆):
// 1. 监听 `llm/stream` 瀑布事件,按 session 累计 DeepSeek 模型的 token 用量。
// 2. 从 models.dev 拉取 DeepSeek 单价(内存缓存 24h + 硬编码兜底),
// 展示时用最新单价把累计 token 换算成人民币估算花费
// 3. 通过 credentials 服务读取 DEEPSEEK_API_KEYcurl 官方 /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/usages5 小时窗口 / 每周配额 / 刷新时间)。
// 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-switchutilization = (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
View File
@@ -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": {