pi-ark-usage 0.1.0 → 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/README.md +19 -13
- package/extensions/index.ts +20 -7
- package/extensions/lib/aksk.ts +263 -0
- package/extensions/lib/arkcli.ts +18 -119
- package/extensions/lib/cache.ts +5 -0
- package/extensions/lib/types.ts +3 -3
- package/extensions/lib/widget.ts +3 -3
- package/package.json +8 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-ark-usage
|
|
2
2
|
|
|
3
|
-
在 pi TUI 中显示**火山引擎方舟 Coding Plan / Agent Plan**
|
|
3
|
+
在 pi TUI 中显示**火山引擎方舟 Coding Plan / Agent Plan** 的套餐用量,数据直接来自火山方舟 OpenAPI `GetCodingPlanUsage`(AK/SK 签名,官方 [`@volcengine/openapi`](https://github.com/volcengine/volc-sdk-nodejs) SDK 的 Signer)。
|
|
4
4
|
|
|
5
5
|
```
|
|
6
6
|
Volc Usage: sess 30%(reset after 4h18m) / wk 37%(reset after 4d) / mo 19%(reset after 29d) now
|
|
@@ -8,7 +8,7 @@ Volc Usage: sess 30%(reset after 4h18m) / wk 37%(reset after 4d) / mo 19%(reset
|
|
|
8
8
|
|
|
9
9
|
## 特性
|
|
10
10
|
|
|
11
|
-
-
|
|
11
|
+
- **AK/SK 直连 OpenAPI**:不走子进程、不依赖 SSO 登录态;凭据只从环境变量或配置文件读取,本扩展不保存、不上传任何 Key
|
|
12
12
|
- **三个周期同屏**:session(会话)/ weekly(周)/ monthly(月),并显示各周期重置倒计时
|
|
13
13
|
- **阈值着色**:低用量绿色、接近上限黄色、超过红线红色
|
|
14
14
|
- **单轮消耗**:以每轮对话开始前的快照为基线,结算时标注各周期增量(如 `(+2)`)
|
|
@@ -18,17 +18,21 @@ Volc Usage: sess 30%(reset after 4h18m) / wk 37%(reset after 4d) / mo 19%(reset
|
|
|
18
18
|
|
|
19
19
|
## 前置条件
|
|
20
20
|
|
|
21
|
-
1.
|
|
21
|
+
1. 火山引擎 AK/SK(具有方舟/Coding Plan 查询权限),按以下顺序解析:
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
1. 环境变量 `VOLC_ACCESSKEY` / `VOLC_SECRETKEY`(可选 `VOLC_SESSION_TOKEN`)
|
|
24
|
+
2. `~/.volc/config`,JSON 格式(与官方 SDK 默认路径一致):
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{ "VOLC_ACCESSKEY": "AKXXX", "VOLC_SECRETKEY": "SKXXX" }
|
|
26
28
|
```
|
|
27
29
|
|
|
28
|
-
|
|
30
|
+
建议将文件权限设为 0600。
|
|
29
31
|
|
|
30
32
|
2. pi 版本 >= 0.86(扩展机制)。
|
|
31
33
|
|
|
34
|
+
> 注:OpenAPI 返回的是凭据所属账号的套餐用量,`/arkset product|seat` 仅影响展示映射,接口本身不支持按产品/Seat 过滤。
|
|
35
|
+
|
|
32
36
|
## 安装
|
|
33
37
|
|
|
34
38
|
开发期可直接加载本地文件:
|
|
@@ -63,15 +67,15 @@ pi install npm:pi-ark-usage
|
|
|
63
67
|
|---|---|
|
|
64
68
|
| `/arkset yellow <n>` | 黄色阈值(百分比,默认 40),须低于红线 |
|
|
65
69
|
| `/arkset red <n>` | 红色阈值(百分比,默认 80),须高于黄线 |
|
|
66
|
-
| `/arkset auto <分钟>` | 空闲自动刷新间隔,0–30 的整数,`0`
|
|
67
|
-
| `/arkset product <id>` |
|
|
70
|
+
| `/arkset auto <分钟>` | 空闲自动刷新间隔,0–30 的整数,`0` 关闭(默认 5 分钟) |
|
|
71
|
+
| `/arkset product <id>` | 展示映射的产品:`auto`(默认)/ `coding-plan` / `agent-plan` / `coding-plan-team` / `agent-plan-team` |
|
|
68
72
|
| `/arkset seat <id>` | 团队版指定 SeatID;`/arkset seat none` 清除 |
|
|
69
73
|
| `/arkset lang <zh\|en>` | UI language, default English |
|
|
70
74
|
| `/arkset reset` | 恢复全部默认设置 |
|
|
71
75
|
|
|
72
76
|
### 产品选择规则
|
|
73
77
|
|
|
74
|
-
- `/arkset product` 非 `auto`
|
|
78
|
+
- `/arkset product` 非 `auto` 时,固定展示指定产品。
|
|
75
79
|
- `auto`(默认):
|
|
76
80
|
1. 按当前模型的 provider 映射(`coding-plan` → coding-plan 等);
|
|
77
81
|
2. 映射不到时,显示最近一次成功抓取的已订阅产品。
|
|
@@ -85,12 +89,14 @@ pi install npm:pi-ark-usage
|
|
|
85
89
|
| `agent_start` | 建立本轮基线(缓存 1 小时内有效;否则限时 3 秒补抓,超时沿用缓存) |
|
|
86
90
|
| `agent_settled` | 本轮结算抓取,并与基线对比标注消耗 |
|
|
87
91
|
| `/arkcheck` | 手动强制刷新 |
|
|
88
|
-
| 可选自动刷新 |
|
|
92
|
+
| 可选自动刷新 | 默认每 5 分钟(`/arkset auto` 可调 0–30,`0` 关闭) |
|
|
93
|
+
|
|
94
|
+
> 注意:`auto` 设置会持久化到磁盘 cache;旧版本用户若曾显式设过 `0`,升级后仍是关闭,需手动 `/arkset auto 5` 开启。
|
|
89
95
|
|
|
90
96
|
## 工作原理
|
|
91
97
|
|
|
92
|
-
1.
|
|
93
|
-
2.
|
|
98
|
+
1. 用官方 `@volcengine/openapi` 的 `Signer` 对 `POST open.volcengineapi.com?Action=GetCodingPlanUsage&Version=2024-01-01`(service `ark`,region `cn-beijing`)做 V4 签名后 fetch。
|
|
99
|
+
2. 解析 `Result.QuotaUsage[]`(`Level/Percent/ResetTimestamp`,秒级时间戳转 `+08:00` ISO 字符串),做结构与取值校验。
|
|
94
100
|
3. 快照存入内存 Map,并以原子写(tmp 文件 + rename)持久化到 `~/.pi/agent/pi-ark-usage/`。
|
|
95
101
|
4. Widget 通过 `ctx.ui.setWidget(..., { placement: "belowEditor" })` 渲染在编辑器下方;age(抓取于多久前)右对齐,终端过窄时自动折成两行。
|
|
96
102
|
|
package/extensions/index.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi-ark-usage
|
|
3
3
|
*
|
|
4
|
-
* Show Volcengine Ark Coding Plan / Agent Plan usage in the pi TUI
|
|
5
|
-
*
|
|
6
|
-
* never
|
|
4
|
+
* Show Volcengine Ark Coding Plan / Agent Plan usage in the pi TUI, fetched
|
|
5
|
+
* directly from the Ark OpenAPI (`GetCodingPlanUsage`) with AK/SK signing via
|
|
6
|
+
* the official `@volcengine/openapi` Signer. This extension never stores keys:
|
|
7
|
+
* credentials come from VOLC_ACCESSKEY/VOLC_SECRETKEY or ~/.volc/config.
|
|
7
8
|
*
|
|
8
9
|
* Widget (below editor): 火山用量: 会话 4%(...) / 周 34%(...) / 月 17%(...)
|
|
9
10
|
* Commands:
|
|
@@ -35,6 +36,7 @@ import {
|
|
|
35
36
|
flushWrites,
|
|
36
37
|
loadCache,
|
|
37
38
|
markShuttingDown,
|
|
39
|
+
resetShutdownState,
|
|
38
40
|
saveCache,
|
|
39
41
|
} from "./lib/cache.js";
|
|
40
42
|
import type {
|
|
@@ -59,7 +61,7 @@ const PRODUCTS = [
|
|
|
59
61
|
|
|
60
62
|
type ProductId = (typeof PRODUCTS)[number];
|
|
61
63
|
|
|
62
|
-
/** provider id (pi models.json) ->
|
|
64
|
+
/** provider id (pi models.json) -> plan product id */
|
|
63
65
|
const PROVIDER_PRODUCT: Record<string, ProductId> = {
|
|
64
66
|
"coding-plan": "coding-plan",
|
|
65
67
|
"agent-plan": "agent-plan",
|
|
@@ -503,15 +505,26 @@ export default function (pi: ExtensionAPI): void {
|
|
|
503
505
|
}
|
|
504
506
|
}
|
|
505
507
|
|
|
506
|
-
pi.on("session_shutdown", async () => {
|
|
507
|
-
shuttingDown = true;
|
|
508
|
-
markShuttingDown();
|
|
508
|
+
pi.on("session_shutdown", async (event) => {
|
|
509
509
|
stopTicker();
|
|
510
510
|
inFlight?.abort();
|
|
511
|
+
// Only a real quit is terminal. Session switches (/new, /resume, /fork,
|
|
512
|
+
// /reload) tear down and then re-enter session_start in the same process;
|
|
513
|
+
// marking terminal shutdown there would block every future refresh.
|
|
514
|
+
if (event.reason === "quit") {
|
|
515
|
+
shuttingDown = true;
|
|
516
|
+
markShuttingDown();
|
|
517
|
+
}
|
|
511
518
|
await flushWrites();
|
|
512
519
|
});
|
|
513
520
|
|
|
514
521
|
pi.on("session_start", (_event, ctx) => {
|
|
522
|
+
// Session switches reuse this module instance in-process: pi has disposed
|
|
523
|
+
// the old TUI widget and torn down the previous runtime, so drop stale
|
|
524
|
+
// handles and revive the shutdown flags before doing anything else.
|
|
525
|
+
shuttingDown = false;
|
|
526
|
+
resetShutdownState();
|
|
527
|
+
activeWidget = null;
|
|
515
528
|
activeProduct = resolveProduct(ctx);
|
|
516
529
|
deltas = null;
|
|
517
530
|
note = null;
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Volcengine Ark OpenAPI client (AK/SK).
|
|
3
|
+
*
|
|
4
|
+
* Replaces the old `arkcli` subprocess wrapper: usage is fetched directly
|
|
5
|
+
* from the Ark OpenAPI (`GetCodingPlanUsage`) using the official
|
|
6
|
+
* `@volcengine/openapi` Signer for V4 request signing.
|
|
7
|
+
*
|
|
8
|
+
* Credential resolution (no secrets are ever logged or persisted here):
|
|
9
|
+
* 1. env: VOLC_ACCESSKEY / VOLC_SECRETKEY (+ optional VOLC_SESSION_TOKEN)
|
|
10
|
+
* 2. ~/.volc/config — JSON {"VOLC_ACCESSKEY": "...", "VOLC_SECRETKEY": "..."}
|
|
11
|
+
* (the same default path the official SDK uses)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { readFile } from "node:fs/promises";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { Signer } from "@volcengine/openapi";
|
|
18
|
+
import type { ArkPlanItem, ArkPlanOutput, ArkPeriod } from "./types.js";
|
|
19
|
+
|
|
20
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
21
|
+
const MAX_JSON_BYTES = 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
const OPEN_HOST = "open.volcengineapi.com";
|
|
24
|
+
const REGION = "cn-beijing";
|
|
25
|
+
const SERVICE = "ark";
|
|
26
|
+
const ARK_VERSION = "2024-01-01";
|
|
27
|
+
const USAGE_ACTION = "GetCodingPlanUsage";
|
|
28
|
+
|
|
29
|
+
/** Product id reported for this API. The Ark OpenAPI exposes the coding plan usage of the AK/SK account. */
|
|
30
|
+
const REPORTED_PRODUCT = "coding-plan";
|
|
31
|
+
|
|
32
|
+
export type { ArkPlanOutput };
|
|
33
|
+
|
|
34
|
+
export type FetchPlanOptions = {
|
|
35
|
+
signal?: AbortSignal;
|
|
36
|
+
timeoutMs?: number;
|
|
37
|
+
/** accepted for interface compatibility; the OpenAPI has no product/seat filter */
|
|
38
|
+
product?: string;
|
|
39
|
+
seat?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type Credentials = {
|
|
43
|
+
accessKeyId: string;
|
|
44
|
+
secretKey: string;
|
|
45
|
+
sessionToken?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
let cachedCreds: Credentials | null | undefined;
|
|
49
|
+
|
|
50
|
+
function readEnvCreds(): Credentials | null {
|
|
51
|
+
const ak = process.env.VOLC_ACCESSKEY;
|
|
52
|
+
const sk = process.env.VOLC_SECRETKEY;
|
|
53
|
+
if (!ak || !sk) return null;
|
|
54
|
+
return {
|
|
55
|
+
accessKeyId: ak,
|
|
56
|
+
secretKey: sk,
|
|
57
|
+
sessionToken: process.env.VOLC_SESSION_TOKEN || undefined,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function readConfigFileCreds(): Promise<Credentials | null> {
|
|
62
|
+
const path = join(homedir(), ".volc", "config");
|
|
63
|
+
let raw: string;
|
|
64
|
+
try {
|
|
65
|
+
raw = await readFile(path, "utf8");
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
let json: unknown;
|
|
70
|
+
try {
|
|
71
|
+
json = JSON.parse(raw);
|
|
72
|
+
} catch {
|
|
73
|
+
throw new Error(`invalid JSON in ${path}`);
|
|
74
|
+
}
|
|
75
|
+
if (!json || typeof json !== "object") return null;
|
|
76
|
+
const cfg = json as Record<string, unknown>;
|
|
77
|
+
const ak = cfg.VOLC_ACCESSKEY ?? cfg.AccessKeyId;
|
|
78
|
+
const sk = cfg.VOLC_SECRETKEY ?? cfg.SecretAccessKey;
|
|
79
|
+
if (typeof ak !== "string" || typeof sk !== "string" || !ak || !sk) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const token = cfg.VOLC_SESSIONTOKEN ?? cfg.SessionToken;
|
|
83
|
+
return {
|
|
84
|
+
accessKeyId: ak,
|
|
85
|
+
secretKey: sk,
|
|
86
|
+
sessionToken: typeof token === "string" && token ? token : undefined,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Resolve credentials once per process; null = none found anywhere. */
|
|
91
|
+
export async function resolveCredentials(): Promise<Credentials | null> {
|
|
92
|
+
if (cachedCreds !== undefined) return cachedCreds;
|
|
93
|
+
cachedCreds = readEnvCreds() ?? (await readConfigFileCreds());
|
|
94
|
+
return cachedCreds;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function checkCredentialsAvailable(): Promise<boolean> {
|
|
98
|
+
return (await resolveCredentials()) !== null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* ------------------------------- response types ------------------------------ */
|
|
102
|
+
|
|
103
|
+
type QuotaEntry = {
|
|
104
|
+
Level?: unknown;
|
|
105
|
+
Percent?: unknown;
|
|
106
|
+
ResetTimestamp?: unknown;
|
|
107
|
+
[k: string]: unknown;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
type UsageResponse = {
|
|
111
|
+
ResponseMetadata?: {
|
|
112
|
+
Error?: { Code?: unknown; Message?: unknown; [k: string]: unknown };
|
|
113
|
+
[k: string]: unknown;
|
|
114
|
+
};
|
|
115
|
+
Result?: {
|
|
116
|
+
Status?: unknown;
|
|
117
|
+
UpdateTimestamp?: unknown;
|
|
118
|
+
QuotaUsage?: QuotaEntry[];
|
|
119
|
+
[k: string]: unknown;
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
function isFiniteNumber(v: unknown): v is number {
|
|
124
|
+
return typeof v === "number" && Number.isFinite(v);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function fmtReset(sec: number): string {
|
|
128
|
+
// API returns epoch seconds; render as ISO 8601 in UTC+8 for readability.
|
|
129
|
+
const tz = new Date(sec * 1000);
|
|
130
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
131
|
+
timeZone: "Asia/Shanghai",
|
|
132
|
+
year: "numeric",
|
|
133
|
+
month: "2-digit",
|
|
134
|
+
day: "2-digit",
|
|
135
|
+
hour: "2-digit",
|
|
136
|
+
minute: "2-digit",
|
|
137
|
+
second: "2-digit",
|
|
138
|
+
hour12: false,
|
|
139
|
+
}).formatToParts(tz);
|
|
140
|
+
const get = (t: string): string =>
|
|
141
|
+
parts.find((p) => p.type === t)?.value ?? "";
|
|
142
|
+
return `${get("year")}-${get("month")}-${get("day")}T${get("hour")}:${get("minute")}:${get("second")}+08:00`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function validatePeriod(raw: QuotaEntry): ArkPeriod | null {
|
|
146
|
+
if (!isFiniteNumber(raw.Percent) || raw.Percent < 0) return null;
|
|
147
|
+
const label =
|
|
148
|
+
typeof raw.Level === "string" && raw.Level.length > 0
|
|
149
|
+
? raw.Level
|
|
150
|
+
: "unknown";
|
|
151
|
+
const out: ArkPeriod = {
|
|
152
|
+
label,
|
|
153
|
+
percent: Math.min(raw.Percent, 100),
|
|
154
|
+
};
|
|
155
|
+
if (isFiniteNumber(raw.ResetTimestamp) && raw.ResetTimestamp > 0) {
|
|
156
|
+
out.reset_at = fmtReset(raw.ResetTimestamp);
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function itemFromResponse(
|
|
162
|
+
res: UsageResponse,
|
|
163
|
+
): { item: ArkPlanItem } | { error: string } {
|
|
164
|
+
const apiError = res.ResponseMetadata?.Error;
|
|
165
|
+
if (apiError) {
|
|
166
|
+
const code = typeof apiError.Code === "string" ? apiError.Code : "Unknown";
|
|
167
|
+
const msg =
|
|
168
|
+
typeof apiError.Message === "string" ? apiError.Message : "API error";
|
|
169
|
+
return { error: `${code}: ${msg}` };
|
|
170
|
+
}
|
|
171
|
+
const result = res.Result;
|
|
172
|
+
if (!result || typeof result !== "object") {
|
|
173
|
+
return { error: "invalid response: missing Result" };
|
|
174
|
+
}
|
|
175
|
+
const entries = Array.isArray(result.QuotaUsage) ? result.QuotaUsage : [];
|
|
176
|
+
const periods = entries
|
|
177
|
+
.map(validatePeriod)
|
|
178
|
+
.filter((p): p is ArkPeriod => p !== null);
|
|
179
|
+
if (periods.length === 0) {
|
|
180
|
+
return { error: "response has no usable quota periods" };
|
|
181
|
+
}
|
|
182
|
+
const item: ArkPlanItem = {
|
|
183
|
+
product: REPORTED_PRODUCT,
|
|
184
|
+
edition: "personal",
|
|
185
|
+
subscribed: true,
|
|
186
|
+
periods,
|
|
187
|
+
};
|
|
188
|
+
if (isFiniteNumber(result.UpdateTimestamp) && result.UpdateTimestamp > 0) {
|
|
189
|
+
item.updated_at = result.UpdateTimestamp * 1000;
|
|
190
|
+
}
|
|
191
|
+
return { item };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Parse and validate the raw OpenAPI JSON. Throws on malformed payload. */
|
|
195
|
+
export function parseUsageResponse(raw: string): ArkPlanOutput {
|
|
196
|
+
if (raw.length > MAX_JSON_BYTES) throw new Error("API response too large");
|
|
197
|
+
let json: UsageResponse;
|
|
198
|
+
try {
|
|
199
|
+
json = JSON.parse(raw) as UsageResponse;
|
|
200
|
+
} catch {
|
|
201
|
+
throw new Error("invalid API response: not JSON");
|
|
202
|
+
}
|
|
203
|
+
if (!json || typeof json !== "object") {
|
|
204
|
+
throw new Error("invalid API response");
|
|
205
|
+
}
|
|
206
|
+
const r = itemFromResponse(json);
|
|
207
|
+
if ("error" in r) throw new Error(r.error);
|
|
208
|
+
return { items: [r.item] };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* --------------------------------- fetching ---------------------------------- */
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Query `GetCodingPlanUsage` via AK/SK-signed OpenAPI request.
|
|
215
|
+
* Throws on missing credentials, transport errors, API errors or unusable data.
|
|
216
|
+
*/
|
|
217
|
+
export async function fetchPlan(
|
|
218
|
+
options: FetchPlanOptions = {},
|
|
219
|
+
): Promise<ArkPlanOutput> {
|
|
220
|
+
const creds = await resolveCredentials();
|
|
221
|
+
if (!creds) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
"no Volcengine credentials: set VOLC_ACCESSKEY/VOLC_SECRETKEY or create ~/.volc/config",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const body = "{}";
|
|
228
|
+
const request = {
|
|
229
|
+
region: REGION,
|
|
230
|
+
method: "POST" as const,
|
|
231
|
+
pathname: "/",
|
|
232
|
+
params: { Action: USAGE_ACTION, Version: ARK_VERSION },
|
|
233
|
+
headers: { "Content-Type": "application/json" } as Record<string, string>,
|
|
234
|
+
body,
|
|
235
|
+
};
|
|
236
|
+
new Signer(request, SERVICE).addAuthorization({
|
|
237
|
+
accessKeyId: creds.accessKeyId,
|
|
238
|
+
secretKey: creds.secretKey,
|
|
239
|
+
sessionToken: creds.sessionToken ?? "",
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const query = new URLSearchParams(request.params).toString();
|
|
243
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
244
|
+
const signal = options.signal ?? AbortSignal.timeout(timeoutMs);
|
|
245
|
+
|
|
246
|
+
let res: Response;
|
|
247
|
+
try {
|
|
248
|
+
res = await fetch(`https://${OPEN_HOST}/?${query}`, {
|
|
249
|
+
method: "POST",
|
|
250
|
+
headers: request.headers,
|
|
251
|
+
body,
|
|
252
|
+
signal,
|
|
253
|
+
});
|
|
254
|
+
} catch (e) {
|
|
255
|
+
throw new Error(`request failed: ${(e as Error).message}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const text = await res.text();
|
|
259
|
+
if (!res.ok) {
|
|
260
|
+
throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`);
|
|
261
|
+
}
|
|
262
|
+
return parseUsageResponse(text);
|
|
263
|
+
}
|
package/extensions/lib/arkcli.ts
CHANGED
|
@@ -1,129 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `arkcli`
|
|
3
|
-
*
|
|
2
|
+
* Compatibility layer: `lib/arkcli.js` used to wrap the `arkcli` binary; the
|
|
3
|
+
* extension now talks to the Ark OpenAPI directly with AK/SK (see `aksk.ts`).
|
|
4
|
+
* All exports keep their names so callers don't change.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const MAX_JSON_BYTES = 1024 * 1024;
|
|
14
|
-
|
|
15
|
-
const PRODUCT_IDS = new Set([
|
|
16
|
-
"coding-plan",
|
|
17
|
-
"agent-plan",
|
|
18
|
-
"coding-plan-team",
|
|
19
|
-
"agent-plan-team",
|
|
20
|
-
]);
|
|
21
|
-
|
|
22
|
-
/** Locate arkcli: explicit env override, else plain name (resolved via PATH). */
|
|
23
|
-
export function arkcliBin(): string {
|
|
24
|
-
return process.env.PI_ARK_USAGE_ARKCLI || "arkcli";
|
|
25
|
-
}
|
|
7
|
+
import type { ArkPlanOutput } from "./types.js";
|
|
8
|
+
import {
|
|
9
|
+
fetchPlan as fetchPlanAksk,
|
|
10
|
+
checkCredentialsAvailable,
|
|
11
|
+
} from "./aksk.js";
|
|
12
|
+
import type { FetchPlanOptions } from "./aksk.js";
|
|
13
|
+
export type { ArkPlanOutput, FetchPlanOptions } from "./aksk.js";
|
|
26
14
|
|
|
15
|
+
/** @deprecated legacy probe kept only for interface compatibility; always true. */
|
|
27
16
|
export async function checkArkcliAvailable(): Promise<boolean> {
|
|
28
|
-
|
|
29
|
-
await execFileAsync(arkcliBin(), ["--help"], { timeout: 5_000 });
|
|
30
|
-
return true;
|
|
31
|
-
} catch {
|
|
32
|
-
// --help may exit non-zero in odd wrappers; fall back to version check.
|
|
33
|
-
try {
|
|
34
|
-
await execFileAsync(arkcliBin(), ["version"], { timeout: 5_000 });
|
|
35
|
-
return true;
|
|
36
|
-
} catch {
|
|
37
|
-
return false;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function isFiniteNumber(v: unknown): v is number {
|
|
43
|
-
return typeof v === "number" && Number.isFinite(v);
|
|
17
|
+
return checkCredentialsAvailable();
|
|
44
18
|
}
|
|
45
19
|
|
|
46
|
-
function validatePeriod(raw: unknown): ArkPeriod | null {
|
|
47
|
-
if (!raw || typeof raw !== "object") return null;
|
|
48
|
-
const p = raw as Record<string, unknown>;
|
|
49
|
-
if (typeof p.label !== "string" || p.label.length === 0) return null;
|
|
50
|
-
if (!isFiniteNumber(p.percent) || p.percent < 0) return null;
|
|
51
|
-
const out: ArkPeriod = {
|
|
52
|
-
label: p.label,
|
|
53
|
-
percent: Math.min(p.percent, 100),
|
|
54
|
-
};
|
|
55
|
-
if (typeof p.reset_at === "string" && p.reset_at.length > 0) {
|
|
56
|
-
out.reset_at = p.reset_at;
|
|
57
|
-
}
|
|
58
|
-
return out;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function validateItem(raw: unknown): ArkPlanItem | null {
|
|
62
|
-
if (!raw || typeof raw !== "object") return null;
|
|
63
|
-
const it = raw as Record<string, unknown>;
|
|
64
|
-
if (typeof it.product !== "string" || !PRODUCT_IDS.has(it.product)) return null;
|
|
65
|
-
if (typeof it.edition !== "string") return null;
|
|
66
|
-
const out: ArkPlanItem = {
|
|
67
|
-
product: it.product,
|
|
68
|
-
edition: it.edition,
|
|
69
|
-
subscribed: it.subscribed === true,
|
|
70
|
-
};
|
|
71
|
-
if (typeof it.error === "string") out.error = it.error;
|
|
72
|
-
if (isFiniteNumber(it.updated_at)) out.updated_at = it.updated_at;
|
|
73
|
-
if (Array.isArray(it.periods)) {
|
|
74
|
-
const periods = it.periods
|
|
75
|
-
.map(validatePeriod)
|
|
76
|
-
.filter((p): p is ArkPeriod => p !== null);
|
|
77
|
-
if (periods.length > 0) out.periods = periods;
|
|
78
|
-
}
|
|
79
|
-
return out;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Parse and validate raw arkcli JSON. Throws on malformed payload. */
|
|
83
|
-
export function parsePlanOutput(raw: string): ArkPlanOutput {
|
|
84
|
-
if (raw.length > MAX_JSON_BYTES) throw new Error("arkcli output too large");
|
|
85
|
-
const json: unknown = JSON.parse(raw);
|
|
86
|
-
if (!json || typeof json !== "object") throw new Error("invalid arkcli output");
|
|
87
|
-
const root = json as Record<string, unknown>;
|
|
88
|
-
if (!Array.isArray(root.items)) throw new Error("invalid arkcli output: missing items");
|
|
89
|
-
const items = root.items
|
|
90
|
-
.map(validateItem)
|
|
91
|
-
.filter((it): it is ArkPlanItem => it !== null);
|
|
92
|
-
if (items.length === 0) throw new Error("arkcli returned no usable items");
|
|
93
|
-
const out: ArkPlanOutput = { items };
|
|
94
|
-
if (root.viewer && typeof root.viewer === "object") {
|
|
95
|
-
out.viewer = root.viewer as ArkPlanOutput["viewer"];
|
|
96
|
-
}
|
|
97
|
-
return out;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export type FetchPlanOptions = {
|
|
101
|
-
signal?: AbortSignal;
|
|
102
|
-
timeoutMs?: number;
|
|
103
|
-
/** specific product id; undefined = auto-discover all subscriptions */
|
|
104
|
-
product?: string;
|
|
105
|
-
seat?: string;
|
|
106
|
-
};
|
|
107
|
-
|
|
108
20
|
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
21
|
+
* Fetch Coding Plan usage.
|
|
22
|
+
* `product`/`seat` options are accepted for interface compatibility but
|
|
23
|
+
* ignored: the AK/SK OpenAPI reports the plan bound to the credential's
|
|
24
|
+
* account and has no product/seat filter.
|
|
111
25
|
*/
|
|
112
|
-
export
|
|
113
|
-
|
|
114
|
-
if (options.product) {
|
|
115
|
-
args.push("--product", options.product);
|
|
116
|
-
}
|
|
117
|
-
if (options.seat) {
|
|
118
|
-
args.push("--seat", options.seat);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const { stdout } = await execFileAsync(arkcliBin(), args, {
|
|
122
|
-
timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
123
|
-
maxBuffer: MAX_JSON_BYTES,
|
|
124
|
-
signal: options.signal,
|
|
125
|
-
// arkcli may print warnings on stderr; they don't fail the call unless exit code != 0.
|
|
126
|
-
windowsHide: true,
|
|
127
|
-
});
|
|
128
|
-
return parsePlanOutput(stdout);
|
|
26
|
+
export function fetchPlan(options: FetchPlanOptions = {}): Promise<ArkPlanOutput> {
|
|
27
|
+
return fetchPlanAksk(options);
|
|
129
28
|
}
|
package/extensions/lib/cache.ts
CHANGED
|
@@ -172,6 +172,11 @@ export function markShuttingDown(): void {
|
|
|
172
172
|
shuttingDown = true;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/** Revive cache writes after an in-process session switch (see index.ts). */
|
|
176
|
+
export function resetShutdownState(): void {
|
|
177
|
+
shuttingDown = false;
|
|
178
|
+
}
|
|
179
|
+
|
|
175
180
|
async function writeFile(data: string): Promise<void> {
|
|
176
181
|
await mkdir(CACHE_DIR, { recursive: true, mode: DIR_MODE });
|
|
177
182
|
const dir = await lstat(CACHE_DIR);
|
package/extensions/lib/types.ts
CHANGED
|
@@ -27,14 +27,14 @@ export type ArkSettings = {
|
|
|
27
27
|
|
|
28
28
|
export type PeriodLabel = "session" | "5h" | "weekly" | "monthly";
|
|
29
29
|
|
|
30
|
-
/** One billing period as returned by
|
|
30
|
+
/** One billing period as returned by the Ark usage API. */
|
|
31
31
|
export type ArkPeriod = {
|
|
32
32
|
label: PeriodLabel | string;
|
|
33
33
|
percent: number;
|
|
34
34
|
reset_at?: string;
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
-
/** One product entry
|
|
37
|
+
/** One product entry in a usage snapshot. */
|
|
38
38
|
export type ArkPlanItem = {
|
|
39
39
|
product: string;
|
|
40
40
|
edition: "personal" | "team" | string;
|
|
@@ -44,7 +44,7 @@ export type ArkPlanItem = {
|
|
|
44
44
|
updated_at?: number;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
-
/** Shape of
|
|
47
|
+
/** Shape of the normalized usage-fetch output. */
|
|
48
48
|
export type ArkPlanOutput = {
|
|
49
49
|
viewer?: {
|
|
50
50
|
auth_method?: string;
|
package/extensions/lib/widget.ts
CHANGED
|
@@ -49,7 +49,7 @@ const ZH: Locale = {
|
|
|
49
49
|
},
|
|
50
50
|
fetching: "请求中",
|
|
51
51
|
failed: "失败",
|
|
52
|
-
noBinary: "
|
|
52
|
+
noBinary: "未配置火山 AK/SK 凭据",
|
|
53
53
|
notSubscribed: "未订阅",
|
|
54
54
|
noData: "暂无数据",
|
|
55
55
|
using: "使用中",
|
|
@@ -87,7 +87,7 @@ const EN: Locale = {
|
|
|
87
87
|
},
|
|
88
88
|
fetching: "fetching",
|
|
89
89
|
failed: "failed",
|
|
90
|
-
noBinary: "
|
|
90
|
+
noBinary: "Volcengine AK/SK credentials not configured",
|
|
91
91
|
notSubscribed: "not subscribed",
|
|
92
92
|
noData: "no data",
|
|
93
93
|
using: "using",
|
|
@@ -124,7 +124,7 @@ export const AUTO_REFRESH_MAX_MINUTES = 30;
|
|
|
124
124
|
export const DEFAULT_SETTINGS: ArkSettings = {
|
|
125
125
|
pctYellow: 40,
|
|
126
126
|
pctRed: 80,
|
|
127
|
-
autoRefreshMinutes:
|
|
127
|
+
autoRefreshMinutes: 5,
|
|
128
128
|
product: "auto",
|
|
129
129
|
seat: "",
|
|
130
130
|
};
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ark-usage",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Show Volcengine Ark Coding Plan / Agent Plan usage in the pi TUI
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Show Volcengine Ark Coding Plan / Agent Plan usage in the pi TUI (AK/SK OpenAPI)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
7
7
|
"pi-package",
|
|
8
8
|
"extension",
|
|
9
9
|
"volcengine",
|
|
10
10
|
"ark",
|
|
11
|
-
"
|
|
11
|
+
"openapi",
|
|
12
12
|
"coding-plan",
|
|
13
13
|
"usage",
|
|
14
14
|
"quota",
|
|
@@ -33,10 +33,14 @@
|
|
|
33
33
|
},
|
|
34
34
|
"main": "extensions/index.ts",
|
|
35
35
|
"scripts": {
|
|
36
|
-
"typecheck": "tsc --noEmit"
|
|
36
|
+
"typecheck": "tsc --noEmit",
|
|
37
|
+
"test": "node --import ./test/ts-resolve-hook.mjs --test --experimental-strip-types test/*.test.ts"
|
|
37
38
|
},
|
|
38
39
|
"devDependencies": {
|
|
39
40
|
"@types/node": "^22.19.19",
|
|
40
41
|
"typescript": "^5.9.3"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@volcengine/openapi": "^1.36.2"
|
|
41
45
|
}
|
|
42
46
|
}
|