feat: opencode-go 用量支持 + DeepSeek 兼容 deepseek-official 路由

- 新增 opencode-go provider:OPENCODE_GO_API_KEY 查询 opencode.ai/zen/go/v1/usage,
  展示 rolling(5h)/weekly/monthly 三窗口已用百分比 + 倒计时 + 刷新时间,
  认证同时带 Authorization: Bearer 与 x-api-key(对齐 OpenCodeMonitor 官方接口)
- DeepSeek 余额分支兼容 deepseek 与 deepseek-official 两条官方路由
  (DSH 自带 dsh-llm-deepseek 路由名为 deepseek-official,与 pi-ai catalog 名 deepseek 并存)
- 2 秒轮询 + provider 缓存 5 分钟 + 切换强制刷新(沿用统一逻辑)
This commit is contained in:
GeekRicardo
2026-08-18 18:47:01 +08:00
parent 947c64a447
commit a68b9a37e5
3 changed files with 183 additions and 4 deletions
+9
View File
@@ -6,6 +6,7 @@ DeepSeek Harness web 插件:在**输入框下方状态栏**展示当前供应
| --- | --- | --- |
| `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` |
| `opencode-go`OpenCode Go | `5小时 2% 3h50m · 7天 9% 4d2h · 30天 26% 22d1h · 5分钟前` | `OPENCODE_GO_API_KEY``GET opencode.ai/zen/go/v1/usage` |
| 其他 provider | 不显示(返回 null | — |
## 实时性(v2+ 优化)
@@ -49,6 +50,7 @@ pm2 restart dsh-web # 若用 pm2 托管;否则用你的启动方式重启
- `~/.dsh/.credentials.yaml` 里配置对应供应商的密钥:
- DeepSeek`DEEPSEEK_API_KEY`
- Kimi Coding`KIMI_CODING_API_KEY`(也兼容 `KIMI_CODE_API_KEY` / `KIMI_API_KEY`,可选 `KIMI_CODE_BASE_URL` 覆盖)
- OpenCode Go`OPENCODE_GO_API_KEY`(也兼容 `OPENCODE_API_KEY`
- Node.js ≥ 20、pnpm 可用。
## 工作原理
@@ -70,6 +72,13 @@ pm2 restart dsh-web # 若用 pm2 托管;否则用你的启动方式重启
- 展示格式对齐 [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 模型不属于此账户,不展示。
### OpenCode Go 用量口径(重要)
- OpenCode Go 是 $10/月订阅,官方配额:5 小时 = $12、每周 = $30、每月 = $60;接口只给已用百分比与重置时间,金额为按配额换算的估算(`percent/100 × 配额`)。
- 展示 `5小时 X% 倒计时 · 7天 Y% 倒计时 · 30天 Z% 倒计时 · N分钟前`,颜色阈值同上。
- 接口要求同时携带 `Authorization: Bearer``x-api-key` 两个请求头(对齐 [OpenCodeMonitor](https://github.com/Hanfei1224/OpenCodeMonitor) 的官方用量接口实现)。
- 仅当 provider 为 `opencode-go``opencode.ai/zen/go`)时展示。
## License
MIT
+50
View File
@@ -210,6 +210,55 @@ window.__ModuleLoader__.load({
return React.createElement("span", { className: "dsb-readout", title: tip.join("\n") }, items);
}
// ── OpenCode Go 读数(5小时 / 7天 / 30天 + 刷新时间)────
function OpencodeReadout(res) {
var w = res.windows;
if (!w || (!w.rolling && !w.weekly && !w.monthly)) {
return React.createElement(
"span",
{ className: "dsb-readout dsb-readout--error", title: res.balanceError || "用量不可用" },
"余额不可用",
);
}
var tip = [];
var labelOf = { rolling: "5小时", weekly: "7天", monthly: "30天" };
var usedUsdOf = function (win, key) {
if (!win || win.percent === null || win.percent === undefined || !win.limitUsd) return null;
return (Number(win.percent) / 100 * Number(win.limitUsd)).toFixed(2);
};
for (var k of ["rolling", "weekly", "monthly"]) {
var win = w[k];
if (!win) continue;
var usd = usedUsdOf(win, k);
tip.push(
labelOf[k] + ":已用 " + fmtPct(win.percent) + (usd !== null ? "$" + usd + "/$" + fmtInt(win.limitUsd) + "" : "") +
(win.resetsAt ? ",重置 " + fmtTime(win.resetsAt) : ""),
);
}
var items = [];
for (var k2 of ["rolling", "weekly", "monthly"]) {
var win2 = w[k2];
if (!win2) continue;
var cd = countdownStr(win2.resetsAt);
items.push(
React.createElement(
"span",
{ key: k2, className: "dsb-readout__item" },
labelOf[k2] + " ",
React.createElement("span", { className: "dsb-readout__pct " + pctClass(win2.percent) }, fmtPct(win2.percent)),
cd ? " " + cd : "",
),
);
}
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) {
@@ -255,6 +304,7 @@ window.__ModuleLoader__.load({
if (res.provider === "deepseek") return DeepSeekReadout(res);
if (res.provider === "kimi-coding") return KimiReadout(res);
if (res.provider === "opencode-go") return OpencodeReadout(res);
return null;
}
+124 -4
View File
@@ -19,8 +19,18 @@ const MODELS_DEV_URL = "https://models.dev/api.json";
const PROVIDER_DEEPSEEK = "deepseek";
const PROVIDER_KIMI = "kimi-coding";
const PROVIDER_OPENCODE = "opencode-go";
const KIMI_DEFAULT_BASE = "https://api.kimi.com/coding";
const KIMI_USAGE_PATH = "/v1/usages";
const OPENCODE_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
// opencode-go 官方美元配额(opencode.ai/docs/go):5小时=$12、每周=$30、每月=$60
const OPENCODE_LIMITS_USD = { rolling: 12, weekly: 30, monthly: 60 };
// DeepSeek 官方有两条 provider 路由:DSH 自带的 deepseek-officialdsh-llm-deepseek
// 与 pi-ai 的 deepseekapi.deepseek.com),余额接口相同,都归入 DeepSeek 分支。
function isDeepSeekProvider(p) {
return p === PROVIDER_DEEPSEEK || p === "deepseek-official";
}
// models.dev 拉取失败时的兜底单价,USD / 百万 token。
const FALLBACK_PRICING = [
@@ -87,6 +97,7 @@ function apply(ctx) {
const caches = {
deepseek: { key: null, fetchedAt: 0, balance: null, balanceError: null },
kimi: { key: null, fetchedAt: 0, status: null },
opencode: { key: null, fetchedAt: 0, status: null },
};
function recordUsage(options, usage) {
@@ -269,6 +280,7 @@ function apply(ctx) {
async function deepseekStatus(sessionId) {
const sel = currentSelection();
const provider = sel && sel.provider ? sel.provider : null;
const model = sel && sel.model ? sel.model : null;
const key = sessionId ? String(sessionId) : "__global__";
const tokens = tokensBySession.get(key) || {
@@ -285,7 +297,7 @@ function apply(ctx) {
const now = Date.now();
let balance = null;
let balanceError = null;
const switched = cache.key !== PROVIDER_DEEPSEEK;
const switched = !isDeepSeekProvider(cache.key);
if (!switched && now - cache.fetchedAt < CACHE_TTL_MS && (cache.balance !== null || cache.balanceError !== null)) {
balance = cache.balance;
balanceError = cache.balanceError;
@@ -293,7 +305,7 @@ function apply(ctx) {
const result = await queryDeepSeekBalance();
balance = result.ok ? result.body : null;
balanceError = result.ok ? null : result.error;
cache.key = PROVIDER_DEEPSEEK;
cache.key = provider;
cache.fetchedAt = now;
cache.balance = balance;
cache.balanceError = balanceError;
@@ -302,7 +314,7 @@ function apply(ctx) {
return {
ok: true,
isSupported: true,
provider: PROVIDER_DEEPSEEK,
provider: provider,
model: model,
spendCny: spendCny,
requests: tokens.requests,
@@ -445,6 +457,113 @@ function apply(ctx) {
return { ok: true, ...status };
}
// ── OpenCode Go:订阅用量 ─────────────────────────────────
// 接口只给已用百分比(0-100 整数)与重置时间,金额按官方配额美元换算。
function parseOpencodeWindow(w) {
if (!w || typeof w !== "object") return null;
const percent = toNum(w.percent);
return {
percent: percent,
resetsAt: typeof w.resetsAt === "string" ? w.resetsAt : null,
};
}
async function queryOpencodeUsage() {
let key;
try {
key = await resolveCredential(["OPENCODE_GO_API_KEY", "OPENCODE_API_KEY"]);
} catch {
return { ok: false, error: "读取凭据失败" };
}
if (!key) return { ok: false, error: "未配置 OPENCODE_GO_API_KEY" };
// 官方接口要求同时带 Bearer 与 x-api-key;网关会拦常见 CLI UA,故伪装浏览器。
const config =
'header = "Authorization: Bearer ' + key + '"\n' +
'header = "x-api-key: ' + key + '"\n' +
'header = "Accept: application/json"\n' +
'header = "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"\n';
let handle;
try {
handle = subprocess.spawn({
argv: ["curl", "-sS", "--max-time", "15", "--config", "-", OPENCODE_USAGE_URL],
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 || "接口返回错误") };
}
const usage = body && body.usage ? body.usage : null;
if (!usage || typeof usage !== "object") return { ok: false, error: "响应缺少 usage 字段" };
const windows = {};
for (const name of ["rolling", "weekly", "monthly"]) {
const w = parseOpencodeWindow(usage[name]);
if (w) {
w.limitUsd = OPENCODE_LIMITS_USD[name] || null;
windows[name] = w;
}
}
if (!windows.rolling && !windows.weekly && !windows.monthly) {
return { ok: false, error: "响应缺少用量窗口字段" };
}
return { ok: true, windows };
}
async function opencodeStatus() {
const sel = currentSelection();
const model = sel && sel.model ? sel.model : null;
const cache = caches.opencode;
const now = Date.now();
const switched = cache.key !== PROVIDER_OPENCODE;
if (!switched && cache.status && now - cache.fetchedAt < CACHE_TTL_MS) {
return { ok: true, ...cache.status };
}
const result = await queryOpencodeUsage();
const status = {
isSupported: true,
provider: PROVIDER_OPENCODE,
model: model,
windows: result.ok ? result.windows : null,
queriedAt: result.ok ? now : null,
balanceError: result.ok ? null : result.error,
};
cache.key = PROVIDER_OPENCODE;
cache.fetchedAt = now;
cache.status = status;
return { ok: true, ...status };
}
// ── 统一入口 ──────────────────────────────────────────────
async function getStatus(sessionId) {
@@ -452,8 +571,9 @@ function apply(ctx) {
const provider = sel ? sel.provider : null;
const model = sel ? sel.model : null;
if (provider === PROVIDER_DEEPSEEK) return deepseekStatus(sessionId);
if (isDeepSeekProvider(provider)) return deepseekStatus(sessionId);
if (provider === PROVIDER_KIMI) return kimiStatus();
if (provider === PROVIDER_OPENCODE) return opencodeStatus();
// 其他 provider:不展示(client 返回 null),但带出当前 provider/model 便于诊断。
return { ok: true, isSupported: false, provider: provider, model: model };