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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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-official(dsh-llm-deepseek)
|
||||
// 与 pi-ai 的 deepseek(api.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 };
|
||||
|
||||
Reference in New Issue
Block a user