pi-multi-quota 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/package.json +29 -0
- package/src/cache.ts +152 -0
- package/src/config.ts +170 -0
- package/src/cookie.ts +148 -0
- package/src/footer.ts +216 -0
- package/src/index.ts +440 -0
- package/src/registry.ts +161 -0
- package/src/sources/ark.ts +257 -0
- package/src/sources/deepseek.ts +189 -0
- package/src/sources/opencode.ts +186 -0
- package/src/types.ts +64 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ark(火山引擎 Coding Plan)额度适配器。
|
|
3
|
+
*
|
|
4
|
+
* 端点、响应结构(实测所得,数值已合成)与失败码见 SPEC.md §2.3。
|
|
5
|
+
* Ark 特例:**鉴权失败时 HTTP 仍是 200**,失败信息在 body 的 `ResponseMetadata.Error.Code` 里,
|
|
6
|
+
* 只看状态码会把失败误判成成功。
|
|
7
|
+
* 零运行时依赖;网络调用经 `fetchImpl` 注入;不写日志,error.message 只含中文短句、不回显凭据与响应体。
|
|
8
|
+
*/
|
|
9
|
+
import { parseArkCookie } from "../cookie.js";
|
|
10
|
+
import { redact } from "../config.js";
|
|
11
|
+
import type { ArkAccountConfig } from "../config.js";
|
|
12
|
+
import type { FetchOptions } from "./opencode.js";
|
|
13
|
+
import type { AccountReport, QuotaWindow, WindowLevel } from "../types.js";
|
|
14
|
+
|
|
15
|
+
/** Ark Coding Plan 用量端点。 */
|
|
16
|
+
export const ARK_USAGE_URL =
|
|
17
|
+
"https://console.volcengine.com/api/top/ark/cn-beijing/2024-01-01/GetCodingPlanUsage?";
|
|
18
|
+
|
|
19
|
+
/** 唯一允许携带凭据的 host(SPEC §6)。 */
|
|
20
|
+
export const ARK_ALLOWED_HOST = "console.volcengine.com";
|
|
21
|
+
|
|
22
|
+
/** 默认超时,与 src/sources/opencode.ts 一致。 */
|
|
23
|
+
const DEFAULT_TIMEOUT_MS = 20000;
|
|
24
|
+
|
|
25
|
+
/** 响应 Level → 内部窗口粒度;表外的 Level 视为接口变更:跳过并记 note。 */
|
|
26
|
+
const LEVELS: ReadonlyMap<string, WindowLevel> = new Map<string, WindowLevel>([
|
|
27
|
+
["session", "session"],
|
|
28
|
+
["weekly", "weekly"],
|
|
29
|
+
["monthly", "monthly"],
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/** 已知失败码 → 可安全展示的中文短句;其余码原样透传,message 走通用短句。 */
|
|
33
|
+
const KNOWN_ERROR_MESSAGES: ReadonlyMap<string, string> = new Map([
|
|
34
|
+
["NotLogin", "cookie 已过期或无效"],
|
|
35
|
+
["InvalidCSRFToken", "cookie 不完整"],
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
39
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
40
|
+
return value as Record<string, unknown>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isAbortError(err: unknown): boolean {
|
|
44
|
+
return err instanceof Error && err.name === "AbortError";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 构造失败报告:error 只含错误码与人可读短句。 */
|
|
48
|
+
function failure(
|
|
49
|
+
accountId: string,
|
|
50
|
+
displayName: string,
|
|
51
|
+
fetchedAt: number,
|
|
52
|
+
code: string,
|
|
53
|
+
message: string,
|
|
54
|
+
): AccountReport {
|
|
55
|
+
return {
|
|
56
|
+
accountId,
|
|
57
|
+
displayName,
|
|
58
|
+
sourceId: "ark",
|
|
59
|
+
kind: "windows",
|
|
60
|
+
fetchedAt,
|
|
61
|
+
error: { code, message },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* URL host 白名单校验(SPEC §6):发送任何凭据**之前**必须先过这一关。
|
|
67
|
+
* URL 不可解析、或 host(含端口)与 `ARK_ALLOWED_HOST` 不完全相等,都返回 false。
|
|
68
|
+
*/
|
|
69
|
+
export function isArkAllowedUrl(url: string): boolean {
|
|
70
|
+
try {
|
|
71
|
+
return new URL(url).host === ARK_ALLOWED_HOST;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* `account.id` → 展示名:"ark-a" → "Ark-A"。
|
|
79
|
+
* 契约类型 `ArkAccountConfig`(见 ../config.ts)只有 id/provider/cookie,没有 displayName,
|
|
80
|
+
* 故由 id 派生;若需其他写法,调用方可在展示层覆盖 report.displayName。
|
|
81
|
+
*/
|
|
82
|
+
function displayNameFor(accountId: string): string {
|
|
83
|
+
const parts = accountId.split(/[-_]+/).filter((part) => part !== "");
|
|
84
|
+
if (parts.length === 0) return accountId;
|
|
85
|
+
return parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("-");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 纯解析:GetCodingPlanUsage payload → AccountReport。
|
|
90
|
+
*
|
|
91
|
+
* `ResponseMetadata.Error` 存在即失败(HTTP 200 也可能是失败);`QuotaUsage` 里
|
|
92
|
+
* Level 未知 / Percent 非法 / Level 重复的条目跳过并记 note;`ResetTimestamp` 已是 epoch 秒,直接使用。
|
|
93
|
+
*/
|
|
94
|
+
export function parseArkUsage(
|
|
95
|
+
payload: unknown,
|
|
96
|
+
fetchedAt: number,
|
|
97
|
+
accountId: string,
|
|
98
|
+
displayName: string,
|
|
99
|
+
): AccountReport {
|
|
100
|
+
const fail = (code: string, message: string): AccountReport =>
|
|
101
|
+
failure(accountId, displayName, fetchedAt, code, message);
|
|
102
|
+
const changed = (): AccountReport => fail("unknown-shape", "接口变更");
|
|
103
|
+
|
|
104
|
+
const root = asRecord(payload);
|
|
105
|
+
if (root === undefined) return changed();
|
|
106
|
+
|
|
107
|
+
const metadata = asRecord(root["ResponseMetadata"]);
|
|
108
|
+
const error = metadata === undefined ? undefined : asRecord(metadata["Error"]);
|
|
109
|
+
if (error !== undefined) {
|
|
110
|
+
const code = error["Code"];
|
|
111
|
+
if (typeof code !== "string" || code === "") return changed();
|
|
112
|
+
const known = KNOWN_ERROR_MESSAGES.get(code);
|
|
113
|
+
if (known !== undefined) return fail(code, known);
|
|
114
|
+
// 未知 code 归入 unknown-shape,而不是把原始 code 塞进 QuotaError.code ——
|
|
115
|
+
// 否则 footer 会展示出 ERROR_TEXTS 表之外的英文错误码。
|
|
116
|
+
// 原始 code 经脱敏后记入 notes,保留排查线索。
|
|
117
|
+
const report = fail("unknown-shape", "接口变更");
|
|
118
|
+
return { ...report, notes: [`Ark 返回未知错误码 ${redact(code)}`] };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const result = asRecord(root["Result"]);
|
|
122
|
+
const rawUsage = result === undefined ? undefined : result["QuotaUsage"];
|
|
123
|
+
if (!Array.isArray(rawUsage)) return changed();
|
|
124
|
+
|
|
125
|
+
const windows: QuotaWindow[] = [];
|
|
126
|
+
const notes: string[] = [];
|
|
127
|
+
|
|
128
|
+
for (const item of rawUsage) {
|
|
129
|
+
const entry = asRecord(item);
|
|
130
|
+
if (entry === undefined) {
|
|
131
|
+
notes.push("存在结构异常的窗口条目,已跳过");
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const rawLevel = entry["Level"];
|
|
135
|
+
const level = typeof rawLevel === "string" ? LEVELS.get(rawLevel) : undefined;
|
|
136
|
+
if (level === undefined) {
|
|
137
|
+
notes.push(`未知窗口 Level "${redact(String(rawLevel))}",已跳过`);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (windows.some((existing) => existing.level === level)) {
|
|
141
|
+
notes.push(`窗口 ${level} 重复出现,已跳过`);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const percent = entry["Percent"];
|
|
145
|
+
if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0) {
|
|
146
|
+
notes.push(`窗口 ${level} 的 Percent 不是非负有限数字,已跳过`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const window: QuotaWindow = { level, percent };
|
|
150
|
+
const resetTimestamp = entry["ResetTimestamp"];
|
|
151
|
+
// epoch 秒,不做毫秒换算
|
|
152
|
+
if (typeof resetTimestamp === "number" && Number.isFinite(resetTimestamp)) {
|
|
153
|
+
window.resetsAt = resetTimestamp;
|
|
154
|
+
}
|
|
155
|
+
windows.push(window);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (windows.length === 0) return changed();
|
|
159
|
+
|
|
160
|
+
const report: AccountReport = {
|
|
161
|
+
accountId,
|
|
162
|
+
displayName,
|
|
163
|
+
sourceId: "ark",
|
|
164
|
+
kind: "windows",
|
|
165
|
+
windows,
|
|
166
|
+
fetchedAt,
|
|
167
|
+
};
|
|
168
|
+
if (notes.length > 0) report.notes = notes;
|
|
169
|
+
return report;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 查询某个 Ark 账号的额度。
|
|
174
|
+
* cookie 缺失 / 白名单不通过 / 网络异常 / 超时 / 非 2xx / 响应畸形,一律返回带 error 的报告,不抛异常。
|
|
175
|
+
*/
|
|
176
|
+
export async function fetchArkUsage(
|
|
177
|
+
account: ArkAccountConfig,
|
|
178
|
+
opts: FetchOptions = {},
|
|
179
|
+
): Promise<AccountReport> {
|
|
180
|
+
const fetchedAt = Date.now();
|
|
181
|
+
const displayName = displayNameFor(account.id);
|
|
182
|
+
const fail = (code: string, message: string): AccountReport =>
|
|
183
|
+
failure(account.id, displayName, fetchedAt, code, message);
|
|
184
|
+
|
|
185
|
+
if (typeof account.cookie !== "string" || account.cookie.trim() === "") {
|
|
186
|
+
return fail("missing-credential", "未配置 Ark cookie");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// host 白名单在解析 cookie 与发请求之前:不匹配绝不发送凭据
|
|
190
|
+
if (!isArkAllowedUrl(ARK_USAGE_URL)) {
|
|
191
|
+
return fail("blocked-host", "请求目标不在白名单内,已阻止");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// x-csrf-token 由 cookie 内联解析得到(SPEC §2.3):用户只维护一个 cookie 字符串
|
|
195
|
+
const parsed = parseArkCookie(account.cookie);
|
|
196
|
+
if (!parsed.ok) return fail("InvalidCSRFToken", parsed.message);
|
|
197
|
+
|
|
198
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
199
|
+
const timeoutMs =
|
|
200
|
+
typeof opts.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0
|
|
201
|
+
? opts.timeoutMs
|
|
202
|
+
: DEFAULT_TIMEOUT_MS;
|
|
203
|
+
|
|
204
|
+
const controller = new AbortController();
|
|
205
|
+
let timedOut = false;
|
|
206
|
+
const timer = setTimeout(() => {
|
|
207
|
+
timedOut = true;
|
|
208
|
+
controller.abort();
|
|
209
|
+
}, timeoutMs);
|
|
210
|
+
const externalSignal = opts.signal;
|
|
211
|
+
const onExternalAbort = (): void => controller.abort();
|
|
212
|
+
if (externalSignal !== undefined) {
|
|
213
|
+
if (externalSignal.aborted) controller.abort();
|
|
214
|
+
else externalSignal.addEventListener("abort", onExternalAbort, { once: true });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
let response: Response;
|
|
219
|
+
try {
|
|
220
|
+
response = await fetchImpl(ARK_USAGE_URL, {
|
|
221
|
+
method: "POST",
|
|
222
|
+
headers: {
|
|
223
|
+
"content-type": "application/json",
|
|
224
|
+
"x-csrf-token": parsed.value.csrfToken,
|
|
225
|
+
cookie: account.cookie,
|
|
226
|
+
Accept: "application/json",
|
|
227
|
+
},
|
|
228
|
+
body: "{}",
|
|
229
|
+
redirect: "error", // 拒绝重定向,防止凭据被转发到白名单外的 host(SPEC §6)
|
|
230
|
+
signal: controller.signal,
|
|
231
|
+
});
|
|
232
|
+
} catch (err) {
|
|
233
|
+
if (timedOut) return fail("timeout", `Ark 接口请求超时(${timeoutMs}ms)`);
|
|
234
|
+
if (isAbortError(err)) return fail("network", "Ark 请求已取消");
|
|
235
|
+
return fail("network", "Ark 网络请求失败");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!response.ok) return fail(`http-${response.status}`, `Ark 接口返回 HTTP ${response.status}`);
|
|
239
|
+
|
|
240
|
+
let payload: unknown;
|
|
241
|
+
try {
|
|
242
|
+
payload = await response.json();
|
|
243
|
+
} catch {
|
|
244
|
+
// 超时可能落在 body 读取阶段(fetch 已 resolve、body 未读完),
|
|
245
|
+
// 此时应报 timeout,而非误报「接口变更」
|
|
246
|
+
if (timedOut || controller.signal.aborted) {
|
|
247
|
+
return fail("timeout", `Ark 响应读取超时(${timeoutMs}ms)`);
|
|
248
|
+
}
|
|
249
|
+
return fail("unknown-shape", "响应不是合法 JSON");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return parseArkUsage(payload, fetchedAt, account.id, displayName);
|
|
253
|
+
} finally {
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
externalSignal?.removeEventListener("abort", onExternalAbort);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek 余额适配器。
|
|
3
|
+
*
|
|
4
|
+
* 数据模型见 SPEC.md §3.2;响应结构为实测所得,数值已合成(SPEC.md §2.2)。
|
|
5
|
+
* 硬约束:金额(total / granted / toppedUp)全程保持接口返回的**字符串**,
|
|
6
|
+
* 不得转 Number —— 转 float 会丢精度(SPEC §2.2 / §3.2)。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { FetchOptions } from "./opencode.js";
|
|
10
|
+
import type { AccountReport, BalanceEntry } from "../types.js";
|
|
11
|
+
|
|
12
|
+
/** DeepSeek 余额查询端点。 */
|
|
13
|
+
export const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance";
|
|
14
|
+
|
|
15
|
+
/** DeepSeek 在 AccountReport 中的账号 id。 */
|
|
16
|
+
export const DEEPSEEK_ACCOUNT_ID = "deepseek";
|
|
17
|
+
|
|
18
|
+
const DISPLAY_NAME = "DeepSeek";
|
|
19
|
+
|
|
20
|
+
/** 默认超时,与 src/sources/opencode.ts 保持一致。 */
|
|
21
|
+
const DEFAULT_TIMEOUT_MS = 20000;
|
|
22
|
+
|
|
23
|
+
/** 构造失败报告:error 只含错误码与人可读短句,不含凭据与响应体。 */
|
|
24
|
+
function failure(fetchedAt: number, code: string, message: string): AccountReport {
|
|
25
|
+
return {
|
|
26
|
+
accountId: DEEPSEEK_ACCOUNT_ID,
|
|
27
|
+
displayName: DISPLAY_NAME,
|
|
28
|
+
sourceId: "deepseek",
|
|
29
|
+
kind: "balance",
|
|
30
|
+
fetchedAt,
|
|
31
|
+
error: { code, message },
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 类型守卫:非对象(含 null 与数组)返回 undefined。 */
|
|
36
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
37
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
38
|
+
return value as Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 字段守卫:只接受非空字符串,数字形态一律不认(避免隐式精度损失)。 */
|
|
42
|
+
function asNonEmptyString(value: unknown): string | undefined {
|
|
43
|
+
if (typeof value !== "string" || value.trim() === "") return undefined;
|
|
44
|
+
return value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isAbortError(err: unknown): boolean {
|
|
48
|
+
return err instanceof Error && err.name === "AbortError";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 纯解析:DeepSeek `/user/balance` payload → AccountReport。
|
|
53
|
+
*
|
|
54
|
+
* - `is_available === false`:账户不可用,返回带 error 且**不带 balances** 的报告。
|
|
55
|
+
* - `balance_infos` 为空或全部结构异常:返回 error(绝不显示 ¥0.00)。
|
|
56
|
+
* - 多币种各自一条 BalanceEntry,不相加、不换算。
|
|
57
|
+
*
|
|
58
|
+
* 畸形 payload 不抛异常,统一走 error 报告,便于 registry 直接展示。
|
|
59
|
+
*/
|
|
60
|
+
export function parseDeepSeekBalance(payload: unknown, fetchedAt: number): AccountReport {
|
|
61
|
+
const root = asRecord(payload);
|
|
62
|
+
if (root === undefined) {
|
|
63
|
+
return failure(fetchedAt, "unknown-shape", "DeepSeek 余额响应不是对象");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (root["is_available"] === false) {
|
|
67
|
+
return failure(fetchedAt, "http-402", "DeepSeek 账户当前不可用(余额不足或账户已停用)");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const rawInfos = root["balance_infos"];
|
|
71
|
+
if (!Array.isArray(rawInfos)) {
|
|
72
|
+
return failure(fetchedAt, "unknown-shape", "DeepSeek 余额响应缺少 balance_infos 数组");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const balances: BalanceEntry[] = [];
|
|
76
|
+
let skipped = 0;
|
|
77
|
+
|
|
78
|
+
for (const rawInfo of rawInfos) {
|
|
79
|
+
const info = asRecord(rawInfo);
|
|
80
|
+
if (info === undefined) {
|
|
81
|
+
skipped += 1;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const currency = asNonEmptyString(info["currency"]);
|
|
85
|
+
const total = asNonEmptyString(info["total_balance"]);
|
|
86
|
+
if (currency === undefined || total === undefined) {
|
|
87
|
+
skipped += 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const entry: BalanceEntry = { currency, total };
|
|
92
|
+
const granted = asNonEmptyString(info["granted_balance"]);
|
|
93
|
+
if (granted !== undefined) entry.granted = granted;
|
|
94
|
+
const toppedUp = asNonEmptyString(info["topped_up_balance"]);
|
|
95
|
+
if (toppedUp !== undefined) entry.toppedUp = toppedUp;
|
|
96
|
+
balances.push(entry);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (balances.length === 0) {
|
|
100
|
+
return failure(fetchedAt, "unknown-shape", "DeepSeek 余额响应没有可用的余额记录");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const report: AccountReport = {
|
|
104
|
+
accountId: DEEPSEEK_ACCOUNT_ID,
|
|
105
|
+
displayName: DISPLAY_NAME,
|
|
106
|
+
sourceId: "deepseek",
|
|
107
|
+
kind: "balance",
|
|
108
|
+
fetchedAt,
|
|
109
|
+
balances,
|
|
110
|
+
};
|
|
111
|
+
if (skipped > 0) {
|
|
112
|
+
report.notes = [`${skipped} 条余额记录结构异常,已跳过`];
|
|
113
|
+
}
|
|
114
|
+
return report;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 拉取 DeepSeek 余额。
|
|
119
|
+
* 网络异常 / 超时 / 非 2xx / 响应畸形一律返回带 error 的 AccountReport,不抛异常。
|
|
120
|
+
*/
|
|
121
|
+
export async function fetchDeepSeekBalance(
|
|
122
|
+
apiKey: string,
|
|
123
|
+
opts: FetchOptions = {},
|
|
124
|
+
): Promise<AccountReport> {
|
|
125
|
+
const fetchedAt = Date.now();
|
|
126
|
+
|
|
127
|
+
if (typeof apiKey !== "string" || apiKey.trim() === "") {
|
|
128
|
+
return failure(fetchedAt, "missing-credential", "未配置 DeepSeek API key");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
132
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
133
|
+
|
|
134
|
+
const controller = new AbortController();
|
|
135
|
+
let timedOut = false;
|
|
136
|
+
const timer = setTimeout(() => {
|
|
137
|
+
timedOut = true;
|
|
138
|
+
controller.abort();
|
|
139
|
+
}, timeoutMs);
|
|
140
|
+
|
|
141
|
+
const externalSignal = opts.signal;
|
|
142
|
+
const onExternalAbort = () => controller.abort();
|
|
143
|
+
if (externalSignal !== undefined) {
|
|
144
|
+
if (externalSignal.aborted) controller.abort();
|
|
145
|
+
else externalSignal.addEventListener("abort", onExternalAbort, { once: true });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
let response: Response;
|
|
150
|
+
try {
|
|
151
|
+
response = await fetchImpl(DEEPSEEK_BALANCE_URL, {
|
|
152
|
+
method: "GET",
|
|
153
|
+
headers: {
|
|
154
|
+
Authorization: `Bearer ${apiKey}`,
|
|
155
|
+
Accept: "application/json",
|
|
156
|
+
},
|
|
157
|
+
redirect: "error",
|
|
158
|
+
signal: controller.signal,
|
|
159
|
+
});
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (timedOut) {
|
|
162
|
+
return failure(fetchedAt, "timeout", `DeepSeek 接口请求超时(${timeoutMs}ms)`);
|
|
163
|
+
}
|
|
164
|
+
if (isAbortError(err)) {
|
|
165
|
+
return failure(fetchedAt, "network", "DeepSeek 请求已取消");
|
|
166
|
+
}
|
|
167
|
+
return failure(fetchedAt, "network", "DeepSeek 网络请求失败");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
return failure(fetchedAt, `http-${response.status}`, `DeepSeek 接口返回 HTTP ${response.status}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let payload: unknown;
|
|
175
|
+
try {
|
|
176
|
+
payload = await response.json();
|
|
177
|
+
} catch {
|
|
178
|
+
if (timedOut || controller.signal.aborted) {
|
|
179
|
+
return failure(fetchedAt, "timeout", `DeepSeek 响应读取超时(${timeoutMs}ms)`);
|
|
180
|
+
}
|
|
181
|
+
return failure(fetchedAt, "unknown-shape", "DeepSeek 响应不是合法 JSON");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return parseDeepSeekBalance(payload, fetchedAt);
|
|
185
|
+
} finally {
|
|
186
|
+
clearTimeout(timer);
|
|
187
|
+
externalSignal?.removeEventListener("abort", onExternalAbort);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode Go(Zen)额度适配器。
|
|
3
|
+
*
|
|
4
|
+
* 端点与响应结构见 SPEC §2.1,错误码语义与展示约束见 SPEC §5 / §6。
|
|
5
|
+
* 零运行时依赖;网络调用通过 `fetchImpl` 注入,测试不触网。
|
|
6
|
+
*/
|
|
7
|
+
import { redact } from "../config.js";
|
|
8
|
+
import type { AccountReport, QuotaWindow, WindowLevel } from "../types.js";
|
|
9
|
+
|
|
10
|
+
export const OPENCODE_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
|
|
11
|
+
export const OPENCODE_ACCOUNT_ID = "opencode";
|
|
12
|
+
|
|
13
|
+
/** fetch 未传 timeoutMs 时的默认超时。 */
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 20000;
|
|
15
|
+
|
|
16
|
+
/** 响应中的窗口字段 → 内部窗口粒度。顺序即 windows 数组的输出顺序。 */
|
|
17
|
+
const WINDOW_FIELDS: ReadonlyArray<readonly [string, WindowLevel]> = [
|
|
18
|
+
["rolling", "session"],
|
|
19
|
+
["weekly", "weekly"],
|
|
20
|
+
["monthly", "monthly"],
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/** 视为可用的窗口状态,其余状态一律降级为 note。 */
|
|
24
|
+
const USABLE_STATUSES: ReadonlySet<string> = new Set(["ok", "rate-limited"]);
|
|
25
|
+
|
|
26
|
+
export interface FetchOptions {
|
|
27
|
+
fetchImpl?: typeof fetch;
|
|
28
|
+
signal?: AbortSignal;
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
33
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** ISO 时间字符串 → epoch 秒;不可解析时返回 undefined(该窗口不带重置时间)。 */
|
|
37
|
+
function toEpochSeconds(value: unknown): number | undefined {
|
|
38
|
+
if (typeof value !== "string") return undefined;
|
|
39
|
+
const ms = Date.parse(value);
|
|
40
|
+
if (!Number.isFinite(ms)) return undefined;
|
|
41
|
+
return Math.floor(ms / 1000);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 从错误对象取可安全展示的短句;绝不回显原始响应体。 */
|
|
45
|
+
function messageOf(err: unknown): string {
|
|
46
|
+
return err instanceof Error && err.message !== "" ? err.message : "用量数据结构无法识别";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isAbortError(err: unknown): boolean {
|
|
50
|
+
return err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 构造失败报告(不含凭据、不含响应体)。 */
|
|
54
|
+
function errorReport(code: string, message: string, fetchedAt: number): AccountReport {
|
|
55
|
+
return {
|
|
56
|
+
accountId: OPENCODE_ACCOUNT_ID,
|
|
57
|
+
displayName: "Zen",
|
|
58
|
+
sourceId: "opencode",
|
|
59
|
+
kind: "windows",
|
|
60
|
+
fetchedAt,
|
|
61
|
+
error: { code, message },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 纯解析:OpenCode `/v1/usage` 响应 → AccountReport。
|
|
67
|
+
*
|
|
68
|
+
* 窗口映射:rolling → session,weekly → weekly,monthly → monthly。
|
|
69
|
+
* 状态非 ok / rate-limited 的窗口跳过并记入 notes;percent 非非负有限数字的窗口同样跳过。
|
|
70
|
+
* 响应结构不可识别或三个窗口全部不可用时抛 Error(message 不含凭据)。
|
|
71
|
+
*/
|
|
72
|
+
export function parseOpenCodeUsage(payload: unknown, fetchedAt: number): AccountReport {
|
|
73
|
+
const usage = isRecord(payload) ? payload.usage : undefined;
|
|
74
|
+
if (!isRecord(usage)) {
|
|
75
|
+
throw new Error("响应缺少 usage 对象");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const windows: QuotaWindow[] = [];
|
|
79
|
+
const notes: string[] = [];
|
|
80
|
+
|
|
81
|
+
for (const [field, level] of WINDOW_FIELDS) {
|
|
82
|
+
const raw = usage[field];
|
|
83
|
+
if (!isRecord(raw)) {
|
|
84
|
+
notes.push(`窗口 ${field} 缺失或结构异常,已跳过`);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const status = raw.status;
|
|
88
|
+
if (typeof status !== "string" || !USABLE_STATUSES.has(status)) {
|
|
89
|
+
notes.push(`窗口 ${field} 状态为 ${typeof status === "string" ? redact(status) : "缺失"},已跳过`);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const percent = raw.percent;
|
|
93
|
+
if (typeof percent !== "number" || !Number.isFinite(percent) || percent < 0) {
|
|
94
|
+
notes.push(`窗口 ${field} 的 percent 不是非负有限数字,已跳过`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
const window: QuotaWindow = { level, percent };
|
|
98
|
+
const resetsAt = toEpochSeconds(raw.resetsAt);
|
|
99
|
+
if (resetsAt !== undefined) window.resetsAt = resetsAt;
|
|
100
|
+
windows.push(window);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (windows.length === 0) {
|
|
104
|
+
throw new Error("响应中没有任何可用窗口");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const report: AccountReport = {
|
|
108
|
+
accountId: OPENCODE_ACCOUNT_ID,
|
|
109
|
+
displayName: "Zen",
|
|
110
|
+
sourceId: "opencode",
|
|
111
|
+
kind: "windows",
|
|
112
|
+
windows,
|
|
113
|
+
fetchedAt,
|
|
114
|
+
};
|
|
115
|
+
if (notes.length > 0) report.notes = notes;
|
|
116
|
+
return report;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 查询 OpenCode Go 额度。
|
|
121
|
+
*
|
|
122
|
+
* 网络异常 / 超时 / 非 2xx / 响应结构不可识别一律返回带 error 的 AccountReport,不抛异常。
|
|
123
|
+
*/
|
|
124
|
+
export async function fetchOpenCodeUsage(apiKey: string, opts: FetchOptions = {}): Promise<AccountReport> {
|
|
125
|
+
const fetchedAt = Date.now();
|
|
126
|
+
if (typeof apiKey !== "string" || apiKey === "") {
|
|
127
|
+
return errorReport("missing-credential", "未配置 OpenCode API key", fetchedAt);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
131
|
+
const timeoutMs =
|
|
132
|
+
typeof opts.timeoutMs === "number" && Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0
|
|
133
|
+
? opts.timeoutMs
|
|
134
|
+
: DEFAULT_TIMEOUT_MS;
|
|
135
|
+
|
|
136
|
+
// 外部 signal 与超时合并到同一个 AbortController:任一触发都中止请求。
|
|
137
|
+
const controller = new AbortController();
|
|
138
|
+
const external = opts.signal;
|
|
139
|
+
const onExternalAbort = (): void => controller.abort();
|
|
140
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
141
|
+
if (external !== undefined) {
|
|
142
|
+
if (external.aborted) controller.abort();
|
|
143
|
+
else external.addEventListener("abort", onExternalAbort, { once: true });
|
|
144
|
+
}
|
|
145
|
+
const cleanup = (): void => {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
external?.removeEventListener("abort", onExternalAbort);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const response = await fetchImpl(OPENCODE_USAGE_URL, {
|
|
152
|
+
method: "GET",
|
|
153
|
+
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
154
|
+
// 拒绝跟随重定向,防止凭据被转发到白名单外的 host(SPEC §6)。
|
|
155
|
+
redirect: "error",
|
|
156
|
+
signal: controller.signal,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
if (controller.signal.aborted) {
|
|
160
|
+
return errorReport("timeout", "请求超时", fetchedAt);
|
|
161
|
+
}
|
|
162
|
+
if (!response.ok) {
|
|
163
|
+
return errorReport(`http-${response.status}`, `HTTP ${response.status}`, fetchedAt);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
let payload: unknown;
|
|
167
|
+
try {
|
|
168
|
+
payload = await response.json();
|
|
169
|
+
} catch {
|
|
170
|
+
return errorReport("unknown-shape", "响应不是合法 JSON", fetchedAt);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
return parseOpenCodeUsage(payload, fetchedAt);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
return errorReport("unknown-shape", messageOf(err), fetchedAt);
|
|
177
|
+
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (controller.signal.aborted || isAbortError(err)) {
|
|
180
|
+
return errorReport("timeout", "请求超时", fetchedAt);
|
|
181
|
+
}
|
|
182
|
+
return errorReport("network", "网络请求失败", fetchedAt);
|
|
183
|
+
} finally {
|
|
184
|
+
cleanup();
|
|
185
|
+
}
|
|
186
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-multi-quota 核心类型定义。
|
|
3
|
+
*
|
|
4
|
+
* 这些类型是跨模块的接口契约,与 SPEC.md §3.2 的数据模型一一对应。
|
|
5
|
+
* 任何修改必须先更新 SPEC.md §3.2,再改本文件。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** 额度窗口的粒度。三个数据源统一映射到这三档。 */
|
|
9
|
+
export type WindowLevel = "session" | "weekly" | "monthly";
|
|
10
|
+
|
|
11
|
+
/** 数据源标识。 */
|
|
12
|
+
export type SourceId = "ark" | "opencode" | "deepseek";
|
|
13
|
+
|
|
14
|
+
/** 单个额度窗口。 */
|
|
15
|
+
export interface QuotaWindow {
|
|
16
|
+
level: WindowLevel;
|
|
17
|
+
/** 已用百分比,0-100。 */
|
|
18
|
+
percent: number;
|
|
19
|
+
/** 重置时间,epoch 秒。缺失表示该窗口不含重置信息。 */
|
|
20
|
+
resetsAt?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 余额条目(pay-as-you-go 类数据源)。
|
|
25
|
+
* 金额全程保持字符串,禁止转 float —— 转换会丢精度。
|
|
26
|
+
*/
|
|
27
|
+
export interface BalanceEntry {
|
|
28
|
+
/** 币种,例如 "CNY" / "USD"。 */
|
|
29
|
+
currency: string;
|
|
30
|
+
/** 总余额,精确保留原始字符串。 */
|
|
31
|
+
total: string;
|
|
32
|
+
/** 赠送余额。 */
|
|
33
|
+
granted?: string;
|
|
34
|
+
/** 充值余额。 */
|
|
35
|
+
toppedUp?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* 归一化错误。message 必须是可安全展示的文本:
|
|
40
|
+
* 只允许包含错误码,严禁包含 cookie、API key 或原始响应体。
|
|
41
|
+
*/
|
|
42
|
+
export interface QuotaError {
|
|
43
|
+
code: string;
|
|
44
|
+
message: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 单个账号的一次查询结果。 */
|
|
48
|
+
export interface AccountReport {
|
|
49
|
+
/** 账号唯一标识,例如 "ark-a" / "ark-b" / "opencode" / "deepseek"。 */
|
|
50
|
+
accountId: string;
|
|
51
|
+
/** 展示名,例如 "Ark-A"。 */
|
|
52
|
+
displayName: string;
|
|
53
|
+
sourceId: SourceId;
|
|
54
|
+
kind: "windows" | "balance";
|
|
55
|
+
windows?: QuotaWindow[];
|
|
56
|
+
balances?: BalanceEntry[];
|
|
57
|
+
/** 抓到数据的时刻,epoch 毫秒。 */
|
|
58
|
+
fetchedAt: number;
|
|
59
|
+
error?: QuotaError;
|
|
60
|
+
/** true 表示展示的是上次成功的数据(本次查询失败)。 */
|
|
61
|
+
stale?: boolean;
|
|
62
|
+
/** 非致命提示,例如某个窗口 status 未知。 */
|
|
63
|
+
notes?: string[];
|
|
64
|
+
}
|