cortico-world-workbuddy 0.3.0 → 0.3.4
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/CHANGELOG.md +127 -2
- package/README.md +71 -54
- package/SECURITY.md +29 -16
- package/describe.json +2 -2
- package/dist/webui.js +58 -72
- package/package.json +3 -3
- package/scripts/clean-legacy-injection.mjs +77 -0
- package/scripts/clean-workbuddy.mjs +101 -0
- package/scripts/contract-regression.mjs +221 -0
- package/scripts/contract.mjs +45 -17
- package/scripts/reinstall.ps1 +15 -4
- package/scripts/selfcheck.mjs +1 -1
- package/scripts/verify-ui.mjs +19 -33
- package/src/ENV_PROMPT.md +4 -10
- package/src/config.js +5 -16
- package/src/credentials.js +11 -3
- package/src/definition.js +3 -3
- package/src/promo.js +64 -12
- package/src/upstream.js +31 -63
- package/src/usage.js +27 -21
- package/src/wbproc.js +0 -7
- package/src/webui/client.js +62 -84
- package/src/world.js +96 -139
package/src/promo.js
CHANGED
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
* 没有可用折扣的促销(只有徽标,例如「订阅优先」)不改变倍率。
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
+
/** WorkBuddy 未声明时区时的默认活动时区。 */
|
|
27
|
+
export const DEFAULT_TIMEZONE = 'Asia/Shanghai';
|
|
28
|
+
|
|
26
29
|
/** `'HH:mm'` → 当天分钟数;`24:00` 表示 1440(一天的终点)。 */
|
|
27
30
|
export function minutesOfClock(value) {
|
|
28
31
|
if (typeof value !== 'string') return undefined;
|
|
@@ -59,7 +62,7 @@ function minutesInZone(nowMs, timeZone) {
|
|
|
59
62
|
* 返回 0 等于把所有活动都当成「在 00:00 生效」,过期促销会被误判成正在打折。
|
|
60
63
|
*/
|
|
61
64
|
export function zonedMinutes(nowMs, timeZone) {
|
|
62
|
-
const wanted = typeof timeZone === 'string' && timeZone.trim() !== '' ? timeZone.trim() :
|
|
65
|
+
const wanted = typeof timeZone === 'string' && timeZone.trim() !== '' ? timeZone.trim() : DEFAULT_TIMEZONE;
|
|
63
66
|
try {
|
|
64
67
|
return minutesInZone(nowMs, wanted) ?? NaN;
|
|
65
68
|
} catch {
|
|
@@ -71,17 +74,55 @@ export function zonedMinutes(nowMs, timeZone) {
|
|
|
71
74
|
}
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
/** 把 YYYY-MM-DD 的当地午夜换算成时间戳;非法时区沿用每日时段的 UTC 回退。 */
|
|
78
|
+
function zonedDateTimestamp(year, month, day, timeZone) {
|
|
79
|
+
const target = Date.UTC(year, month - 1, day);
|
|
80
|
+
const wanted = typeof timeZone === 'string' && timeZone.trim() !== '' ? timeZone.trim() : DEFAULT_TIMEZONE;
|
|
81
|
+
let formatter;
|
|
82
|
+
try {
|
|
83
|
+
formatter = new Intl.DateTimeFormat('en-US', {
|
|
84
|
+
timeZone: wanted,
|
|
85
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
86
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
|
|
87
|
+
});
|
|
88
|
+
} catch {
|
|
89
|
+
formatter = new Intl.DateTimeFormat('en-US', {
|
|
90
|
+
timeZone: 'UTC',
|
|
91
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
92
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
let guess = target;
|
|
96
|
+
// 先按 UTC 猜,再用该时区偏移修正;迭代两次可覆盖 DST 切换前后的偏移变化。
|
|
97
|
+
for (let i = 0; i < 2; i += 1) {
|
|
98
|
+
const parts = formatter.formatToParts(new Date(guess));
|
|
99
|
+
const get = (type) => Number(parts.find((p) => p.type === type)?.value);
|
|
100
|
+
const localAsUtc = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'));
|
|
101
|
+
guess = target - (localAsUtc - guess);
|
|
102
|
+
}
|
|
103
|
+
return guess;
|
|
104
|
+
}
|
|
105
|
+
|
|
74
106
|
/**
|
|
75
107
|
* 把上游给的时间点归一成毫秒时间戳。
|
|
76
|
-
* 数字也认(< 1e12 视为 Unix
|
|
108
|
+
* 数字也认(< 1e12 视为 Unix 秒);纯日期按活动时区解释,validUntil 含当天整日。
|
|
77
109
|
*/
|
|
78
|
-
export function timestampOf(value) {
|
|
110
|
+
export function timestampOf(value, timeZone, endOfDay = false) {
|
|
79
111
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
80
112
|
return value < 1e12 ? Math.round(value * 1000) : Math.round(value);
|
|
81
113
|
}
|
|
82
114
|
if (typeof value === 'string' && value.trim() !== '') {
|
|
83
115
|
const text = value.trim();
|
|
84
116
|
if (/^\d+(?:\.\d+)?$/.test(text)) return timestampOf(Number(text));
|
|
117
|
+
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text);
|
|
118
|
+
if (dateOnly) {
|
|
119
|
+
const year = Number(dateOnly[1]);
|
|
120
|
+
const month = Number(dateOnly[2]);
|
|
121
|
+
const day = Number(dateOnly[3]);
|
|
122
|
+
const check = new Date(Date.UTC(year, month - 1, day));
|
|
123
|
+
if (check.getUTCFullYear() !== year || check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day) return undefined;
|
|
124
|
+
return zonedDateTimestamp(year, month, day + (endOfDay ? 1 : 0), timeZone);
|
|
125
|
+
}
|
|
85
126
|
const parsed = Date.parse(text);
|
|
86
127
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
87
128
|
}
|
|
@@ -212,10 +253,12 @@ export function absoluteWindowText(promo, formatDate) {
|
|
|
212
253
|
const fmt = formatDate ?? ((ms) => new Date(ms).toISOString().slice(0, 10));
|
|
213
254
|
const from = schedule?.validFrom;
|
|
214
255
|
const until = schedule?.validUntil;
|
|
256
|
+
// 纯日期的 validUntil 在内部存成次日 00:00(排他边界),展示仍用上游写的当天日期。
|
|
257
|
+
const untilText = schedule?.validUntilDate ?? (until === undefined ? undefined : fmt(until));
|
|
215
258
|
if (from === undefined && until === undefined) return '';
|
|
216
|
-
if (from !== undefined && until !== undefined) return `${fmt(from)} ~ ${
|
|
259
|
+
if (from !== undefined && until !== undefined) return `${fmt(from)} ~ ${untilText}`;
|
|
217
260
|
if (from !== undefined) return `${fmt(from)} 起`;
|
|
218
|
-
return `至 ${
|
|
261
|
+
return `至 ${untilText}`;
|
|
219
262
|
}
|
|
220
263
|
|
|
221
264
|
/** 折扣文案:`5 折(0.50x)`、`免费(0x)`。 */
|
|
@@ -249,14 +292,22 @@ function parseDiscount(raw) {
|
|
|
249
292
|
export function parseSchedule(raw) {
|
|
250
293
|
if (typeof raw !== 'object' || raw === null) return undefined;
|
|
251
294
|
const timezone = typeof raw.timezone === 'string' && raw.timezone !== '' ? raw.timezone : undefined;
|
|
252
|
-
const validFrom = timestampOf(raw.validFrom);
|
|
253
|
-
const validUntil = timestampOf(raw.validUntil);
|
|
254
|
-
const
|
|
255
|
-
? raw.
|
|
256
|
-
.map((w) => ({ start: w?.start, end: w?.end }))
|
|
257
|
-
.filter((w) => minutesOfClock(w.start) !== undefined && minutesOfClock(w.end) !== undefined)
|
|
295
|
+
const validFrom = timestampOf(raw.validFrom, timezone);
|
|
296
|
+
const validUntil = timestampOf(raw.validUntil, timezone, true);
|
|
297
|
+
const validUntilDate = typeof raw.validUntil === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(raw.validUntil.trim())
|
|
298
|
+
? raw.validUntil.trim()
|
|
258
299
|
: undefined;
|
|
259
|
-
const
|
|
300
|
+
const rawDaily = Array.isArray(raw.daily) ? raw.daily : undefined;
|
|
301
|
+
const daily =
|
|
302
|
+
rawDaily === undefined
|
|
303
|
+
? undefined
|
|
304
|
+
: rawDaily
|
|
305
|
+
.map((w) => ({ start: w?.start, end: w?.end }))
|
|
306
|
+
.filter((w) => minutesOfClock(w.start) !== undefined && minutesOfClock(w.end) !== undefined);
|
|
307
|
+
// 上游确实给了时段、但一条都解析不出来时,绝不能当成「没有时段限制」全时段生效。
|
|
308
|
+
// 0.3.0 写成 `daily === undefined && ...`,而过滤后得到的是空数组 [],判定恒为 false,
|
|
309
|
+
// 于是把写错的时段当作全天打折(审查报告第 2 条)。
|
|
310
|
+
const dailyUnusable = rawDaily !== undefined && rawDaily.length > 0 && (daily === undefined || daily.length === 0);
|
|
260
311
|
if (
|
|
261
312
|
timezone === undefined &&
|
|
262
313
|
validFrom === undefined &&
|
|
@@ -270,6 +321,7 @@ export function parseSchedule(raw) {
|
|
|
270
321
|
...(timezone === undefined ? {} : { timezone }),
|
|
271
322
|
...(validFrom === undefined || Number.isNaN(validFrom) ? {} : { validFrom }),
|
|
272
323
|
...(validUntil === undefined || Number.isNaN(validUntil) ? {} : { validUntil }),
|
|
324
|
+
...(validUntilDate === undefined || validUntil === undefined ? {} : { validUntilDate }),
|
|
273
325
|
...(daily === undefined ? {} : { daily }),
|
|
274
326
|
...(dailyUnusable ? { dailyUnusable: true } : {}),
|
|
275
327
|
};
|
package/src/upstream.js
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* WorkBuddy 上游客户端:模型目录、积分余额、chat completions 流式、token 刷新。
|
|
3
3
|
*
|
|
4
4
|
* 协议参照 dsh-connect-workbuddy(MIT)与 workbuddy2api(MIT)的实测结论:
|
|
5
|
-
* - 模型目录:GET /v2/enterprises/personal/models
|
|
5
|
+
* - 模型目录:GET /v2/enterprises/personal/models(WorkBuddy网关;
|
|
6
6
|
* 每个模型条目带 id / name / maxInputTokens / maxOutputTokens /
|
|
7
7
|
* credits(积分倍率字符串,如 "x0.79")/ supportsImages / reasoning 等)
|
|
8
8
|
* - 对话: POST /v2/chat/completions,强制 stream:true,SSE 回包
|
|
9
|
-
* -
|
|
10
|
-
* chat 请求绝不携带 refresh token(安全红线)
|
|
9
|
+
* - 登录态: token 失效时重新从本机凭据发现(桌面端自己刷新落盘);本模块不做
|
|
10
|
+
* 自动刷新,chat 请求绝不携带 refresh token(安全红线)
|
|
11
11
|
* - 归属头: X-IDE-Name / X-IDE-Type / X-Product = WorkBuddy,让用量页
|
|
12
12
|
* 把这笔调用记成官方客户端发的
|
|
13
13
|
*
|
|
@@ -17,11 +17,8 @@
|
|
|
17
17
|
|
|
18
18
|
import { attachPromos, parsePromos } from './promo.js';
|
|
19
19
|
|
|
20
|
-
/**
|
|
21
|
-
export const
|
|
22
|
-
cn: 'https://copilot.tencent.com',
|
|
23
|
-
global: 'https://www.workbuddy.ai',
|
|
24
|
-
};
|
|
20
|
+
/** WorkBuddy 客户端的官方上游域名。本扩展只支持这一套上游。 */
|
|
21
|
+
export const WB_BASE = 'https://copilot.tencent.com';
|
|
25
22
|
|
|
26
23
|
const MODELS_PATH = '/v2/enterprises/personal/models';
|
|
27
24
|
/**
|
|
@@ -33,7 +30,6 @@ const MODELS_PATH = '/v2/enterprises/personal/models';
|
|
|
33
30
|
*/
|
|
34
31
|
const CONFIG_PATH = '/v3/config';
|
|
35
32
|
const CHAT_PATH = '/v2/chat/completions';
|
|
36
|
-
const REFRESH_PATH = '/v2/auth/refresh';
|
|
37
33
|
/**
|
|
38
34
|
* 计费域积分读数(官方客户端「套餐与积分」页同族接口;失败时优雅降级)。
|
|
39
35
|
*
|
|
@@ -44,11 +40,8 @@ const REFRESH_PATH = '/v2/auth/refresh';
|
|
|
44
40
|
* 路径来自 dsh-connect-workbuddy 5.17.0 的 billingBase()/get-user-resource。
|
|
45
41
|
*/
|
|
46
42
|
const CREDITS_PATH = '/v2/billing/meter/get-user-resource';
|
|
47
|
-
/**
|
|
48
|
-
const WB_BILLING_BASE =
|
|
49
|
-
cn: 'https://www.codebuddy.cn',
|
|
50
|
-
global: 'https://www.workbuddy.ai',
|
|
51
|
-
};
|
|
43
|
+
/** 计费域(积分/签到/福利)根地址。 */
|
|
44
|
+
const WB_BILLING_BASE = 'https://www.codebuddy.cn';
|
|
52
45
|
|
|
53
46
|
const PRODUCT_UA = 'WorkBuddy/2.63.2';
|
|
54
47
|
/**
|
|
@@ -93,15 +86,12 @@ function commonHeaders(cred, extra = {}) {
|
|
|
93
86
|
|
|
94
87
|
export class WbUpstream {
|
|
95
88
|
/**
|
|
96
|
-
* @param {{ baseUrl: string,
|
|
89
|
+
* @param {{ baseUrl: string, log?: { warn(msg: string): void } }} opts
|
|
97
90
|
*/
|
|
98
91
|
constructor(opts) {
|
|
99
92
|
this.baseUrl = (opts.baseUrl ?? '').replace(/\/+$/, '');
|
|
100
93
|
/** 用户显式配置了 baseUrl(代理 / 镜像):计费请求也走它。 */
|
|
101
|
-
this.customBase = Boolean(this.baseUrl) &&
|
|
102
|
-
// 计费域只看区域,不看 baseUrl:以前按域名子串猜,自定义 baseUrl(例如走代理/镜像)
|
|
103
|
-
// 会悄悄落到国际计费域 www.workbuddy.ai,查出来的余额是另一个账号的。
|
|
104
|
-
this.region = opts.region === 'global' ? 'global' : 'cn';
|
|
94
|
+
this.customBase = Boolean(this.baseUrl) && this.baseUrl !== WB_BASE;
|
|
105
95
|
this.log = opts.log ?? { warn: () => {} };
|
|
106
96
|
}
|
|
107
97
|
|
|
@@ -112,11 +102,11 @@ export class WbUpstream {
|
|
|
112
102
|
/**
|
|
113
103
|
* 计费域的完整地址(积分、签到、福利都在这边,和 chat 域不同)。
|
|
114
104
|
*
|
|
115
|
-
* 没配 `baseUrl`
|
|
105
|
+
* 没配 `baseUrl` 时用官方计费域;配了自定义 baseUrl(代理/镜像)就跟着走同一个出口,
|
|
116
106
|
* 否则 chat 走代理、积分直连官方域名,代理用户的网络与隐私假设会被打破。
|
|
117
107
|
*/
|
|
118
108
|
billingUrl(path) {
|
|
119
|
-
return `${this.customBase ? this.baseUrl : WB_BILLING_BASE
|
|
109
|
+
return `${this.customBase ? this.baseUrl : WB_BILLING_BASE}${path}`;
|
|
120
110
|
}
|
|
121
111
|
|
|
122
112
|
/**
|
|
@@ -132,6 +122,10 @@ export class WbUpstream {
|
|
|
132
122
|
* @param {{ accessToken: string }} cred
|
|
133
123
|
*/
|
|
134
124
|
async fetchCatalog(cred) {
|
|
125
|
+
// 每次请求重新判定:认证失败标记留在实例上会污染后续请求(同一个 WbUpstream
|
|
126
|
+
// 被复用时,上一次的 401 会让下一次成功请求也被判成登录态失效)。
|
|
127
|
+
this._authFailed = false;
|
|
128
|
+
this._authStatus = 0;
|
|
135
129
|
try {
|
|
136
130
|
const res = await fetch(this.url(CONFIG_PATH), {
|
|
137
131
|
method: 'GET',
|
|
@@ -148,6 +142,7 @@ export class WbUpstream {
|
|
|
148
142
|
if (!res.ok) {
|
|
149
143
|
// 静默降级会让「为什么没有限时优惠」变成悬案,这里至少留一条 warn。
|
|
150
144
|
this._authFailed = res.status === 401 || res.status === 403;
|
|
145
|
+
if (this._authFailed) this._authStatus = res.status;
|
|
151
146
|
this.log?.warn?.(
|
|
152
147
|
`产品配置 HTTP ${res.status}${this._authFailed ? '(认证失败,不再降级)' : ',退回旧目录路径(本次没有促销信息)'}。`,
|
|
153
148
|
);
|
|
@@ -168,7 +163,13 @@ export class WbUpstream {
|
|
|
168
163
|
// 认证类失败(401/403)再打一次旧接口没有意义:token 本身是死的,
|
|
169
164
|
// 降级只会把真实错误(session_dead)换成 legacy 的二次失败,掩盖问题。
|
|
170
165
|
if (this._authFailed) {
|
|
171
|
-
|
|
166
|
+
// 构造函数第二参是 info 对象(status / kind)。0.3.0 写成 'session_dead'
|
|
167
|
+
// 字符串,读不到 kind,错误恒为 'server'、status=0,上层于是不清 token、
|
|
168
|
+
// 面板也不提示重新登录(审查报告里最严重的一条)。
|
|
169
|
+
throw new WbUpstreamError('WorkBuddy 登录态已失效(产品配置返回 401/403),请重新登录客户端。', {
|
|
170
|
+
status: this._authStatus,
|
|
171
|
+
kind: 'session_dead',
|
|
172
|
+
});
|
|
172
173
|
}
|
|
173
174
|
const models = await this.fetchModelsLegacy(cred);
|
|
174
175
|
return { models, promos: [], source: 'legacy' };
|
|
@@ -198,7 +199,7 @@ export class WbUpstream {
|
|
|
198
199
|
throw new WbUpstreamError(`模型目录请求失败 HTTP ${res.status}`, {
|
|
199
200
|
status: res.status,
|
|
200
201
|
code: body?.code,
|
|
201
|
-
kind: res.status === 401 ? 'session_dead' : 'server',
|
|
202
|
+
kind: res.status === 401 || res.status === 403 ? 'session_dead' : 'server',
|
|
202
203
|
});
|
|
203
204
|
}
|
|
204
205
|
if (body && typeof body.code === 'number' && body.code !== 0 && body.code !== 200) {
|
|
@@ -277,44 +278,6 @@ export class WbUpstream {
|
|
|
277
278
|
return { total, used, packages, accounts };
|
|
278
279
|
}
|
|
279
280
|
|
|
280
|
-
/**
|
|
281
|
-
* 刷新 access token。刷新令牌只出现在这一条请求里。
|
|
282
|
-
* @param {{ refreshToken: string }} cred
|
|
283
|
-
* @returns {Promise<{ accessToken: string, refreshToken?: string, expiresIn?: number }>}
|
|
284
|
-
*/
|
|
285
|
-
async refresh(cred) {
|
|
286
|
-
const res = await fetch(this.url(REFRESH_PATH), {
|
|
287
|
-
method: 'POST',
|
|
288
|
-
headers: {
|
|
289
|
-
Accept: 'application/json',
|
|
290
|
-
'Content-Type': 'application/json',
|
|
291
|
-
'User-Agent': PRODUCT_UA,
|
|
292
|
-
'X-Requested-With': 'XMLHttpRequest',
|
|
293
|
-
'X-Refresh-Token': cred.refreshToken,
|
|
294
|
-
'X-Auth-Refresh-Source': 'workbuddy',
|
|
295
|
-
},
|
|
296
|
-
body: '{}',
|
|
297
|
-
signal: AbortSignal.timeout(10_000),
|
|
298
|
-
});
|
|
299
|
-
const body = await res.json().catch(() => null);
|
|
300
|
-
if (!res.ok || !body) {
|
|
301
|
-
throw new WbUpstreamError(`token 刷新失败 HTTP ${res.status}`, {
|
|
302
|
-
status: res.status,
|
|
303
|
-
kind: 'session_dead',
|
|
304
|
-
});
|
|
305
|
-
}
|
|
306
|
-
const data = body.data ?? body;
|
|
307
|
-
const accessToken = data.accessToken ?? data.access_token;
|
|
308
|
-
if (typeof accessToken !== 'string' || accessToken === '') {
|
|
309
|
-
throw new WbUpstreamError('token 刷新响应里没有 accessToken', { kind: 'session_dead' });
|
|
310
|
-
}
|
|
311
|
-
return {
|
|
312
|
-
accessToken,
|
|
313
|
-
refreshToken: data.refreshToken ?? data.refresh_token,
|
|
314
|
-
expiresIn: data.expiresIn ?? data.expires_in,
|
|
315
|
-
};
|
|
316
|
-
}
|
|
317
|
-
|
|
318
281
|
/**
|
|
319
282
|
* 发起一次对话(SSE 流式),把增量文本逐段交给 onDelta。
|
|
320
283
|
* @param {{ accessToken: string }} cred
|
|
@@ -343,7 +306,7 @@ export class WbUpstream {
|
|
|
343
306
|
}
|
|
344
307
|
let text = '';
|
|
345
308
|
let credit;
|
|
346
|
-
let usage;
|
|
309
|
+
let usage = {};
|
|
347
310
|
const decoder = new TextDecoder();
|
|
348
311
|
let buf = '';
|
|
349
312
|
/** 处理一行 SSE;末尾残留(没有换行)也走这里。 */
|
|
@@ -375,7 +338,7 @@ export class WbUpstream {
|
|
|
375
338
|
// 最后一段 SSE 带完整用量(实测:prompt_tokens / completion_tokens / total_tokens /
|
|
376
339
|
// 明细里的 cached_tokens、reasoning_tokens,以及本次真实扣费 credits 的 `credit`)。
|
|
377
340
|
const u = evt.usage;
|
|
378
|
-
|
|
341
|
+
const next = {
|
|
379
342
|
promptTokens: numberOrUndefined(u.prompt_tokens),
|
|
380
343
|
completionTokens: numberOrUndefined(u.completion_tokens),
|
|
381
344
|
totalTokens: numberOrUndefined(u.total_tokens),
|
|
@@ -386,6 +349,10 @@ export class WbUpstream {
|
|
|
386
349
|
numberOrUndefined(u.completion_thinking_tokens),
|
|
387
350
|
credit: numberOrUndefined(u.credit),
|
|
388
351
|
};
|
|
352
|
+
// SSE 可能分多帧返回用量,末帧只带 credit;逐字段合并,不能用末帧覆盖前帧 token。
|
|
353
|
+
for (const [key, value] of Object.entries(next)) {
|
|
354
|
+
if (value !== undefined) usage[key] = value;
|
|
355
|
+
}
|
|
389
356
|
if (typeof u.credit === 'number') credit = u.credit;
|
|
390
357
|
}
|
|
391
358
|
};
|
|
@@ -408,6 +375,7 @@ export class WbUpstream {
|
|
|
408
375
|
|
|
409
376
|
/** `0` 是合法用量,只有 null / undefined / NaN 才算「没有」。 */
|
|
410
377
|
function numberOrUndefined(v) {
|
|
378
|
+
if (v === null || v === undefined || v === '') return undefined;
|
|
411
379
|
const n = Number(v);
|
|
412
380
|
return Number.isFinite(n) ? n : undefined;
|
|
413
381
|
}
|
package/src/usage.js
CHANGED
|
@@ -73,17 +73,17 @@ export class UsageLedger {
|
|
|
73
73
|
/**
|
|
74
74
|
* 追加一条调用记录。
|
|
75
75
|
* @param {{
|
|
76
|
-
* ts?: number, model: string,
|
|
76
|
+
* ts?: number, model: string, ok?: boolean, error?: string,
|
|
77
77
|
* promptTokens?: number, completionTokens?: number, totalTokens?: number,
|
|
78
78
|
* cachedTokens?: number, reasoningTokens?: number, credit?: number | null,
|
|
79
|
-
* multiplier?: number | null,
|
|
79
|
+
* multiplier?: number | null, baseMultiplier?: number | null,
|
|
80
|
+
* promoId?: string, source?: string, durationMs?: number,
|
|
80
81
|
* }} entry
|
|
81
82
|
*/
|
|
82
83
|
async append(entry) {
|
|
83
84
|
const record = {
|
|
84
85
|
ts: typeof entry.ts === 'number' ? entry.ts : Date.now(),
|
|
85
86
|
model: String(entry.model ?? '—'),
|
|
86
|
-
region: entry.region,
|
|
87
87
|
ok: entry.ok !== false,
|
|
88
88
|
...(entry.error === undefined ? {} : { error: String(entry.error).slice(0, 300) }),
|
|
89
89
|
...num('promptTokens', entry.promptTokens),
|
|
@@ -95,6 +95,9 @@ export class UsageLedger {
|
|
|
95
95
|
...(typeof entry.multiplier === 'number' && Number.isFinite(entry.multiplier)
|
|
96
96
|
? { multiplier: entry.multiplier }
|
|
97
97
|
: {}),
|
|
98
|
+
...(typeof entry.baseMultiplier === 'number' && Number.isFinite(entry.baseMultiplier)
|
|
99
|
+
? { baseMultiplier: entry.baseMultiplier }
|
|
100
|
+
: {}),
|
|
98
101
|
...(entry.promoId === undefined ? {} : { promoId: String(entry.promoId) }),
|
|
99
102
|
...(entry.source === undefined ? {} : { source: String(entry.source) }),
|
|
100
103
|
...num('durationMs', entry.durationMs),
|
|
@@ -182,13 +185,18 @@ export class UsageLedger {
|
|
|
182
185
|
}
|
|
183
186
|
}
|
|
184
187
|
|
|
185
|
-
/**
|
|
188
|
+
/** 写入余额快照(走与账本同一条写队列,先写临时文件再改名,避免并发互相覆盖)。 */
|
|
186
189
|
async writeBalance(snapshot) {
|
|
190
|
+
const text = JSON.stringify(snapshot, null, 2);
|
|
187
191
|
try {
|
|
188
|
-
await
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
+
await this._withLock(async () => {
|
|
193
|
+
await mkdir(dirname(this.balancePath), { recursive: true });
|
|
194
|
+
// 0.3.0 用固定名 BALANCE_FILE.tmp 且不排队:两个并发写会抢同一个临时文件,
|
|
195
|
+
// 互相覆盖甚至 rename 到一个已被删掉的路径。队列串行 + 带 pid 的临时名。
|
|
196
|
+
const tmp = join(this.dir, `${BALANCE_FILE}.${process.pid}.tmp`);
|
|
197
|
+
await writeFile(tmp, text, 'utf8');
|
|
198
|
+
await rename(tmp, this.balancePath);
|
|
199
|
+
});
|
|
192
200
|
return true;
|
|
193
201
|
} catch (err) {
|
|
194
202
|
this.log.warn?.(`写余额快照失败:${err?.message ?? err}`);
|
|
@@ -202,10 +210,8 @@ export class UsageLedger {
|
|
|
202
210
|
*
|
|
203
211
|
* @param {Array<{ts:number, model:string, ok?:boolean, credit?:number, promptTokens?:number, completionTokens?:number, totalTokens?:number, cachedTokens?:number, reasoningTokens?:number}>} records
|
|
204
212
|
* @param {number} nowMs
|
|
205
|
-
* @param {{ days?: number }} [opts]
|
|
206
213
|
*/
|
|
207
|
-
export function summarize(records, nowMs
|
|
208
|
-
const windowDays = opts.days ?? 30;
|
|
214
|
+
export function summarize(records, nowMs) {
|
|
209
215
|
const startOfToday = startOfDay(nowMs);
|
|
210
216
|
const windows = {
|
|
211
217
|
today: startOfToday,
|
|
@@ -217,13 +223,13 @@ export function summarize(records, nowMs, opts = {}) {
|
|
|
217
223
|
const totals = {};
|
|
218
224
|
for (const [key, from] of Object.entries(windows)) totals[key] = emptyTotals();
|
|
219
225
|
|
|
220
|
-
/** 按模型聚合(近
|
|
226
|
+
/** 按模型聚合(近 30 天,与概览的 month 行同口径)。 */
|
|
221
227
|
const byModel = new Map();
|
|
222
|
-
/** 按天聚合(近
|
|
228
|
+
/** 按天聚合(近 30 天,用于迷你趋势)。 */
|
|
223
229
|
const byDay = new Map();
|
|
224
230
|
|
|
225
231
|
for (const rec of records) {
|
|
226
|
-
const credit =
|
|
232
|
+
const credit = typeof rec.credit === 'number' && Number.isFinite(rec.credit) ? rec.credit : undefined;
|
|
227
233
|
const prompt = numberOr(rec.promptTokens, 0);
|
|
228
234
|
const completion = numberOr(rec.completionTokens, 0);
|
|
229
235
|
const total = numberOr(rec.totalTokens, prompt + completion);
|
|
@@ -256,34 +262,34 @@ export function summarize(records, nowMs, opts = {}) {
|
|
|
256
262
|
ts: rec.ts,
|
|
257
263
|
model: rec.model,
|
|
258
264
|
ok: rec.ok !== false,
|
|
259
|
-
credit:
|
|
265
|
+
credit: typeof rec.credit === 'number' && Number.isFinite(rec.credit) ? rec.credit : null,
|
|
260
266
|
total: numberOr(rec.totalTokens, numberOr(rec.promptTokens, 0) + numberOr(rec.completionTokens, 0)),
|
|
261
267
|
prompt: numberOr(rec.promptTokens, 0),
|
|
262
268
|
completion: numberOr(rec.completionTokens, 0),
|
|
269
|
+
cached: numberOr(rec.cachedTokens, 0),
|
|
263
270
|
multiplier: rec.multiplier,
|
|
271
|
+
baseMultiplier: rec.baseMultiplier,
|
|
264
272
|
durationMs: rec.durationMs,
|
|
265
273
|
}));
|
|
266
274
|
|
|
267
|
-
const spanDays = Math.max(1, Math.ceil((nowMs - startOfToday) / 864e5) + 1);
|
|
268
275
|
return {
|
|
269
|
-
windowDays,
|
|
270
|
-
spanDays,
|
|
271
276
|
totals,
|
|
272
277
|
byModel: modelRows.slice(0, 10),
|
|
273
|
-
byDay: dayRows.slice(-
|
|
278
|
+
byDay: dayRows.slice(-30),
|
|
274
279
|
recent,
|
|
275
280
|
recordCount: records.length,
|
|
276
281
|
};
|
|
277
282
|
}
|
|
278
283
|
|
|
279
284
|
function emptyTotals() {
|
|
280
|
-
return { calls: 0, failed: 0, credit: 0, prompt: 0, completion: 0, total: 0, cached: 0, reasoning: 0 };
|
|
285
|
+
return { calls: 0, failed: 0, credit: 0, creditUnknown: 0, prompt: 0, completion: 0, total: 0, cached: 0, reasoning: 0 };
|
|
281
286
|
}
|
|
282
287
|
|
|
283
288
|
function accumulate(target, v) {
|
|
284
289
|
target.calls += 1;
|
|
285
290
|
if (!v.ok) target.failed += 1;
|
|
286
|
-
target.credit += v.credit;
|
|
291
|
+
if (typeof v.credit === 'number') target.credit += v.credit;
|
|
292
|
+
else target.creditUnknown += 1;
|
|
287
293
|
target.prompt += v.prompt;
|
|
288
294
|
target.completion += v.completion;
|
|
289
295
|
target.total += v.total;
|
package/src/wbproc.js
CHANGED
|
@@ -129,10 +129,3 @@ export async function detectWorkBuddy(force = false) {
|
|
|
129
129
|
if (state.via !== 'error') cache = { at: now, value };
|
|
130
130
|
return value;
|
|
131
131
|
}
|
|
132
|
-
|
|
133
|
-
/** 给面板/提示词用的一行摘要。 */
|
|
134
|
-
export function describeProc(state) {
|
|
135
|
-
if (!state) return 'unknown';
|
|
136
|
-
if (state.running) return `running (pid ${state.mainPid ?? '?'})`;
|
|
137
|
-
return 'stopped';
|
|
138
|
-
}
|