modelquota 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +17 -0
- package/dist/db.d.ts +8 -0
- package/dist/db.js +71 -0
- package/dist/mirror/path.d.ts +3 -0
- package/dist/mirror/path.js +15 -0
- package/dist/mirror/reader.d.ts +2 -0
- package/dist/mirror/reader.js +14 -0
- package/dist/mirror/writer.d.ts +2 -0
- package/dist/mirror/writer.js +15 -0
- package/dist/providers/deepseek.d.ts +2 -0
- package/dist/providers/deepseek.js +48 -0
- package/dist/providers/kimi.d.ts +2 -0
- package/dist/providers/kimi.js +87 -0
- package/dist/providers/minimax.d.ts +2 -0
- package/dist/providers/minimax.js +79 -0
- package/dist/providers/opencode-go.d.ts +13 -0
- package/dist/providers/opencode-go.js +119 -0
- package/dist/providers/types.d.ts +12 -0
- package/dist/providers/types.js +1 -0
- package/dist/providers/zhipu.d.ts +2 -0
- package/dist/providers/zhipu.js +79 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +117 -0
- package/dist/tui.d.ts +3 -0
- package/dist/tui.js +76 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.js +1 -0
- package/dist/ui/panel.d.ts +6 -0
- package/dist/ui/panel.js +120 -0
- package/dist/ui/view.d.ts +9 -0
- package/dist/ui/view.js +6 -0
- package/package.json +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# modelquota
|
|
2
|
+
|
|
3
|
+
opencode 插件:在 TUI 右侧栏实时显示**当前正在使用的模型供应商**的套餐余量。
|
|
4
|
+
|
|
5
|
+
## 功能
|
|
6
|
+
|
|
7
|
+
- **按当前模型显示**:右侧栏只显示当前会话正在使用的模型对应供应商(跟随模型切换,发消息后立即更新)
|
|
8
|
+
- **多供应商支持**:DeepSeek / Kimi For Coding / MiniMax / 智谱 GLM / OpenCode Go
|
|
9
|
+
- **已用视角**:统一显示"已用 X%"或"已用 used/total",颜色随使用率变化(>80% 红 / >50% 黄)
|
|
10
|
+
- **配置零侵入**:直接复用 opencode 现有的 provider 配置(apiKey/baseURL),不额外存储
|
|
11
|
+
|
|
12
|
+
## 安装
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm i -g opencode-ai # 需要 opencode 1.18+
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
在 `~/.config/opencode/opencode.json`(全局)或项目 `opencode.json` 的 `plugin` 数组加:
|
|
19
|
+
|
|
20
|
+
```json
|
|
21
|
+
{
|
|
22
|
+
"plugin": ["modelquota"]
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
在 `~/.config/opencode/tui.json` 加(TUI 插件,需要和 server 插件一起注册):
|
|
27
|
+
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"plugin": ["modelquota"]
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
重启 opencode 生效。
|
|
35
|
+
|
|
36
|
+
## 配置
|
|
37
|
+
|
|
38
|
+
### 1. 供应商 API Key(复用 opencode 配置,无需额外设置)
|
|
39
|
+
|
|
40
|
+
在 opencode.json 的 `provider` 里配置了对应供应商即可自动显示(有 key 才显示):
|
|
41
|
+
|
|
42
|
+
| 供应商 | provider id | 显示内容 |
|
|
43
|
+
|--------|------------|---------|
|
|
44
|
+
| DeepSeek | `deepseek` | 余额(CNY) |
|
|
45
|
+
| Kimi For Coding | `kimiforcoding` | 周 / 5h 配额(次)+ 并发 |
|
|
46
|
+
| MiniMax | `minimax` | 5h / 周 已用百分比 + 加成 x1.5 |
|
|
47
|
+
| 智谱 GLM | `zhipu` | 5h / 周 已用百分比 |
|
|
48
|
+
| OpenCode Go | (见下文) | 5h / 周 / 月 已用百分比 |
|
|
49
|
+
|
|
50
|
+
### 2. OpenCode Go 用量(需要额外配置)
|
|
51
|
+
|
|
52
|
+
OpenCode Go 没有公开的 API 查询接口,插件通过浏览器登录态 cookie 调用官方控制台的 server action 获取用量。
|
|
53
|
+
|
|
54
|
+
在 `~/.config/opencode/modelquota.json` 配置:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"opencodeGo": {
|
|
59
|
+
"cookie": "你的 auth cookie",
|
|
60
|
+
"serverId": "控制台 server action 的 id",
|
|
61
|
+
"workspaceId": "你的 workspace id"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
**获取方法**:
|
|
67
|
+
1. 浏览器登录 https://opencode.ai/auth
|
|
68
|
+
2. 打开 https://opencode.ai/workspace/<你的workspaceID>/go 页面
|
|
69
|
+
3. F12 → Network 面板,刷新页面,找到 `_server?id=xxx&args=...` 的请求
|
|
70
|
+
4. `id=` 后面的值 → `serverId`;请求头里的 Cookie `auth=xxx` → `cookie`;URL 中的 `wrk_xxx` → `workspaceId`
|
|
71
|
+
|
|
72
|
+
> cookie 有效期约 1 年,过期后重新抓取一次即可。
|
|
73
|
+
|
|
74
|
+
## 工作原理
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
┌─────────────────────────┐ mirror JSON ┌─────────────────────────┐
|
|
78
|
+
│ server plugin │ ───────────────────▶ │ TUI plugin │
|
|
79
|
+
│ 循环查各供应商套餐 API │ ~/.local/share/ │ 渲染到 sidebar_content │
|
|
80
|
+
│ 写快照 + 当前模型 │ opencode/storage/ │ slot(跟随当前模型) │
|
|
81
|
+
└─────────────────────────┘ modelquota/<hash>.json │
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
- 当前模型通过 `chat.message` 事件实时记录(每次发消息更新)
|
|
85
|
+
- TUI 端从 sync 缓存(Solid 响应式)读取消息流,自动跟随模型切换
|
|
86
|
+
|
|
87
|
+
## 开发
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npm install
|
|
91
|
+
npm run typecheck # 类型检查
|
|
92
|
+
npm run build # 编译到 dist/
|
|
93
|
+
npm run deploy # 部署到本地全局插件目录(开发用)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
MIT
|
package/dist/config.d.ts
ADDED
package/dist/config.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
export function modelQuotaConfigPath() {
|
|
5
|
+
return join(homedir(), ".config", "opencode", "modelquota.json");
|
|
6
|
+
}
|
|
7
|
+
export function readModelQuotaConfig() {
|
|
8
|
+
const path = modelQuotaConfigPath();
|
|
9
|
+
if (!existsSync(path))
|
|
10
|
+
return {};
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
}
|
package/dist/db.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function opencodeDbPath(): string;
|
|
2
|
+
export type LatestSessionModel = {
|
|
3
|
+
sessionID: string;
|
|
4
|
+
providerID: string;
|
|
5
|
+
modelID: string;
|
|
6
|
+
} | null;
|
|
7
|
+
export declare function queryLatestSessionModel(): Promise<LatestSessionModel>;
|
|
8
|
+
export declare function queryGoUsageFromDb(): Promise<number[]>;
|
package/dist/db.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export function opencodeDbPath() {
|
|
4
|
+
return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "opencode", "opencode.db");
|
|
5
|
+
}
|
|
6
|
+
// 兼容 bun:sqlite(opencode 运行时)与 node:sqlite(本地验证)
|
|
7
|
+
async function openDb(path) {
|
|
8
|
+
try {
|
|
9
|
+
const mod = await import("node:sqlite");
|
|
10
|
+
const db = new mod.DatabaseSync(path, { readOnly: true });
|
|
11
|
+
return {
|
|
12
|
+
prepare: (sql) => ({ all: () => db.prepare(sql).all() }),
|
|
13
|
+
close: () => db.close(),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
const mod = await import("bun:sqlite");
|
|
18
|
+
const db = new mod.Database(path, { readonly: true });
|
|
19
|
+
return {
|
|
20
|
+
prepare: (sql) => ({ all: () => db.query(sql).all() }),
|
|
21
|
+
close: () => db.close(),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// 从 opencode 本地 SQLite 查最近更新的 session 的模型(/model 切换后立即生效)
|
|
26
|
+
export async function queryLatestSessionModel() {
|
|
27
|
+
let db = null;
|
|
28
|
+
try {
|
|
29
|
+
db = await openDb(opencodeDbPath());
|
|
30
|
+
const rows = db.prepare("SELECT id, model FROM session WHERE model IS NOT NULL AND model != '' ORDER BY time_updated DESC LIMIT 1").all();
|
|
31
|
+
const row = rows[0];
|
|
32
|
+
if (!row?.id || !row.model)
|
|
33
|
+
return null;
|
|
34
|
+
const model = JSON.parse(String(row.model));
|
|
35
|
+
if (!model.providerID || !model.id)
|
|
36
|
+
return null;
|
|
37
|
+
return {
|
|
38
|
+
sessionID: String(row.id),
|
|
39
|
+
providerID: model.providerID,
|
|
40
|
+
modelID: model.id,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
db?.close();
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function queryGoUsageFromDb() {
|
|
51
|
+
const windows = [
|
|
52
|
+
{ label: "5h", ms: 5 * 3_600_000, total: 12 },
|
|
53
|
+
{ label: "周", ms: 7 * 24 * 3_600_000, total: 30 },
|
|
54
|
+
{ label: "月", ms: 30 * 24 * 3_600_000, total: 60 },
|
|
55
|
+
];
|
|
56
|
+
let db = null;
|
|
57
|
+
try {
|
|
58
|
+
db = await openDb(opencodeDbPath());
|
|
59
|
+
const rows = db.prepare("SELECT cost, time_created FROM session WHERE model LIKE '%opencode-go%' AND cost IS NOT NULL AND cost > 0").all();
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
return windows.map((window) => rows
|
|
62
|
+
.filter((row) => Number(row.time_created) >= now - window.ms)
|
|
63
|
+
.reduce((sum, row) => sum + Number(row.cost ?? 0), 0));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return [0, 0, 0];
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
db?.close();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
const STORAGE_DIR = "modelquota";
|
|
5
|
+
export function canonicalProjectDir(directory) {
|
|
6
|
+
// 统一正斜杠 + 去尾部斜杠,保证 server/TUI 两侧 hash 一致
|
|
7
|
+
return directory.replace(/\\/g, "/").replace(/\/$/, "");
|
|
8
|
+
}
|
|
9
|
+
export function mirrorStorageDir() {
|
|
10
|
+
return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "opencode", "storage", STORAGE_DIR);
|
|
11
|
+
}
|
|
12
|
+
export function mirrorFilePath(projectDir) {
|
|
13
|
+
const projectHash = createHash("sha1").update(canonicalProjectDir(projectDir)).digest("hex").slice(0, 16);
|
|
14
|
+
return join(mirrorStorageDir(), `${projectHash}.json`);
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
export function readMirror(filePath) {
|
|
3
|
+
try {
|
|
4
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
5
|
+
const parsed = JSON.parse(raw);
|
|
6
|
+
if (parsed && parsed.version === 1 && Array.isArray(parsed.providers)) {
|
|
7
|
+
return parsed;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
// 文件不存在或损坏:返回 null
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { mkdirSync, renameSync, writeFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
export function writeMirror(filePath, snapshot) {
|
|
4
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
5
|
+
// 原子写入:先写临时文件再 rename,避免 TUI 读到半截 JSON
|
|
6
|
+
const tmpPath = join(dirname(filePath), `.${filePath.split(/[\\/]/).pop()}.tmp`);
|
|
7
|
+
writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2));
|
|
8
|
+
try {
|
|
9
|
+
renameSync(tmpPath, filePath);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
rmSync(tmpPath, { force: true });
|
|
13
|
+
writeFileSync(filePath, JSON.stringify(snapshot, null, 2));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
function formatCny(value) {
|
|
2
|
+
const num = Number.parseFloat(value);
|
|
3
|
+
if (Number.isNaN(num))
|
|
4
|
+
return value;
|
|
5
|
+
return num.toFixed(2);
|
|
6
|
+
}
|
|
7
|
+
export const deepseekProvider = {
|
|
8
|
+
id: "deepseek",
|
|
9
|
+
name: "DeepSeek",
|
|
10
|
+
async fetch(config) {
|
|
11
|
+
if (!config.apiKey) {
|
|
12
|
+
throw new Error("missing apiKey in opencode.json provider.deepseek");
|
|
13
|
+
}
|
|
14
|
+
const baseURL = config.baseURL ?? "https://api.deepseek.com";
|
|
15
|
+
const url = `${baseURL.replace(/\/$/, "")}/user/balance`;
|
|
16
|
+
const response = await fetch(url, {
|
|
17
|
+
headers: {
|
|
18
|
+
Accept: "application/json",
|
|
19
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
|
24
|
+
}
|
|
25
|
+
const body = (await response.json());
|
|
26
|
+
const infos = body.balance_infos ?? [];
|
|
27
|
+
const total = infos.reduce((sum, info) => sum + (Number.parseFloat(info.total_balance) || 0), 0);
|
|
28
|
+
const toppedUp = infos.reduce((sum, info) => sum + (Number.parseFloat(info.topped_up_balance) || 0), 0);
|
|
29
|
+
const granted = infos.reduce((sum, info) => sum + (Number.parseFloat(info.granted_balance) || 0), 0);
|
|
30
|
+
const metrics = [
|
|
31
|
+
{ label: "余额", used: undefined, total: total, unit: "CNY" },
|
|
32
|
+
];
|
|
33
|
+
if (toppedUp > 0) {
|
|
34
|
+
metrics.push({ label: "充值", used: undefined, total: toppedUp, unit: "CNY" });
|
|
35
|
+
}
|
|
36
|
+
if (granted > 0) {
|
|
37
|
+
metrics.push({ label: "赠送", used: undefined, total: granted, unit: "CNY" });
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
id: "deepseek",
|
|
41
|
+
name: "DeepSeek",
|
|
42
|
+
plan: "API",
|
|
43
|
+
metrics,
|
|
44
|
+
status: total <= 0 ? "low" : total < 10 ? "warn" : "ok",
|
|
45
|
+
updatedAt: Date.now(),
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
function num(value) {
|
|
2
|
+
if (value === undefined)
|
|
3
|
+
return 0;
|
|
4
|
+
const parsed = Number.parseFloat(String(value));
|
|
5
|
+
return Number.isNaN(parsed) ? 0 : parsed;
|
|
6
|
+
}
|
|
7
|
+
// usage 总览是周窗口(resetTime 约 7 天后),limits[] 是 5h 窗口
|
|
8
|
+
function windowLabel(unit, resetTime) {
|
|
9
|
+
if (unit) {
|
|
10
|
+
switch (unit) {
|
|
11
|
+
case "TIME_UNIT_MINUTE":
|
|
12
|
+
return "5h";
|
|
13
|
+
case "TIME_UNIT_HOUR":
|
|
14
|
+
return "h";
|
|
15
|
+
case "TIME_UNIT_DAY":
|
|
16
|
+
return "天";
|
|
17
|
+
default:
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (resetTime) {
|
|
22
|
+
const days = (Date.parse(resetTime) - Date.now()) / 86_400_000;
|
|
23
|
+
if (days >= 6)
|
|
24
|
+
return "周";
|
|
25
|
+
if (days >= 0.2)
|
|
26
|
+
return "h";
|
|
27
|
+
}
|
|
28
|
+
return "窗口";
|
|
29
|
+
}
|
|
30
|
+
function quotaMetric(label, limit, remaining, used) {
|
|
31
|
+
if (limit <= 0)
|
|
32
|
+
return null;
|
|
33
|
+
const effectiveUsed = used > 0 ? used : Math.max(0, limit - remaining);
|
|
34
|
+
return { label, used: effectiveUsed, total: limit, unit: "次" };
|
|
35
|
+
}
|
|
36
|
+
export const kimiProvider = {
|
|
37
|
+
id: "kimiforcoding",
|
|
38
|
+
name: "Kimi For Coding",
|
|
39
|
+
async fetch(config) {
|
|
40
|
+
if (!config.apiKey) {
|
|
41
|
+
throw new Error("missing apiKey in opencode.json provider.kimiforcoding");
|
|
42
|
+
}
|
|
43
|
+
const baseURL = config.baseURL ?? "https://api.kimi.com/coding/v1";
|
|
44
|
+
const url = `${baseURL.replace(/\/$/, "")}/usages`;
|
|
45
|
+
const response = await fetch(url, {
|
|
46
|
+
headers: {
|
|
47
|
+
Accept: "application/json",
|
|
48
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
|
53
|
+
}
|
|
54
|
+
const body = (await response.json());
|
|
55
|
+
const metrics = [];
|
|
56
|
+
// 周窗口(usage 总览)
|
|
57
|
+
const usage = body.usage;
|
|
58
|
+
if (usage) {
|
|
59
|
+
const metric = quotaMetric(windowLabel(undefined, usage.resetTime), num(usage.limit), num(usage.remaining), num(usage.used));
|
|
60
|
+
if (metric)
|
|
61
|
+
metrics.push(metric);
|
|
62
|
+
}
|
|
63
|
+
// 5h 窗口(limits[])
|
|
64
|
+
for (const lim of body.limits ?? []) {
|
|
65
|
+
const detail = lim.detail;
|
|
66
|
+
if (!detail)
|
|
67
|
+
continue;
|
|
68
|
+
const metric = quotaMetric(windowLabel(lim.window?.timeUnit, detail.resetTime), num(detail.limit), num(detail.remaining), num(detail.used));
|
|
69
|
+
if (metric)
|
|
70
|
+
metrics.push(metric);
|
|
71
|
+
}
|
|
72
|
+
const parallel = num(body.parallel?.limit);
|
|
73
|
+
if (parallel > 0) {
|
|
74
|
+
metrics.push({ label: "并发", used: undefined, total: parallel, unit: "次" });
|
|
75
|
+
}
|
|
76
|
+
const first = metrics.find((metric) => metric.used !== undefined && metric.total !== undefined);
|
|
77
|
+
const ratio = first && first.total ? first.used / first.total : 0;
|
|
78
|
+
return {
|
|
79
|
+
id: "kimiforcoding",
|
|
80
|
+
name: "Kimi For Coding",
|
|
81
|
+
plan: "Coding Plan",
|
|
82
|
+
metrics,
|
|
83
|
+
status: ratio >= 0.8 ? "low" : ratio >= 0.5 ? "warn" : "ok",
|
|
84
|
+
updatedAt: Date.now(),
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// cc-switch 解析逻辑:剩余百分比反转为已用;status 1=激活 3=无周限额
|
|
2
|
+
function statusOf(usedPercent) {
|
|
3
|
+
if (usedPercent > 80)
|
|
4
|
+
return "low";
|
|
5
|
+
if (usedPercent > 50)
|
|
6
|
+
return "warn";
|
|
7
|
+
return "ok";
|
|
8
|
+
}
|
|
9
|
+
function fmtRemaining(ms) {
|
|
10
|
+
if (ms <= 0)
|
|
11
|
+
return "0h";
|
|
12
|
+
const hours = Math.floor(ms / 3_600_000);
|
|
13
|
+
const minutes = Math.floor((ms % 3_600_000) / 60_000);
|
|
14
|
+
if (hours > 0)
|
|
15
|
+
return `${hours}h${minutes}m`;
|
|
16
|
+
return `${minutes}m`;
|
|
17
|
+
}
|
|
18
|
+
export const minimaxProvider = {
|
|
19
|
+
id: "minimax",
|
|
20
|
+
name: "MiniMax",
|
|
21
|
+
async fetch(config) {
|
|
22
|
+
if (!config.apiKey) {
|
|
23
|
+
throw new Error("missing apiKey in opencode.json provider.minimax");
|
|
24
|
+
}
|
|
25
|
+
const baseURL = config.baseURL ?? "https://api.minimaxi.com/v1";
|
|
26
|
+
// cc-switch 使用的接口(与 /v1/token_plan/remains 不同)
|
|
27
|
+
const url = `${baseURL.replace(/\/$/, "")}/api/openplatform/coding_plan/remains`;
|
|
28
|
+
const response = await fetch(url, {
|
|
29
|
+
headers: {
|
|
30
|
+
"Content-Type": "application/json",
|
|
31
|
+
Accept: "application/json",
|
|
32
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
|
37
|
+
}
|
|
38
|
+
const body = (await response.json());
|
|
39
|
+
if (body.base_resp && body.base_resp.status_code !== 0) {
|
|
40
|
+
throw new Error(body.base_resp.status_msg ?? `code ${body.base_resp.status_code}`);
|
|
41
|
+
}
|
|
42
|
+
// 只取 general(编程额度),跳过 video 等非编程模型
|
|
43
|
+
const general = (body.model_remains ?? []).find((model) => model.model_name === "general");
|
|
44
|
+
if (!general) {
|
|
45
|
+
throw new Error("no general quota in response");
|
|
46
|
+
}
|
|
47
|
+
const metrics = [];
|
|
48
|
+
// 5h 桶:剩余百分比 → 已用百分比
|
|
49
|
+
if (general.current_interval_remaining_percent !== undefined) {
|
|
50
|
+
metrics.push({
|
|
51
|
+
label: "5h",
|
|
52
|
+
remainingPercent: general.current_interval_remaining_percent,
|
|
53
|
+
unit: "%",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
// 周桶:仅当 current_weekly_status == 1 激活(=3 无周限额)
|
|
57
|
+
if (general.current_weekly_status === 1) {
|
|
58
|
+
metrics.push({
|
|
59
|
+
label: "周",
|
|
60
|
+
remainingPercent: general.current_weekly_remaining_percent,
|
|
61
|
+
unit: "%",
|
|
62
|
+
boostPermille: general.weekly_boost_permille > 1000 ? general.weekly_boost_permille : undefined,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// 状态取两桶已用百分比的最大值
|
|
66
|
+
const intervalUsed = 100 - (general.current_interval_remaining_percent ?? 100);
|
|
67
|
+
const weeklyUsed = general.current_weekly_status === 1 ? 100 - (general.current_weekly_remaining_percent ?? 100) : 0;
|
|
68
|
+
const maxUsed = Math.max(intervalUsed, weeklyUsed);
|
|
69
|
+
return {
|
|
70
|
+
id: "minimax",
|
|
71
|
+
name: "MiniMax",
|
|
72
|
+
plan: "Token Plan",
|
|
73
|
+
metrics,
|
|
74
|
+
status: statusOf(maxUsed),
|
|
75
|
+
message: `重置 ${fmtRemaining(general.remains_time)}`,
|
|
76
|
+
updatedAt: Date.now(),
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { QuotaProvider } from "./types.js";
|
|
2
|
+
export declare const GO_LIMITS: {
|
|
3
|
+
label: string;
|
|
4
|
+
total: number;
|
|
5
|
+
field: string;
|
|
6
|
+
}[];
|
|
7
|
+
export type GoQueryConfig = {
|
|
8
|
+
cookie: string;
|
|
9
|
+
serverId: string;
|
|
10
|
+
workspaceId: string;
|
|
11
|
+
};
|
|
12
|
+
export declare function opencodeAuthCookiePath(): string;
|
|
13
|
+
export declare const opencodeGoProvider: QuotaProvider;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export const GO_LIMITS = [
|
|
4
|
+
{ label: "5h", total: 12, field: "rollingUsage" },
|
|
5
|
+
{ label: "周", total: 30, field: "weeklyUsage" },
|
|
6
|
+
{ label: "月", total: 60, field: "monthlyUsage" },
|
|
7
|
+
];
|
|
8
|
+
// 从 SolidStart 序列化响应中提取用量字段
|
|
9
|
+
// 序列化协议非严格 JSON(键无引号、布尔用 !0/!1),需做轻量转换
|
|
10
|
+
function parseGoUsage(raw) {
|
|
11
|
+
const fieldPattern = /(rollingUsage|weeklyUsage|monthlyUsage):\$\w+\[\d+\]=\{([^}]*)\}/g;
|
|
12
|
+
const result = {};
|
|
13
|
+
let match;
|
|
14
|
+
let found = false;
|
|
15
|
+
while ((match = fieldPattern.exec(raw)) !== null) {
|
|
16
|
+
found = true;
|
|
17
|
+
const name = match[1];
|
|
18
|
+
// match[2] 不含开头 `{`(被正则消耗),补上后再做键引号转换
|
|
19
|
+
const body = `{${match[2]}}`
|
|
20
|
+
.replace(/([{,]\s*)([A-Za-z_$][\w$]*)\s*:/g, '$1"$2":') // 键加引号
|
|
21
|
+
.replace(/!0/g, "true")
|
|
22
|
+
.replace(/!1/g, "false");
|
|
23
|
+
try {
|
|
24
|
+
result[name] = JSON.parse(body);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// 单字段解析失败跳过
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return found ? result : null;
|
|
31
|
+
}
|
|
32
|
+
// 官方用量 server action 参数(workspace 嵌入 args)
|
|
33
|
+
function buildArgs(workspaceId) {
|
|
34
|
+
const args = {
|
|
35
|
+
t: { t: 9, i: 0, l: 1, a: [{ t: 1, s: workspaceId }], o: 0 },
|
|
36
|
+
f: 31,
|
|
37
|
+
m: [],
|
|
38
|
+
};
|
|
39
|
+
return encodeURIComponent(JSON.stringify(args));
|
|
40
|
+
}
|
|
41
|
+
export function opencodeAuthCookiePath() {
|
|
42
|
+
return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "opencode", "auth.json");
|
|
43
|
+
}
|
|
44
|
+
function fmtDuration(sec) {
|
|
45
|
+
if (sec <= 0)
|
|
46
|
+
return "已重置";
|
|
47
|
+
if (sec < 3600)
|
|
48
|
+
return `${Math.floor(sec / 60)}m`;
|
|
49
|
+
if (sec < 86400)
|
|
50
|
+
return `${Math.floor(sec / 3600)}h${Math.floor((sec % 3600) / 60)}m`;
|
|
51
|
+
return `${Math.floor(sec / 86400)}天`;
|
|
52
|
+
}
|
|
53
|
+
export const opencodeGoProvider = {
|
|
54
|
+
id: "opencode-go",
|
|
55
|
+
name: "OpenCode Go",
|
|
56
|
+
async fetch(config) {
|
|
57
|
+
const query = config.options;
|
|
58
|
+
if (!query?.cookie || !query.serverId || !query.workspaceId) {
|
|
59
|
+
throw new Error("Go 余量不可用:未配置 cookie,请按 README 指引抓取后写入 modelquota.json");
|
|
60
|
+
}
|
|
61
|
+
const url = `https://opencode.ai/_server?id=${encodeURIComponent(query.serverId)}&args=${buildArgs(query.workspaceId)}`;
|
|
62
|
+
let response;
|
|
63
|
+
try {
|
|
64
|
+
response = await fetch(url, {
|
|
65
|
+
headers: {
|
|
66
|
+
Accept: "*/*",
|
|
67
|
+
Cookie: `oc_locale=zh; auth=${query.cookie}`,
|
|
68
|
+
"x-server-id": query.serverId,
|
|
69
|
+
"x-server-instance": "server-fn:5",
|
|
70
|
+
Referer: `https://opencode.ai/workspace/${query.workspaceId}/go`,
|
|
71
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36",
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
throw new Error("Go 余量不可用:网络请求失败(可能未开代理),请检查网络后重试");
|
|
77
|
+
}
|
|
78
|
+
if (!response.ok) {
|
|
79
|
+
// 401/403 = cookie 过期;其余(404/500)= 接口变更
|
|
80
|
+
if (response.status === 401 || response.status === 403 || response.status === 302) {
|
|
81
|
+
throw new Error("Go 余量不可用:登录已过期,请更新 modelquota.json 的 cookie");
|
|
82
|
+
}
|
|
83
|
+
throw new Error(`Go 余量不可用:查询接口异常(HTTP ${response.status}),请重新抓包 serverId`);
|
|
84
|
+
}
|
|
85
|
+
const raw = await response.text();
|
|
86
|
+
// 响应是 SolidStart 序列化协议(含 $R[n]= 引用赋值,非严格 JSON),
|
|
87
|
+
// 直接正则提取三个用量字段的 JSON 对象
|
|
88
|
+
const usage = parseGoUsage(raw);
|
|
89
|
+
if (!usage) {
|
|
90
|
+
throw new Error("Go 余量不可用:响应格式异常(接口可能已变更),请重新抓包 serverId");
|
|
91
|
+
}
|
|
92
|
+
const metrics = GO_LIMITS.map((limit) => {
|
|
93
|
+
const fieldUsage = usage[limit.field];
|
|
94
|
+
const usedPercent = fieldUsage?.usagePercent ?? 0;
|
|
95
|
+
return {
|
|
96
|
+
label: limit.label,
|
|
97
|
+
remainingPercent: Math.max(0, 100 - usedPercent),
|
|
98
|
+
unit: "%",
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
const firstUsage = usage.rollingUsage;
|
|
102
|
+
const resetText = firstUsage?.resetInSec !== undefined ? `重置 ${fmtDuration(firstUsage.resetInSec)}` : undefined;
|
|
103
|
+
// 状态按官方 usagePercent:>80 红 / >50 黄
|
|
104
|
+
const percents = GO_LIMITS.map((limit) => {
|
|
105
|
+
const fieldUsage = usage[limit.field];
|
|
106
|
+
return fieldUsage?.usagePercent ?? 0;
|
|
107
|
+
});
|
|
108
|
+
const maxUsed = Math.max(...percents);
|
|
109
|
+
return {
|
|
110
|
+
id: "opencode-go",
|
|
111
|
+
name: "OpenCode Go",
|
|
112
|
+
plan: "$10/mo",
|
|
113
|
+
metrics,
|
|
114
|
+
status: maxUsed > 80 ? "low" : maxUsed > 50 ? "warn" : "ok",
|
|
115
|
+
message: resetText,
|
|
116
|
+
updatedAt: Date.now(),
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ProviderSnapshot } from "../types.js";
|
|
2
|
+
export type ProviderConfig = {
|
|
3
|
+
id: string;
|
|
4
|
+
apiKey?: string;
|
|
5
|
+
baseURL?: string;
|
|
6
|
+
options?: Record<string, unknown>;
|
|
7
|
+
};
|
|
8
|
+
export interface QuotaProvider {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly name: string;
|
|
11
|
+
fetch(config: ProviderConfig): Promise<ProviderSnapshot>;
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// cc-switch 解析:unit:3 → 5h 桶,unit:6 → 周桶;只认 TOKENS_LIMIT
|
|
2
|
+
function statusOf(usedPercent) {
|
|
3
|
+
if (usedPercent > 80)
|
|
4
|
+
return "low";
|
|
5
|
+
if (usedPercent > 50)
|
|
6
|
+
return "warn";
|
|
7
|
+
return "ok";
|
|
8
|
+
}
|
|
9
|
+
function fmtResetTime(value) {
|
|
10
|
+
if (value === undefined)
|
|
11
|
+
return undefined;
|
|
12
|
+
const num = typeof value === "number" ? value : Number.parseInt(value, 10);
|
|
13
|
+
if (!Number.isFinite(num))
|
|
14
|
+
return undefined;
|
|
15
|
+
const hours = (num - Date.now()) / 3_600_000;
|
|
16
|
+
if (hours <= 0)
|
|
17
|
+
return undefined;
|
|
18
|
+
if (hours < 24)
|
|
19
|
+
return `${Math.floor(hours)}h${Math.floor((hours % 1) * 60)}m`;
|
|
20
|
+
return `${Math.floor(hours / 24)}天`;
|
|
21
|
+
}
|
|
22
|
+
export const zhipuProvider = {
|
|
23
|
+
id: "zhipu",
|
|
24
|
+
name: "智谱 GLM",
|
|
25
|
+
async fetch(config) {
|
|
26
|
+
if (!config.apiKey) {
|
|
27
|
+
throw new Error("missing apiKey in opencode.json provider.zhipu");
|
|
28
|
+
}
|
|
29
|
+
// cc-switch:智谱不加 Bearer 前缀,key 直接放 Authorization
|
|
30
|
+
// 查询接口在域名根路径(不是 /v4 或 /api/paas),需从 baseURL 提取 host
|
|
31
|
+
const baseURL = config.baseURL ?? "https://open.bigmodel.cn";
|
|
32
|
+
const host = new URL(baseURL).host;
|
|
33
|
+
const url = `https://${host}/api/monitor/usage/quota/limit`;
|
|
34
|
+
const response = await fetch(url, {
|
|
35
|
+
headers: {
|
|
36
|
+
Authorization: config.apiKey,
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
"Accept-Language": "en-US,en",
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
if (!response.ok) {
|
|
42
|
+
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
|
43
|
+
}
|
|
44
|
+
const body = (await response.json());
|
|
45
|
+
if (body.success === false || (body.code !== undefined && body.code !== 200)) {
|
|
46
|
+
throw new Error(body.msg ?? `code ${body.code}`);
|
|
47
|
+
}
|
|
48
|
+
const limits = body.data?.limits ?? [];
|
|
49
|
+
const metrics = [];
|
|
50
|
+
let maxUsed = 0;
|
|
51
|
+
for (const limit of limits) {
|
|
52
|
+
if ((limit.type ?? "").toUpperCase() !== "TOKENS_LIMIT")
|
|
53
|
+
continue;
|
|
54
|
+
const usedPercent = limit.percentage ?? 0;
|
|
55
|
+
if (limit.unit === 3) {
|
|
56
|
+
// 5h 桶
|
|
57
|
+
metrics.push({ label: "5h", remainingPercent: Math.max(0, 100 - usedPercent), unit: "%" });
|
|
58
|
+
maxUsed = Math.max(maxUsed, usedPercent);
|
|
59
|
+
}
|
|
60
|
+
else if (limit.unit === 6) {
|
|
61
|
+
// 周桶
|
|
62
|
+
metrics.push({ label: "周", remainingPercent: Math.max(0, 100 - usedPercent), unit: "%" });
|
|
63
|
+
maxUsed = Math.max(maxUsed, usedPercent);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const level = body.data?.level;
|
|
67
|
+
return {
|
|
68
|
+
id: "zhipu",
|
|
69
|
+
name: "智谱 GLM",
|
|
70
|
+
plan: level ? `GLM ${level}` : "Coding Plan",
|
|
71
|
+
metrics,
|
|
72
|
+
status: statusOf(maxUsed),
|
|
73
|
+
message: fmtResetTime(limits.find((l) => l.unit === 6)?.nextResetTime)
|
|
74
|
+
? `重置 ${fmtResetTime(limits.find((l) => l.unit === 6)?.nextResetTime)}`
|
|
75
|
+
: undefined,
|
|
76
|
+
updatedAt: Date.now(),
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
};
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { readModelQuotaConfig } from "./config.js";
|
|
2
|
+
import { queryLatestSessionModel } from "./db.js";
|
|
3
|
+
import { mirrorFilePath } from "./mirror/path.js";
|
|
4
|
+
import { writeMirror } from "./mirror/writer.js";
|
|
5
|
+
import { deepseekProvider } from "./providers/deepseek.js";
|
|
6
|
+
import { kimiProvider } from "./providers/kimi.js";
|
|
7
|
+
import { minimaxProvider } from "./providers/minimax.js";
|
|
8
|
+
import { zhipuProvider } from "./providers/zhipu.js";
|
|
9
|
+
import { opencodeGoProvider } from "./providers/opencode-go.js";
|
|
10
|
+
const REFRESH_INTERVAL_MS = 60_000;
|
|
11
|
+
function providerOptions(config, providerID) {
|
|
12
|
+
const provider = config.provider?.[providerID];
|
|
13
|
+
return provider?.options ?? {};
|
|
14
|
+
}
|
|
15
|
+
function buildProviders(config) {
|
|
16
|
+
const list = [];
|
|
17
|
+
const deepseek = providerOptions(config, "deepseek");
|
|
18
|
+
if (deepseek.apiKey) {
|
|
19
|
+
list.push({
|
|
20
|
+
id: deepseekProvider.id,
|
|
21
|
+
name: deepseekProvider.name,
|
|
22
|
+
fetch: () => deepseekProvider.fetch({ id: "deepseek", ...deepseek }),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
const kimi = providerOptions(config, "kimiforcoding");
|
|
26
|
+
if (kimi.apiKey) {
|
|
27
|
+
list.push({
|
|
28
|
+
id: kimiProvider.id,
|
|
29
|
+
name: kimiProvider.name,
|
|
30
|
+
fetch: () => kimiProvider.fetch({ id: "kimiforcoding", ...kimi }),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const minimax = providerOptions(config, "minimax");
|
|
34
|
+
if (minimax.apiKey) {
|
|
35
|
+
list.push({
|
|
36
|
+
id: minimaxProvider.id,
|
|
37
|
+
name: minimaxProvider.name,
|
|
38
|
+
fetch: () => minimaxProvider.fetch({ id: "minimax", ...minimax }),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
const zhipu = providerOptions(config, "zhipu");
|
|
42
|
+
if (zhipu.apiKey) {
|
|
43
|
+
list.push({
|
|
44
|
+
id: zhipuProvider.id,
|
|
45
|
+
name: zhipuProvider.name,
|
|
46
|
+
fetch: () => zhipuProvider.fetch({ id: "zhipu", ...zhipu }),
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
// OpenCode Go:官方 server action 查询(cookie + serverId + workspaceId 在 modelquota.json)
|
|
50
|
+
// 未配置也注册,fetch 时给出友好提示
|
|
51
|
+
const goConfig = readModelQuotaConfig().opencodeGo;
|
|
52
|
+
list.push({
|
|
53
|
+
id: opencodeGoProvider.id,
|
|
54
|
+
name: opencodeGoProvider.name,
|
|
55
|
+
fetch: () => opencodeGoProvider.fetch({ id: "opencode-go", options: goConfig }),
|
|
56
|
+
});
|
|
57
|
+
return list;
|
|
58
|
+
}
|
|
59
|
+
const server = async ({ directory, client }) => {
|
|
60
|
+
let providers = [];
|
|
61
|
+
async function refresh() {
|
|
62
|
+
const filePath = mirrorFilePath(directory);
|
|
63
|
+
const providerSnapshots = await Promise.all(providers.map(async (provider) => {
|
|
64
|
+
const config = { id: provider.id };
|
|
65
|
+
try {
|
|
66
|
+
return await provider.fetch(config);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
await client.app.log({
|
|
70
|
+
body: {
|
|
71
|
+
service: "modelquota",
|
|
72
|
+
level: "error",
|
|
73
|
+
message: `${provider.id} fetch failed: ${String(error)}`,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
const failed = {
|
|
77
|
+
id: provider.id,
|
|
78
|
+
name: provider.name,
|
|
79
|
+
metrics: [],
|
|
80
|
+
status: "error",
|
|
81
|
+
message: String(error),
|
|
82
|
+
updatedAt: Date.now(),
|
|
83
|
+
};
|
|
84
|
+
return failed;
|
|
85
|
+
}
|
|
86
|
+
}));
|
|
87
|
+
// /model 切换后 session 表立即更新,从这里查当前模型最可靠
|
|
88
|
+
const latest = await queryLatestSessionModel();
|
|
89
|
+
const snapshot = {
|
|
90
|
+
version: 1,
|
|
91
|
+
generatedAt: Date.now(),
|
|
92
|
+
providers: providerSnapshots,
|
|
93
|
+
current: latest,
|
|
94
|
+
};
|
|
95
|
+
writeMirror(filePath, snapshot);
|
|
96
|
+
}
|
|
97
|
+
const timer = setInterval(() => void refresh(), REFRESH_INTERVAL_MS);
|
|
98
|
+
return {
|
|
99
|
+
config: async (input) => {
|
|
100
|
+
providers = buildProviders(input);
|
|
101
|
+
void refresh();
|
|
102
|
+
},
|
|
103
|
+
event: async ({ event }) => {
|
|
104
|
+
if (event.type === "session.idle" ||
|
|
105
|
+
event.type === "session.created" ||
|
|
106
|
+
event.type === "session.updated" ||
|
|
107
|
+
event.type === "message.updated") {
|
|
108
|
+
void refresh();
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
const module = {
|
|
114
|
+
id: "modelquota",
|
|
115
|
+
server,
|
|
116
|
+
};
|
|
117
|
+
export default module;
|
package/dist/tui.d.ts
ADDED
package/dist/tui.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { mirrorFilePath } from "./mirror/path.js";
|
|
2
|
+
import { readMirror } from "./mirror/reader.js";
|
|
3
|
+
import { buildViewNodes } from "./ui/panel.js";
|
|
4
|
+
const POLL_INTERVAL_MS = 1_000;
|
|
5
|
+
const STALE_MS = 30_000;
|
|
6
|
+
function materialize(nodes, solid) {
|
|
7
|
+
const root = solid.createElement("box");
|
|
8
|
+
solid.setProp(root, "flexDirection", "column");
|
|
9
|
+
for (const node of nodes) {
|
|
10
|
+
solid.insert(root, materializeNode(node, solid));
|
|
11
|
+
}
|
|
12
|
+
return root;
|
|
13
|
+
}
|
|
14
|
+
function materializeNode(node, solid) {
|
|
15
|
+
const element = solid.createElement(node.kind);
|
|
16
|
+
for (const [name, value] of Object.entries(node.props)) {
|
|
17
|
+
solid.setProp(element, name, value);
|
|
18
|
+
}
|
|
19
|
+
if (node.kind === "text") {
|
|
20
|
+
solid.insert(element, node.text ?? "");
|
|
21
|
+
}
|
|
22
|
+
for (const child of node.children ?? []) {
|
|
23
|
+
solid.insert(element, materializeNode(child, solid));
|
|
24
|
+
}
|
|
25
|
+
return element;
|
|
26
|
+
}
|
|
27
|
+
function viewKey(snapshot, currentProvider) {
|
|
28
|
+
if (!snapshot)
|
|
29
|
+
return `null|${currentProvider ?? "-"}`;
|
|
30
|
+
return `${currentProvider ?? "-"}|${JSON.stringify(snapshot.providers)}|${JSON.stringify(snapshot.current)}`;
|
|
31
|
+
}
|
|
32
|
+
// 从 sync 缓存消息流推断当前 provider(响应式数据源)
|
|
33
|
+
function providerFromSyncMessages(api) {
|
|
34
|
+
const route = api.route.current;
|
|
35
|
+
if (route.name !== "session")
|
|
36
|
+
return null;
|
|
37
|
+
const sessionID = route.params?.sessionID;
|
|
38
|
+
if (typeof sessionID !== "string")
|
|
39
|
+
return null;
|
|
40
|
+
const messages = api.state.session.messages(sessionID);
|
|
41
|
+
// 找最后一条 assistant 消息
|
|
42
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
43
|
+
const message = messages[i];
|
|
44
|
+
if (message.role === "assistant" && typeof message.providerID === "string" && message.providerID.length > 0) {
|
|
45
|
+
return message.providerID;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
function resolveProviderFromSync(api) {
|
|
51
|
+
return providerFromSyncMessages(api);
|
|
52
|
+
}
|
|
53
|
+
const module = {
|
|
54
|
+
id: "modelquota:tui",
|
|
55
|
+
tui: async (api) => {
|
|
56
|
+
const solid = await import("@opentui/solid").catch(() => null);
|
|
57
|
+
if (!solid) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const directory = api.state.path.directory;
|
|
61
|
+
const filePath = mirrorFilePath(directory);
|
|
62
|
+
// 渲染只读 sync 缓存(响应式 → Solid 自动重渲),不用 mirror 文件
|
|
63
|
+
api.slots.register({
|
|
64
|
+
order: 1000,
|
|
65
|
+
slots: {
|
|
66
|
+
sidebar_content: () => {
|
|
67
|
+
const snapshot = readMirror(filePath); // 只取 providers 列表,current 从 sync 取
|
|
68
|
+
const provider = resolveProviderFromSync(api) || snapshot?.current?.providerID || null;
|
|
69
|
+
return materialize(buildViewNodes(snapshot, api.theme.current, provider), solid);
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
api.renderer.requestRender();
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
export default module;
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type ProviderStatus = "ok" | "warn" | "low" | "error";
|
|
2
|
+
export type Metric = {
|
|
3
|
+
label: string;
|
|
4
|
+
used?: number;
|
|
5
|
+
total?: number;
|
|
6
|
+
unit: string;
|
|
7
|
+
remainingPercent?: number;
|
|
8
|
+
boostPermille?: number;
|
|
9
|
+
};
|
|
10
|
+
export type ProviderSnapshot = {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
plan?: string;
|
|
14
|
+
metrics: Metric[];
|
|
15
|
+
status: ProviderStatus;
|
|
16
|
+
message?: string;
|
|
17
|
+
updatedAt: number;
|
|
18
|
+
};
|
|
19
|
+
export type MirrorSnapshot = {
|
|
20
|
+
version: 1;
|
|
21
|
+
generatedAt: number;
|
|
22
|
+
providers: ProviderSnapshot[];
|
|
23
|
+
current?: {
|
|
24
|
+
sessionID: string;
|
|
25
|
+
providerID: string;
|
|
26
|
+
modelID?: string;
|
|
27
|
+
} | null;
|
|
28
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { TuiThemeCurrent } from "@opencode-ai/plugin/tui";
|
|
2
|
+
import type { MirrorSnapshot } from "../types.js";
|
|
3
|
+
import type { ViewNode } from "./view.js";
|
|
4
|
+
type ThemeLike = Pick<TuiThemeCurrent, "text" | "textMuted" | "error" | "warning" | "success" | "info" | "accent" | "borderSubtle">;
|
|
5
|
+
export declare function buildViewNodes(snapshot: MirrorSnapshot | null, theme: ThemeLike, currentProviderID?: string | null): ViewNode[];
|
|
6
|
+
export {};
|
package/dist/ui/panel.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { box, text } from "./view.js";
|
|
2
|
+
function pct(used, total) {
|
|
3
|
+
if (used === undefined || total === undefined || total <= 0)
|
|
4
|
+
return 0;
|
|
5
|
+
return Math.min(1, used / total);
|
|
6
|
+
}
|
|
7
|
+
function bar(ratio, width = 10) {
|
|
8
|
+
const filled = Math.round(ratio * width);
|
|
9
|
+
return `${"█".repeat(filled)}${"░".repeat(width - filled)}`;
|
|
10
|
+
}
|
|
11
|
+
// 剩余百分比着色(对齐 MiniMax mmx-cli:剩余 >=50 绿 / >=20 黄 / <20 红)
|
|
12
|
+
function remainingColor(percent, theme) {
|
|
13
|
+
if (percent < 20)
|
|
14
|
+
return theme.error;
|
|
15
|
+
if (percent < 50)
|
|
16
|
+
return theme.warning;
|
|
17
|
+
return theme.success;
|
|
18
|
+
}
|
|
19
|
+
// 已用百分比着色(与剩余对称:已用 >80 红 / >50 黄 / 其余正常)
|
|
20
|
+
function usedColor(percent, theme) {
|
|
21
|
+
if (percent > 80)
|
|
22
|
+
return theme.error;
|
|
23
|
+
if (percent > 50)
|
|
24
|
+
return theme.warning;
|
|
25
|
+
return theme.text;
|
|
26
|
+
}
|
|
27
|
+
function statusColor(status, theme) {
|
|
28
|
+
switch (status) {
|
|
29
|
+
case "ok":
|
|
30
|
+
return theme.success;
|
|
31
|
+
case "warn":
|
|
32
|
+
return theme.warning;
|
|
33
|
+
case "low":
|
|
34
|
+
return theme.error;
|
|
35
|
+
case "error":
|
|
36
|
+
return theme.error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function currencySymbol(unit) {
|
|
40
|
+
if (unit === "USD")
|
|
41
|
+
return "$";
|
|
42
|
+
if (unit === "CNY")
|
|
43
|
+
return "¥";
|
|
44
|
+
return unit;
|
|
45
|
+
}
|
|
46
|
+
function formatNumber(value) {
|
|
47
|
+
if (value >= 1_000_000)
|
|
48
|
+
return `${(value / 1_000_000).toFixed(1)}M`;
|
|
49
|
+
if (value >= 1_000)
|
|
50
|
+
return `${(value / 1_000).toFixed(1)}k`;
|
|
51
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
|
52
|
+
}
|
|
53
|
+
function metricNodes(metrics, theme) {
|
|
54
|
+
return metrics.flatMap((metric) => {
|
|
55
|
+
// 剩余百分比型(无固定总量,如 MiniMax general 5h/周)
|
|
56
|
+
if (metric.remainingPercent !== undefined) {
|
|
57
|
+
const remaining = Math.min(100, Math.max(0, metric.remainingPercent));
|
|
58
|
+
const used = 100 - remaining;
|
|
59
|
+
const color = usedColor(used, theme);
|
|
60
|
+
const boost = metric.boostPermille ? ` x${(metric.boostPermille / 1000).toFixed(1)}` : "";
|
|
61
|
+
return [text({ fg: color }, ` ${metric.label} ${bar(used / 100)} 已用 ${used}%${boost}`)];
|
|
62
|
+
}
|
|
63
|
+
// 余额型
|
|
64
|
+
if (metric.used === undefined || metric.total === undefined) {
|
|
65
|
+
const value = metric.total ?? metric.used ?? 0;
|
|
66
|
+
const color = value < 20 ? theme.warning : value < 5 ? theme.error : theme.text;
|
|
67
|
+
return [
|
|
68
|
+
text({ fg: color }, ` ${metric.label}: ${currencySymbol(metric.unit)}${formatNumber(value)}`),
|
|
69
|
+
];
|
|
70
|
+
}
|
|
71
|
+
// 配额型(used/total)
|
|
72
|
+
const ratio = pct(metric.used, metric.total);
|
|
73
|
+
const usedPercent = ratio * 100;
|
|
74
|
+
const color = usedColor(usedPercent, theme);
|
|
75
|
+
return [
|
|
76
|
+
text({ fg: color }, ` ${metric.label} ${bar(ratio)} 已用 ${formatNumber(metric.used ?? 0)}/${formatNumber(metric.total ?? 0)}`),
|
|
77
|
+
];
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function providerNodes(provider, theme, isCurrent = false) {
|
|
81
|
+
const nameColor = statusColor(provider.status, theme);
|
|
82
|
+
const prefix = isCurrent ? "● " : " ";
|
|
83
|
+
const nameNode = text({ fg: isCurrent ? theme.accent : nameColor }, `${prefix}${provider.name}${provider.plan ? ` (${provider.plan})` : ""}${isCurrent ? " *" : ""}`);
|
|
84
|
+
if (provider.status === "error") {
|
|
85
|
+
return [
|
|
86
|
+
box({ flexDirection: "column" }, [
|
|
87
|
+
nameNode,
|
|
88
|
+
text({ fg: theme.error }, ` error: ${provider.message ?? "unknown"}`),
|
|
89
|
+
]),
|
|
90
|
+
];
|
|
91
|
+
}
|
|
92
|
+
const head = provider.metrics.length > 0 ? nameNode : text({ fg: theme.textMuted }, `${provider.name} (no data)`);
|
|
93
|
+
return [
|
|
94
|
+
box({ flexDirection: "column" }, [
|
|
95
|
+
head,
|
|
96
|
+
...metricNodes(provider.metrics, theme),
|
|
97
|
+
]),
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
export function buildViewNodes(snapshot, theme, currentProviderID = null) {
|
|
101
|
+
if (!snapshot || snapshot.providers.length === 0) {
|
|
102
|
+
return [
|
|
103
|
+
box({ flexDirection: "column", gap: 1 }, [
|
|
104
|
+
text({ fg: theme.textMuted }, "modelquota"),
|
|
105
|
+
text({ fg: theme.textMuted }, " waiting for data..."),
|
|
106
|
+
]),
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
const current = snapshot.providers.find((provider) => provider.id === currentProviderID);
|
|
110
|
+
const others = snapshot.providers.filter((provider) => provider.id !== currentProviderID);
|
|
111
|
+
// 有当前使用的供应商时:只显示它(顶置高亮);否则显示全部
|
|
112
|
+
const ordered = current ? [current, ...others] : snapshot.providers;
|
|
113
|
+
const visible = current ? [current] : ordered;
|
|
114
|
+
return [
|
|
115
|
+
box({ flexDirection: "column", gap: 1 }, [
|
|
116
|
+
text({ fg: theme.info }, "Quota"),
|
|
117
|
+
...visible.flatMap((provider) => providerNodes(provider, theme, current?.id === provider.id)),
|
|
118
|
+
]),
|
|
119
|
+
];
|
|
120
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type ViewNodeKind = "box" | "text";
|
|
2
|
+
export type ViewNode = {
|
|
3
|
+
readonly kind: ViewNodeKind;
|
|
4
|
+
readonly props: Readonly<Record<string, unknown>>;
|
|
5
|
+
readonly text?: string;
|
|
6
|
+
readonly children?: readonly ViewNode[];
|
|
7
|
+
};
|
|
8
|
+
export declare function box(props: Readonly<Record<string, unknown>>, children?: readonly ViewNode[]): ViewNode;
|
|
9
|
+
export declare function text(props: Readonly<Record<string, unknown>>, value: string): ViewNode;
|
package/dist/ui/view.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "modelquota",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "opencode 插件:在 TUI 右侧栏显示当前使用的模型供应商套餐余量",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"opencode",
|
|
8
|
+
"opencode-plugin",
|
|
9
|
+
"quota",
|
|
10
|
+
"usage",
|
|
11
|
+
"tokens",
|
|
12
|
+
"deepseek",
|
|
13
|
+
"kimi",
|
|
14
|
+
"minimax",
|
|
15
|
+
"zhipu",
|
|
16
|
+
"glm",
|
|
17
|
+
"opencode-go"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"import": "./dist/server.js",
|
|
23
|
+
"types": "./dist/server.d.ts"
|
|
24
|
+
},
|
|
25
|
+
"./tui": {
|
|
26
|
+
"import": "./dist/tui.js",
|
|
27
|
+
"types": "./dist/tui.d.ts"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"build": "tsc -p tsconfig.build.json",
|
|
36
|
+
"deploy": "node scripts/deploy.mjs",
|
|
37
|
+
"prepublishOnly": "npm run build"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@opencode-ai/plugin": "1.14.30"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@opentui/core": "^0.2.16",
|
|
44
|
+
"@opentui/solid": "^0.2.16",
|
|
45
|
+
"@types/node": "^22.0.0",
|
|
46
|
+
"typescript": "^5.8.0"
|
|
47
|
+
}
|
|
48
|
+
}
|