@zhushanwen/pi-statusline 0.1.1 → 0.1.2
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/package.json +15 -2
- package/src/index.ts +32 -26
- package/src/cache.ts +0 -190
- package/src/providers/index.ts +0 -33
- package/src/providers/kimi-coding.ts +0 -131
- package/src/providers/minimax.ts +0 -130
- package/src/providers/opencode-go.ts +0 -115
- package/src/providers/tavily.ts +0 -101
- package/src/providers/types.ts +0 -41
- package/src/providers/zhipu.ts +0 -147
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-statusline",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Pi statusline extension — shows context usage, token speed, and provider quota in the footer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -21,8 +21,21 @@
|
|
|
21
21
|
"src/",
|
|
22
22
|
"index.ts"
|
|
23
23
|
],
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@zhushanwen/pi-quota-providers": "0.1.1"
|
|
26
|
+
},
|
|
24
27
|
"peerDependencies": {
|
|
25
|
-
"@mariozechner/pi-coding-agent": "*"
|
|
28
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
29
|
+
"@earendil-works/pi-tui": "*",
|
|
30
|
+
"@earendil-works/pi-ai": "*"
|
|
31
|
+
},
|
|
32
|
+
"peerDependenciesMeta": {
|
|
33
|
+
"@earendil-works/pi-tui": {
|
|
34
|
+
"optional": true
|
|
35
|
+
},
|
|
36
|
+
"@earendil-works/pi-ai": {
|
|
37
|
+
"optional": true
|
|
38
|
+
}
|
|
26
39
|
},
|
|
27
40
|
"scripts": {
|
|
28
41
|
"typecheck": "npx tsc --noEmit"
|
package/src/index.ts
CHANGED
|
@@ -14,15 +14,25 @@
|
|
|
14
14
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
|
15
15
|
import type { ExtensionAPI, ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@mariozechner/pi-coding-agent";
|
|
16
16
|
import { truncateToWidth } from "@mariozechner/pi-tui";
|
|
17
|
+
|
|
18
|
+
// ── 本地事件类型 ───────────────────────────────────────
|
|
19
|
+
// Pi SDK 将 ContextEvent 定义为 any,此处用具体接口替代
|
|
20
|
+
interface PiMessageEvent {
|
|
21
|
+
message: { role: string } & Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PiThinkingLevelEvent {
|
|
25
|
+
level: string;
|
|
26
|
+
}
|
|
17
27
|
import {
|
|
18
28
|
readCache,
|
|
19
29
|
triggerUpdate,
|
|
20
30
|
trackSpeed,
|
|
31
|
+
PROVIDERS,
|
|
21
32
|
type CacheData,
|
|
22
33
|
type SpeedData,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
import type { QuotaWindow } from "./providers/types.js";
|
|
34
|
+
type QuotaWindow,
|
|
35
|
+
} from "@zhushanwen/pi-quota-providers";
|
|
26
36
|
// ── 常量 ───────────────────────────────────────────────
|
|
27
37
|
|
|
28
38
|
const SEP = "│";
|
|
@@ -33,21 +43,18 @@ const RUN_UPDATE_MS = 5000;
|
|
|
33
43
|
/** 标题列宽(按最长 "opencode-go"=11, +4 空格余量) */
|
|
34
44
|
const TITLE_COL_W = 15;
|
|
35
45
|
|
|
36
|
-
// ── ANSI
|
|
37
|
-
|
|
38
|
-
const R = "\x1b[0m";
|
|
46
|
+
// ── Bar rendering (no ANSI) ──────────────────────────
|
|
39
47
|
|
|
40
|
-
|
|
48
|
+
/** Render a usage bar with Unicode block chars and semantic fg tokens. */
|
|
49
|
+
function barSegment(pct: number, theme: Theme, w = 6): string {
|
|
41
50
|
const p = Math.max(0, Math.min(100, Math.round(pct)));
|
|
42
|
-
if (p <= 0 && w <= 6) return `${R}${"\x1b[48;5;239m"}${" ".repeat(w)}${R}`;
|
|
43
51
|
const filled = Math.floor((p * w) / 100);
|
|
44
|
-
const
|
|
45
|
-
p >= 80 ? "
|
|
46
|
-
: p >= 60 ? "
|
|
47
|
-
: p >= 40 ? "
|
|
48
|
-
: "
|
|
49
|
-
|
|
50
|
-
return `${fillBg}${" ".repeat(filled)}${emptyBg}${" ".repeat(w - filled)}${R}`;
|
|
52
|
+
const fillToken =
|
|
53
|
+
p >= 80 ? "error"
|
|
54
|
+
: p >= 60 ? "warning"
|
|
55
|
+
: p >= 40 ? "accent"
|
|
56
|
+
: "success";
|
|
57
|
+
return theme.fg(fillToken, "█".repeat(filled)) + theme.fg("muted", "░".repeat(w - filled));
|
|
51
58
|
}
|
|
52
59
|
|
|
53
60
|
/** 构建一个窗口列,所有单元格 data 区域固定可见宽度。 */
|
|
@@ -58,10 +65,10 @@ function winCol(
|
|
|
58
65
|
wide: boolean,
|
|
59
66
|
d: (s: string) => string,
|
|
60
67
|
v: (s: string) => string,
|
|
68
|
+
theme: Theme,
|
|
61
69
|
): string {
|
|
62
70
|
const l = d(label);
|
|
63
71
|
if (pct === null) {
|
|
64
|
-
// infinite: 占位宽度 = 正常列 data 宽
|
|
65
72
|
const dataW = wide ? 20 : 12;
|
|
66
73
|
return `${l} ${v(padCenter("∞", dataW))}`;
|
|
67
74
|
}
|
|
@@ -70,11 +77,9 @@ function winCol(
|
|
|
70
77
|
const rtStr = rtRaw.padEnd(6);
|
|
71
78
|
|
|
72
79
|
if (wide) {
|
|
73
|
-
const bar =
|
|
74
|
-
// pct(4) + 2空格 + bar(6 可见) + 2空格 + reset(6) = 20 可见
|
|
80
|
+
const bar = barSegment(pct, theme, 6);
|
|
75
81
|
return `${l} ${v(pctStr)} ${bar} ${v(rtStr)}`;
|
|
76
82
|
}
|
|
77
|
-
// pct(4) + 2空格 + reset(6) = 12 可见
|
|
78
83
|
return `${l} ${v(pctStr)} ${v(rtStr)}`;
|
|
79
84
|
}
|
|
80
85
|
|
|
@@ -184,7 +189,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
184
189
|
|
|
185
190
|
let tui: { requestRender(): void } | null = null;
|
|
186
191
|
|
|
187
|
-
pi.on("session_start", async (_event:
|
|
192
|
+
pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
|
|
188
193
|
state.sessionStart = Date.now();
|
|
189
194
|
state.lastLlmTime = 0;
|
|
190
195
|
state.speed = { current: 0, day: 0, d7: 0, d30: 0 };
|
|
@@ -192,6 +197,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
192
197
|
state.thinkingLevel = pi.getThinkingLevel();
|
|
193
198
|
refreshTotals(state, ctx);
|
|
194
199
|
|
|
200
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any — SDK ExtensionContext.ui 类型缺失 setFooter
|
|
195
201
|
(ctx.ui as any).setFooter((t: { requestRender(): void }, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
|
|
196
202
|
tui = t;
|
|
197
203
|
const unsub = footerData.onBranchChange(() => t.requestRender());
|
|
@@ -207,14 +213,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
207
213
|
triggerUpdate();
|
|
208
214
|
});
|
|
209
215
|
|
|
210
|
-
pi.on("message_start", async (event:
|
|
216
|
+
pi.on("message_start", async (event: PiMessageEvent) => {
|
|
211
217
|
if (event.message.role === "assistant") {
|
|
212
218
|
state.assistantStart = Date.now();
|
|
213
219
|
state.isAgentBusy = true;
|
|
214
220
|
}
|
|
215
221
|
});
|
|
216
222
|
|
|
217
|
-
pi.on("message_end", async (event:
|
|
223
|
+
pi.on("message_end", async (event: PiMessageEvent, ctx: ExtensionContext) => {
|
|
218
224
|
if (event.message.role === "assistant") {
|
|
219
225
|
const msg = event.message as AssistantMessage;
|
|
220
226
|
if (!msg.usage) return;
|
|
@@ -249,7 +255,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
249
255
|
state.thinkingLevel = pi.getThinkingLevel();
|
|
250
256
|
tui?.requestRender();
|
|
251
257
|
});
|
|
252
|
-
pi.on("thinking_level_select", async (event:
|
|
258
|
+
pi.on("thinking_level_select", async (event: PiThinkingLevelEvent) => {
|
|
253
259
|
state.thinkingLevel = event.level;
|
|
254
260
|
if (!state.isAgentBusy) tui?.requestRender();
|
|
255
261
|
});
|
|
@@ -367,7 +373,7 @@ function buildLines(
|
|
|
367
373
|
? `${d("ctx")} ${v(fmtTokens(st.contextTokens))}/${v(fmtTokens(st.contextWindow))}`
|
|
368
374
|
: `${d("ctx")} ${v(`${st.usedPct}%`)}`;
|
|
369
375
|
const ctxBarStr = wide
|
|
370
|
-
? `${
|
|
376
|
+
? `${barSegment(st.usedPct, theme)} ${v(`${st.usedPct}%`)}`
|
|
371
377
|
: `${v(`${st.usedPct}%`)}`;
|
|
372
378
|
|
|
373
379
|
const line2Parts: string[] = [`${ctxSizeStr} ${ctxBarStr}`];
|
|
@@ -379,7 +385,7 @@ function buildLines(
|
|
|
379
385
|
const treeDisplayPct = treePct === 0 && st.treeTokens > 0 ? "<1" : `${treePct}`;
|
|
380
386
|
const treeSizeStr = `${d("tree")} ${v(fmtTokens(st.treeTokens))}/${v(fmtTokens(st.contextWindow))}`;
|
|
381
387
|
const treeBarStr = wide
|
|
382
|
-
? `${
|
|
388
|
+
? `${barSegment(treePct || 1, theme)} ${v(`${treeDisplayPct}%`)}`
|
|
383
389
|
: `${v(`${treeDisplayPct}%`)}`;
|
|
384
390
|
line2Parts.push(`${treeSizeStr} ${treeBarStr}`);
|
|
385
391
|
}
|
|
@@ -406,7 +412,7 @@ function buildLines(
|
|
|
406
412
|
const title = d(row.name.padEnd(TITLE_COL_W));
|
|
407
413
|
const cells = COLS.map((col, i) => {
|
|
408
414
|
const win = row.wins[i]!;
|
|
409
|
-
return winCol(col.label, win.pct, win.resetSec, wide, d, v);
|
|
415
|
+
return winCol(col.label, win.pct, win.resetSec, wide, d, v, theme);
|
|
410
416
|
});
|
|
411
417
|
lines.push(title + cells.join(` ${DOT} `));
|
|
412
418
|
}
|
package/src/cache.ts
DELETED
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Statusline 数据缓存层
|
|
3
|
-
*
|
|
4
|
-
* 架构(重构后):
|
|
5
|
-
* - 各 provider 单独实现在 providers/*.ts,通过 PROVIDERS 注册表管理
|
|
6
|
-
* - cache.ts 只负责:TTL 缓存、并发控制、Promise.allSettled 拉取、磁盘持久化
|
|
7
|
-
* - 新增 provider:实现 QuotaProvider 接口 → 在 PROVIDERS 注册(零改动 cache.ts)
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
11
|
-
import { homedir } from "node:os";
|
|
12
|
-
import { join } from "node:path";
|
|
13
|
-
import { PROVIDERS } from "./providers/index.js";
|
|
14
|
-
|
|
15
|
-
// ── Paths ──────────────────────────────────────────────
|
|
16
|
-
const HOME = homedir();
|
|
17
|
-
const PI_DIR = join(HOME, ".pi");
|
|
18
|
-
const CACHE_PATH = join(PI_DIR, "statusline_cache.json");
|
|
19
|
-
const SPEED_DIR = join(PI_DIR, "token-stats");
|
|
20
|
-
const CACHE_TTL_MS = 300_000; // 5 分钟:套餐用量刷新间隔
|
|
21
|
-
|
|
22
|
-
// ── CacheData(动态 schema,无需手动维护字段)───────
|
|
23
|
-
// provider 数据以 provider.id 为 key 存储,类型安全由 provider normalize 保证。
|
|
24
|
-
export interface CacheData {
|
|
25
|
-
updatedAt: number;
|
|
26
|
-
[providerId: string]: unknown;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const EMPTY_CACHE: CacheData = { updatedAt: 0 };
|
|
30
|
-
|
|
31
|
-
export interface SpeedData {
|
|
32
|
-
current: number;
|
|
33
|
-
day: number;
|
|
34
|
-
d7: number;
|
|
35
|
-
d30: number;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
// ── Cache 公共 API ─────────────────────────────────────
|
|
39
|
-
|
|
40
|
-
export function readCache(): CacheData {
|
|
41
|
-
const cached = readCacheSync();
|
|
42
|
-
if (Date.now() - cached.updatedAt > CACHE_TTL_MS) triggerUpdate();
|
|
43
|
-
return cached;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
let updating = false;
|
|
47
|
-
let lastUpdateAt = 0; // 上次实际发起网络请求的时间
|
|
48
|
-
|
|
49
|
-
export function triggerUpdate(): void {
|
|
50
|
-
if (updating) return;
|
|
51
|
-
// 距上次实际请求不足 TTL 一半时跳过,避免 message_end 高频触发
|
|
52
|
-
if (Date.now() - lastUpdateAt < CACHE_TTL_MS / 2) return;
|
|
53
|
-
updating = true;
|
|
54
|
-
lastUpdateAt = Date.now();
|
|
55
|
-
doUpdate()
|
|
56
|
-
.finally(() => {
|
|
57
|
-
updating = false;
|
|
58
|
-
})
|
|
59
|
-
.catch(() => {});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
async function doUpdate(): Promise<void> {
|
|
63
|
-
const old = readCacheSync();
|
|
64
|
-
const results = await Promise.allSettled(PROVIDERS.map((p) => p.fetch()));
|
|
65
|
-
|
|
66
|
-
const cache: Record<string, unknown> = { updatedAt: Date.now() };
|
|
67
|
-
for (let i = 0; i < PROVIDERS.length; i++) {
|
|
68
|
-
const p = PROVIDERS[i]!;
|
|
69
|
-
const r = results[i]!;
|
|
70
|
-
const oldVal = (old as Record<string, unknown>)[p.id] ?? null;
|
|
71
|
-
if (r.status === "rejected") {
|
|
72
|
-
// 记录到 stderr 方便排查,不持久化
|
|
73
|
-
console.error(`[statusline] ${p.id} fetch failed:`, r.reason?.message ?? r.reason);
|
|
74
|
-
}
|
|
75
|
-
cache[p.id] =
|
|
76
|
-
r.status === "fulfilled" && r.value !== null ? r.value : oldVal;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// 原子写入:先写临时文件再 rename,防止半写损坏
|
|
80
|
-
try {
|
|
81
|
-
mkdirSync(PI_DIR, { recursive: true });
|
|
82
|
-
const tmpPath = `${CACHE_PATH}.tmp`;
|
|
83
|
-
writeFileSync(tmpPath, JSON.stringify(cache, null, 2), "utf-8");
|
|
84
|
-
renameSync(tmpPath, CACHE_PATH);
|
|
85
|
-
} catch {
|
|
86
|
-
// 写入失败不影响下次读取(保留旧缓存)
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function readCacheSync(): CacheData {
|
|
91
|
-
try {
|
|
92
|
-
const parsed = JSON.parse(readFileSync(CACHE_PATH, "utf-8"));
|
|
93
|
-
if (typeof parsed !== "object" || parsed === null) return { ...EMPTY_CACHE };
|
|
94
|
-
// 确保 updatedAt 存在,其余字段原样保留(由 provider 动态管理)
|
|
95
|
-
return { ...parsed, updatedAt: parsed.updatedAt ?? 0 };
|
|
96
|
-
} catch {
|
|
97
|
-
return { ...EMPTY_CACHE };
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// ── Token Speed(与 provider 无关,保留在此)──────────────
|
|
102
|
-
|
|
103
|
-
// 每条记录存储 [outputTokens, durationMs],用于正确计算加权平均速度
|
|
104
|
-
type SpeedRecord = [number, number];
|
|
105
|
-
|
|
106
|
-
export function trackSpeed(
|
|
107
|
-
outputTokens: number,
|
|
108
|
-
durationMs: number,
|
|
109
|
-
model: string,
|
|
110
|
-
): SpeedData {
|
|
111
|
-
const current =
|
|
112
|
-
durationMs > 0 ? Math.round((outputTokens / durationMs) * 1000) : 0;
|
|
113
|
-
if (!model || current <= 0) return { current, day: 0, d7: 0, d30: 0 };
|
|
114
|
-
|
|
115
|
-
const safeName = model.replace(/[/\\\s:]/g, "_");
|
|
116
|
-
const filePath = join(SPEED_DIR, `${safeName}.json`);
|
|
117
|
-
const today = new Date().toISOString().slice(0, 10);
|
|
118
|
-
|
|
119
|
-
const records: Record<string, SpeedRecord[]> = {};
|
|
120
|
-
try {
|
|
121
|
-
if (existsSync(filePath)) {
|
|
122
|
-
const raw = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
123
|
-
for (const [date, entries] of Object.entries(raw)) {
|
|
124
|
-
// 跳过旧格式(number[])或混合格式
|
|
125
|
-
if (!Array.isArray(entries)) continue;
|
|
126
|
-
if (entries.length > 0 && !Array.isArray(entries[0])) continue;
|
|
127
|
-
// 过滤掉同日期内混入的旧格式纯数字
|
|
128
|
-
records[date] = entries.filter(
|
|
129
|
-
(e): e is SpeedRecord => Array.isArray(e) && e.length >= 2,
|
|
130
|
-
);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
} catch {
|
|
134
|
-
/* istanbul ignore next — 文件损坏时回退到空记录 */
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
if (!records[today]) records[today] = [];
|
|
138
|
-
records[today].push([outputTokens, durationMs]);
|
|
139
|
-
|
|
140
|
-
// 清理 30 天前的数据
|
|
141
|
-
const cutoff = new Date(Date.now() - 30 * 86_400_000)
|
|
142
|
-
.toISOString()
|
|
143
|
-
.slice(0, 10);
|
|
144
|
-
for (const d of Object.keys(records)) {
|
|
145
|
-
if (d < cutoff) delete records[d];
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
try {
|
|
149
|
-
mkdirSync(SPEED_DIR, { recursive: true });
|
|
150
|
-
writeFileSync(filePath, JSON.stringify(records));
|
|
151
|
-
} catch {
|
|
152
|
-
/* istanbul ignore next — 写入失败不影响功能 */
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// 加权平均:sum(tokens) / sum(duration) * 1000
|
|
156
|
-
const avgSpeed = (entries: SpeedRecord[]): number => {
|
|
157
|
-
let totalTokens = 0;
|
|
158
|
-
let totalDuration = 0;
|
|
159
|
-
for (const entry of entries) {
|
|
160
|
-
if (!Array.isArray(entry) || entry.length < 2) continue;
|
|
161
|
-
totalTokens += entry[0];
|
|
162
|
-
totalDuration += entry[1];
|
|
163
|
-
}
|
|
164
|
-
return totalDuration > 0
|
|
165
|
-
? Math.round((totalTokens / totalDuration) * 1000)
|
|
166
|
-
: 0;
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
const dayEntries: SpeedRecord[] = [];
|
|
170
|
-
const d7Entries: SpeedRecord[] = [];
|
|
171
|
-
const d30Entries: SpeedRecord[] = [];
|
|
172
|
-
const now = Date.now();
|
|
173
|
-
|
|
174
|
-
for (const [date, entries] of Object.entries(records)) {
|
|
175
|
-
d30Entries.push(...entries);
|
|
176
|
-
if ((now - new Date(date).getTime()) / 86_400_000 < 7) {
|
|
177
|
-
d7Entries.push(...entries);
|
|
178
|
-
}
|
|
179
|
-
if (date === today) {
|
|
180
|
-
dayEntries.push(...entries);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return {
|
|
185
|
-
current,
|
|
186
|
-
day: avgSpeed(dayEntries),
|
|
187
|
-
d7: avgSpeed(d7Entries),
|
|
188
|
-
d30: avgSpeed(d30Entries),
|
|
189
|
-
};
|
|
190
|
-
}
|
package/src/providers/index.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Provider 注册表
|
|
3
|
-
*
|
|
4
|
-
* 新增 provider 流程:
|
|
5
|
-
* 1. 在 providers/ 下新建 xxx.ts,实现 QuotaProvider 接口
|
|
6
|
-
* 2. 在下面追加一行 import + PROVIDERS.push(...)
|
|
7
|
-
* 3. 在 cache.ts 的 CacheData interface 和 readCacheSync 里加一个字段(兼容老缓存)
|
|
8
|
-
* 4. 如果 provider 需要被特殊显示(如 tavily 在 line 2),在 index.ts 加引用
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { kimiCodingProvider } from "./kimi-coding.js";
|
|
12
|
-
import { minimaxProvider } from "./minimax.js";
|
|
13
|
-
import { opencodeGoProvider } from "./opencode-go.js";
|
|
14
|
-
import type { QuotaProvider } from "./types.js";
|
|
15
|
-
import { tavilyProvider } from "./tavily.js";
|
|
16
|
-
import { zhipuProvider } from "./zhipu.js";
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* 注册顺序即 statusline 显示顺序。
|
|
20
|
-
* 显示在套餐用量行的 provider(normalize 不返回 null)。
|
|
21
|
-
* tavily 也在列表里但 normalize 返回 null,由 index.ts 单独从 cache 读取 available/total。
|
|
22
|
-
*/
|
|
23
|
-
export const PROVIDERS: QuotaProvider[] = [
|
|
24
|
-
zhipuProvider,
|
|
25
|
-
opencodeGoProvider,
|
|
26
|
-
kimiCodingProvider,
|
|
27
|
-
minimaxProvider,
|
|
28
|
-
tavilyProvider,
|
|
29
|
-
];
|
|
30
|
-
|
|
31
|
-
/** 通过 id 查 provider(渲染层备用) */
|
|
32
|
-
export const providerById = (id: string): QuotaProvider | undefined =>
|
|
33
|
-
PROVIDERS.find((p) => p.id === id);
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
INFINITE_WIN,
|
|
6
|
-
type NormalizedQuotaRow,
|
|
7
|
-
type QuotaProvider,
|
|
8
|
-
} from "./types.js";
|
|
9
|
-
|
|
10
|
-
const HOME = homedir();
|
|
11
|
-
const SECRETS_DIR = join(HOME, ".pi", "agent", "secrets");
|
|
12
|
-
const KIMI_API_KEY_PATH = join(SECRETS_DIR, "kimi-coding-api-key.txt");
|
|
13
|
-
|
|
14
|
-
export interface KimiCodingWindow {
|
|
15
|
-
limit: number;
|
|
16
|
-
remaining: number;
|
|
17
|
-
usedPct: number;
|
|
18
|
-
resetTime: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
interface KimiLimitDetail {
|
|
22
|
-
limit?: number;
|
|
23
|
-
remaining?: number;
|
|
24
|
-
resetTime?: string;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
interface KimiLimit {
|
|
28
|
-
detail?: KimiLimitDetail;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
interface KimiUsage {
|
|
32
|
-
limit?: number;
|
|
33
|
-
used?: number;
|
|
34
|
-
resetTime?: string;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
interface KimiApiResponse {
|
|
38
|
-
limits?: KimiLimit[];
|
|
39
|
-
usage?: KimiUsage;
|
|
40
|
-
}
|
|
41
|
-
export interface KimiCodingData {
|
|
42
|
-
rollingWindow: KimiCodingWindow;
|
|
43
|
-
dailyLimit: number;
|
|
44
|
-
dailyUsed: number;
|
|
45
|
-
dailyResetTime: string;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
async function fetchKimiCoding(): Promise<KimiCodingData | null> {
|
|
49
|
-
let apiKey = process.env.KIMI_API_KEY ?? "";
|
|
50
|
-
if (!apiKey && existsSync(KIMI_API_KEY_PATH)) {
|
|
51
|
-
apiKey = readFileSync(KIMI_API_KEY_PATH, "utf-8").trim();
|
|
52
|
-
}
|
|
53
|
-
if (!apiKey) return null;
|
|
54
|
-
|
|
55
|
-
try {
|
|
56
|
-
const resp = await fetch("https://api.kimi.com/coding/v1/usages", {
|
|
57
|
-
headers: {
|
|
58
|
-
authorization: `Bearer ${apiKey}`,
|
|
59
|
-
"content-type": "application/json",
|
|
60
|
-
},
|
|
61
|
-
signal: AbortSignal.timeout(5000),
|
|
62
|
-
});
|
|
63
|
-
if (!resp.ok) return null;
|
|
64
|
-
const data = (await resp.json()) as KimiApiResponse;
|
|
65
|
-
|
|
66
|
-
const win = (data?.limits ?? [])[0];
|
|
67
|
-
const winLimit = Number(win?.detail?.limit ?? 0);
|
|
68
|
-
const winRemaining = Number(win?.detail?.remaining ?? 0);
|
|
69
|
-
const dailyLimit = Number(data?.usage?.limit ?? 0);
|
|
70
|
-
const dailyUsed = Number(data?.usage?.used ?? 0);
|
|
71
|
-
|
|
72
|
-
return {
|
|
73
|
-
rollingWindow: {
|
|
74
|
-
limit: winLimit,
|
|
75
|
-
remaining: winRemaining,
|
|
76
|
-
usedPct:
|
|
77
|
-
winLimit > 0
|
|
78
|
-
? Math.round(((winLimit - winRemaining) / winLimit) * 100)
|
|
79
|
-
: 0,
|
|
80
|
-
resetTime: win?.detail?.resetTime ?? "",
|
|
81
|
-
},
|
|
82
|
-
dailyLimit,
|
|
83
|
-
dailyUsed,
|
|
84
|
-
dailyResetTime: data?.usage?.resetTime ?? "",
|
|
85
|
-
};
|
|
86
|
-
} catch {
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export const kimiCodingProvider: QuotaProvider<KimiCodingData> = {
|
|
92
|
-
id: "kimiCoding",
|
|
93
|
-
label: "kimi-coding",
|
|
94
|
-
fetch: fetchKimiCoding,
|
|
95
|
-
normalize(raw): NormalizedQuotaRow | null {
|
|
96
|
-
if (!raw?.rollingWindow) return null;
|
|
97
|
-
const rw = raw.rollingWindow;
|
|
98
|
-
const ki5h =
|
|
99
|
-
rw.limit > 0
|
|
100
|
-
? {
|
|
101
|
-
pct: rw.usedPct,
|
|
102
|
-
resetSec: rw.resetTime
|
|
103
|
-
? isoResetRemaining(rw.resetTime)
|
|
104
|
-
: null,
|
|
105
|
-
}
|
|
106
|
-
: INFINITE_WIN;
|
|
107
|
-
|
|
108
|
-
const kiWk =
|
|
109
|
-
raw.dailyLimit > 0
|
|
110
|
-
? {
|
|
111
|
-
pct: Math.round((raw.dailyUsed / raw.dailyLimit) * 100),
|
|
112
|
-
resetSec: raw.dailyResetTime
|
|
113
|
-
? isoResetRemaining(raw.dailyResetTime)
|
|
114
|
-
: null,
|
|
115
|
-
}
|
|
116
|
-
: INFINITE_WIN;
|
|
117
|
-
|
|
118
|
-
return {
|
|
119
|
-
label: "kimi-coding",
|
|
120
|
-
wins: [ki5h, kiWk, INFINITE_WIN],
|
|
121
|
-
};
|
|
122
|
-
},
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
/** ISO 时间戳 → 剩余秒 */
|
|
126
|
-
function isoResetRemaining(iso: string): number {
|
|
127
|
-
return Math.max(
|
|
128
|
-
0,
|
|
129
|
-
Math.floor((new Date(iso).getTime() - Date.now()) / 1000),
|
|
130
|
-
);
|
|
131
|
-
}
|
package/src/providers/minimax.ts
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* MiniMax (MiniMax) Token Plan 用量 provider
|
|
3
|
-
*
|
|
4
|
-
* API: GET https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains
|
|
5
|
-
* Auth: Bearer <MINIMAX_API_KEY>
|
|
6
|
-
*
|
|
7
|
-
* 响应字段(实测):
|
|
8
|
-
* model_remains[].model_name "general" | "video" | ...
|
|
9
|
-
* model_remains[].current_interval_remaining_percent 5h 剩余百分比 (0-100)
|
|
10
|
-
* model_remains[].current_interval_status 1=订阅 3=未订阅
|
|
11
|
-
* model_remains[].remains_time 5h 窗口剩余毫秒
|
|
12
|
-
* model_remains[].current_weekly_remaining_percent 周剩余百分比
|
|
13
|
-
* model_remains[].current_weekly_status
|
|
14
|
-
* model_remains[].weekly_remains_time 周窗口剩余毫秒
|
|
15
|
-
*
|
|
16
|
-
* 注:国内 api.minimaxi.com 端点实测稳定可用,不需要 UA 伪装。
|
|
17
|
-
* 国外 www.minimax.io 端点需要 UA 伪装且当前 key 会被拒。
|
|
18
|
-
*/
|
|
19
|
-
|
|
20
|
-
import {
|
|
21
|
-
INFINITE_WIN,
|
|
22
|
-
type NormalizedQuotaRow,
|
|
23
|
-
type QuotaProvider,
|
|
24
|
-
} from "./types.js";
|
|
25
|
-
|
|
26
|
-
const API_URL =
|
|
27
|
-
"https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains";
|
|
28
|
-
|
|
29
|
-
interface MinimaxBaseResp {
|
|
30
|
-
status_code?: number;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
interface MinimaxApiResponse {
|
|
34
|
-
base_resp?: MinimaxBaseResp;
|
|
35
|
-
model_remains?: MinimaxModelRemains[];
|
|
36
|
-
}
|
|
37
|
-
export interface MinimaxModelRemains {
|
|
38
|
-
model_name: string;
|
|
39
|
-
current_interval_remaining_percent: number;
|
|
40
|
-
current_interval_status: number;
|
|
41
|
-
remains_time: number;
|
|
42
|
-
current_weekly_remaining_percent: number;
|
|
43
|
-
current_weekly_status: number;
|
|
44
|
-
weekly_remains_time: number;
|
|
45
|
-
// 一些 API 版本还带的字段(保留以防 schema 变化)
|
|
46
|
-
current_interval_total_count?: number;
|
|
47
|
-
current_weekly_total_count?: number;
|
|
48
|
-
[key: string]: unknown;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface MinimaxData {
|
|
52
|
-
models: MinimaxModelRemains[];
|
|
53
|
-
raw: unknown; // 原始响应,便于未来排错
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async function fetchMinimax(): Promise<MinimaxData | null> {
|
|
57
|
-
const token = process.env.MINIMAX_API_KEY ?? "";
|
|
58
|
-
if (!token) return null;
|
|
59
|
-
|
|
60
|
-
try {
|
|
61
|
-
const resp = await fetch(API_URL, {
|
|
62
|
-
headers: {
|
|
63
|
-
authorization: `Bearer ${token}`,
|
|
64
|
-
"content-type": "application/json",
|
|
65
|
-
accept: "application/json",
|
|
66
|
-
},
|
|
67
|
-
signal: AbortSignal.timeout(5000),
|
|
68
|
-
});
|
|
69
|
-
if (!resp.ok) return null;
|
|
70
|
-
const data = (await resp.json()) as MinimaxApiResponse;
|
|
71
|
-
|
|
72
|
-
if (data?.base_resp?.status_code !== 0) return null;
|
|
73
|
-
const models = (data?.model_remains ?? []) as MinimaxModelRemains[];
|
|
74
|
-
if (models.length === 0) return null;
|
|
75
|
-
|
|
76
|
-
return { models, raw: data };
|
|
77
|
-
} catch {
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** status 字段语义:1=正常订阅;其他值(3=未订阅,0/2=异常)→ 当作无限 */
|
|
83
|
-
const isActive = (s: number | undefined) => s === 1;
|
|
84
|
-
|
|
85
|
-
export const minimaxProvider: QuotaProvider<MinimaxData> = {
|
|
86
|
-
id: "minimax",
|
|
87
|
-
label: "minimax-token",
|
|
88
|
-
fetch: fetchMinimax,
|
|
89
|
-
normalize(raw): NormalizedQuotaRow | null {
|
|
90
|
-
// 关注 model_name === "general"(文本/LLM 用量),过滤掉 video 等无关项
|
|
91
|
-
if (!raw?.models) return null;
|
|
92
|
-
const general = raw.models.find((m) => m.model_name === "general");
|
|
93
|
-
if (!general) return null;
|
|
94
|
-
|
|
95
|
-
const win5h = toWindow(
|
|
96
|
-
general.current_interval_remaining_percent,
|
|
97
|
-
general.current_interval_status,
|
|
98
|
-
general.remains_time,
|
|
99
|
-
);
|
|
100
|
-
const winWk = toWindow(
|
|
101
|
-
general.current_weekly_remaining_percent,
|
|
102
|
-
general.current_weekly_status,
|
|
103
|
-
general.weekly_remains_time,
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
return {
|
|
107
|
-
label: "minimax-token",
|
|
108
|
-
wins: [win5h, winWk, INFINITE_WIN], // 此 API 不提供月维度
|
|
109
|
-
};
|
|
110
|
-
},
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* 把 API 的"剩余百分比"反转为"已用百分比",并判断是否无限/未订阅。
|
|
115
|
-
* - status != 1: 当作无限
|
|
116
|
-
* - total=0 且 percent=100: 也当作无限(典型未订阅状态)
|
|
117
|
-
*/
|
|
118
|
-
function toWindow(
|
|
119
|
-
remainingPercent: number | undefined,
|
|
120
|
-
status: number | undefined,
|
|
121
|
-
remainsMs: number | undefined,
|
|
122
|
-
): { pct: number | null; resetSec: number | null } {
|
|
123
|
-
if (!isActive(status)) return INFINITE_WIN;
|
|
124
|
-
const rem = Number(remainingPercent ?? 0);
|
|
125
|
-
// 已用百分比 = 100 - 剩余
|
|
126
|
-
const used = Math.max(0, Math.min(100, 100 - rem));
|
|
127
|
-
const resetSec =
|
|
128
|
-
remainsMs && remainsMs > 0 ? Math.ceil(remainsMs / 1000) : null;
|
|
129
|
-
return { pct: used, resetSec };
|
|
130
|
-
}
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
type NormalizedQuotaRow,
|
|
6
|
-
type QuotaProvider,
|
|
7
|
-
} from "./types.js";
|
|
8
|
-
|
|
9
|
-
const HOME = homedir();
|
|
10
|
-
const SECRETS_DIR = join(HOME, ".pi", "agent", "secrets");
|
|
11
|
-
const OPENCODE_COOKIE_PATH = join(SECRETS_DIR, "opencode-cookie.txt");
|
|
12
|
-
const OPENCODE_WORKSPACE_URL =
|
|
13
|
-
"https://opencode.ai/workspace/wrk_01KM5Q3EEQEHZJ3V5PXF5JCR62/go";
|
|
14
|
-
|
|
15
|
-
export interface OpenCodeGoUsage {
|
|
16
|
-
status: "ok" | "rate-limited" | "unknown";
|
|
17
|
-
usagePercent: number;
|
|
18
|
-
resetInSec: number;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface OpenCodeGoData {
|
|
22
|
-
rolling: OpenCodeGoUsage;
|
|
23
|
-
weekly: OpenCodeGoUsage;
|
|
24
|
-
monthly: OpenCodeGoUsage;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async function fetchOpenCodeGo(): Promise<OpenCodeGoData | null> {
|
|
28
|
-
let cookie = process.env.OPENCODE_COOKIE ?? "";
|
|
29
|
-
if (!cookie && existsSync(OPENCODE_COOKIE_PATH)) {
|
|
30
|
-
cookie = readFileSync(OPENCODE_COOKIE_PATH, "utf-8").trim();
|
|
31
|
-
}
|
|
32
|
-
if (!cookie) return null;
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
const resp = await fetch(OPENCODE_WORKSPACE_URL, {
|
|
36
|
-
headers: {
|
|
37
|
-
accept: "text/html",
|
|
38
|
-
cookie,
|
|
39
|
-
"user-agent": "Mozilla/5.0",
|
|
40
|
-
},
|
|
41
|
-
signal: AbortSignal.timeout(8000),
|
|
42
|
-
redirect: "manual",
|
|
43
|
-
});
|
|
44
|
-
// 需要 cookie 才能获取数据,302 说明 cookie 过期
|
|
45
|
-
if (resp.status !== 200) return null;
|
|
46
|
-
const html = await resp.text();
|
|
47
|
-
return parseOpenCodeGo(html);
|
|
48
|
-
} catch {
|
|
49
|
-
return null;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
function parseOpenCodeGo(html: string): OpenCodeGoData | null {
|
|
54
|
-
// SSR HTML 中嵌入了数据:rollingUsage/weeklyUsage/monthlyUsage
|
|
55
|
-
// 格式:rollingUsage:$R[N]={status:"ok",resetInSec:18000,usagePercent:0}
|
|
56
|
-
const extract = (
|
|
57
|
-
name: string,
|
|
58
|
-
): { status: string; resetInSec: number; usagePercent: number } | null => {
|
|
59
|
-
const re = new RegExp(
|
|
60
|
-
`${name}:\\$R\\[\\d+\\]=\\{([^}]+)\\}`,
|
|
61
|
-
);
|
|
62
|
-
const m = html.match(re);
|
|
63
|
-
if (!m) return null;
|
|
64
|
-
const obj = m[1]!;
|
|
65
|
-
const statusM = obj.match(/status:"([^"]+)"/);
|
|
66
|
-
const resetM = obj.match(/resetInSec:(\d+)/);
|
|
67
|
-
const pctM = obj.match(/usagePercent:(\d+)/);
|
|
68
|
-
if (!statusM || !resetM || !pctM) return null;
|
|
69
|
-
return {
|
|
70
|
-
status: statusM[1],
|
|
71
|
-
resetInSec: Number(resetM[1]),
|
|
72
|
-
usagePercent: Number(pctM[1]),
|
|
73
|
-
};
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
const rolling = extract("rollingUsage");
|
|
77
|
-
const weekly = extract("weeklyUsage");
|
|
78
|
-
const monthly = extract("monthlyUsage");
|
|
79
|
-
if (!rolling || !weekly || !monthly) return null;
|
|
80
|
-
|
|
81
|
-
return {
|
|
82
|
-
rolling: {
|
|
83
|
-
status: rolling.status as OpenCodeGoUsage["status"],
|
|
84
|
-
usagePercent: rolling.usagePercent,
|
|
85
|
-
resetInSec: rolling.resetInSec,
|
|
86
|
-
},
|
|
87
|
-
weekly: {
|
|
88
|
-
status: weekly.status as OpenCodeGoUsage["status"],
|
|
89
|
-
usagePercent: weekly.usagePercent,
|
|
90
|
-
resetInSec: weekly.resetInSec,
|
|
91
|
-
},
|
|
92
|
-
monthly: {
|
|
93
|
-
status: monthly.status as OpenCodeGoUsage["status"],
|
|
94
|
-
usagePercent: monthly.usagePercent,
|
|
95
|
-
resetInSec: monthly.resetInSec,
|
|
96
|
-
},
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export const opencodeGoProvider: QuotaProvider<OpenCodeGoData> = {
|
|
101
|
-
id: "opencodeGo",
|
|
102
|
-
label: "opencode-go",
|
|
103
|
-
fetch: fetchOpenCodeGo,
|
|
104
|
-
normalize(raw): NormalizedQuotaRow | null {
|
|
105
|
-
if (!raw?.rolling || !raw?.weekly || !raw?.monthly) return null;
|
|
106
|
-
const toWin = (u: { usagePercent: number; resetInSec: number }) => ({
|
|
107
|
-
pct: u.usagePercent,
|
|
108
|
-
resetSec: u.resetInSec > 0 ? u.resetInSec : null,
|
|
109
|
-
});
|
|
110
|
-
return {
|
|
111
|
-
label: "opencode-go",
|
|
112
|
-
wins: [toWin(raw.rolling), toWin(raw.weekly), toWin(raw.monthly)],
|
|
113
|
-
};
|
|
114
|
-
},
|
|
115
|
-
};
|
package/src/providers/tavily.ts
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
type NormalizedQuotaRow,
|
|
6
|
-
type QuotaProvider,
|
|
7
|
-
} from "./types.js";
|
|
8
|
-
|
|
9
|
-
const HOME = homedir();
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
interface TavilyUsageEntry {
|
|
13
|
-
credits: number;
|
|
14
|
-
requests: number;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
interface TavilyApiUsageEntry {
|
|
18
|
-
plan_usage: number;
|
|
19
|
-
plan_limit: number;
|
|
20
|
-
key_usage: number;
|
|
21
|
-
key_limit: number;
|
|
22
|
-
plan_name: string;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
interface TavilyState {
|
|
26
|
-
usage: Record<string, TavilyUsageEntry>;
|
|
27
|
-
exhausted?: Record<string, unknown>;
|
|
28
|
-
api_usage?: Record<string, TavilyApiUsageEntry>;
|
|
29
|
-
}
|
|
30
|
-
export interface TavilyData {
|
|
31
|
-
available: number;
|
|
32
|
-
total: number;
|
|
33
|
-
planUsage: number;
|
|
34
|
-
planLimit: number;
|
|
35
|
-
planName: string;
|
|
36
|
-
keyUsage: number;
|
|
37
|
-
keyLimit: number;
|
|
38
|
-
credits: number;
|
|
39
|
-
requests: number;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function readTavily(): Promise<TavilyData | null> {
|
|
43
|
-
const stateFile = join(HOME, ".tavily", "state.json");
|
|
44
|
-
try {
|
|
45
|
-
const data = JSON.parse(readFileSync(stateFile, "utf-8")) as TavilyState;
|
|
46
|
-
if (!data?.usage) return null;
|
|
47
|
-
|
|
48
|
-
const total = Object.keys(data.usage).length;
|
|
49
|
-
const exhausted = Object.keys(data.exhausted ?? {}).length;
|
|
50
|
-
const entries = Object.values(data.usage) as TavilyUsageEntry[];
|
|
51
|
-
const credits = entries.reduce(
|
|
52
|
-
(s, v) => s + (v.credits ?? 0),
|
|
53
|
-
0,
|
|
54
|
-
);
|
|
55
|
-
const requests = entries.reduce(
|
|
56
|
-
(s, v) => s + (v.requests ?? 0),
|
|
57
|
-
0,
|
|
58
|
-
);
|
|
59
|
-
|
|
60
|
-
const apiEntries = Object.values(data.api_usage ?? {}) as TavilyApiUsageEntry[];
|
|
61
|
-
let planUsage = 0;
|
|
62
|
-
let planLimit = 0;
|
|
63
|
-
let keyUsage = 0;
|
|
64
|
-
let keyLimit = 0;
|
|
65
|
-
let planName = "";
|
|
66
|
-
for (const v of apiEntries) {
|
|
67
|
-
planUsage += v.plan_usage ?? 0;
|
|
68
|
-
planLimit += v.plan_limit ?? 0;
|
|
69
|
-
keyUsage += v.key_usage ?? 0;
|
|
70
|
-
keyLimit += v.key_limit ?? 0;
|
|
71
|
-
if (!planName) planName = v.plan_name ?? "";
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return {
|
|
75
|
-
available: total - exhausted,
|
|
76
|
-
total,
|
|
77
|
-
planUsage,
|
|
78
|
-
planLimit,
|
|
79
|
-
planName,
|
|
80
|
-
keyUsage,
|
|
81
|
-
keyLimit,
|
|
82
|
-
credits,
|
|
83
|
-
requests,
|
|
84
|
-
};
|
|
85
|
-
} catch {
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Tavily 不参与 3 窗口套餐显示(它在 line 2 的 "tavily X/Y" 单独展示)。
|
|
92
|
-
* 这里仍然注册一个 provider 用于统一数据获取,但 normalize 返回 null(不显示行)。
|
|
93
|
-
*/
|
|
94
|
-
export const tavilyProvider: QuotaProvider<TavilyData> = {
|
|
95
|
-
id: "tavily",
|
|
96
|
-
label: "tavily",
|
|
97
|
-
fetch: readTavily,
|
|
98
|
-
normalize(_raw): NormalizedQuotaRow | null {
|
|
99
|
-
return null; // 渲染层从 cache.tavily 单独取 available/total
|
|
100
|
-
},
|
|
101
|
-
};
|
package/src/providers/types.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Provider 抽象契约
|
|
3
|
-
*
|
|
4
|
-
* 设计要点:
|
|
5
|
-
* - 每个 provider 自带 fetch + normalize,自包含;新增 provider 只需新建文件 + 注册
|
|
6
|
-
* - normalize 返回归一化行(含 label 和三窗口),label 可基于 raw 数据动态生成
|
|
7
|
-
* - fetch 失败 / 无 token 返回 null,框架会保留旧值(Promise.allSettled 模式)
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
/** 归一化的配额窗口。pct=null 表示无限/未订阅。resetSec=null 表示无限。 */
|
|
11
|
-
export interface QuotaWindow {
|
|
12
|
-
pct: number | null;
|
|
13
|
-
resetSec: number | null;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export const INFINITE_WIN: QuotaWindow = { pct: null, resetSec: null };
|
|
17
|
-
|
|
18
|
-
/** 三个窗口的位置:5h、week、month。 */
|
|
19
|
-
export type QuotaWins = [QuotaWindow, QuotaWindow, QuotaWindow];
|
|
20
|
-
|
|
21
|
-
/** 归一化结果:可直接渲染的一行。 */
|
|
22
|
-
export interface NormalizedQuotaRow {
|
|
23
|
-
/** 实际显示名(可由 raw 数据动态生成,如 Z.ai-pro) */
|
|
24
|
-
label: string;
|
|
25
|
-
wins: QuotaWins;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Provider 实现需要遵守的契约。
|
|
30
|
-
*
|
|
31
|
-
* - `id`: 在 CacheData 上的 key,**新增字段后须更新 readCacheSync 的旧值兼容**。
|
|
32
|
-
* - `label`: 默认显示名(fallback);normalize 返回的对象里若带 label 则优先使用。
|
|
33
|
-
* - `fetch()`: 拉取原始数据。失败/无凭证返回 null。
|
|
34
|
-
* - `normalize(raw)`: 把原始数据归一化为单行。无法解析返回 null(不显示该行)。
|
|
35
|
-
*/
|
|
36
|
-
export interface QuotaProvider<T = unknown> {
|
|
37
|
-
id: string;
|
|
38
|
-
label: string;
|
|
39
|
-
fetch(): Promise<T | null>;
|
|
40
|
-
normalize(raw: T): NormalizedQuotaRow | null;
|
|
41
|
-
}
|
package/src/providers/zhipu.ts
DELETED
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
INFINITE_WIN,
|
|
6
|
-
type NormalizedQuotaRow,
|
|
7
|
-
type QuotaProvider,
|
|
8
|
-
} from "./types.js";
|
|
9
|
-
|
|
10
|
-
const HOME = homedir();
|
|
11
|
-
|
|
12
|
-
export interface ZhipuData {
|
|
13
|
-
label: string;
|
|
14
|
-
tokensPct: number;
|
|
15
|
-
timePct: number;
|
|
16
|
-
timeCurrent: number;
|
|
17
|
-
resetTime: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
interface ZhipuLimit {
|
|
22
|
-
type: string;
|
|
23
|
-
percentage?: number;
|
|
24
|
-
currentValue?: number;
|
|
25
|
-
nextResetTime?: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
interface ZhipuApiData {
|
|
29
|
-
level?: string;
|
|
30
|
-
limits?: ZhipuLimit[];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
interface ZhipuApiResponse {
|
|
34
|
-
success?: boolean;
|
|
35
|
-
data?: ZhipuApiData;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
async function fetchZhipu(): Promise<ZhipuData | null> {
|
|
39
|
-
// 优先环境变量,兼容文件
|
|
40
|
-
let token = process.env.ZAI_AUTH_TOKEN ?? "";
|
|
41
|
-
if (!token) {
|
|
42
|
-
const tokenPaths = [
|
|
43
|
-
join(HOME, ".pi", ".zhipu_auth_token"),
|
|
44
|
-
join(HOME, ".claude", ".zhipu_auth_token"),
|
|
45
|
-
];
|
|
46
|
-
for (const p of tokenPaths) {
|
|
47
|
-
if (existsSync(p)) {
|
|
48
|
-
token = readFileSync(p, "utf-8").trim();
|
|
49
|
-
break;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
if (!token) return null;
|
|
54
|
-
|
|
55
|
-
try {
|
|
56
|
-
const resp = await fetch(
|
|
57
|
-
"https://bigmodel.cn/api/monitor/usage/quota/limit",
|
|
58
|
-
{
|
|
59
|
-
headers: {
|
|
60
|
-
accept: "application/json, text/plain, */*",
|
|
61
|
-
authorization: token,
|
|
62
|
-
"bigmodel-organization":
|
|
63
|
-
"org-8F82302F73594F44B2bdCc5A57BCfD1f",
|
|
64
|
-
"bigmodel-project":
|
|
65
|
-
"proj_8E86D38C8211410Baa4852408071D1F2",
|
|
66
|
-
referer:
|
|
67
|
-
"https://bigmodel.cn/usercenter/glm-coding/usage",
|
|
68
|
-
"user-agent": "Mozilla/5.0",
|
|
69
|
-
},
|
|
70
|
-
signal: AbortSignal.timeout(5000),
|
|
71
|
-
},
|
|
72
|
-
);
|
|
73
|
-
if (!resp.ok) return null;
|
|
74
|
-
const data = await resp.json() as ZhipuApiResponse;
|
|
75
|
-
return processZhipu(data);
|
|
76
|
-
} catch {
|
|
77
|
-
return null;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
function processZhipu(data: ZhipuApiResponse): ZhipuData | null {
|
|
82
|
-
if (!data?.success) return null;
|
|
83
|
-
const d = data.data;
|
|
84
|
-
const label = d?.level ? `Z.ai-${d.level}` : "Z.ai";
|
|
85
|
-
|
|
86
|
-
let tokensPct = 0;
|
|
87
|
-
let timePct = 0;
|
|
88
|
-
let timeCurrent = 0;
|
|
89
|
-
let resetMs = 0;
|
|
90
|
-
|
|
91
|
-
for (const lim of d?.limits ?? []) {
|
|
92
|
-
if (lim.type === "TOKENS_LIMIT") {
|
|
93
|
-
tokensPct = lim.percentage ?? 0;
|
|
94
|
-
if (lim.nextResetTime) resetMs = Number(lim.nextResetTime);
|
|
95
|
-
} else if (lim.type === "TIME_LIMIT") {
|
|
96
|
-
timePct = lim.percentage ?? 0;
|
|
97
|
-
timeCurrent = lim.currentValue ?? 0;
|
|
98
|
-
if (!resetMs && lim.nextResetTime)
|
|
99
|
-
resetMs = Number(lim.nextResetTime);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
let resetTime = "";
|
|
104
|
-
if (resetMs) {
|
|
105
|
-
const rem =
|
|
106
|
-
Math.floor(resetMs / 1000) - Math.floor(Date.now() / 1000);
|
|
107
|
-
if (rem > 0) {
|
|
108
|
-
const days = Math.floor(rem / 86400);
|
|
109
|
-
const hrs = Math.floor((rem % 86400) / 3600);
|
|
110
|
-
const mins = Math.floor((rem % 3600) / 60);
|
|
111
|
-
if (days > 0) resetTime = `${days}d${hrs}h`;
|
|
112
|
-
else if (hrs > 0) resetTime = `${hrs}h${mins}m`;
|
|
113
|
-
else resetTime = `${mins}m`;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
return { label, tokensPct, timePct, timeCurrent, resetTime };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export const zhipuProvider: QuotaProvider<ZhipuData> = {
|
|
121
|
-
id: "zhipu",
|
|
122
|
-
label: "Z.ai", // fallback;实际显示来自 raw.label(Z.ai-pro 等)
|
|
123
|
-
fetch: fetchZhipu,
|
|
124
|
-
normalize(raw): NormalizedQuotaRow | null {
|
|
125
|
-
const resetSec = raw.resetTime ? parseZaiResetSec(raw.resetTime) : null;
|
|
126
|
-
return {
|
|
127
|
-
label: raw.label || "Z.ai",
|
|
128
|
-
wins: [
|
|
129
|
-
{ pct: raw.tokensPct, resetSec },
|
|
130
|
-
INFINITE_WIN,
|
|
131
|
-
INFINITE_WIN,
|
|
132
|
-
],
|
|
133
|
-
};
|
|
134
|
-
},
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
/** 把 ZAI 的 resetTime(如 "4h11m"/"3d20h")转成剩余秒 */
|
|
138
|
-
function parseZaiResetSec(label: string): number {
|
|
139
|
-
const dM = label.match(/(\d+)d/);
|
|
140
|
-
const hM = label.match(/(\d+)h/);
|
|
141
|
-
const mM = label.match(/(\d+)m/);
|
|
142
|
-
let sec = 0;
|
|
143
|
-
if (dM) sec += Number(dM[1]) * 86400;
|
|
144
|
-
if (hM) sec += Number(hM[1]) * 3600;
|
|
145
|
-
if (mM) sec += Number(mM[1]) * 60;
|
|
146
|
-
return sec;
|
|
147
|
-
}
|