pi-firecode 0.8.1 → 1.0.1
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 +58 -24
- package/README.zh-CN.md +74 -0
- package/config.example.jsonc +47 -57
- package/dist/config.example.jsonc +90 -0
- package/dist/index.js +11464 -0
- package/dist/master/prompts/master.en.md +22 -0
- package/dist/master/prompts/master.zh.md +22 -0
- package/dist/master/prompts/worker.en.md +1 -0
- package/dist/master/prompts/worker.zh.md +1 -0
- package/{review → dist/review}/prompts/review.en.md +7 -6
- package/{review → dist/review}/prompts/review.zh.md +7 -6
- package/dist/watcher/prompts/watch.en.md +43 -0
- package/{watcher → dist/watcher}/prompts/watch.zh.md +1 -1
- package/package.json +10 -27
- package/config.ts +0 -474
- package/deliver.ts +0 -32
- package/flame-frames.ts +0 -460
- package/format.ts +0 -80
- package/header.ts +0 -92
- package/herdr-client.ts +0 -60
- package/index.ts +0 -62
- package/jsonc.ts +0 -34
- package/master/event-card.ts +0 -106
- package/master/event-format.ts +0 -52
- package/master/index.ts +0 -1200
- package/master/prompt.ts +0 -26
- package/master/prompts/master.zh.md +0 -17
- package/master/prompts/worker.zh.md +0 -1
- package/master/role.ts +0 -13
- package/master/spawn.ts +0 -193
- package/master/state.ts +0 -201
- package/provider/claude-sub.ts +0 -129
- package/provider/openai-native/index.ts +0 -8
- package/provider/openai-native/src/compact-client.ts +0 -362
- package/provider/openai-native/src/config.ts +0 -297
- package/provider/openai-native/src/extension.ts +0 -93
- package/provider/openai-native/src/native-compaction.ts +0 -151
- package/provider/openai-native/src/native-details.ts +0 -157
- package/provider/openai-native/src/native-replay.ts +0 -253
- package/provider/openai-native/src/native-runtime.ts +0 -144
- package/provider/openai-native/src/options.ts +0 -85
- package/provider/openai-native/src/request-pipeline.ts +0 -21
- package/provider/openai-native/src/responses-input.ts +0 -433
- package/review/advisor.ts +0 -112
- package/review/card.ts +0 -385
- package/review/checkpoint.ts +0 -384
- package/review/evidence.ts +0 -232
- package/review/index.ts +0 -1246
- package/review/outcome.ts +0 -74
- package/review/progress.ts +0 -324
- package/review/prompt.ts +0 -210
- package/review/reviewer.ts +0 -356
- package/review/session.ts +0 -129
- package/review/state.ts +0 -905
- package/review/ui.ts +0 -528
- package/session/bark.ts +0 -157
- package/session/herdr-display.ts +0 -87
- package/session/presets.ts +0 -257
- package/session/rename.ts +0 -46
- package/session/stats.ts +0 -310
- package/session/working-flame.ts +0 -116
- package/statusbar/index.ts +0 -118
- package/statusbar/quota-cache.ts +0 -60
- package/statusbar/quota-parse.ts +0 -85
- package/statusbar/quota.ts +0 -203
- package/statusbar/render.ts +0 -181
- package/statusbar/tps.ts +0 -87
- package/theme.ts +0 -84
- package/tools/grouping.ts +0 -233
- package/tools/index.ts +0 -183
- package/tools/line.ts +0 -194
- package/tools/parts.ts +0 -143
- package/tools/timing.ts +0 -22
- package/watcher/card.ts +0 -85
- package/watcher/index.ts +0 -204
- package/watcher/observer.ts +0 -75
- package/watcher/transcript.ts +0 -64
- /package/{review → dist/review}/prompts/advisor.en.md +0 -0
- /package/{review → dist/review}/prompts/advisor.zh.md +0 -0
package/session/working-flame.ts
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 火焰 working 效果:工作回合期间编辑器上方居中燃烧的多行火焰 widget。
|
|
3
|
-
*
|
|
4
|
-
* 走 setWidget(aboveEditor):多行组件的合法槽位(审查活动框同款),由 TUI
|
|
5
|
-
* 排版器独立渲染,与工具输出物理隔离——旧 working-style.ts 的事故是把多行帧
|
|
6
|
-
* 塞进单行 spinner 通道,这里结构上不可能复发。agent_start 挂载、agent_end 撤下,
|
|
7
|
-
* 动画计时器随组件 dispose 清理。高度随终端行数自适应,宽度不够时逐级缩小。
|
|
8
|
-
*
|
|
9
|
-
* 火焰即工作信号:回合或审查占用期隐藏宿主的 「Working...」 文本行。
|
|
10
|
-
* review 模块也写 setWorkingVisible,且它的写入在同一同步调度链里排在占用事件之后;
|
|
11
|
-
* 因此本模块的所有 UI 写入经微任务合并调度,在调度链收尾后作为最终仲裁者落地,
|
|
12
|
-
* 消除「占用期 agent_end 无条件复显 Working 行」一类最后写者竞态。
|
|
13
|
-
*/
|
|
14
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
-
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
16
|
-
import { FLAME_FRAME_COUNT, flameFrameLines, flameFrameWidth } from "../flame-frames.js";
|
|
17
|
-
|
|
18
|
-
const WIDGET_KEY = "firecode-working-flame";
|
|
19
|
-
const FRAME_MS = 100;
|
|
20
|
-
const MAX_HEIGHT = 10;
|
|
21
|
-
const MIN_HEIGHT = 3;
|
|
22
|
-
|
|
23
|
-
/** 高度自适应:约占终端四分之一,钳在 3–10 行;行数未知时给满高。 */
|
|
24
|
-
export function flameHeightFor(terminalRows: number | undefined): number {
|
|
25
|
-
if (!terminalRows || !Number.isFinite(terminalRows)) return MAX_HEIGHT;
|
|
26
|
-
return Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, Math.floor(terminalRows / 4)));
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** 宽度自适应:装不下就逐级降高(火焰宽随高缩),实在不行才隐藏。 */
|
|
30
|
-
export function flameFitHeight(height: number, width: number): number {
|
|
31
|
-
for (let fit = height; fit >= MIN_HEIGHT; fit -= 1) {
|
|
32
|
-
if (flameFrameWidth(fit) <= width) return fit;
|
|
33
|
-
}
|
|
34
|
-
return 0;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export function registerWorkingFlame(pi: ExtensionAPI): void {
|
|
38
|
-
let turnActive = false;
|
|
39
|
-
let reviewHeld = false;
|
|
40
|
-
let ui: ExtensionContext["ui"] | undefined;
|
|
41
|
-
let pending = false;
|
|
42
|
-
|
|
43
|
-
const apply = () => {
|
|
44
|
-
pending = false;
|
|
45
|
-
if (!ui) return;
|
|
46
|
-
// Working 行:回合内由大火焰替代,审查占用期由审查活动条替代,两者都不在才复显。
|
|
47
|
-
if (typeof ui.setWorkingVisible === "function")
|
|
48
|
-
ui.setWorkingVisible(!turnActive && !reviewHeld);
|
|
49
|
-
if (typeof ui.setWidget !== "function") return;
|
|
50
|
-
if (turnActive && !reviewHeld)
|
|
51
|
-
ui.setWidget(
|
|
52
|
-
WIDGET_KEY,
|
|
53
|
-
(tui: TUI) => new WorkingFlame(tui, () => tui.requestRender()),
|
|
54
|
-
{ placement: "aboveEditor" },
|
|
55
|
-
);
|
|
56
|
-
else ui.setWidget(WIDGET_KEY, undefined);
|
|
57
|
-
};
|
|
58
|
-
/** 微任务合并:同一调度链内的多个事件只落地一次,且排在 review 的同步写入之后。 */
|
|
59
|
-
const sync = () => {
|
|
60
|
-
if (pending) return;
|
|
61
|
-
pending = true;
|
|
62
|
-
queueMicrotask(apply);
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
pi.on("agent_start", (_event, ctx) => {
|
|
66
|
-
if (ctx.mode !== "tui") return;
|
|
67
|
-
ui = ctx.ui;
|
|
68
|
-
turnActive = true;
|
|
69
|
-
sync();
|
|
70
|
-
});
|
|
71
|
-
pi.on("agent_end", (_event, ctx) => {
|
|
72
|
-
if (ctx.mode !== "tui") return;
|
|
73
|
-
ui = ctx.ui;
|
|
74
|
-
turnActive = false;
|
|
75
|
-
sync();
|
|
76
|
-
});
|
|
77
|
-
// 审查占用频道(review 模块发布):审查活跃期活动条自带火焰,大火焰退让避免双火同烧。
|
|
78
|
-
pi.events?.on?.("herdr:blocked", (data: { active?: boolean } | undefined) => {
|
|
79
|
-
reviewHeld = Boolean(data?.active);
|
|
80
|
-
sync();
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** 居中的多行火焰;素材自带 ANSI 颜色与行尾复位。 */
|
|
85
|
-
class WorkingFlame implements Component {
|
|
86
|
-
private frame = 0;
|
|
87
|
-
private readonly timer: ReturnType<typeof setInterval>;
|
|
88
|
-
|
|
89
|
-
constructor(
|
|
90
|
-
private readonly tui: TUI,
|
|
91
|
-
requestRender: () => void,
|
|
92
|
-
) {
|
|
93
|
-
this.timer = setInterval(() => {
|
|
94
|
-
this.frame += 1;
|
|
95
|
-
requestRender();
|
|
96
|
-
}, FRAME_MS);
|
|
97
|
-
this.timer.unref?.();
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
render(width: number): string[] {
|
|
101
|
-
const rows = (this.tui as TUI & { terminal?: { rows?: number } }).terminal?.rows;
|
|
102
|
-
const height = flameFitHeight(flameHeightFor(rows), Math.max(0, width));
|
|
103
|
-
if (height === 0) return [];
|
|
104
|
-
const flameWidth = flameFrameWidth(height);
|
|
105
|
-
const indent = " ".repeat(Math.max(0, Math.floor((width - flameWidth) / 2)));
|
|
106
|
-
return flameFrameLines(height, this.frame % FLAME_FRAME_COUNT).map(
|
|
107
|
-
(line) => `${indent}${line}`,
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
invalidate(): void {}
|
|
112
|
-
|
|
113
|
-
dispose(): void {
|
|
114
|
-
clearInterval(this.timer);
|
|
115
|
-
}
|
|
116
|
-
}
|
package/statusbar/index.ts
DELETED
|
@@ -1,118 +0,0 @@
|
|
|
1
|
-
/** 底部两行状态栏:位置/会话名 + 模型/额度/上下文/缓存/速度。 */
|
|
2
|
-
import { basename, join } from "node:path";
|
|
3
|
-
import { type ExtensionAPI, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { formatModelName } from "../format.js";
|
|
5
|
-
import { thinkingColor } from "../theme.js";
|
|
6
|
-
import { fileQuotaCache } from "./quota-cache.js";
|
|
7
|
-
import { registerQuota } from "./quota.js";
|
|
8
|
-
import type { QuotaStatus } from "./quota-parse.js";
|
|
9
|
-
import {
|
|
10
|
-
alignRight,
|
|
11
|
-
fitMetadataLine,
|
|
12
|
-
fitStatusLine,
|
|
13
|
-
reviewStatus,
|
|
14
|
-
statusBadges,
|
|
15
|
-
latestCacheHitPercent,
|
|
16
|
-
renderCache,
|
|
17
|
-
renderContext,
|
|
18
|
-
renderLocation,
|
|
19
|
-
renderQuota,
|
|
20
|
-
renderTps,
|
|
21
|
-
} from "./render.js";
|
|
22
|
-
import { type TpsStatus, registerTps } from "./tps.js";
|
|
23
|
-
|
|
24
|
-
export function registerStatusBar(pi: ExtensionAPI, subsession = false): void {
|
|
25
|
-
if (subsession) return;
|
|
26
|
-
let quota: QuotaStatus | undefined;
|
|
27
|
-
let tpsStatus: TpsStatus | undefined;
|
|
28
|
-
let requestRender = () => {};
|
|
29
|
-
|
|
30
|
-
registerQuota(
|
|
31
|
-
pi,
|
|
32
|
-
(status) => {
|
|
33
|
-
quota = status;
|
|
34
|
-
requestRender();
|
|
35
|
-
},
|
|
36
|
-
fileQuotaCache(join(getAgentDir(), "tmp")),
|
|
37
|
-
);
|
|
38
|
-
registerTps(pi, (status) => {
|
|
39
|
-
tpsStatus = status;
|
|
40
|
-
requestRender();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
// 助手消息落定后缓存命中率才可算。
|
|
44
|
-
pi.on("message_end", (event) => {
|
|
45
|
-
if (event.message.role === "assistant") requestRender();
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
pi.on("session_start", (_event, ctx) => {
|
|
49
|
-
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
50
|
-
requestRender = () => tui.requestRender();
|
|
51
|
-
const unsubscribe = footerData.onBranchChange(requestRender);
|
|
52
|
-
|
|
53
|
-
return {
|
|
54
|
-
dispose: unsubscribe,
|
|
55
|
-
invalidate() {},
|
|
56
|
-
render(width: number): string[] {
|
|
57
|
-
const statuses = footerData.getExtensionStatuses();
|
|
58
|
-
const review = reviewStatus(statuses);
|
|
59
|
-
const model = ctx.model;
|
|
60
|
-
const thinkingLevel = pi.getThinkingLevel();
|
|
61
|
-
const modelCore = `${theme.fg("accent", `🧠 ${formatModelName(model?.id)}`)}${
|
|
62
|
-
model?.reasoning
|
|
63
|
-
? theme.fg(thinkingColor(thinkingLevel), `/${thinkingLevel}`)
|
|
64
|
-
: ""
|
|
65
|
-
}`;
|
|
66
|
-
const modelText = statuses.has("pi-openai-native-fast")
|
|
67
|
-
? `${modelCore}${theme.fg("warning", " · ⚡fast")}`
|
|
68
|
-
: modelCore;
|
|
69
|
-
|
|
70
|
-
const location = renderLocation(
|
|
71
|
-
theme,
|
|
72
|
-
basename(ctx.sessionManager.getCwd()) || "/",
|
|
73
|
-
footerData.getGitBranch(),
|
|
74
|
-
);
|
|
75
|
-
const sessionName = ctx.sessionManager.getSessionName();
|
|
76
|
-
const title = sessionName ? theme.fg("dim", `💬 ${sessionName}`) : "";
|
|
77
|
-
|
|
78
|
-
const usage = ctx.getContextUsage();
|
|
79
|
-
const percent = usage?.percent;
|
|
80
|
-
const contextWindow = usage?.contextWindow ?? model?.contextWindow ?? 0;
|
|
81
|
-
const separator = ` ${theme.fg("dim", "|")}`;
|
|
82
|
-
|
|
83
|
-
const statusLine = fitStatusLine(
|
|
84
|
-
{
|
|
85
|
-
model: modelText,
|
|
86
|
-
modelCompact: modelCore,
|
|
87
|
-
quota: quota ? renderQuota(theme, quota) : "",
|
|
88
|
-
quotaCompact: quota ? renderQuota(theme, quota, true) : "",
|
|
89
|
-
context: renderContext(theme, percent, contextWindow),
|
|
90
|
-
contextCompact: renderContext(theme, percent, contextWindow, true),
|
|
91
|
-
cache: renderCache(
|
|
92
|
-
theme,
|
|
93
|
-
latestCacheHitPercent(ctx.sessionManager.getEntries()),
|
|
94
|
-
),
|
|
95
|
-
tps: renderTps(theme, tpsStatus),
|
|
96
|
-
},
|
|
97
|
-
width,
|
|
98
|
-
separator,
|
|
99
|
-
);
|
|
100
|
-
return [
|
|
101
|
-
alignRight(
|
|
102
|
-
fitMetadataLine(location, title, width, separator, statusBadges(statuses, separator)),
|
|
103
|
-
review,
|
|
104
|
-
width,
|
|
105
|
-
),
|
|
106
|
-
statusLine,
|
|
107
|
-
];
|
|
108
|
-
},
|
|
109
|
-
};
|
|
110
|
-
});
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
pi.on("thinking_level_select", () => requestRender());
|
|
114
|
-
pi.on("session_shutdown", (_event, ctx) => {
|
|
115
|
-
requestRender = () => {};
|
|
116
|
-
ctx.ui.setFooter(undefined);
|
|
117
|
-
});
|
|
118
|
-
}
|
package/statusbar/quota-cache.ts
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 配额结果的跨进程缓存:同时开多个 pi 会话时共享一次抓取,
|
|
3
|
-
* 也共享失败退避,避免离线时每个进程各自重试。
|
|
4
|
-
*/
|
|
5
|
-
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
-
import type { QuotaWindow } from "./quota-parse.js";
|
|
8
|
-
|
|
9
|
-
export type QuotaCacheEntry = {
|
|
10
|
-
/** 抓取成功的窗口;失败时为空数组 */
|
|
11
|
-
windows: QuotaWindow[];
|
|
12
|
-
/** 早于此刻不再发起请求(成功用 TTL,失败用退避) */
|
|
13
|
-
nextAttemptAt: number;
|
|
14
|
-
/** 连续失败次数,驱动退避档位 */
|
|
15
|
-
failures: number;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
export interface QuotaCache {
|
|
19
|
-
read(provider: string): QuotaCacheEntry | undefined;
|
|
20
|
-
write(provider: string, entry: QuotaCacheEntry): void;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function isEntry(value: unknown): value is QuotaCacheEntry {
|
|
24
|
-
const entry = value as QuotaCacheEntry | undefined;
|
|
25
|
-
return (
|
|
26
|
-
!!entry &&
|
|
27
|
-
typeof entry === "object" &&
|
|
28
|
-
Array.isArray(entry.windows) &&
|
|
29
|
-
typeof entry.nextAttemptAt === "number" &&
|
|
30
|
-
typeof entry.failures === "number"
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** 每个 provider 一个文件,避免多进程写入互相覆盖。 */
|
|
35
|
-
export function fileQuotaCache(directory: string): QuotaCache {
|
|
36
|
-
const pathFor = (provider: string) =>
|
|
37
|
-
join(directory, `firecode-quota-${provider}.json`);
|
|
38
|
-
|
|
39
|
-
return {
|
|
40
|
-
read(provider) {
|
|
41
|
-
try {
|
|
42
|
-
const parsed: unknown = JSON.parse(readFileSync(pathFor(provider), "utf8"));
|
|
43
|
-
return isEntry(parsed) ? parsed : undefined;
|
|
44
|
-
} catch {
|
|
45
|
-
return undefined;
|
|
46
|
-
}
|
|
47
|
-
},
|
|
48
|
-
write(provider, entry) {
|
|
49
|
-
const path = pathFor(provider);
|
|
50
|
-
const temporary = `${path}.${process.pid}.tmp`;
|
|
51
|
-
try {
|
|
52
|
-
mkdirSync(directory, { recursive: true });
|
|
53
|
-
writeFileSync(temporary, JSON.stringify(entry));
|
|
54
|
-
renameSync(temporary, path);
|
|
55
|
-
} catch {
|
|
56
|
-
// 缓存是加速手段,写失败只意味着下次照常请求
|
|
57
|
-
}
|
|
58
|
-
},
|
|
59
|
-
};
|
|
60
|
-
}
|
package/statusbar/quota-parse.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
/** 供应商用量响应 → 剩余百分比窗口。响应结构不是公开契约,解析一律容错。 */
|
|
2
|
-
|
|
3
|
-
type JsonRecord = Record<string, unknown>;
|
|
4
|
-
|
|
5
|
-
export type QuotaWindow = {
|
|
6
|
-
label: string;
|
|
7
|
-
remaining: number;
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
export type QuotaStatus =
|
|
11
|
-
| { state: "loading" }
|
|
12
|
-
| { state: "ready"; windows: QuotaWindow[] }
|
|
13
|
-
| { state: "unavailable" };
|
|
14
|
-
|
|
15
|
-
export const record = (value: unknown): JsonRecord | undefined =>
|
|
16
|
-
typeof value === "object" && value !== null ? (value as JsonRecord) : undefined;
|
|
17
|
-
|
|
18
|
-
export const finiteNumber = (value: unknown): number | undefined =>
|
|
19
|
-
typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
20
|
-
|
|
21
|
-
/** 秒级时间戳或 ISO 字符串 → 毫秒时间戳。 */
|
|
22
|
-
export const epochMillis = (value: unknown): number | undefined => {
|
|
23
|
-
if (typeof value === "number" && Number.isFinite(value)) return value * 1_000;
|
|
24
|
-
if (typeof value !== "string") return undefined;
|
|
25
|
-
const parsed = Date.parse(value);
|
|
26
|
-
return Number.isFinite(parsed) ? parsed : undefined;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
|
-
const remainingPercent = (used: number) =>
|
|
30
|
-
Math.round(Math.max(0, Math.min(100, 100 - used)));
|
|
31
|
-
|
|
32
|
-
function windowFromUsage(
|
|
33
|
-
label: string,
|
|
34
|
-
value: unknown,
|
|
35
|
-
usedKey: string,
|
|
36
|
-
): QuotaWindow | undefined {
|
|
37
|
-
const used = finiteNumber(record(value)?.[usedKey]);
|
|
38
|
-
return used === undefined ? undefined : { label, remaining: remainingPercent(used) };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const defined = (window: QuotaWindow | undefined): window is QuotaWindow =>
|
|
42
|
-
Boolean(window);
|
|
43
|
-
|
|
44
|
-
export function parseOpenAIQuota(value: unknown): QuotaWindow[] {
|
|
45
|
-
const rateLimit = record(record(value)?.rate_limit);
|
|
46
|
-
const primary = record(rateLimit?.primary_window);
|
|
47
|
-
const secondary = record(rateLimit?.secondary_window);
|
|
48
|
-
const label = (window: JsonRecord | undefined, fallback: string) => {
|
|
49
|
-
const seconds = finiteNumber(window?.limit_window_seconds);
|
|
50
|
-
if (!seconds) return fallback;
|
|
51
|
-
if (seconds % 86_400 === 0) return `${seconds / 86_400}d`;
|
|
52
|
-
if (seconds % 3_600 === 0) return `${seconds / 3_600}h`;
|
|
53
|
-
return fallback;
|
|
54
|
-
};
|
|
55
|
-
return [
|
|
56
|
-
windowFromUsage(label(primary, "5h"), primary, "used_percent"),
|
|
57
|
-
windowFromUsage(label(secondary, "7d"), secondary, "used_percent"),
|
|
58
|
-
].filter(defined);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function parseAnthropicQuota(value: unknown): QuotaWindow[] {
|
|
62
|
-
const source = record(value);
|
|
63
|
-
return [
|
|
64
|
-
windowFromUsage("5h", source?.five_hour, "utilization"),
|
|
65
|
-
windowFromUsage("7d", source?.seven_day, "utilization"),
|
|
66
|
-
].filter(defined);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function parseGrokQuota(
|
|
70
|
-
monthlyValue: unknown,
|
|
71
|
-
weeklyValue: unknown,
|
|
72
|
-
): QuotaWindow[] {
|
|
73
|
-
const monthly = record(record(monthlyValue)?.config);
|
|
74
|
-
const weekly = record(record(weeklyValue)?.config);
|
|
75
|
-
const monthlyLimit = finiteNumber(record(monthly?.monthlyLimit)?.val);
|
|
76
|
-
const monthlyUsed = finiteNumber(record(monthly?.used)?.val);
|
|
77
|
-
const monthlyWindow =
|
|
78
|
-
monthlyLimit !== undefined && monthlyLimit > 0 && monthlyUsed !== undefined
|
|
79
|
-
? { label: "30d", remaining: remainingPercent((monthlyUsed / monthlyLimit) * 100) }
|
|
80
|
-
: undefined;
|
|
81
|
-
return [
|
|
82
|
-
windowFromUsage("7d", weekly, "creditUsagePercent"),
|
|
83
|
-
monthlyWindow,
|
|
84
|
-
].filter(defined);
|
|
85
|
-
}
|
package/statusbar/quota.ts
DELETED
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 订阅额度抓取:事件驱动(会话启动、切模型、每轮结束),无定时轮询。
|
|
3
|
-
* 新鲜度与失败退避都落在跨进程缓存里,多个 pi 会话共享同一次请求。
|
|
4
|
-
*/
|
|
5
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
-
import { homedir } from "node:os";
|
|
7
|
-
import { join } from "node:path";
|
|
8
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import type { QuotaCache } from "./quota-cache.js";
|
|
10
|
-
import {
|
|
11
|
-
type QuotaStatus,
|
|
12
|
-
type QuotaWindow,
|
|
13
|
-
epochMillis,
|
|
14
|
-
parseAnthropicQuota,
|
|
15
|
-
parseGrokQuota,
|
|
16
|
-
parseOpenAIQuota,
|
|
17
|
-
record,
|
|
18
|
-
} from "./quota-parse.js";
|
|
19
|
-
|
|
20
|
-
// 供应商自家 CLI 使用的只读接口,schema 不是公开契约。
|
|
21
|
-
const OPENAI_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
22
|
-
const ANTHROPIC_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
23
|
-
const GROK_BILLING_URL = "https://cli-chat-proxy.grok.com/v1/billing";
|
|
24
|
-
const FRESH_MS = 60_000;
|
|
25
|
-
const REQUEST_TIMEOUT_MS = 3_000;
|
|
26
|
-
/** 连续失败的退避档位,末档为上限 */
|
|
27
|
-
const BACKOFF_MS = [60_000, 120_000, 300_000];
|
|
28
|
-
|
|
29
|
-
function decodeJwtPayload(token: string): ReturnType<typeof record> {
|
|
30
|
-
const payload = token.split(".")[1];
|
|
31
|
-
if (!payload) return undefined;
|
|
32
|
-
try {
|
|
33
|
-
return record(JSON.parse(Buffer.from(payload, "base64url").toString("utf8")));
|
|
34
|
-
} catch {
|
|
35
|
-
return undefined;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function openAIAccountId(token: string): string | undefined {
|
|
40
|
-
const claims = record(decodeJwtPayload(token)?.["https://api.openai.com/auth"]);
|
|
41
|
-
return typeof claims?.chatgpt_account_id === "string" ? claims.chatgpt_account_id : undefined;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function grokCliToken(): string | undefined {
|
|
45
|
-
// Grok billing 拒绝 pi 的 OAuth token,只认官方 CLI 登录态。
|
|
46
|
-
const path = join(homedir(), ".grok", "auth.json");
|
|
47
|
-
if (!existsSync(path)) return undefined;
|
|
48
|
-
try {
|
|
49
|
-
const auth = record(JSON.parse(readFileSync(path, "utf8")));
|
|
50
|
-
for (const [scope, value] of Object.entries(auth ?? {})) {
|
|
51
|
-
if (!scope.startsWith("https://auth.x.ai::")) continue;
|
|
52
|
-
const credential = record(value);
|
|
53
|
-
const token = credential?.key;
|
|
54
|
-
const expiresAt = epochMillis(credential?.expires_at);
|
|
55
|
-
if (typeof token === "string" && token.length > 0 && (!expiresAt || expiresAt > Date.now()))
|
|
56
|
-
return token;
|
|
57
|
-
}
|
|
58
|
-
} catch {
|
|
59
|
-
return undefined;
|
|
60
|
-
}
|
|
61
|
-
return undefined;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async function fetchJson(url: string, headers: Record<string, string>): Promise<unknown> {
|
|
65
|
-
const response = await fetch(url, {
|
|
66
|
-
headers,
|
|
67
|
-
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
68
|
-
});
|
|
69
|
-
if (!response.ok) throw new Error(`Quota request failed: ${response.status}`);
|
|
70
|
-
return response.json();
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
type QuotaRequest =
|
|
74
|
-
| { provider: "openai-codex" | "anthropic"; token: Promise<string | undefined> }
|
|
75
|
-
| { provider: "xai"; token: string | undefined };
|
|
76
|
-
|
|
77
|
-
type QuotaProvider = QuotaRequest["provider"];
|
|
78
|
-
|
|
79
|
-
async function loadQuota(request: QuotaRequest): Promise<QuotaWindow[]> {
|
|
80
|
-
if (request.provider === "openai-codex") {
|
|
81
|
-
const token = await request.token;
|
|
82
|
-
if (!token) return [];
|
|
83
|
-
const accountId = openAIAccountId(token);
|
|
84
|
-
return parseOpenAIQuota(
|
|
85
|
-
await fetchJson(OPENAI_USAGE_URL, {
|
|
86
|
-
Authorization: `Bearer ${token}`,
|
|
87
|
-
...(accountId ? { "ChatGPT-Account-Id": accountId } : {}),
|
|
88
|
-
Accept: "application/json",
|
|
89
|
-
}),
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
if (request.provider === "anthropic") {
|
|
93
|
-
const token = await request.token;
|
|
94
|
-
if (!token) return [];
|
|
95
|
-
return parseAnthropicQuota(
|
|
96
|
-
await fetchJson(ANTHROPIC_USAGE_URL, {
|
|
97
|
-
Authorization: `Bearer ${token}`,
|
|
98
|
-
"anthropic-beta": "oauth-2025-04-20",
|
|
99
|
-
Accept: "application/json",
|
|
100
|
-
}),
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
const { token } = request;
|
|
104
|
-
if (!token) return [];
|
|
105
|
-
const headers = {
|
|
106
|
-
Authorization: `Bearer ${token}`,
|
|
107
|
-
"x-xai-token-auth": "xai-grok-cli",
|
|
108
|
-
Accept: "application/json",
|
|
109
|
-
};
|
|
110
|
-
const [monthly, weekly] = await Promise.allSettled([
|
|
111
|
-
fetchJson(GROK_BILLING_URL, headers),
|
|
112
|
-
fetchJson(`${GROK_BILLING_URL}?format=credits`, headers),
|
|
113
|
-
]);
|
|
114
|
-
if (monthly.status === "rejected" && weekly.status === "rejected") throw monthly.reason;
|
|
115
|
-
return parseGrokQuota(
|
|
116
|
-
monthly.status === "fulfilled" ? monthly.value : undefined,
|
|
117
|
-
weekly.status === "fulfilled" ? weekly.value : undefined,
|
|
118
|
-
);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const isQuotaProvider = (provider: string | undefined): provider is QuotaProvider =>
|
|
122
|
-
provider === "openai-codex" || provider === "anthropic" || provider === "xai";
|
|
123
|
-
|
|
124
|
-
/** 只有订阅制(OAuth 登录)的供应商才有额度可查。 */
|
|
125
|
-
function subscriptionProvider(ctx: ExtensionContext): QuotaProvider | undefined {
|
|
126
|
-
const model = ctx.model;
|
|
127
|
-
const provider = model?.provider;
|
|
128
|
-
if (!isQuotaProvider(provider)) return undefined;
|
|
129
|
-
if (
|
|
130
|
-
(provider === "anthropic" || provider === "xai") &&
|
|
131
|
-
(!model || !ctx.modelRegistry.isUsingOAuth(model))
|
|
132
|
-
)
|
|
133
|
-
return undefined;
|
|
134
|
-
return provider;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function quotaRequest(ctx: ExtensionContext, provider: QuotaProvider): QuotaRequest {
|
|
138
|
-
if (provider === "xai") return { provider, token: grokCliToken() };
|
|
139
|
-
return { provider, token: ctx.modelRegistry.getApiKeyForProvider(provider) };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const statusOf = (windows: QuotaWindow[]): QuotaStatus =>
|
|
143
|
-
windows.length ? { state: "ready", windows } : { state: "unavailable" };
|
|
144
|
-
|
|
145
|
-
export function registerQuota(
|
|
146
|
-
pi: ExtensionAPI,
|
|
147
|
-
update: (status?: QuotaStatus) => void,
|
|
148
|
-
cache: QuotaCache,
|
|
149
|
-
) {
|
|
150
|
-
let requestGeneration = 0;
|
|
151
|
-
|
|
152
|
-
const refresh = (ctx: ExtensionContext, force = false) => {
|
|
153
|
-
const provider = subscriptionProvider(ctx);
|
|
154
|
-
if (!provider) {
|
|
155
|
-
requestGeneration++;
|
|
156
|
-
update();
|
|
157
|
-
return;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const now = Date.now();
|
|
161
|
-
const cached = cache.read(provider);
|
|
162
|
-
if (cached && now < cached.nextAttemptAt) {
|
|
163
|
-
requestGeneration++;
|
|
164
|
-
update(statusOf(cached.windows));
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
if (force) update({ state: "loading" });
|
|
168
|
-
|
|
169
|
-
const generation = ++requestGeneration;
|
|
170
|
-
// 先占住退避窗口,避免同一进程内并发请求叠加。
|
|
171
|
-
cache.write(provider, {
|
|
172
|
-
windows: cached?.windows ?? [],
|
|
173
|
-
nextAttemptAt: now + FRESH_MS,
|
|
174
|
-
failures: cached?.failures ?? 0,
|
|
175
|
-
});
|
|
176
|
-
void loadQuota(quotaRequest(ctx, provider))
|
|
177
|
-
.then((windows) => {
|
|
178
|
-
cache.write(provider, {
|
|
179
|
-
windows,
|
|
180
|
-
nextAttemptAt: Date.now() + FRESH_MS,
|
|
181
|
-
failures: 0,
|
|
182
|
-
});
|
|
183
|
-
if (generation === requestGeneration) update(statusOf(windows));
|
|
184
|
-
})
|
|
185
|
-
.catch(() => {
|
|
186
|
-
const failures = (cached?.failures ?? 0) + 1;
|
|
187
|
-
cache.write(provider, {
|
|
188
|
-
windows: [],
|
|
189
|
-
nextAttemptAt: Date.now() + BACKOFF_MS[Math.min(failures, BACKOFF_MS.length) - 1],
|
|
190
|
-
failures,
|
|
191
|
-
});
|
|
192
|
-
if (generation === requestGeneration) update({ state: "unavailable" });
|
|
193
|
-
});
|
|
194
|
-
};
|
|
195
|
-
|
|
196
|
-
pi.on("session_start", (_event, ctx) => refresh(ctx, true));
|
|
197
|
-
pi.on("model_select", (_event, ctx) => refresh(ctx, true));
|
|
198
|
-
pi.on("agent_end", (_event, ctx) => refresh(ctx));
|
|
199
|
-
pi.on("session_shutdown", () => {
|
|
200
|
-
requestGeneration++;
|
|
201
|
-
update();
|
|
202
|
-
});
|
|
203
|
-
}
|